From 5d6f2a1524ba37560fe6cdea5aa684dc196dbc99 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Mon, 11 May 2026 15:15:03 +0800 Subject: [PATCH 001/188] fix(ui): use part_text_accum_delta to prevent markdown cutoff during streaming (#26822) --- .../app/src/context/global-sync/child-store.ts | 1 + .../context/global-sync/event-reducer.test.ts | 1 + .../src/context/global-sync/event-reducer.ts | 17 +++++++++++++++++ .../context/global-sync/session-cache.test.ts | 5 +++++ .../src/context/global-sync/session-cache.ts | 4 ++++ packages/app/src/context/global-sync/types.ts | 3 +++ packages/ui/src/components/message-part.tsx | 5 +++-- packages/ui/src/context/data.tsx | 3 +++ 8 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 0138310cd..737c6bedc 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -231,6 +231,7 @@ export function createChildStoreManager(input: { limit: 5, message: {}, part: {}, + part_text_accum_delta: {}, }) children[key] = child disposers.set(key, dispose) diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index 892129788..f02ac5c7b 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -81,6 +81,7 @@ const baseState = (input: Partial = {}) => limit: 10, message: {}, part: {}, + part_text_accum_delta: {}, ...input, }) as State diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 5f43c341b..13d34ef6c 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -211,6 +211,12 @@ export function applyDirectoryEvent(input: { const result = Binary.search(messages, props.messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) } + const parts = draft.part[props.messageID] + if (parts) { + for (const part of parts) { + delete draft.part_text_accum_delta[part.id] + } + } delete draft.part[props.messageID] }), ) @@ -219,6 +225,11 @@ export function applyDirectoryEvent(input: { case "message.part.updated": { const part = (event.properties as { part: Part }).part if (SKIP_PARTS.has(part.type)) break + input.setStore( + produce((draft) => { + delete draft.part_text_accum_delta[part.id] + }), + ) const parts = input.store.part[part.messageID] if (!parts) { input.setStore("part", part.messageID, [part]) @@ -240,6 +251,11 @@ export function applyDirectoryEvent(input: { } case "message.part.removed": { const props = event.properties as { messageID: string; partID: string } + input.setStore( + produce((draft) => { + delete draft.part_text_accum_delta[props.partID] + }), + ) const parts = input.store.part[props.messageID] if (!parts) break const result = Binary.search(parts, props.partID, (p) => p.id) @@ -263,6 +279,7 @@ export function applyDirectoryEvent(input: { if (!parts) break const result = Binary.search(parts, props.partID, (p) => p.id) if (!result.found) break + input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta) input.setStore( "part", props.messageID, diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 472ac219e..4b2be505e 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -39,6 +39,7 @@ describe("app session cache", () => { part: Record permission: Record question: Record + part_text_accum_delta: Record } = { session_status: { ses_1: { type: "busy" } as SessionStatus }, session_diff: { ses_1: [] }, @@ -47,12 +48,14 @@ describe("app session cache", () => { part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, permission: { ses_1: [] as PermissionRequest[] }, question: { ses_1: [] as QuestionRequest[] }, + part_text_accum_delta: { prt_1: "streamed text" }, } dropSessionCaches(store, ["ses_1"]) expect(store.message.ses_1).toBeUndefined() expect(store.part.msg_1).toBeUndefined() + expect(store.part_text_accum_delta.prt_1).toBeUndefined() expect(store.todo.ses_1).toBeUndefined() expect(store.session_diff.ses_1).toBeUndefined() expect(store.session_status.ses_1).toBeUndefined() @@ -70,6 +73,7 @@ describe("app session cache", () => { part: Record permission: Record question: Record + part_text_accum_delta: Record } = { session_status: {}, session_diff: {}, @@ -78,6 +82,7 @@ describe("app session cache", () => { part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, permission: {}, question: {}, + part_text_accum_delta: {}, } dropSessionCaches(store, ["ses_1"]) diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 6f4d81062..05cdc8464 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -18,6 +18,7 @@ type SessionCache = { part: Record permission: Record question: Record + part_text_accum_delta: Record } export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable) { @@ -27,6 +28,9 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable stale.has(part?.sessionID ?? ""))) continue + for (const part of parts) { + delete store.part_text_accum_delta[part.id] + } delete store.part[key] } diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index e3ec83c5e..6bf42a073 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -72,6 +72,9 @@ export type State = { part: { [messageID: string]: Part[] } + part_text_accum_delta: { + [partID: string]: string + } } export type VcsCache = { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 137f68975..7a7d5b15f 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1461,7 +1461,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { const streaming = createMemo( () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number", ) - const text = () => (part().text ?? "").trim() + const text = () => (data.store.part_text_accum_delta?.[part().id] ?? part().text ?? "").trim() const isLastTextPart = createMemo(() => { const last = (data.store.part?.[props.message.id] ?? []) .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim()) @@ -1521,11 +1521,12 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { } PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { + const data = useData() const part = () => props.part as ReasoningPart const streaming = createMemo( () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number", ) - const text = () => part().text.trim() + const text = () => (data.store.part_text_accum_delta?.[part().id] ?? part().text).trim() return ( diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index 632bed0cf..3d015257f 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -24,6 +24,9 @@ type Data = { part: { [messageID: string]: Part[] } + part_text_accum_delta?: { + [partID: string]: string + } } export type NavigateToSessionFn = (sessionID: string) => void From 7e997cfba418f481e08b06214907ebf938c5f6dd Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 11 May 2026 12:57:26 +0530 Subject: [PATCH 002/188] refactor(scout): resolve configured reference mentions (#26701) --- packages/opencode/src/agent/agent.ts | 71 -------- packages/opencode/src/session/prompt.ts | 170 +++++++++++++++++- .../src/v2/session-message-updater.ts | 1 + packages/opencode/src/v2/session-message.ts | 1 + packages/opencode/src/v2/session-prompt.ts | 13 ++ packages/opencode/test/agent/agent.test.ts | 52 +----- packages/opencode/test/session/prompt.test.ts | 97 ++++++++++ .../test/session/snapshot-tool-race.test.ts | 1 + packages/sdk/js/src/v2/gen/types.gen.ts | 14 ++ 9 files changed, 304 insertions(+), 116 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 5917240cd..777f6e6d1 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -26,7 +26,6 @@ import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" import { zod } from "@opencode-ai/core/effect-zod" import { withStatics, type DeepMutable } from "@opencode-ai/core/schema" -import { Reference } from "@/reference/reference" export const Info = Schema.Struct({ name: Schema.String, @@ -301,76 +300,6 @@ export const layer = Layer.effect( item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) } - function referencePrompt(reference: Reference.Resolved) { - if (reference.kind === "local") { - return [ - `You are configured reference @${reference.name}, a read-only research agent for external reference material.`, - `Local directory: ${reference.path}`, - `Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`, - `Return exact absolute file paths for findings whenever possible.`, - ].join("\n\n") - } - - if (reference.kind === "invalid") { - return [ - `You are configured reference @${reference.name}, but this reference is not usable yet.`, - `Configured repository: ${reference.repository}`, - `Problem: ${reference.message}`, - `Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`, - ].join("\n\n") - } - - return [ - `You are configured reference @${reference.name}, a read-only research agent for external reference material.`, - `Repository: ${reference.repository}`, - ...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []), - `Cached directory: ${reference.path}`, - `OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`, - `Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`, - `Return exact absolute file paths for findings whenever possible.`, - ].join("\n\n") - } - - function referenceDescription(reference: Reference.Resolved) { - if (reference.kind === "local") return `Scout reference for local directory ${reference.path}` - if (reference.kind === "git") return `Scout reference for repository ${reference.repository}` - return `Invalid Scout reference for repository ${reference.repository}` - } - - if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) { - const resolvedReferences = Reference.resolveAll({ - references: cfg.reference ?? {}, - directory: ctx.directory, - worktree: ctx.worktree, - }) - for (const resolved of resolvedReferences) { - if (agents[resolved.name]) continue - const localPath = resolved.kind === "invalid" ? undefined : resolved.path - agents[resolved.name] = { - name: resolved.name, - description: referenceDescription(resolved), - permission: Permission.merge( - agents.scout.permission, - Permission.fromConfig({ - repo_clone: "deny", - ...(localPath - ? { - external_directory: { - [localPath]: "allow", - [path.join(localPath, "*")]: "allow", - }, - } - : {}), - }), - ), - prompt: referencePrompt(resolved), - options: { reference: cfg.reference?.[resolved.name], resolved }, - mode: "subagent", - native: false, - } - } - } - // Ensure Truncate.GLOB is allowed unless explicitly configured for (const name in agents) { const agent = agents[name] diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7f4f60855..3b919e2f0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -56,7 +56,8 @@ import { EffectBridge } from "@/effect/bridge" import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" import { Modelv2 } from "@/v2/model" -import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt" +import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@/v2/session-prompt" +import { Reference } from "@/reference/reference" import * as DateTime from "effect/DateTime" import { eq } from "@/storage/db" import * as Database from "@/storage/db" @@ -81,6 +82,45 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc const log = Log.create({ service: "session.prompt" }) const elog = EffectLogger.create({ service: "session.prompt" }) +type ReferencePromptMetadata = { + name: string + kind: "local" | "git" | "invalid" + path?: string + repository?: string + branch?: string + target?: string + targetPath?: string + problem?: string + source: { value: string; start: number; end: number } +} + +function stringField(record: Record, key: string) { + return typeof record[key] === "string" ? record[key] : undefined +} + +function referencePromptMetadata(input: unknown): ReferencePromptMetadata | undefined { + if (!input || typeof input !== "object" || Array.isArray(input)) return + const record = input as Record + const name = stringField(record, "name") + const kind = stringField(record, "kind") + if (!name || (kind !== "local" && kind !== "git" && kind !== "invalid")) return + if (!record.source || typeof record.source !== "object" || Array.isArray(record.source)) return + const source = record.source as Record + const value = stringField(source, "value") + if (!value || typeof source.start !== "number" || typeof source.end !== "number") return + return { + name, + kind, + path: stringField(record, "path"), + repository: stringField(record, "repository"), + branch: stringField(record, "branch"), + target: stringField(record, "target"), + targetPath: stringField(record, "targetPath"), + problem: stringField(record, "problem"), + source: { value, start: source.start, end: source.end }, + } +} + export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect @@ -119,6 +159,7 @@ export const layer = Layer.effect( const summary = yield* SessionSummary.Service const sys = yield* SystemPrompt.Service const llm = yield* LLM.Service + const references = yield* Reference.Service const sync = yield* SyncEvent.Service const runner = Effect.fn("SessionPrompt.runner")(function* () { return yield* EffectBridge.make() @@ -141,12 +182,116 @@ export const layer = Layer.effect( const parts: Types.DeepMutable = [{ type: "text", text: template }] const files = ConfigMarkdown.files(template) const seen = new Set() + const mentionSource = (match: RegExpMatchArray) => { + const start = match.index ?? 0 + return { value: match[0], start, end: start + match[0].length } + } + const referenceTextPart = (input: { + reference: Reference.Resolved + source: ReturnType + target?: string + targetPath?: string + problem?: string + }): MessageV2.TextPartInput => { + const metadata: ReferencePromptMetadata = { + name: input.reference.name, + kind: input.reference.kind, + ...(input.reference.kind === "invalid" + ? { repository: input.reference.repository } + : { path: input.reference.path }), + ...(input.reference.kind === "git" + ? { repository: input.reference.repository, branch: input.reference.branch } + : {}), + ...(input.target === undefined ? {} : { target: input.target }), + ...(input.targetPath ? { targetPath: input.targetPath } : {}), + problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined), + source: input.source, + } + const label = metadata.target === undefined ? `@${metadata.name}` : `@${metadata.name}/${metadata.target}` + return { + type: "text", + synthetic: true, + text: [ + `Referenced configured reference ${label}.`, + ...(metadata.kind === "local" ? ["Kind: local directory"] : []), + ...(metadata.kind === "git" ? ["Kind: git repository"] : []), + ...(metadata.repository ? [`Repository: ${metadata.repository}`] : []), + ...(metadata.branch ? [`Branch/ref: ${metadata.branch}`] : []), + ...(metadata.path ? [`Reference root: ${metadata.path}`] : []), + ...(metadata.targetPath ? [`Resolved path: ${metadata.targetPath}`] : []), + ...(metadata.problem + ? [`Problem: ${metadata.problem}`] + : [ + "For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.", + ]), + ].join("\n"), + metadata: { reference: metadata }, + } + } yield* Effect.forEach( files, Effect.fnUntraced(function* (match) { const name = match[1] + if (!name) return if (seen.has(name)) return seen.add(name) + + const slash = name.indexOf("/") + const alias = slash === -1 ? name : name.slice(0, slash) + const reference = yield* references.get(alias) + if (reference) { + const source = mentionSource(match) + if (reference.kind === "invalid") { + parts.push( + referenceTextPart({ reference, source, target: slash === -1 ? undefined : name.slice(slash + 1) }), + ) + return + } + + yield* references.ensure(reference.path) + if (slash === -1) { + parts.push(referenceTextPart({ reference, source })) + return + } + + const target = name.slice(slash + 1) + const targetPath = path.resolve(reference.path, target) + if (!AppFileSystem.contains(reference.path, targetPath)) { + parts.push( + referenceTextPart({ + reference, + source, + target, + targetPath, + problem: `Path escapes configured reference @${alias}: ${target}`, + }), + ) + return + } + + const info = yield* fsys.stat(targetPath).pipe(Effect.option) + if (Option.isNone(info)) { + parts.push( + referenceTextPart({ + reference, + source, + target, + targetPath, + problem: `Path does not exist inside configured reference @${alias}: ${target}`, + }), + ) + return + } + + parts.push({ + type: "file", + url: pathToFileURL(targetPath).href, + filename: name, + mime: info.value.type === "Directory" ? "application/x-directory" : "text/plain", + }) + return + } + const filepath = name.startsWith("~/") ? path.join(os.homedir(), name.slice(2)) : path.resolve(ctx.worktree, name) @@ -1326,6 +1471,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (part.type === "text") { if (part.synthetic) result.synthetic.push(part.text) else result.text.push(part.text) + const reference = referencePromptMetadata(part.metadata?.reference) + if (reference) { + result.references.push( + new ReferenceAttachment({ + name: reference.name, + kind: reference.kind, + uri: reference.path ? pathToFileURL(reference.path).href : undefined, + repository: reference.repository, + branch: reference.branch, + target: reference.target, + targetUri: reference.targetPath ? pathToFileURL(reference.targetPath).href : undefined, + problem: reference.problem, + source: new Source({ + start: reference.source.start, + end: reference.source.end, + text: reference.source.value, + }), + }), + ) + } } if (part.type === "file") { result.files.push( @@ -1363,6 +1528,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the text: [] as string[], files: [] as FileAttachment[], agents: [] as AgentAttachment[], + references: [] as ReferenceAttachment[], synthetic: [] as string[], }, ) @@ -1375,6 +1541,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the text: nextPrompt.text.join("\n"), files: nextPrompt.files, agents: nextPrompt.agents, + references: nextPrompt.references, }, }) } @@ -1817,6 +1984,7 @@ export const defaultLayer = Layer.suspend(() => Agent.defaultLayer, SystemPrompt.defaultLayer, LLM.defaultLayer, + Reference.defaultLayer, Bus.layer, CrossSpawnSpawner.defaultLayer, SyncEvent.defaultLayer, diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts index 80ecb1011..bbdf59c55 100644 --- a/packages/opencode/src/v2/session-message-updater.ts +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -123,6 +123,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve text: event.data.prompt.text, files: event.data.prompt.files, agents: event.data.prompt.agents, + references: event.data.prompt.references, time: { created: event.data.timestamp }, }), ) diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts index 024e28c45..62fc75fc8 100644 --- a/packages/opencode/src/v2/session-message.ts +++ b/packages/opencode/src/v2/session-message.ts @@ -34,6 +34,7 @@ export class User extends Schema.Class("Session.Message.User")({ text: Prompt.fields.text, files: Prompt.fields.files, agents: Prompt.fields.agents, + references: Prompt.fields.references, type: Schema.Literal("user"), time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, diff --git a/packages/opencode/src/v2/session-prompt.ts b/packages/opencode/src/v2/session-prompt.ts index 86d8e52eb..14167fc28 100644 --- a/packages/opencode/src/v2/session-prompt.ts +++ b/packages/opencode/src/v2/session-prompt.ts @@ -29,8 +29,21 @@ export class AgentAttachment extends Schema.Class("Prompt.Agent source: Source.pipe(Schema.optional), }) {} +export class ReferenceAttachment extends Schema.Class("Prompt.ReferenceAttachment")({ + name: Schema.String, + kind: Schema.Literals(["local", "git", "invalid"]), + uri: Schema.String.pipe(Schema.optional), + repository: Schema.String.pipe(Schema.optional), + branch: Schema.String.pipe(Schema.optional), + target: Schema.String.pipe(Schema.optional), + targetUri: Schema.String.pipe(Schema.optional), + problem: Schema.String.pipe(Schema.optional), + source: Source.pipe(Schema.optional), +}) {} + export class Prompt extends Schema.Class("Prompt")({ text: Schema.String, files: Schema.Array(FileAttachment).pipe(Schema.optional), agents: Schema.Array(AgentAttachment).pipe(Schema.optional), + references: Schema.Array(ReferenceAttachment).pipe(Schema.optional), }) {} diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index cb6f60503..df68fdfdc 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -141,16 +141,12 @@ test("scout agent allows repo cloning and repo cache reads", async () => { }) }) -test("reference config creates scout-backed subagents", async () => { +test("reference config does not create subagents", async () => { await withExperimentalScout(true, async () => { await using tmp = await tmpdir({ config: { reference: { effect: "github.com/effect/effect-smol", - effectDev: { - repository: "https://github.com/effect/effect-smol", - branch: "dev", - }, effectFull: { repository: "Effect-TS/effect", branch: "main", @@ -165,45 +161,13 @@ test("reference config creates scout-backed subagents", async () => { await WithInstance.provide({ directory: tmp.path, fn: async () => { - const effect = await load(tmp.path, (svc) => svc.get("effect")) - const effectDev = await load(tmp.path, (svc) => svc.get("effectDev")) - const effectFull = await load(tmp.path, (svc) => svc.get("effectFull")) - const local = await load(tmp.path, (svc) => svc.get("localdocs")) - const localFull = await load(tmp.path, (svc) => svc.get("localdocsFull")) - - expect(effect).toBeDefined() - expect(effect?.mode).toBe("subagent") - expect(effect?.prompt).toContain("Repository: github.com/effect/effect-smol") - expect(effect?.prompt).toContain( - `Cached directory: ${path.join(Global.Path.repos, "github.com", "effect", "effect-smol")}`, - ) - expect(effect?.prompt).toContain("Do not call repo_clone") - expect(evalPerm(effect, "repo_clone")).toBe("deny") - - expect(effectDev).toBeDefined() - expect(effectDev?.prompt).toContain("Problem: Reference conflicts with @effect") - expect(effectDev?.prompt).not.toContain("Cached directory:") - - expect(effectFull).toBeDefined() - expect(effectFull?.mode).toBe("subagent") - expect(effectFull?.prompt).toContain("Repository: Effect-TS/effect") - expect(effectFull?.prompt).toContain("Branch/ref: main") - expect(evalPerm(effectFull, "repo_clone")).toBe("deny") - - expect(local).toBeDefined() - expect(local?.mode).toBe("subagent") - expect(local?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../docs")}`) - expect( - Permission.evaluate( - "external_directory", - path.join(path.resolve(tmp.path, "../docs"), "README.md"), - local!.permission, - ).action, - ).toBe("allow") - - expect(localFull).toBeDefined() - expect(localFull?.mode).toBe("subagent") - expect(localFull?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../local-docs")}`) + const agents = await load(tmp.path, (svc) => svc.list()) + const names = agents.map((agent) => agent.name) + expect(names).toContain("scout") + expect(names).not.toContain("effect") + expect(names).not.toContain("effectFull") + expect(names).not.toContain("localdocs") + expect(names).not.toContain("localdocsFull") }, }) }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 42c9a81cd..cb771aee3 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -2,6 +2,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { FetchHttpClient } from "effect/unstable/http" import { expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" +import fs from "fs/promises" import path from "path" import { fileURLToPath } from "url" import { NamedError } from "@opencode-ai/core/util/error" @@ -203,6 +204,7 @@ function makeHttp() { SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), Layer.provide(Image.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(summary), Layer.provideMerge(run), Layer.provideMerge(compact), @@ -1791,6 +1793,101 @@ it.live("keeps stored part order stable when file resolution is async", () => ), ) +it.live("resolves configured reference mentions before workspace paths and agents", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const docs = path.join(dir, "external-docs") + yield* Effect.promise(() => fs.mkdir(path.join(docs, "guide"), { recursive: true })) + yield* Effect.promise(() => fs.mkdir(path.join(dir, "docs"), { recursive: true })) + yield* Effect.promise(() => Bun.write(path.join(docs, "README.md"), "reference readme")) + yield* Effect.promise(() => Bun.write(path.join(docs, "guide", "intro.md"), "reference intro")) + yield* Effect.promise(() => Bun.write(path.join(dir, "docs", "README.md"), "workspace readme")) + + const prompt = yield* SessionPrompt.Service + const parts = yield* prompt.resolvePromptParts( + "Use @docs and @docs/README.md and @docs/guide and @docs/missing.md and @docs/README.md and @build", + ) + const references = parts.filter( + (part): part is MessageV2.TextPartInput => + part.type === "text" && part.synthetic === true && part.text.startsWith("Referenced configured reference "), + ) + const files = parts.filter((part): part is MessageV2.FilePartInput => part.type === "file") + const agents = parts.filter((part): part is MessageV2.AgentPartInput => part.type === "agent") + const bare = references.find((part) => part.text.includes("@docs.")) + const missing = references.find((part) => part.text.includes("@docs/missing.md")) + const guide = files.find((part) => part.filename === "docs/guide") + + expect(references.length).toBe(2) + expect(bare?.metadata?.reference).toMatchObject({ + name: "docs", + kind: "local", + path: docs, + }) + expect(missing?.text).toContain("Path does not exist inside configured reference @docs") + expect(missing?.metadata?.reference).toMatchObject({ + target: "missing.md", + targetPath: path.join(docs, "missing.md"), + }) + + expect(files.length).toBe(2) + expect(files.map((file) => fileURLToPath(file.url)).sort()).toEqual( + [path.join(docs, "README.md"), path.join(docs, "guide")].sort(), + ) + expect(guide?.mime).toBe("application/x-directory") + expect(agents.map((agent) => agent.name)).toEqual(["build"]) + }), + { + git: true, + config: { + ...cfg, + reference: { + docs: "./external-docs", + }, + }, + }, + ), +) + +it.live("injects metadata for bare configured reference mentions", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const docs = path.join(dir, "external-docs") + yield* Effect.promise(() => fs.mkdir(docs, { recursive: true })) + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + const message = yield* prompt.prompt({ + sessionID: session.id, + noReply: true, + parts: yield* prompt.resolvePromptParts("Use @docs for context"), + }) + + const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const synthetic = stored.parts + .filter((part): part is MessageV2.TextPart => part.type === "text" && part.synthetic === true) + const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs.")) + + expect(reference?.metadata?.reference).toMatchObject({ name: "docs", kind: "local", path: docs }) + expect(synthetic.some((part) => part.text.includes(`Reference root: ${docs}`))).toBe(true) + expect(synthetic.some((part) => part.text.includes("subagent scout"))).toBe(true) + + yield* sessions.remove(session.id) + }), + { + git: true, + config: { + ...cfg, + reference: { + docs: "./external-docs", + }, + }, + }, + ), +) + // Special characters in filenames it.live("handles filenames with # character", () => diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 5c47df4c0..8640612e9 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -154,6 +154,7 @@ function makeHttp() { SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), Layer.provide(Image.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(run), Layer.provideMerge(compact), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5bba8efc0..da80645ad 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -771,6 +771,7 @@ export type Prompt = { text: string files?: Array agents?: Array + references?: Array } export type GlobalEvent = { @@ -2718,6 +2719,18 @@ export type PromptAgentAttachment = { source?: PromptSource } +export type PromptReferenceAttachment = { + name: string + kind: "local" | "git" | "invalid" + uri?: string + repository?: string + branch?: string + target?: string + targetUri?: string + problem?: string + source?: PromptSource +} + export type EventSessionNextPrompted = { id: string type: "session.next.prompted" @@ -3121,6 +3134,7 @@ export type SessionMessageUser = { text: string files?: Array agents?: Array + references?: Array type: "user" } From 3bd98ea05500c8b93b0270b9131cb53d94a635f5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 11 May 2026 07:28:32 +0000 Subject: [PATCH 003/188] chore: generate --- packages/opencode/test/session/prompt.test.ts | 5 +- packages/sdk/openapi.json | 47 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index cb771aee3..f5c167465 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1866,8 +1866,9 @@ it.live("injects metadata for bare configured reference mentions", () => }) const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id }) - const synthetic = stored.parts - .filter((part): part is MessageV2.TextPart => part.type === "text" && part.synthetic === true) + const synthetic = stored.parts.filter( + (part): part is MessageV2.TextPart => part.type === "text" && part.synthetic === true, + ) const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs.")) expect(reference?.metadata?.reference).toMatchObject({ name: "docs", kind: "local", path: docs }) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index fb29d68ed..df0427f45 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11120,6 +11120,12 @@ "items": { "$ref": "#/components/schemas/PromptAgentAttachment" } + }, + "references": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptReferenceAttachment" + } } }, "required": ["text"], @@ -17039,6 +17045,41 @@ "required": ["name"], "additionalProperties": false }, + "PromptReferenceAttachment": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": ["local", "git", "invalid"] + }, + "uri": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "target": { + "type": "string" + }, + "targetUri": { + "type": "string" + }, + "problem": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/PromptSource" + } + }, + "required": ["name", "kind"], + "additionalProperties": false + }, "EventSessionNextPrompted": { "type": "object", "properties": { @@ -18231,6 +18272,12 @@ "$ref": "#/components/schemas/PromptAgentAttachment" } }, + "references": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptReferenceAttachment" + } + }, "type": { "type": "string", "enum": ["user"] From 2d0d3d596ec06e0d83216ece48001f190cc4581b Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 11 May 2026 13:40:36 +0530 Subject: [PATCH 004/188] feat(compaction): serialize compaction tail (#26830) --- packages/opencode/src/config/config.ts | 4 +- packages/opencode/src/session/compaction.ts | 60 +++++++++++-------- packages/opencode/src/session/message-v2.ts | 42 +------------ .../opencode/test/session/compaction.test.ts | 50 +++++++++------- .../test/session/messages-pagination.test.ts | 16 ++--- 5 files changed, 77 insertions(+), 95 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 41ccac749..114a38803 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -273,10 +273,10 @@ export const Info = Schema.Struct({ }), tail_turns: Schema.optional(NonNegativeInt).annotate({ description: - "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)", + "Number of recent user turns, including their following assistant/tool responses, to serialize into the compaction summary (default: 2)", }), preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({ - description: "Maximum number of tokens from recent turns to preserve verbatim after compaction", + description: "Maximum number of tokens from recent turns to serialize into the compaction summary", }), reserved: Schema.optional(NonNegativeInt).annotate({ description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.", diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 4eafbdf74..3ca4f074f 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -79,12 +79,10 @@ Rules: type Turn = { start: number end: number - id: MessageID } type Tail = { start: number - id: MessageID } type CompletedCompaction = { @@ -121,19 +119,41 @@ function completedCompactions(messages: MessageV2.WithParts[]) { }) } -function buildPrompt(input: { previousSummary?: string; context: string[] }) { +function buildPrompt(input: { previousSummary?: string; context: string[]; tail?: string }) { + const source = input.tail + ? "the conversation history above and the serialized recent conversation tail below" + : "the conversation history above" const anchor = input.previousSummary ? [ - "Update the anchored summary below using the conversation history above.", + `Update the anchored summary below using ${source}.`, "Preserve still-true details, remove stale details, and merge in the new facts.", "", input.previousSummary, "", ].join("\n") - : "Create a new anchored summary from the conversation history above." - return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n") + : `Create a new anchored summary from ${source}.` + const tail = input.tail + ? [ + "Fold this serialized recent conversation tail into the summary; it is not provider message history.", + "", + input.tail, + "", + ].join("\n") + : undefined + return [anchor, ...(tail ? [tail] : []), SUMMARY_TEMPLATE, ...input.context].join("\n\n") } +const serialize = Effect.fn("SessionCompaction.serialize")(function* (input: { + messages: MessageV2.WithParts[] + model: Provider.Model +}) { + const messages = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, { + stripMedia: true, + toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, + }) + return messages.length ? JSON.stringify(messages, null, 2) : undefined +}) + function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) { return ( input.cfg.compaction?.preserve_recent_tokens ?? @@ -150,7 +170,6 @@ function turns(messages: MessageV2.WithParts[]) { result.push({ start: i, end: messages.length, - id: msg.info.id, }) } for (let i = 0; i < result.length - 1; i++) { @@ -177,7 +196,6 @@ function splitTurn(input: { if (size > input.budget) continue return { start, - id: input.messages[start]!.info.id, } satisfies Tail } return undefined @@ -244,8 +262,7 @@ export const layer: Layer.Layer< messages: MessageV2.WithParts[] model: Provider.Model }) { - const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model) - return Token.estimate(JSON.stringify(msgs)) + return Token.estimate((yield* serialize(input)) ?? "") }) const select = Effect.fn("SessionCompaction.select")(function* (input: { @@ -254,10 +271,10 @@ export const layer: Layer.Layer< model: Provider.Model }) { const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS - if (limit <= 0) return { head: input.messages, tail_start_id: undefined } + if (limit <= 0) return { head: input.messages, tail: [] } const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model }) const all = turns(input.messages) - if (!all.length) return { head: input.messages, tail_start_id: undefined } + if (!all.length) return { head: input.messages, tail: [] } const recent = all.slice(-limit) const sizes = yield* Effect.forEach( recent, @@ -276,7 +293,7 @@ export const layer: Layer.Layer< const size = sizes[i] if (total + size <= budget) { total += size - keep = { start: turn.start, id: turn.id } + keep = { start: turn.start } continue } const remaining = budget - total @@ -292,10 +309,10 @@ export const layer: Layer.Layer< break } - if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined } + if (!keep) return { head: input.messages, tail: [] } return { head: input.messages.slice(0, keep.start), - tail_start_id: keep.id, + tail: input.messages.slice(keep.start), } }) @@ -406,7 +423,10 @@ export const layer: Layer.Layer< { sessionID: input.sessionID }, { context: [], prompt: undefined }, ) - const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }) + const tailMessages = structuredClone(selected.tail) + yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: tailMessages }) + const tail = yield* serialize({ messages: tailMessages, model }) + const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context, tail }) const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { @@ -473,13 +493,6 @@ export const layer: Layer.Layer< return "stop" } - if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) { - yield* session.updatePart({ - ...compactionPart, - tail_start_id: selected.tail_start_id, - }) - } - if (result === "continue" && input.auto) { if (replay) { const original = replay.info @@ -575,7 +588,6 @@ export const layer: Layer.Layer< sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), text: summary ?? "", - include: selected.tail_start_id, }) } yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index a8c8dabc8..e3539021b 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -840,12 +840,13 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( return part.metadata?.anthropic?.signature != null }) for (const part of msg.parts) { + if (msg.info.summary && part.type !== "text") continue if (part.type === "text") { const text = part.text === "" && hasSignedReasoning ? " " : part.text assistantMessage.parts.push({ type: "text", text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + ...(differentModel || msg.info.summary ? {} : { providerMetadata: part.metadata }), }) } if (part.type === "step-start") @@ -1071,53 +1072,16 @@ export function get(input: { sessionID: SessionID; messageID: MessageID }): With export function filterCompacted(msgs: Iterable) { const result = [] as WithParts[] const completed = new Set() - let retain: MessageID | undefined for (const msg of msgs) { result.push(msg) - if (retain) { - if (msg.info.id === retain) break - continue - } if (msg.info.role === "user" && completed.has(msg.info.id)) { - const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction") - if (!part) continue - if (!part.tail_start_id) break - retain = part.tail_start_id - if (msg.info.id === retain) break + if (msg.parts.some((item): item is CompactionPart => item.type === "compaction")) break continue } - if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction")) - break if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error) completed.add(msg.info.parentID) } result.reverse() - const compactionIndex = result.findLastIndex( - (msg) => - msg.info.role === "user" && - msg.parts.some((item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined), - ) - const compaction = result[compactionIndex] - const part = compaction?.parts.find( - (item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined, - ) - const summaryIndex = compaction - ? result.findIndex( - (msg, index) => - index > compactionIndex && - msg.info.role === "assistant" && - msg.info.summary && - msg.info.parentID === compaction.info.id, - ) - : -1 - const tailIndex = part?.tail_start_id ? result.findIndex((msg) => msg.info.id === part.tail_start_id) : -1 - if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) { - return [ - ...result.slice(compactionIndex, summaryIndex + 1), - ...result.slice(tailIndex, compactionIndex), - ...result.slice(summaryIndex + 1), - ] - } return result } diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 8f987b4d1..13400d79c 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -926,12 +926,12 @@ describe("session.compaction.process", () => { ) itCompaction.instance( - "persists tail_start_id for retained recent turns", + "does not persist tail_start_id for serialized recent turns", Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) yield* createUserMessage(session.id, "first") - const keep = yield* createUserMessage(session.id, "second") + yield* createUserMessage(session.id, "second") yield* createUserMessage(session.id, "third") yield* createSummaryCompaction(session.id) @@ -947,18 +947,18 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) + expect(part?.tail_start_id).toBeUndefined() }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })), ) itCompaction.instance( - "shrinks retained tail to fit preserve token budget", + "does not persist tail_start_id when shrinking serialized tail", Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) yield* createUserMessage(session.id, "first") yield* createUserMessage(session.id, "x".repeat(2_000)) - const keep = yield* createUserMessage(session.id, "tiny") + yield* createUserMessage(session.id, "tiny") yield* createSummaryCompaction(session.id) const msgs = yield* ssn.messages({ sessionID: session.id }) @@ -973,7 +973,7 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) + expect(part?.tail_start_id).toBeUndefined() }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })), ) @@ -1005,7 +1005,7 @@ describe("session.compaction.process", () => { ) itCompaction.instance( - "falls back to full summary when retained tail media exceeds preserve token budget", + "serializes retained tail media as text in the summary input", () => { const stub = llm() let captured = "" @@ -1078,15 +1078,16 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) + expect(part?.tail_start_id).toBeUndefined() expect(captured).toContain("zzzz") - expect(captured).not.toContain("keep tail") + expect(captured).toContain("keep tail") const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id]) + expect(filtered.map((msg) => msg.info.id)).toEqual([parent!, expect.any(String)]) expect(filtered[1]?.info.role).toBe("assistant") expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) + expect(filtered.map((msg) => msg.info.id)).not.toContain(keep.id) }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) }, { git: true }, @@ -1353,13 +1354,13 @@ describe("session.compaction.process", () => { ) itCompaction.instance( - "summarizes only the head while keeping recent tail out of summary input", + "summarizes the head while serializing recent tail into summary input", () => { const stub = llm() - let captured = "" + let captured: LLM.StreamInput["messages"] = [] stub.push( reply("summary", (input) => { - captured = JSON.stringify(input.messages) + captured = input.messages }), ) return Effect.gen(function* () { @@ -1380,10 +1381,15 @@ describe("session.compaction.process", () => { auto: false, }) - expect(captured).toContain("older context") - expect(captured).not.toContain("keep this turn") - expect(captured).not.toContain("and this one too") - expect(captured).not.toContain("What did we do so far?") + const head = JSON.stringify(captured.slice(0, -1)) + const prompt = JSON.stringify(captured.at(-1)) + expect(head).toContain("older context") + expect(head).not.toContain("keep this turn") + expect(head).not.toContain("and this one too") + expect(prompt).toContain("keep this turn") + expect(prompt).toContain("and this one too") + expect(prompt).toContain("recent-conversation-tail") + expect(prompt).not.toContain("What did we do so far?") }).pipe(withCompaction({ llm: stub.layer })) }, { git: true }, @@ -1431,7 +1437,7 @@ describe("session.compaction.process", () => { { git: true }, ) - itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => { + itCompaction.instance("does not replay recent pre-compaction turns across repeated compactions", () => { const stub = llm() stub.push(reply("summary one")) stub.push(reply("summary two")) @@ -1462,8 +1468,8 @@ describe("session.compaction.process", () => { expect(ids).not.toContain(u1.id) expect(ids).not.toContain(u2.id) - expect(ids).toContain(u3.id) - expect(ids).toContain(u4.id) + expect(ids).not.toContain(u3.id) + expect(ids).not.toContain(u4.id) expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true) expect( filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), @@ -1472,7 +1478,7 @@ describe("session.compaction.process", () => { }) itCompaction.instance( - "ignores previous summaries when sizing the retained tail", + "ignores previous summaries when sizing the serialized tail", Effect.gen(function* () { const ssn = yield* SessionNs.Service const test = yield* TestInstance @@ -1511,7 +1517,7 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) + expect(part?.tail_start_id).toBeUndefined() }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })), ) }) diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 05ec2bad4..86e1d85d0 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -785,7 +785,7 @@ describe("MessageV2.filterCompacted", () => { }) }) - test("retains original tail when compaction stores tail_start_id", async () => { + test("ignores original tail when compaction stores tail_start_id", async () => { await WithInstance.provide({ directory: root, fn: async () => { @@ -834,14 +834,14 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3]) + expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) await svc.remove(session.id) }, }) }) - test("fork remaps compaction tail_start_id for filterCompacted", async () => { + test("fork keeps legacy tail_start_id without replaying the tail", async () => { await WithInstance.provide({ directory: root, fn: async () => { @@ -889,7 +889,7 @@ describe("MessageV2.filterCompacted", () => { }) const parentFiltered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3]) + expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) const forked = await svc.fork({ sessionID: session.id }) const childFiltered = MessageV2.filterCompacted(MessageV2.stream(forked.id)) @@ -899,7 +899,7 @@ describe("MessageV2.filterCompacted", () => { expect(tailPart?.type).toBe("compaction") if (!tailPart || tailPart.type !== "compaction") throw new Error("Expected forked compaction part") expect(tailPart.tail_start_id).toBeDefined() - expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(true) + expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(false) await svc.remove(forked.id) await svc.remove(session.id) @@ -907,7 +907,7 @@ describe("MessageV2.filterCompacted", () => { }) }) - test("retains an assistant tail when compaction starts inside a turn", async () => { + test("does not replay an assistant tail when compaction starts inside a turn", async () => { await WithInstance.provide({ directory: root, fn: async () => { @@ -964,7 +964,7 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result.map((item) => item.info.id)).toEqual([c1, s1, a3, u3, a4]) + expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a4]) await svc.remove(session.id) }, @@ -1041,7 +1041,7 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result.map((item) => item.info.id)).toEqual([c2, s2, u3, a3, u4, a4]) + expect(result.map((item) => item.info.id)).toEqual([c2, s2, u4, a4]) await svc.remove(session.id) }, From c933504d9c7074104d74ee1e15fa3b09d1afa84e Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Mon, 11 May 2026 17:21:59 +0800 Subject: [PATCH 005/188] fix(ui): better handle patch file support when rendering patch/edit tools (#26828) --- packages/ui/src/components/message-part.tsx | 46 +++++++++++++++------ packages/ui/src/components/session-diff.ts | 18 +++++--- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 7a7d5b15f..35598a170 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -264,6 +264,7 @@ function getDirectory(path: string | undefined) { } import type { IconProps } from "./icon" +import { normalize } from "./session-diff" export type ToolInfo = { icon: IconProps["name"] @@ -1878,6 +1879,31 @@ ToolRegistry.register({ const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "") const filename = () => getFilename(props.input.filePath ?? "") const pending = () => props.status === "pending" || props.status === "running" + + const fileCompProps = createMemo(() => { + try { + if (props.metadata?.filediff) { + const diff = normalize({ + ...props.metadata?.filediff, + status: "modified", + }) + const fileDiff = diff.fileDiff + if (fileDiff) return { fileDiff, hunkSeparators: fileDiff.isPartial ? "simple" : "line-info-basic" } + } + } catch {} + + return { + before: { + name: props.metadata?.filediff?.file || props.input.filePath, + contents: props.metadata?.filediff?.before || props.input.oldString || "", + }, + after: { + name: props.metadata?.filediff?.file || props.input.filePath, + contents: props.metadata?.filediff?.after || props.input.newString || "", + }, + } + }) + return (
- +
@@ -2111,7 +2126,12 @@ ToolRegistry.register({
- +
diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index 60dcffd83..52ef0a4bb 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -1,4 +1,4 @@ -import { parseDiffFromFile, type FileDiffMetadata } from "@pierre/diffs" +import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs" import { formatPatch, parsePatch, structuredPatch } from "diff" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" @@ -34,6 +34,8 @@ function patch(diff: ReviewDiff) { const afterLines: Array<{ text: string; newline: boolean }> = [] let previous: "-" | "+" | " " | undefined + const patchIsPartial = patch.hunks.every((h) => h.oldStart > 1) + for (const hunk of patch.hunks) { for (const line of hunk.lines) { if (line.startsWith("\\")) { @@ -67,9 +69,10 @@ function patch(diff: ReviewDiff) { before: beforeLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""), after: afterLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""), patch: diff.patch, + patchIsPartial, } } catch { - return { before: "", after: "", patch: diff.patch } + return { before: "", after: "", patch: diff.patch, patchIsPartial: false } } } return { @@ -86,27 +89,32 @@ function patch(diff: ReviewDiff) { { context: Number.MAX_SAFE_INTEGER }, ), ), + patchIsPartial: false, } } -function file(file: string, patch: string, before: string, after: string) { +function file(file: string, patch: string, before: string, after: string, partial = false) { const hit = cache.get(patch) if (hit) return hit - const value = parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after }) + let value: FileDiffMetadata | undefined + if (partial) value = parsePatchFiles(patch)[0]?.files[0] + if (value === undefined) value = parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after }) + cache.set(patch, value) return value } export function normalize(diff: ReviewDiff): ViewDiff { const next = patch(diff) + const fileDiff = file(diff.file, next.patch, next.before, next.after, next.patchIsPartial) return { file: diff.file, patch: next.patch, additions: diff.additions, deletions: diff.deletions, status: diff.status, - fileDiff: file(diff.file, next.patch, next.before, next.after), + fileDiff, } } From 8874d4a42ff13704ac50124ceda328eccc283253 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 11 May 2026 09:37:14 -0400 Subject: [PATCH 006/188] zen: deekseek v4 flash free --- packages/web/src/content/docs/ar/zen.mdx | 88 +++++++++++---------- packages/web/src/content/docs/bs/zen.mdx | 88 +++++++++++---------- packages/web/src/content/docs/da/zen.mdx | 88 +++++++++++---------- packages/web/src/content/docs/de/zen.mdx | 88 +++++++++++---------- packages/web/src/content/docs/es/zen.mdx | 88 +++++++++++---------- packages/web/src/content/docs/fr/zen.mdx | 88 +++++++++++---------- packages/web/src/content/docs/it/zen.mdx | 88 +++++++++++---------- packages/web/src/content/docs/nb/zen.mdx | 88 +++++++++++---------- packages/web/src/content/docs/pl/zen.mdx | 88 +++++++++++---------- packages/web/src/content/docs/pt-br/zen.mdx | 88 +++++++++++---------- packages/web/src/content/docs/ru/zen.mdx | 88 +++++++++++---------- packages/web/src/content/docs/th/zen.mdx | 88 +++++++++++---------- packages/web/src/content/docs/tr/zen.mdx | 88 +++++++++++---------- packages/web/src/content/docs/zen.mdx | 88 +++++++++++---------- packages/web/src/content/docs/zh-cn/zen.mdx | 86 ++++++++++---------- packages/web/src/content/docs/zh-tw/zen.mdx | 88 +++++++++++---------- 16 files changed, 735 insertions(+), 671 deletions(-) diff --git a/packages/web/src/content/docs/ar/zen.mdx b/packages/web/src/content/docs/ar/zen.mdx index 748384c21..4f5dd6b45 100644 --- a/packages/web/src/content/docs/ar/zen.mdx +++ b/packages/web/src/content/docs/ar/zen.mdx @@ -57,48 +57,49 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن يمكنك أيضا الوصول إلى نماذجنا عبر نقاط نهاية API التالية. -| النموذج | معرّف النموذج | نقطة النهاية | حزمة AI SDK | -| --------------------- | --------------------- | -------------------------------------------------- | --------------------------- | -| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | -| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| النموذج | معرّف النموذج | نقطة النهاية | حزمة AI SDK | +| ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | يستخدم [معرّف النموذج](/docs/config/#models) في إعدادات OpenCode الصيغة `opencode/`. على سبيل المثال، بالنسبة إلى GPT 5.5، ستستخدم `opencode/gpt-5.5` في إعداداتك. @@ -121,6 +122,7 @@ https://opencode.ai/zen/v1/models | النموذج | الإدخال | الإخراج | القراءة المخزنة | الكتابة المخزنة | | --------------------------------- | ------- | ------- | --------------- | --------------- | | Big Pickle | Free | Free | Free | - | +| DeepSeek V4 Flash Free | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | @@ -173,6 +175,7 @@ https://opencode.ai/zen/v1/models النماذج المجانية: +- DeepSeek V4 Flash Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - MiniMax M2.5 Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Ring 2.6 1T Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Nemotron 3 Super Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. @@ -225,6 +228,7 @@ https://opencode.ai/zen/v1/models تتم استضافة جميع نماذجنا في الولايات المتحدة. يلتزم مزوّدونا بسياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج، مع الاستثناءات التالية: - Big Pickle: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. +- DeepSeek V4 Flash Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - MiniMax M2.5 Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - Ring 2.6 1T Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - Nemotron 3 Super Free (نقاط نهاية NVIDIA المجانية): يُقدَّم بموجب [شروط خدمة النسخة التجريبية من واجهة NVIDIA API](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). للاستخدام التجريبي فقط، وليس للإنتاج أو البيانات الحساسة. تقوم NVIDIA بتسجيل المطالبات والمخرجات لتحسين نماذجها وخدماتها. لا ترسل بيانات شخصية أو سرية. diff --git a/packages/web/src/content/docs/bs/zen.mdx b/packages/web/src/content/docs/bs/zen.mdx index 22299b12c..ec2ab1e60 100644 --- a/packages/web/src/content/docs/bs/zen.mdx +++ b/packages/web/src/content/docs/bs/zen.mdx @@ -62,48 +62,49 @@ Naplata se vrši po zahtjevu i možete dodavati kredit na svoj račun. Našim modelima možete pristupiti i preko sljedećih API endpointa. -| Model | Model ID | Endpoint | AI SDK Package | -| --------------------- | --------------------- | -------------------------------------------------- | --------------------------- | -| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | -| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Model | Model ID | Endpoint | AI SDK Package | +| ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) u vašoj OpenCode konfiguraciji koristi format `opencode/`. Na primjer, za GPT 5.5 u konfiguraciji biste @@ -128,6 +129,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**. | Model | Input | Output | Cached Read | Cached Write | | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | +| DeepSeek V4 Flash Free | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | @@ -180,6 +182,7 @@ Naknade za kreditne kartice prosljeđujemo po stvarnom trošku (4.4% + $0.30 po Besplatni modeli: +- DeepSeek V4 Flash Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. - MiniMax M2.5 Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. - Ring 2.6 1T Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. - Nemotron 3 Super Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. @@ -237,6 +240,7 @@ Svi naši modeli su hostovani u US. Naši provajderi prate zero-retention politi i ne koriste vaše podatke za treniranje modela, uz sljedeće izuzetke: - Big Pickle: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. +- DeepSeek V4 Flash Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - MiniMax M2.5 Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - Ring 2.6 1T Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - Nemotron 3 Super Free (besplatni NVIDIA endpointi): Dostupan je prema [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Samo za probnu upotrebu, nije za produkciju niti osjetljive podatke. NVIDIA bilježi promptove i izlaze radi poboljšanja svojih modela i usluga. Nemojte slati lične ili povjerljive podatke. diff --git a/packages/web/src/content/docs/da/zen.mdx b/packages/web/src/content/docs/da/zen.mdx index 0fb2c9737..dec96852e 100644 --- a/packages/web/src/content/docs/da/zen.mdx +++ b/packages/web/src/content/docs/da/zen.mdx @@ -62,48 +62,49 @@ Du bliver opkrævet pr. anmodning, og du kan tilføje kredit til din konto. Du kan også få adgang til vores modeller gennem følgende API-endpoints. -| Model | Model ID | Endpoint | AI SDK-pakke | -| --------------------- | --------------------- | -------------------------------------------------- | --------------------------- | -| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | -| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Model | Model ID | Endpoint | AI SDK-pakke | +| ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) i din OpenCode-konfiguration bruger formatet `opencode/`. For eksempel ville du for GPT 5.5 @@ -128,6 +129,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**. | Model | Input | Output | Cached Read | Cached Write | | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | +| DeepSeek V4 Flash Free | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | @@ -180,6 +182,7 @@ Kreditkortgebyrer videregives til kostpris (4.4% + $0.30 pr. transaktion); vi op De gratis modeller: +- DeepSeek V4 Flash Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. - MiniMax M2.5 Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. - Ring 2.6 1T Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. - Nemotron 3 Super Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. @@ -235,6 +238,7 @@ at opkræve dig mere end $20, hvis din saldo kommer under $5. Alle vores modeller hostes i US. Vores udbydere følger en nul-opbevaringspolitik og bruger ikke dine data til modeltræning, med følgende undtagelser: - Big Pickle: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. +- DeepSeek V4 Flash Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - MiniMax M2.5 Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - Ring 2.6 1T Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - Nemotron 3 Super Free (gratis NVIDIA-endpoints): Leveres under [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Kun til prøvebrug, ikke til produktion eller følsomme data. Prompts og outputs logges af NVIDIA for at forbedre deres modeller og tjenester. Indsend ikke personlige eller fortrolige data. diff --git a/packages/web/src/content/docs/de/zen.mdx b/packages/web/src/content/docs/de/zen.mdx index 425d9b651..297481ee6 100644 --- a/packages/web/src/content/docs/de/zen.mdx +++ b/packages/web/src/content/docs/de/zen.mdx @@ -53,48 +53,49 @@ Dir wird pro Anfrage berechnet, und du kannst deinem Konto Guthaben hinzufügen. Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen. -| Model | Model ID | Endpoint | AI SDK Package | -| --------------------- | --------------------- | -------------------------------------------------- | --------------------------- | -| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | -| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Model | Model ID | Endpoint | AI SDK Package | +| ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | Die [Model-ID](/docs/config/#models) in deiner OpenCode-Konfiguration verwendet das Format `opencode/`. Für GPT 5.5 würdest du zum Beispiel `opencode/gpt-5.5` in deiner Konfiguration verwenden. @@ -117,6 +118,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M | Model | Input | Output | Cached Read | Cached Write | | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | +| DeepSeek V4 Flash Free | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | @@ -169,6 +171,7 @@ Kreditkartengebühren werden zum Selbstkostenpreis weitergegeben (4.4% + $0.30 p Die kostenlosen Modelle: +- DeepSeek V4 Flash Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. - MiniMax M2.5 Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. - Ring 2.6 1T Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. - Nemotron 3 Super Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. @@ -221,6 +224,7 @@ Angenommen, du setzt ein monatliches Nutzungslimit von $20, dann wird Zen in ein Alle unsere Modelle werden in den USA gehostet. Unsere Provider folgen einer Zero-Retention-Richtlinie und verwenden deine Daten nicht zum Trainieren von Modellen, mit den folgenden Ausnahmen: - Big Pickle: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. +- DeepSeek V4 Flash Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - MiniMax M2.5 Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - Ring 2.6 1T Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - Nemotron 3 Super Free (kostenlose NVIDIA-Endpunkte): Bereitgestellt gemäß den [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Nur für Testzwecke, nicht für Produktion oder sensible Daten. Eingaben und Ausgaben werden von NVIDIA protokolliert, um seine Modelle und Dienste zu verbessern. Übermitteln Sie keine personenbezogenen oder vertraulichen Daten. diff --git a/packages/web/src/content/docs/es/zen.mdx b/packages/web/src/content/docs/es/zen.mdx index 8e0e51293..9d4a88c24 100644 --- a/packages/web/src/content/docs/es/zen.mdx +++ b/packages/web/src/content/docs/es/zen.mdx @@ -62,48 +62,49 @@ Se te cobra por solicitud y puedes agregar créditos a tu cuenta. También puedes acceder a nuestros modelos a través de los siguientes endpoints de API. -| Modelo | Model ID | Endpoint | AI SDK Package | -| --------------------- | --------------------- | -------------------------------------------------- | --------------------------- | -| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | -| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Modelo | Model ID | Endpoint | AI SDK Package | +| ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | El [identificador del modelo](/docs/config/#models) en tu configuración de OpenCode usa el formato `opencode/`. Por ejemplo, para GPT 5.5, usarías @@ -128,6 +129,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p | Modelo | Entrada | Salida | Lectura en caché | Escritura en caché | | --------------------------------- | ------- | ------- | ---------------- | ------------------ | | Big Pickle | Free | Free | Free | - | +| DeepSeek V4 Flash Free | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | @@ -180,6 +182,7 @@ Las comisiones de tarjeta de crédito se trasladan al costo (4.4% + $0.30 por tr Los modelos gratuitos: +- DeepSeek V4 Flash Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. - MiniMax M2.5 Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. - Ring 2.6 1T Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. - Nemotron 3 Super Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. @@ -235,6 +238,7 @@ cobrándote más de $20 si tu saldo baja de $5. Todos nuestros modelos están alojados en US. Nuestros proveedores siguen una política de zero-retention y no usan tus datos para el entrenamiento de modelos, con las siguientes excepciones: - Big Pickle: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. +- DeepSeek V4 Flash Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - MiniMax M2.5 Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - Ring 2.6 1T Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - Nemotron 3 Super Free (endpoints gratuitos de NVIDIA): Se ofrece bajo los [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Solo para uso de prueba, no para producción ni datos sensibles. NVIDIA registra los prompts y las salidas para mejorar sus modelos y servicios. No envíes datos personales ni confidenciales. diff --git a/packages/web/src/content/docs/fr/zen.mdx b/packages/web/src/content/docs/fr/zen.mdx index 0d07ee314..8b9959753 100644 --- a/packages/web/src/content/docs/fr/zen.mdx +++ b/packages/web/src/content/docs/fr/zen.mdx @@ -53,48 +53,49 @@ La facturation se fait à la requête et vous pouvez ajouter des crédits à vot Vous pouvez également accéder à nos modèles via les points de terminaison API suivants. -| Modèle | ID du modèle | Point de terminaison | Package AI SDK | -| --------------------- | --------------------- | -------------------------------------------------- | --------------------------- | -| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | -| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Modèle | ID du modèle | Point de terminaison | Package AI SDK | +| ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | Le [model id](/docs/config/#models) dans votre configuration OpenCode utilise le format `opencode/`. Par exemple, pour GPT 5.5, vous utiliseriez `opencode/gpt-5.5` dans votre configuration. @@ -117,6 +118,7 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c | Modèle | Input | Output | Cached Read | Cached Write | | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | +| DeepSeek V4 Flash Free | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | @@ -169,6 +171,7 @@ Les frais de carte de crédit sont répercutés au prix coûtant (4.4% + $0.30 p Les modèles gratuits : +- DeepSeek V4 Flash Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. - MiniMax M2.5 Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. - Ring 2.6 1T Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. - Nemotron 3 Super Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. @@ -221,6 +224,7 @@ Par exemple, si vous définissez une limite d'utilisation mensuelle à $20, Zen Tous nos modèles sont hébergés aux US. Nos fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles, avec les exceptions suivantes : - Big Pickle : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. +- DeepSeek V4 Flash Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - MiniMax M2.5 Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - Ring 2.6 1T Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - Nemotron 3 Super Free (endpoints NVIDIA gratuits) : Fourni dans le cadre des [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Réservé à un usage d'essai, pas à la production ni aux données sensibles. Les prompts et les sorties sont journalisés par NVIDIA pour améliorer ses modèles et services. N'envoyez pas de données personnelles ou confidentielles. diff --git a/packages/web/src/content/docs/it/zen.mdx b/packages/web/src/content/docs/it/zen.mdx index df24babb2..83adc90b4 100644 --- a/packages/web/src/content/docs/it/zen.mdx +++ b/packages/web/src/content/docs/it/zen.mdx @@ -62,48 +62,49 @@ Ti viene addebitato ogni richiesta e puoi aggiungere credito al tuo account. Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API. -| Modello | Model ID | Endpoint | Pacchetto AI SDK | -| --------------------- | --------------------- | -------------------------------------------------- | --------------------------- | -| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | -| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Modello | Model ID | Endpoint | Pacchetto AI SDK | +| ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | Il [model id](/docs/config/#models) nella config di OpenCode usa il formato `opencode/`. Per esempio, per GPT 5.5, useresti @@ -128,6 +129,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**. | Modello | Input | Output | Cached Read | Cached Write | | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | +| DeepSeek V4 Flash Free | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | @@ -180,6 +182,7 @@ Le commissioni della carta di credito vengono trasferite al costo (4.4% + $0.30 I modelli gratuiti: +- DeepSeek V4 Flash Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. - MiniMax M2.5 Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. - Ring 2.6 1T Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. - Nemotron 3 Super Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. @@ -235,6 +238,7 @@ per addebitarti più di $20 se il tuo saldo scende sotto $5. Tutti i nostri modelli sono ospitati negli US. I nostri provider seguono una policy di zero-retention e non usano i tuoi dati per l'addestramento dei modelli, con le seguenti eccezioni: - Big Pickle: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. +- DeepSeek V4 Flash Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - MiniMax M2.5 Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - Ring 2.6 1T Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - Nemotron 3 Super Free (endpoint NVIDIA gratuiti): fornito secondo i [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Solo per uso di prova, non per produzione o dati sensibili. NVIDIA registra prompt e output per migliorare i propri modelli e servizi. Non inviare dati personali o riservati. diff --git a/packages/web/src/content/docs/nb/zen.mdx b/packages/web/src/content/docs/nb/zen.mdx index c5726f4b4..3f7faa80d 100644 --- a/packages/web/src/content/docs/nb/zen.mdx +++ b/packages/web/src/content/docs/nb/zen.mdx @@ -62,48 +62,49 @@ Du blir belastet per forespørsel, og du kan legge til kreditt på kontoen din. Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter. -| Modell | Modell-ID | Endepunkt | AI SDK-pakke | -| --------------------- | --------------------- | -------------------------------------------------- | --------------------------- | -| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | -| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Modell | Modell-ID | Endepunkt | AI SDK-pakke | +| ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [modell-id](/docs/config/#models) i OpenCode-konfigurasjonen din bruker formatet `opencode/`. For eksempel, for GPT 5.5, ville du @@ -128,6 +129,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**. | Modell | Inndata | Utdata | Bufret lesing | Bufret skriving | | --------------------------------- | ------- | ------- | ------------- | --------------- | | Big Pickle | Free | Free | Free | - | +| DeepSeek V4 Flash Free | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | @@ -180,6 +182,7 @@ Kredittkortgebyrer videreføres til kostpris (4.4% + $0.30 per transaction); vi Gratis-modellene: +- DeepSeek V4 Flash Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. - MiniMax M2.5 Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. - Ring 2.6 1T Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. - Nemotron 3 Super Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. @@ -235,6 +238,7 @@ med å belaste deg mer enn $20 hvis saldoen din går under $5. Alle modellene våre hostes i US. Leverandørene våre følger en policy for zero-retention og bruker ikke dataene dine til modelltrening, med følgende unntak: - Big Pickle: I gratisperioden kan innsamlede data brukes til å forbedre modellen. +- DeepSeek V4 Flash Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - MiniMax M2.5 Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - Ring 2.6 1T Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - Nemotron 3 Super Free (gratis NVIDIA-endepunkter): Leveres under [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Kun for prøvebruk, ikke for produksjon eller sensitive data. Prompter og svar logges av NVIDIA for å forbedre modellene og tjenestene deres. Ikke send inn personopplysninger eller konfidensielle data. diff --git a/packages/web/src/content/docs/pl/zen.mdx b/packages/web/src/content/docs/pl/zen.mdx index f22d10b3a..e989ac49d 100644 --- a/packages/web/src/content/docs/pl/zen.mdx +++ b/packages/web/src/content/docs/pl/zen.mdx @@ -62,48 +62,49 @@ Płatność jest naliczana za każde żądanie i możesz doładować swoje konto Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API. -| Model | ID modelu | Endpoint | Pakiet AI SDK | -| --------------------- | --------------------- | -------------------------------------------------- | --------------------------- | -| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | -| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Model | ID modelu | Endpoint | Pakiet AI SDK | +| ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [ID modelu](/docs/config/#models) w Twojej konfiguracji OpenCode używa formatu `opencode/`. Na przykład dla GPT 5.5 użyjesz w konfiguracji @@ -128,6 +129,7 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów* | Model | Wejście | Wyjście | Odczyt z cache | Zapis do cache | | --------------------------------- | ------- | ------- | -------------- | -------------- | | Big Pickle | Free | Free | Free | - | +| DeepSeek V4 Flash Free | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | @@ -181,6 +183,7 @@ Opłaty za karty kredytowe są przenoszone po kosztach (4.4% + $0.30 per transac Darmowe modele: +- DeepSeek V4 Flash Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. - MiniMax M2.5 Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. - Ring 2.6 1T Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. - Nemotron 3 Super Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. @@ -236,6 +239,7 @@ ostatecznie obciążyć Cię kwotą wyższą niż $20, jeśli Twoje saldo spadni Wszystkie nasze modele są hostowane w US. Nasi dostawcy stosują politykę zero-retention i nie wykorzystują Twoich danych do trenowania modeli, z następującymi wyjątkami: - Big Pickle: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. +- DeepSeek V4 Flash Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - MiniMax M2.5 Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - Ring 2.6 1T Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - Nemotron 3 Super Free (darmowe endpointy NVIDIA): Udostępniany zgodnie z [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Tylko do użytku próbnego, nie do produkcji ani danych wrażliwych. NVIDIA rejestruje prompty i odpowiedzi, aby ulepszać swoje modele i usługi. Nie przesyłaj danych osobowych ani poufnych. diff --git a/packages/web/src/content/docs/pt-br/zen.mdx b/packages/web/src/content/docs/pt-br/zen.mdx index 55678781a..00cc3a3a4 100644 --- a/packages/web/src/content/docs/pt-br/zen.mdx +++ b/packages/web/src/content/docs/pt-br/zen.mdx @@ -53,48 +53,49 @@ Você é cobrado por solicitação e pode adicionar créditos à sua conta. Você também pode acessar nossos modelos pelos seguintes endpoints de API. -| Modelo | ID do modelo | Endpoint | Pacote AI SDK | -| --------------------- | --------------------- | -------------------------------------------------- | --------------------------- | -| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | -| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Modelo | ID do modelo | Endpoint | Pacote AI SDK | +| ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | O [model id](/docs/config/#models) na sua configuração do OpenCode usa o formato `opencode/`. Por exemplo, para GPT 5.5, você usaria `opencode/gpt-5.5` na sua configuração. @@ -117,6 +118,7 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**. | Modelo | Entrada | Saída | Leitura em cache | Escrita em cache | | --------------------------------- | ------- | ------- | ---------------- | ---------------- | | Big Pickle | Free | Free | Free | - | +| DeepSeek V4 Flash Free | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | @@ -169,6 +171,7 @@ As taxas de cartão de crédito são repassadas a preço de custo (4.4% + $0.30 Os modelos gratuitos: +- DeepSeek V4 Flash Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. - MiniMax M2.5 Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. - Ring 2.6 1T Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. - Nemotron 3 Super Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. @@ -221,6 +224,7 @@ Por exemplo, digamos que você defina um limite mensal de uso de $20; o Zen não Todos os nossos modelos são hospedados nos US. Nossos provedores seguem uma política de zero-retention e não usam seus dados para treinamento de modelos, com as seguintes exceções: - Big Pickle: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. +- DeepSeek V4 Flash Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - MiniMax M2.5 Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - Ring 2.6 1T Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - Nemotron 3 Super Free (endpoints gratuitos da NVIDIA): Fornecido sob os [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Apenas para uso de avaliação, não para produção nem dados sensíveis. A NVIDIA registra prompts e saídas para melhorar seus modelos e serviços. Não envie dados pessoais ou confidenciais. diff --git a/packages/web/src/content/docs/ru/zen.mdx b/packages/web/src/content/docs/ru/zen.mdx index c685d2dac..5a3485f98 100644 --- a/packages/web/src/content/docs/ru/zen.mdx +++ b/packages/web/src/content/docs/ru/zen.mdx @@ -62,48 +62,49 @@ OpenCode Zen работает как любой другой провайдер Вы также можете получить доступ к нашим моделям через следующие конечные точки API. -| Модель | Идентификатор модели | Конечная точка | Пакет AI SDK | -| --------------------- | --------------------- | -------------------------------------------------- | --------------------------- | -| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | -| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Модель | Идентификатор модели | Конечная точка | Пакет AI SDK | +| ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [идентификатор модели](/docs/config/#models) в вашей конфигурации OpenCode использует формат `opencode/`. Например, для GPT 5.5 вам нужно @@ -128,6 +129,7 @@ https://opencode.ai/zen/v1/models | Модель | Вход | Выход | Cached Read | Cached Write | | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | +| DeepSeek V4 Flash Free | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | @@ -180,6 +182,7 @@ https://opencode.ai/zen/v1/models Бесплатные модели: +- DeepSeek V4 Flash Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - MiniMax M2.5 Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Ring 2.6 1T Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Nemotron 3 Super Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. @@ -235,6 +238,7 @@ https://opencode.ai/zen/v1/models Все наши модели размещены в US. Наши провайдеры придерживаются политики нулевого хранения и не используют ваши данные для обучения моделей, за следующими исключениями: - Big Pickle: во время бесплатного периода собранные данные могут использоваться для улучшения модели. +- DeepSeek V4 Flash Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - MiniMax M2.5 Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - Ring 2.6 1T Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - Nemotron 3 Super Free (бесплатные эндпоинты NVIDIA): предоставляется в соответствии с [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Только для пробного использования, не для продакшена и не для чувствительных данных. NVIDIA логирует запросы и ответы, чтобы улучшать свои модели и сервисы. Не отправляйте персональные или конфиденциальные данные. diff --git a/packages/web/src/content/docs/th/zen.mdx b/packages/web/src/content/docs/th/zen.mdx index 68139d962..6006baada 100644 --- a/packages/web/src/content/docs/th/zen.mdx +++ b/packages/web/src/content/docs/th/zen.mdx @@ -55,48 +55,49 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน คุณยังสามารถเข้าถึงโมเดลของเราผ่าน API endpoints ต่อไปนี้ได้ -| Model | Model ID | Endpoint | AI SDK Package | -| --------------------- | --------------------- | -------------------------------------------------- | --------------------------- | -| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | -| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Model | Model ID | Endpoint | AI SDK Package | +| ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) ใน OpenCode config ของคุณใช้รูปแบบ `opencode/` ตัวอย่างเช่น สำหรับ GPT 5.5 คุณจะใช้ `opencode/gpt-5.5` ใน config ของคุณ @@ -119,6 +120,7 @@ https://opencode.ai/zen/v1/models | Model | Input | Output | Cached Read | Cached Write | | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | +| DeepSeek V4 Flash Free | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | @@ -171,6 +173,7 @@ https://opencode.ai/zen/v1/models โมเดลฟรี: +- DeepSeek V4 Flash Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - MiniMax M2.5 Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Ring 2.6 1T Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Nemotron 3 Super Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล @@ -223,6 +226,7 @@ https://opencode.ai/zen/v1/models โมเดลทั้งหมดของเราโฮสต์อยู่ใน US provider ของเราปฏิบัติตามนโยบาย zero-retention และจะไม่ใช้ข้อมูลของคุณเพื่อฝึกโมเดล ยกเว้นกรณีต่อไปนี้: - Big Pickle: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล +- DeepSeek V4 Flash Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - MiniMax M2.5 Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - Ring 2.6 1T Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - Nemotron 3 Super Free (endpoint ฟรีของ NVIDIA): ให้บริการภายใต้ [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) ใช้สำหรับการทดลองเท่านั้น ไม่เหมาะสำหรับ production หรือข้อมูลที่อ่อนไหว NVIDIA จะบันทึก prompt และ output เพื่อนำไปปรับปรุงโมเดลและบริการของตน โปรดอย่าส่งข้อมูลส่วนบุคคลหรือข้อมูลลับ. diff --git a/packages/web/src/content/docs/tr/zen.mdx b/packages/web/src/content/docs/tr/zen.mdx index 5d4ed76ad..86cdd8489 100644 --- a/packages/web/src/content/docs/tr/zen.mdx +++ b/packages/web/src/content/docs/tr/zen.mdx @@ -53,48 +53,49 @@ OpenCode Zen, OpenCode'daki diğer sağlayıcılar gibi çalışır. Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsiniz. -| Model | Model ID | Endpoint | AI SDK Package | -| --------------------- | --------------------- | -------------------------------------------------- | --------------------------- | -| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | -| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Model | Model ID | Endpoint | AI SDK Package | +| ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode yapılandırmanızdaki [model id](/docs/config/#models) `opencode/` biçimini kullanır. Örneğin, GPT 5.5 için yapılandırmanızda `opencode/gpt-5.5` kullanırsınız. @@ -117,6 +118,7 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya | Model | Input | Output | Cached Read | Cached Write | | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | +| DeepSeek V4 Flash Free | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | @@ -169,6 +171,7 @@ Kredi kartı ücretleri maliyet üzerinden yansıtılır (%4.4 + işlem başına Ücretsiz modeller: +- DeepSeek V4 Flash Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. - MiniMax M2.5 Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. - Ring 2.6 1T Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. - Nemotron 3 Super Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. @@ -221,6 +224,7 @@ Ayrıca tüm çalışma alanı için ve ekibinizdeki her üye için aylık kulla Tüm modellerimiz US'de barındırılıyor. Sağlayıcılarımız zero-retention politikası uygular ve aşağıdaki istisnalar dışında verilerinizi model eğitimi için kullanmaz: - Big Pickle: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. +- DeepSeek V4 Flash Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - MiniMax M2.5 Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - Ring 2.6 1T Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - Nemotron 3 Super Free (ücretsiz NVIDIA uç noktaları): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) kapsamında sunulur. Yalnızca deneme amaçlıdır; üretim veya hassas veriler için uygun değildir. NVIDIA, modellerini ve hizmetlerini geliştirmek için promptları ve çıktıları kaydeder. Kişisel veya gizli veri göndermeyin. diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index 2d180b30b..363bc6cc8 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -62,48 +62,49 @@ You are charged per request and you can add credits to your account. You can also access our models through the following API endpoints. -| Model | Model ID | Endpoint | AI SDK Package | -| --------------------- | --------------------- | -------------------------------------------------- | --------------------------- | -| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | -| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Model | Model ID | Endpoint | AI SDK Package | +| ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | The [model id](/docs/config/#models) in your OpenCode config uses the format `opencode/`. For example, for GPT 5.5, you would @@ -128,6 +129,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | Model | Input | Output | Cached Read | Cached Write | | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | +| DeepSeek V4 Flash Free | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | @@ -180,6 +182,7 @@ Credit card fees are passed along at cost (4.4% + $0.30 per transaction); we don The free models: +- DeepSeek V4 Flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - MiniMax M2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Ring 2.6 1T Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Nemotron 3 Super Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. @@ -235,6 +238,7 @@ charging you more than $20 if your balance goes below $5. All our models are hosted in the US. Our providers follow a zero-retention policy and do not use your data for model training, with the following exceptions: - Big Pickle: During its free period, collected data may be used to improve the model. +- DeepSeek V4 Flash Free: During its free period, collected data may be used to improve the model. - MiniMax M2.5 Free: During its free period, collected data may be used to improve the model. - Ring 2.6 1T Free: During its free period, collected data may be used to improve the model. - Nemotron 3 Super Free (NVIDIA free endpoints): Provided under the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Trial use only — not for production or sensitive data. Prompts and outputs are logged by NVIDIA to improve its models and services. Do not submit personal or confidential data. diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index e81e51a67..7fa82de43 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -54,47 +54,48 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。 你也可以通过以下 API 端点访问我们的模型。 | 模型 | 模型 ID | 端点 | AI SDK 包 | -| --------------------- | --------------------- | -------------------------------------------------- | --------------------------- | -| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | -| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | 在你的 OpenCode 配置中,[模型 ID](/docs/config/#models) 使用 `opencode/` 格式。例如,对于 GPT 5.5,你需要在配置中使用 `opencode/gpt-5.5`。 @@ -117,6 +118,7 @@ https://opencode.ai/zen/v1/models | 模型 | 输入 | 输出 | 缓存读取 | 缓存写入 | | --------------------------------- | ------ | ------- | -------- | -------- | | Big Pickle | Free | Free | Free | - | +| DeepSeek V4 Flash Free | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | @@ -169,6 +171,7 @@ https://opencode.ai/zen/v1/models 免费模型: +- DeepSeek V4 Flash Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - MiniMax M2.5 Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Ring 2.6 1T Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Nemotron 3 Super Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 @@ -221,6 +224,7 @@ https://opencode.ai/zen/v1/models 我们所有模型都托管在 US。我们的提供商遵循零保留政策,不会将你的数据用于模型训练,但以下情况除外: - Big Pickle:在免费期间,收集的数据可能会被用于改进模型。 +- DeepSeek V4 Flash Free:在免费期间,收集的数据可能会被用于改进模型。 - MiniMax M2.5 Free:在免费期间,收集的数据可能会被用于改进模型。 - Ring 2.6 1T Free:在免费期间,收集的数据可能会被用于改进模型。 - Nemotron 3 Super Free(NVIDIA 免费端点):根据 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) 提供。仅供试用,不适用于生产环境或敏感数据。NVIDIA 会记录提示词和输出内容,以改进其模型和服务。请勿提交个人或机密数据。 diff --git a/packages/web/src/content/docs/zh-tw/zen.mdx b/packages/web/src/content/docs/zh-tw/zen.mdx index 163454301..7a6e2315f 100644 --- a/packages/web/src/content/docs/zh-tw/zen.mdx +++ b/packages/web/src/content/docs/zh-tw/zen.mdx @@ -57,48 +57,49 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。 你也可以透過以下 API 端點存取我們的模型。 -| 模型 | Model ID | 端點 | AI SDK Package | -| --------------------- | --------------------- | -------------------------------------------------- | --------------------------- | -| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | -| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| 模型 | Model ID | 端點 | AI SDK Package | +| ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode 設定中的 [模型 ID](/docs/config/#models) 會使用 `opencode/` 格式。例如,如果是 GPT 5.5,你會在設定中使用 `opencode/gpt-5.5`。 @@ -122,6 +123,7 @@ https://opencode.ai/zen/v1/models | 模型 | 輸入 | 輸出 | 快取讀取 | 快取寫入 | | --------------------------------- | ------ | ------- | -------- | -------- | | Big Pickle | Free | Free | Free | - | +| DeepSeek V4 Flash Free | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | @@ -175,6 +177,7 @@ https://opencode.ai/zen/v1/models 免費模型: +- DeepSeek V4 Flash Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - MiniMax M2.5 Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - Ring 2.6 1T Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - Nemotron 3 Super Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 @@ -228,6 +231,7 @@ https://opencode.ai/zen/v1/models 我們所有模型都託管於美國。我們的供應商遵循零保留政策,且不會將你的資料用於模型訓練,但以下情況除外: - Big Pickle: 在免費期間,收集到的資料可能會用於改進模型。 +- DeepSeek V4 Flash Free: 在免費期間,收集到的資料可能會用於改進模型。 - MiniMax M2.5 Free: 在免費期間,收集到的資料可能會用於改進模型。 - Ring 2.6 1T Free: 在免費期間,收集到的資料可能會用於改進模型。 - Nemotron 3 Super Free(NVIDIA 免費端點):依據 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) 提供。僅供試用,不適用於正式環境或敏感資料。NVIDIA 會記錄提示詞與輸出內容,以改進其模型與服務。請勿提交個人或機密資料。 From e93b1f3a7f52fa00da3a2139ff1d21bff1723486 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 11 May 2026 13:38:44 +0000 Subject: [PATCH 007/188] chore: generate --- packages/web/src/content/docs/zh-cn/zen.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index 7fa82de43..442bde20e 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -53,7 +53,7 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。 你也可以通过以下 API 端点访问我们的模型。 -| 模型 | 模型 ID | 端点 | AI SDK 包 | +| 模型 | 模型 ID | 端点 | AI SDK 包 | | ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | From 1eff01b6a584fa095dd9063377307f5d73eb8553 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 11 May 2026 09:43:10 -0400 Subject: [PATCH 008/188] sync --- packages/web/src/content/docs/ja/zen.mdx | 88 +++++++++++++----------- packages/web/src/content/docs/ko/zen.mdx | 88 +++++++++++++----------- 2 files changed, 92 insertions(+), 84 deletions(-) diff --git a/packages/web/src/content/docs/ja/zen.mdx b/packages/web/src/content/docs/ja/zen.mdx index 05d6c8b81..e45c162a3 100644 --- a/packages/web/src/content/docs/ja/zen.mdx +++ b/packages/web/src/content/docs/ja/zen.mdx @@ -53,48 +53,49 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動 以下の API エンドポイントを通じて、私たちのモデルにアクセスすることもできます。 -| Model | Model ID | Endpoint | AI SDK Package | -| --------------------- | --------------------- | -------------------------------------------------- | --------------------------- | -| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | -| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Model | Model ID | Endpoint | AI SDK Package | +| ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode 設定で使う [model id](/docs/config/#models) は `opencode/` 形式です。たとえば、GPT 5.5 では設定に `opencode/gpt-5.5` を使用します。 @@ -117,6 +118,7 @@ https://opencode.ai/zen/v1/models | Model | Input | Output | Cached Read | Cached Write | | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | +| DeepSeek V4 Flash Free | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | @@ -169,6 +171,7 @@ https://opencode.ai/zen/v1/models 無料モデル: +- DeepSeek V4 Flash Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - MiniMax M2.5 Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Ring 2.6 1T Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Nemotron 3 Super Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 @@ -221,6 +224,7 @@ https://opencode.ai/zen/v1/models すべてのモデルは米国でホストされています。私たちのプロバイダーはゼロ保持ポリシーに従っており、以下の例外を除き、モデル学習にあなたのデータを使用しません。 - Big Pickle: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 +- DeepSeek V4 Flash Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - MiniMax M2.5 Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - Ring 2.6 1T Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - Nemotron 3 Super Free(NVIDIA の無料エンドポイント): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) に基づいて提供されます。試用専用であり、本番環境や機密性の高いデータには使用しないでください。プロンプトと出力は、NVIDIA が自社のモデルとサービスを改善するために記録します。個人情報や機密データは送信しないでください。 diff --git a/packages/web/src/content/docs/ko/zen.mdx b/packages/web/src/content/docs/ko/zen.mdx index 513093a2c..87fb4aa8e 100644 --- a/packages/web/src/content/docs/ko/zen.mdx +++ b/packages/web/src/content/docs/ko/zen.mdx @@ -53,48 +53,49 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다. 다음 API 엔드포인트를 통해서도 모델에 접근할 수 있습니다. -| 모델 | 모델 ID | 엔드포인트 | AI SDK 패키지 | -| --------------------- | --------------------- | -------------------------------------------------- | --------------------------- | -| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | -| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | -| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| 모델 | 모델 ID | 엔드포인트 | AI SDK 패키지 | +| ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | +| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | +| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode config에서 사용하는 [모델 ID](/docs/config/#models)는 `opencode/` 형식입니다. 예를 들어 GPT 5.5를 사용하려면 config에서 `opencode/gpt-5.5`를 사용하면 됩니다. @@ -117,6 +118,7 @@ https://opencode.ai/zen/v1/models | 모델 | 입력 | 출력 | Cached Read | Cached Write | | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | +| DeepSeek V4 Flash Free | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | @@ -169,6 +171,7 @@ https://opencode.ai/zen/v1/models 무료 모델: +- DeepSeek V4 Flash Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - MiniMax M2.5 Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Ring 2.6 1T Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Nemotron 3 Super Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. @@ -221,6 +224,7 @@ https://opencode.ai/zen/v1/models 모든 모델은 미국에서 호스팅됩니다. provider는 zero-retention 정책을 따르며, 다음 예외를 제외하고는 데이터를 모델 학습에 사용하지 않습니다. - Big Pickle: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. +- DeepSeek V4 Flash Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - MiniMax M2.5 Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - Ring 2.6 1T Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - Nemotron 3 Super Free(NVIDIA 무료 엔드포인트): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)에 따라 제공됩니다. 평가판 전용이며 프로덕션 환경이나 민감한 데이터에는 사용할 수 없습니다. NVIDIA는 자사 모델과 서비스를 개선하기 위해 프롬프트와 출력을 기록합니다. 개인 정보나 기밀 데이터는 제출하지 마세요. From fca89e5e4dd14226c4b754796573e99e507631b9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 11 May 2026 13:44:39 +0000 Subject: [PATCH 009/188] chore: generate --- packages/web/src/content/docs/ko/zen.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/ko/zen.mdx b/packages/web/src/content/docs/ko/zen.mdx index 87fb4aa8e..6af8cf14b 100644 --- a/packages/web/src/content/docs/ko/zen.mdx +++ b/packages/web/src/content/docs/ko/zen.mdx @@ -53,7 +53,7 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다. 다음 API 엔드포인트를 통해서도 모델에 접근할 수 있습니다. -| 모델 | 모델 ID | 엔드포인트 | AI SDK 패키지 | +| 모델 | 모델 ID | 엔드포인트 | AI SDK 패키지 | | ---------------------- | ---------------------- | -------------------------------------------------- | --------------------------- | | GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | From 6c9f12bb2dee10a747218a39fb4dd94eff6a34ef Mon Sep 17 00:00:00 2001 From: Victor Navarro Date: Mon, 11 May 2026 16:04:54 +0200 Subject: [PATCH 010/188] chore: exclude status 429 from free model alerts (#26879) --- infra/monitoring.ts | 62 +++++++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index b4ece54be..c08d39f26 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -1,6 +1,8 @@ import { SECRET } from "./secret" import { domain } from "./stage" +const description = "Managed by SST (Don't edit in Honeycomb UI)" + const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", { name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`, url: `https://${domain}/honeycomb/webhook`, @@ -25,6 +27,16 @@ const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", { ], }) +// Honeycomb can keep stale query-local calculated fields when the name is unchanged, +// so tie the field name to the expression while avoiding deploy-to-deploy churn. +// https://github.com/honeycombio/terraform-provider-honeycombio/issues/852 +const calculatedField = (field: { name: string; expression: string }) => ({ + ...field, + name: `${field.name}_${( + Array.from(field.expression).reduce((result, char) => Math.imul(31, result) + char.charCodeAt(0), 0) >>> 0 + ).toString(36)}`, +}) + const modelHttpErrorsQuery = (product: "go" | "zen") => { const filters = [ { column: "model", op: "exists" }, @@ -32,21 +44,26 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => { { column: "user_agent", op: "contains", value: "opencode" }, { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, ] + const failedHttpStatus = calculatedField({ + name: "is_failed_http_status", + expression: + product === "go" + ? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)` + : `IF(AND(EQUALS($status, "429"), $isFreeTier), 0, AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`, + }) return honeycomb.getQuerySpecificationOutput({ breakdowns: ["model"], - calculatedFields: [ - { - name: "is_failed_http_status", - expression: - product === "go" - ? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)` - : `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`, - }, - ], + calculatedFields: [failedHttpStatus], calculations: [ { op: "COUNT", name: "TOTAL", filterCombination: "AND", filters }, - { op: "SUM", name: "FAILED", column: "is_failed_http_status", filterCombination: "AND", filters }, + { + op: "SUM", + name: "FAILED", + column: failedHttpStatus.name, + filterCombination: "AND", + filters, + }, ], formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 100), DIV($FAILED, $TOTAL), 0)" }], timeRange: 900, @@ -59,31 +76,30 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => { { column: "user_agent", op: "contains", value: "opencode" }, { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, ] + const successHttpStatus = calculatedField({ + name: "is_success_http_status", + expression: `IF(AND(GTE($status, "200"), LT($status, "400")), 1, 0)`, + }) + const failedProviderHttpStatus = calculatedField({ + name: "is_failed_provider_http_status", + expression: `IF(GT($llm.error.code, "400"), 1, 0)`, + }) return honeycomb.getQuerySpecificationOutput({ breakdowns: ["provider"], - calculatedFields: [ - { - name: "is_success_http_status", - expression: `IF(AND(GTE($status, "200"), LT($status, "400")), 1, 0)`, - }, - { - name: "is_failed_provider_http_status", - expression: `IF(GT($llm.error.code, "400"), 1, 0)`, - }, - ], + calculatedFields: [successHttpStatus, failedProviderHttpStatus], calculations: [ { op: "SUM", name: "SUCCESS", - column: "is_success_http_status", + column: successHttpStatus.name, filterCombination: "AND", filters: [...filters, { column: "event_type", op: "=", value: "completions" }], }, { op: "SUM", name: "FAILED", - column: "is_failed_provider_http_status", + column: failedProviderHttpStatus.name, filterCombination: "AND", filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }], }, @@ -95,8 +111,6 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => { }).json } -const description = "Managed by SST (Don't edit in Honeycomb UI)" - new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { name: "Increased Model HTTP Errors [Go]", description, From d821650b4867398420b58e2a620e3b55e4c22190 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 11 May 2026 16:12:32 +0200 Subject: [PATCH 011/188] add default diff parser for markdown fenced code blocks (#26887) --- packages/opencode/parsers-config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/opencode/parsers-config.ts b/packages/opencode/parsers-config.ts index b4951afa2..9d6ba4b7b 100644 --- a/packages/opencode/parsers-config.ts +++ b/packages/opencode/parsers-config.ts @@ -286,5 +286,13 @@ export default { ], }, }, + { + filetype: "diff", + aliases: ["udiff", "patch"], + wasm: "https://github.com/tree-sitter-grammars/tree-sitter-diff/releases/download/v0.1.0/tree-sitter-diff.wasm", + queries: { + highlights: ["https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-diff/master/queries/highlights.scm"], + }, + }, ], } From 6592d804603ea9e4166cffb33cd4309e91e892d3 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 11 May 2026 14:13:39 +0000 Subject: [PATCH 012/188] chore: generate --- packages/opencode/parsers-config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/parsers-config.ts b/packages/opencode/parsers-config.ts index 9d6ba4b7b..9bcb9e85c 100644 --- a/packages/opencode/parsers-config.ts +++ b/packages/opencode/parsers-config.ts @@ -291,7 +291,9 @@ export default { aliases: ["udiff", "patch"], wasm: "https://github.com/tree-sitter-grammars/tree-sitter-diff/releases/download/v0.1.0/tree-sitter-diff.wasm", queries: { - highlights: ["https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-diff/master/queries/highlights.scm"], + highlights: [ + "https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-diff/master/queries/highlights.scm", + ], }, }, ], From 64c504212aa71eef9b0a3d009bcc4229b98057a1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 10:22:45 -0400 Subject: [PATCH 013/188] Parse command config with Effect Schema (#26801) --- packages/opencode/src/config/command.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts index c611f3c19..fa64aef8c 100644 --- a/packages/opencode/src/config/command.ts +++ b/packages/opencode/src/config/command.ts @@ -1,12 +1,10 @@ export * as ConfigCommand from "./command" import * as Log from "@opencode-ai/core/util/log" -import { Schema } from "effect" +import { Cause, Exit, Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import { Glob } from "@opencode-ai/core/util/glob" import { Bus } from "@/bus" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" import { configEntryNameFromPath } from "./entry-name" import { InvalidError } from "./error" import * as ConfigMarkdown from "./markdown" @@ -20,10 +18,12 @@ export const Info = Schema.Struct({ agent: Schema.optional(Schema.String), model: Schema.optional(ConfigModelID), subtask: Schema.optional(Schema.Boolean), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type Info = Schema.Schema.Type +const decodeInfo = Schema.decodeUnknownExit(Info) + export async function load(dir: string) { const result: Record = {} for (const item of await Glob.scan("{command,commands}/**/*.md", { @@ -51,12 +51,12 @@ export async function load(dir: string) { ...md.data, template: md.content.trim(), } - const parsed = Info.zod.safeParse(config) - if (parsed.success) { - result[config.name] = parsed.data + const parsed = decodeInfo(config, { errors: "all", propertyOrder: "original" }) + if (Exit.isSuccess(parsed)) { + result[config.name] = parsed.value continue } - throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error }) + throw new InvalidError({ path: item, message: Cause.pretty(parsed.cause) }, { cause: Cause.squash(parsed.cause) }) } return result } From bcee24798852ed40166bf6905ab0b8a77be4f531 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 11:05:31 -0400 Subject: [PATCH 014/188] Define project update input with Effect Schema (#26803) --- packages/opencode/src/project/project.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 25feb657c..91d272ea6 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -1,4 +1,3 @@ -import z from "zod" import { and } from "drizzle-orm" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" @@ -89,13 +88,13 @@ export function fromRow(row: Row): Info { } } -export const UpdateInput = z.object({ - projectID: ProjectID.zod, - name: z.string().optional(), - icon: zod(ProjectIcon).optional(), - commands: zod(ProjectCommands).optional(), +export const UpdateInput = Schema.Struct({ + projectID: ProjectID, + name: Schema.optional(Schema.String), + icon: Schema.optional(ProjectIcon), + commands: Schema.optional(ProjectCommands), }) -export type UpdateInput = z.infer +export type UpdateInput = Types.DeepMutable> export const UpdatePayload = Schema.Struct({ name: Schema.optional(Schema.String), From f240bba8e70bb1e0fd197dbb06a136f4f696c888 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 11:10:18 -0400 Subject: [PATCH 015/188] chore(http-recorder): remove content-matching dispatch mode (#26792) --- packages/http-recorder/README.md | 23 +++++------ packages/http-recorder/src/effect.ts | 10 ++--- packages/http-recorder/src/matching.ts | 18 --------- .../http-recorder/test/record-replay.test.ts | 39 ++++--------------- packages/llm/AGENTS.md | 2 +- 5 files changed, 20 insertions(+), 72 deletions(-) diff --git a/packages/http-recorder/README.md b/packages/http-recorder/README.md index f6aaed435..5920c9670 100644 --- a/packages/http-recorder/README.md +++ b/packages/http-recorder/README.md @@ -70,19 +70,15 @@ Cassettes are normal source files — review them, diff them, commit them. ## Request matching -By default, requests match on canonicalized method, URL, headers, and JSON -body (object keys sorted). Two dispatch strategies are available: +Replay walks the cassette in record order via an internal cursor: the Nth +request executed at runtime is served by the Nth recorded interaction, and +each one is validated as the cursor advances. Request equality is computed +on canonicalized method, URL, headers, and JSON body (object keys sorted). -- **`match`** (default) — find the first recorded interaction whose request - matches the incoming request. Same request twice returns the same response. -- **`sequential`** — return interactions in the order they were recorded, - validating each one matches as the cursor advances. Use for ordered flows - where the same URL is hit multiple times with meaningful state changes - (pagination, retries, polling). - -```ts -HttpRecorder.cassetteLayer("flow/poll-until-done", { dispatch: "sequential" }) -``` +This is deliberately strict — content-based dispatch was removed because +it silently returns the first recorded response for repeated identical +requests, masking state changes that retry/polling/cache-hit tests need to +observe. If you reorder requests in a test, re-record the cassette. Supply your own matcher via `match: (incoming, recorded) => boolean` for custom equivalence (e.g. ignoring a timestamp field in the body). @@ -194,7 +190,6 @@ type RecordReplayOptions = { directory?: string // default: /test/fixtures/recordings metadata?: Record // merged into cassette.metadata redactor?: Redactor // default: Redactor.defaults() - dispatch?: "match" | "sequential" // default: "match" match?: (incoming, recorded) => boolean // custom matcher } ``` @@ -211,4 +206,4 @@ type RecordReplayOptions = { | `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. | | `schema.ts` | Effect Schema definitions for the cassette JSON format. | | `storage.ts` | Path resolution, JSON encode/decode, sync existence check. | -| `matching.ts` | Request matcher, canonicalization, dispatch strategies, mismatch diagnostics. | +| `matching.ts` | Request matcher, canonicalization, sequential cursor, mismatch diagnostics. | diff --git a/packages/http-recorder/src/effect.ts b/packages/http-recorder/src/effect.ts index e6c3ccbc1..61193a013 100644 --- a/packages/http-recorder/src/effect.ts +++ b/packages/http-recorder/src/effect.ts @@ -11,7 +11,7 @@ import { UrlParams, } from "effect/unstable/http" import * as CassetteService from "./cassette" -import { defaultMatcher, selectMatch, selectSequential, type RequestMatcher } from "./matching" +import { defaultMatcher, selectSequential, type RequestMatcher } from "./matching" import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder" import { defaults, type Redactor } from "./redactor" import { redactUrl } from "./redaction" @@ -24,7 +24,6 @@ export interface RecordReplayOptions { readonly directory?: string readonly metadata?: CassetteMetadata readonly redactor?: Redactor - readonly dispatch?: "match" | "sequential" readonly match?: RequestMatcher } @@ -71,7 +70,6 @@ export const recordingLayer = ( const match = options.match ?? defaultMatcher const requested = options.mode ?? "auto" const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested - const sequential = options.dispatch === "sequential" const replay = yield* makeReplayState(cassetteService, name, httpInteractions) const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) => @@ -119,14 +117,12 @@ export const recordingLayer = ( transportError(request, `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`), ), ) - const result = sequential - ? selectSequential(interactions, incoming, match, yield* replay.cursor) - : selectMatch(interactions, incoming, match) + const result = selectSequential(interactions, incoming, match, yield* replay.cursor) if (!result.interaction) return yield* Effect.fail( transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`), ) - if (sequential) yield* replay.advance + yield* replay.advance return HttpClientResponse.fromWeb( request, new Response(decodeResponseBody(result.interaction.response), result.interaction.response), diff --git a/packages/http-recorder/src/matching.ts b/packages/http-recorder/src/matching.ts index 9af85a2f3..ab647ab37 100644 --- a/packages/http-recorder/src/matching.ts +++ b/packages/http-recorder/src/matching.ts @@ -92,24 +92,6 @@ export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot return lines } -export const mismatchDetail = (interactions: ReadonlyArray, incoming: RequestSnapshot): string => { - if (interactions.length === 0) return "cassette has no recorded HTTP interactions" - const ranked = interactions - .map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) })) - .toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index) - const best = ranked[0] - return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n") -} - -export const selectMatch = ( - interactions: ReadonlyArray, - incoming: RequestSnapshot, - match: RequestMatcher, -): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => { - const interaction = interactions.find((candidate) => match(incoming, candidate.request)) - return { interaction, detail: interaction ? "" : mismatchDetail(interactions, incoming) } -} - export const selectSequential = ( interactions: ReadonlyArray, incoming: RequestSnapshot, diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts index 7613563fd..503f87ac5 100644 --- a/packages/http-recorder/test/record-replay.test.ts +++ b/packages/http-recorder/test/record-replay.test.ts @@ -230,19 +230,10 @@ describe("http-recorder", () => { ) }) - test("default matcher dispatches multi-interaction cassettes by request shape", async () => { - await run( - Effect.gen(function* () { - expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') - expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}') - }), - ) - }) - - test("sequential dispatch returns recorded responses in order for identical requests", async () => { + test("replay returns recorded responses in order for identical requests", async () => { await runWith( "record-replay/retry", - { dispatch: "sequential" }, + {}, Effect.gen(function* () { expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}') @@ -250,21 +241,8 @@ describe("http-recorder", () => { ) }) - test("default matcher returns the first match for identical requests", async () => { - await runWith( - "record-replay/retry", - {}, - Effect.gen(function* () { - expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') - expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') - }), - ) - }) - - test("sequential dispatch reports cursor exhaustion when more requests are made than recorded", async () => { - await runWith( - "record-replay/multi-step", - { dispatch: "sequential" }, + test("replay reports cursor exhaustion when more requests are made than recorded", async () => { + await run( Effect.gen(function* () { yield* post("https://example.test/echo", { step: 1 }) yield* post("https://example.test/echo", { step: 2 }) @@ -274,10 +252,8 @@ describe("http-recorder", () => { ) }) - test("sequential dispatch still validates each recorded request", async () => { - await runWith( - "record-replay/multi-step", - { dispatch: "sequential" }, + test("replay validates each recorded request in order", async () => { + await run( Effect.gen(function* () { yield* post("https://example.test/echo", { step: 1 }) const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) @@ -331,14 +307,13 @@ describe("http-recorder", () => { } }) - test("mismatch diagnostics show closest redacted request differences", async () => { + test("mismatch diagnostics show redacted request differences against the expected interaction", async () => { await run( Effect.gen(function* () { const exit = yield* Effect.exit( post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }), ) const message = failureText(exit) - expect(message).toContain("closest interaction: #1") expect(message).toContain("url:") expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D") expect(message).toContain("body:") diff --git a/packages/llm/AGENTS.md b/packages/llm/AGENTS.md index b20847da3..8d51b5cfe 100644 --- a/packages/llm/AGENTS.md +++ b/packages/llm/AGENTS.md @@ -289,6 +289,6 @@ Filters apply in replay and record mode. Combine them with `RECORD=true` when re **Binary response bodies.** Most providers stream text (SSE, JSON). AWS Bedrock streams binary AWS event-stream frames whose CRC32 fields would be mangled by a UTF-8 round-trip — those bodies are stored as base64 with `bodyEncoding: "base64"` on the response snapshot. Detection is by `Content-Type` in `@opencode-ai/http-recorder` (currently `application/vnd.amazon.eventstream` and `application/octet-stream`); cassettes for SSE/JSON routes omit the field and decode as text. -**Matching strategies.** Replay defaults to structural matching, which finds an interaction by comparing method, URL, allow-listed headers, and the canonical JSON body. This is the right choice for tool loops because each round's request differs (the message history grows). For scenarios where successive requests are byte-identical and expect different responses (retries, polling), pass `dispatch: "sequential"` in `RecordReplayOptions` — replay then walks the cassette in record order via an internal cursor. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk. +**Matching strategy.** Replay walks the cassette in record order via an internal cursor: the Nth runtime request is served by the Nth recorded interaction, and each one is validated by comparing method, URL, allow-listed headers, and the canonical JSON body. This handles tool loops (each round's request differs as history grows) and retry/polling scenarios (successive byte-identical requests with different responses) uniformly. If a test reorders its requests, re-record the cassette. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk. Do not blanket re-record an entire test file when adding one cassette. `RECORD=true` rewrites every recorded case that runs, and provider streams contain volatile IDs, timestamps, fingerprints, and obfuscation fields. Prefer deleting the one cassette you intend to refresh, or run a focused test pattern that only registers the scenario you want to record. Keep stable existing cassettes unchanged unless their request shape or expected behavior changed. From 4bae84c8b0bf8bf491bdea07722a3e53a56d8cc6 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 11 May 2026 20:50:03 +0530 Subject: [PATCH 016/188] feat(scout): autocomplete configured mentions (#26843) --- .../cmd/tui/component/prompt/autocomplete.tsx | 150 +++++++++++++++++- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/session/prompt.ts | 116 +++++++++----- packages/opencode/test/session/prompt.test.ts | 66 +++++++- packages/web/src/content/docs/tui.mdx | 6 + 5 files changed, 293 insertions(+), 47 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 7f390f0eb..3242de94d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -18,6 +18,8 @@ import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" import { useBindings } from "../../keymap" +import { Reference } from "@/reference/reference" +import type { Config } from "@/config/config" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -260,6 +262,87 @@ export function Autocomplete(props: { } } + function createReferenceFilePart(input: { + alias: string + root: string + item: string + lineRange?: { startLine: number; endLine?: number } + }) { + const filename = `${input.alias}/${ + input.lineRange && !input.item.endsWith("/") + ? `${input.item}#${input.lineRange.startLine}${input.lineRange.endLine ? `-${input.lineRange.endLine}` : ""}` + : input.item + }` + const urlObj = pathToFileURL(path.join(input.root, input.item)) + + if (input.lineRange && !input.item.endsWith("/")) { + urlObj.searchParams.set("start", String(input.lineRange.startLine)) + if (input.lineRange.endLine !== undefined) { + urlObj.searchParams.set("end", String(input.lineRange.endLine)) + } + } + + return { + filename, + url: urlObj.href, + part: { + type: "file" as const, + mime: input.item.endsWith("/") ? "application/x-directory" : "text/plain", + filename, + url: urlObj.href, + source: { + type: "file" as const, + text: { + start: 0, + end: 0, + value: "", + }, + path: filename, + }, + }, + } + } + + function referencePromptText(reference: Reference.Resolved) { + const problem = reference.kind === "invalid" ? reference.message : undefined + return [ + `Referenced configured reference @${reference.name}.`, + ...(reference.kind === "local" ? ["Kind: local directory"] : []), + ...(reference.kind === "git" ? ["Kind: git repository"] : []), + ...(reference.kind === "invalid" ? [`Repository: ${reference.repository}`] : []), + ...(reference.kind === "git" ? [`Repository: ${reference.repository}`] : []), + ...(reference.kind === "git" && reference.branch ? [`Branch/ref: ${reference.branch}`] : []), + ...(reference.kind === "invalid" ? [] : [`Reference root: ${reference.path}`]), + ...(problem + ? [`Problem: ${problem}`] + : [ + "For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.", + ]), + ].join("\n") + } + + const references = createMemo(() => + Reference.resolveAll({ + references: (sync.data.config.reference ?? {}) as NonNullable, + directory: sync.path.directory || process.cwd(), + worktree: sync.path.worktree || sync.path.directory || process.cwd(), + }), + ) + + const referenceSearch = createMemo(() => { + if (!store.visible || store.visible === "/") return + const { lineRange, baseQuery } = extractLineRange(search()) + const slash = baseQuery.indexOf("/") + if (slash === -1) return + const reference = references().find((item) => item.name === baseQuery.slice(0, slash)) + if (!reference || reference.kind === "invalid") return + return { + reference, + query: baseQuery.slice(slash + 1), + lineRange, + } + }) + function normalizeMentionPath(filePath: string) { const baseDir = sync.path.directory || process.cwd() const absolute = path.resolve(filePath) @@ -291,6 +374,7 @@ export function Autocomplete(props: { () => search(), async (query) => { if (!store.visible || store.visible === "/") return [] + if (referenceSearch()) return [] const { lineRange, baseQuery } = extractLineRange(query ?? "") @@ -339,6 +423,43 @@ export function Autocomplete(props: { }, ) + const [referenceFiles] = createResource( + () => referenceSearch(), + async (match) => { + if (!match) return [] + + const result = await sdk.client.find.files({ + directory: match.reference.path, + query: match.query, + limit: 50, + }) + + if (result.error || !result.data) return [] + + const width = props.anchor().width - 4 + return result.data.map((item): AutocompleteOption => { + const { filename, part } = createReferenceFilePart({ + alias: match.reference.name, + root: match.reference.path, + item, + lineRange: match.lineRange, + }) + return { + display: Locale.truncateMiddle(filename, width), + value: filename, + isDirectory: item.endsWith("/"), + path: filename, + onSelect: () => { + insertPart(filename, part) + }, + } + }) + }, + { + initialValue: [], + }, + ) + const mcpResources = createMemo(() => { if (!store.visible || store.visible === "/") return [] @@ -397,6 +518,22 @@ export function Autocomplete(props: { ) }) + const referenceAliases = createMemo(() => + references().map( + (reference): AutocompleteOption => ({ + display: "@" + reference.name, + description: reference.kind === "invalid" ? reference.message : " configured reference", + onSelect: () => { + insertPart(reference.name, { + type: "text", + text: referencePromptText(reference), + synthetic: true, + }) + }, + }), + ), + ) + const commands = createMemo((): AutocompleteOption[] => { const results: AutocompleteOption[] = [...command.slashes()] @@ -428,11 +565,18 @@ export function Autocomplete(props: { const options = createMemo((prev: AutocompleteOption[] | undefined) => { const filesValue = files() + const referenceFilesValue = referenceFiles() + const referenceSearchValue = referenceSearch() const agentsValue = agents() + const referenceAliasesValue = referenceAliases() const commandsValue = commands() const mixed: AutocompleteOption[] = - store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue] + store.visible === "@" + ? referenceSearchValue + ? referenceFilesValue || [] + : [...referenceAliasesValue, ...agentsValue, ...(filesValue || []), ...mcpResources()] + : [...commandsValue] const searchValue = search() @@ -440,7 +584,7 @@ export function Autocomplete(props: { return mixed } - if (files.loading && prev && prev.length > 0) { + if ((files.loading || referenceFiles.loading) && prev && prev.length > 0) { return prev } @@ -505,7 +649,7 @@ export function Autocomplete(props: { const input = props.input() const currentCursorOffset = input.cursorOffset - const displayText = selected.display.trimEnd() + const displayText = (selected.value ?? selected.display).trimEnd() const path = displayText.startsWith("@") ? displayText.slice(1) : displayText input.cursorOffset = store.index diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 114a38803..c05d562c9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -145,7 +145,7 @@ export const Info = Schema.Struct({ }), skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }), reference: Schema.optional(ConfigReference.Info).annotate({ - description: "Named git or local directory references that can be @ mentioned as Scout-backed subagents", + description: "Named git or local directory references that can be mentioned as @alias or @alias/path", }), watcher: Schema.optional( Schema.Struct({ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3b919e2f0..6033b0944 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -121,6 +121,45 @@ function referencePromptMetadata(input: unknown): ReferencePromptMetadata | unde } } +function referenceTextPart(input: { + reference: Reference.Resolved + source: ReferencePromptMetadata["source"] + target?: string + targetPath?: string + problem?: string +}): MessageV2.TextPartInput { + const metadata: ReferencePromptMetadata = { + name: input.reference.name, + kind: input.reference.kind, + ...(input.reference.kind === "invalid" ? { repository: input.reference.repository } : { path: input.reference.path }), + ...(input.reference.kind === "git" ? { repository: input.reference.repository, branch: input.reference.branch } : {}), + ...(input.target === undefined ? {} : { target: input.target }), + ...(input.targetPath ? { targetPath: input.targetPath } : {}), + problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined), + source: input.source, + } + const label = metadata.target === undefined ? `@${metadata.name}` : `@${metadata.name}/${metadata.target}` + return { + type: "text", + synthetic: true, + text: [ + `Referenced configured reference ${label}.`, + ...(metadata.kind === "local" ? ["Kind: local directory"] : []), + ...(metadata.kind === "git" ? ["Kind: git repository"] : []), + ...(metadata.repository ? [`Repository: ${metadata.repository}`] : []), + ...(metadata.branch ? [`Branch/ref: ${metadata.branch}`] : []), + ...(metadata.path ? [`Reference root: ${metadata.path}`] : []), + ...(metadata.targetPath ? [`Resolved path: ${metadata.targetPath}`] : []), + ...(metadata.problem + ? [`Problem: ${metadata.problem}`] + : [ + "For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.", + ]), + ].join("\n"), + metadata: { reference: metadata }, + } +} + export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect @@ -186,48 +225,6 @@ export const layer = Layer.effect( const start = match.index ?? 0 return { value: match[0], start, end: start + match[0].length } } - const referenceTextPart = (input: { - reference: Reference.Resolved - source: ReturnType - target?: string - targetPath?: string - problem?: string - }): MessageV2.TextPartInput => { - const metadata: ReferencePromptMetadata = { - name: input.reference.name, - kind: input.reference.kind, - ...(input.reference.kind === "invalid" - ? { repository: input.reference.repository } - : { path: input.reference.path }), - ...(input.reference.kind === "git" - ? { repository: input.reference.repository, branch: input.reference.branch } - : {}), - ...(input.target === undefined ? {} : { target: input.target }), - ...(input.targetPath ? { targetPath: input.targetPath } : {}), - problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined), - source: input.source, - } - const label = metadata.target === undefined ? `@${metadata.name}` : `@${metadata.name}/${metadata.target}` - return { - type: "text", - synthetic: true, - text: [ - `Referenced configured reference ${label}.`, - ...(metadata.kind === "local" ? ["Kind: local directory"] : []), - ...(metadata.kind === "git" ? ["Kind: git repository"] : []), - ...(metadata.repository ? [`Repository: ${metadata.repository}`] : []), - ...(metadata.branch ? [`Branch/ref: ${metadata.branch}`] : []), - ...(metadata.path ? [`Reference root: ${metadata.path}`] : []), - ...(metadata.targetPath ? [`Resolved path: ${metadata.targetPath}`] : []), - ...(metadata.problem - ? [`Problem: ${metadata.problem}`] - : [ - "For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.", - ]), - ].join("\n"), - metadata: { reference: metadata }, - } - } yield* Effect.forEach( files, Effect.fnUntraced(function* (match) { @@ -1156,6 +1153,30 @@ NOTE: At any point in time through this workflow you should feel free to ask the id: part.id ? PartID.make(part.id) : PartID.ascending(), }) + const referenceContextFromFilePart = Effect.fnUntraced(function* ( + part: Extract, + filepath: string, + ) { + const name = part.filename?.replace(/#\d+(?:-\d*)?$/, "") + if (!name) return + const slash = name.indexOf("/") + if (slash === -1) return + + const reference = yield* references.get(name.slice(0, slash)) + if (!reference || reference.kind === "invalid") return + if (!AppFileSystem.contains(reference.path, filepath)) return + + const target = path.relative(reference.path, filepath).split(path.sep).join("/") + if (!target || target.startsWith("../") || target === "..") return + + return referenceTextPart({ + reference, + source: part.source?.text ?? { value: `@${name}`, start: 0, end: name.length + 1 }, + target, + targetPath: filepath, + }) + }) + const resolvePart: (part: PromptInput["parts"][number]) => Effect.Effect[]> = Effect.fn( "SessionPrompt.resolveUserPart", )(function* (part) { @@ -1238,6 +1259,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the case "file:": { log.info("file", { mime: part.mime }) const filepath = fileURLToPath(part.url) + const referenceContext = yield* referenceContextFromFilePart(part, filepath) const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime const { read } = yield* registry.named() @@ -1283,6 +1305,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the } const args = { filePath: filepath, offset, limit } const pieces: Draft[] = [ + ...(referenceContext + ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] + : []), { messageID: info.id, sessionID: input.sessionID, @@ -1348,6 +1373,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the error: new NamedError.Unknown({ message }).toObject(), }) return [ + ...(referenceContext + ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] + : []), { messageID: info.id, sessionID: input.sessionID, @@ -1358,6 +1386,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the ] } return [ + ...(referenceContext + ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] + : []), { messageID: info.id, sessionID: input.sessionID, @@ -1377,6 +1408,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } return [ + ...(referenceContext ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] : []), { messageID: info.id, sessionID: input.sessionID, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index f5c167465..104346530 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -4,7 +4,7 @@ import { expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" import fs from "fs/promises" import path from "path" -import { fileURLToPath } from "url" +import { fileURLToPath, pathToFileURL } from "url" import { NamedError } from "@opencode-ai/core/util/error" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" @@ -1889,6 +1889,70 @@ it.live("injects metadata for bare configured reference mentions", () => ), ) +it.live("injects metadata for configured reference file attachments", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const docs = path.join(dir, "external-docs") + const readme = path.join(docs, "README.md") + yield* Effect.promise(() => fs.mkdir(docs, { recursive: true })) + yield* Effect.promise(() => Bun.write(readme, "reference readme")) + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + const message = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [ + { type: "text", text: "Read @docs/README.md" }, + { + type: "file", + mime: "text/plain", + filename: "docs/README.md", + url: pathToFileURL(readme).href, + source: { + type: "file", + path: "docs/README.md", + text: { value: "@docs/README.md", start: 5, end: 20 }, + }, + }, + ], + }) + + const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const synthetic = stored.parts.filter( + (part): part is MessageV2.TextPart => part.type === "text" && part.synthetic === true, + ) + const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs/README.md.")) + + expect(reference?.metadata?.reference).toMatchObject({ + name: "docs", + kind: "local", + path: docs, + target: "README.md", + targetPath: readme, + source: { value: "@docs/README.md", start: 5, end: 20 }, + }) + expect(synthetic.findIndex((part) => part === reference)).toBeLessThan( + synthetic.findIndex((part) => part.text.startsWith("Called the Read tool with the following input:")), + ) + + yield* sessions.remove(session.id) + }), + { + git: true, + config: { + ...cfg, + reference: { + docs: "./external-docs", + }, + }, + }, + ), +) + // Special characters in filenames it.live("handles filenames with # character", () => diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 72d9658d1..b9103c702 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -41,6 +41,12 @@ How is auth handled in @packages/functions/src/api/index.ts? The content of the file is added to the conversation automatically. +Configured references also appear in `@` autocomplete. Type `@alias` to add the reference root as context, or type `@alias/` to autocomplete files inside that reference. + +```text "@docs/README.md" +Compare our setup with @docs/README.md +``` + --- ## Bash commands From 19fce2bc6f0a42e750553e4627f5ca6b6c26dfac Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 11 May 2026 15:21:20 +0000 Subject: [PATCH 017/188] chore: generate --- packages/opencode/src/session/prompt.ts | 8 ++++++-- packages/opencode/test/session/prompt.test.ts | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 6033b0944..4950be084 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -131,8 +131,12 @@ function referenceTextPart(input: { const metadata: ReferencePromptMetadata = { name: input.reference.name, kind: input.reference.kind, - ...(input.reference.kind === "invalid" ? { repository: input.reference.repository } : { path: input.reference.path }), - ...(input.reference.kind === "git" ? { repository: input.reference.repository, branch: input.reference.branch } : {}), + ...(input.reference.kind === "invalid" + ? { repository: input.reference.repository } + : { path: input.reference.path }), + ...(input.reference.kind === "git" + ? { repository: input.reference.repository, branch: input.reference.branch } + : {}), ...(input.target === undefined ? {} : { target: input.target }), ...(input.targetPath ? { targetPath: input.targetPath } : {}), problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined), diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 104346530..382195494 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1925,7 +1925,9 @@ it.live("injects metadata for configured reference file attachments", () => const synthetic = stored.parts.filter( (part): part is MessageV2.TextPart => part.type === "text" && part.synthetic === true, ) - const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs/README.md.")) + const reference = synthetic.find((part) => + part.text.startsWith("Referenced configured reference @docs/README.md."), + ) expect(reference?.metadata?.reference).toMatchObject({ name: "docs", From 52f7ba7d4d6bdd924586bfddf41f19bcc4a07528 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 11:48:30 -0400 Subject: [PATCH 018/188] fix(llm): drop removed dispatch option from recorded cache tests (#26900) --- .../test/provider/anthropic-messages-cache.recorded.test.ts | 5 ++--- .../test/provider/bedrock-converse-cache.recorded.test.ts | 5 ++--- packages/llm/test/provider/gemini-cache.recorded.test.ts | 5 ++--- .../test/provider/openai-responses-cache.recorded.test.ts | 6 +++--- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts b/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts index cb144b1a5..68b7e0a4a 100644 --- a/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts +++ b/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts @@ -31,10 +31,9 @@ const recorded = recordedTests({ provider: "anthropic", protocol: "anthropic-messages", requires: ["ANTHROPIC_API_KEY"], - // Two identical requests in one cassette — match by recording order so the - // second call replays the cached-hit interaction. + // Two identical requests in one cassette — replay walks the cassette in + // recording order so the second call replays the cached-hit interaction. options: { - dispatch: "sequential", redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }), }, }) diff --git a/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts b/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts index 16c44099c..2771046f8 100644 --- a/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts +++ b/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts @@ -38,9 +38,8 @@ const recorded = recordedTests({ provider: "amazon-bedrock", protocol: "bedrock-converse", requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"], - // Two identical requests in one cassette — match by recording order so the - // second call replays the cached-hit interaction. - options: { dispatch: "sequential" }, + // Two identical requests in one cassette — replay walks the cassette in + // recording order so the second call replays the cached-hit interaction. }) describe("Bedrock Converse cache recorded", () => { diff --git a/packages/llm/test/provider/gemini-cache.recorded.test.ts b/packages/llm/test/provider/gemini-cache.recorded.test.ts index c3b3e55b3..b86980c43 100644 --- a/packages/llm/test/provider/gemini-cache.recorded.test.ts +++ b/packages/llm/test/provider/gemini-cache.recorded.test.ts @@ -29,9 +29,8 @@ const recorded = recordedTests({ provider: "google", protocol: "gemini", requires: ["GOOGLE_GENERATIVE_AI_API_KEY"], - // Two identical requests in one cassette — match by recording order so the - // second call replays the cached-hit interaction. - options: { dispatch: "sequential" }, + // Two identical requests in one cassette — replay walks the cassette in + // recording order so the second call replays the cached-hit interaction. }) describe("Gemini cache recorded", () => { diff --git a/packages/llm/test/provider/openai-responses-cache.recorded.test.ts b/packages/llm/test/provider/openai-responses-cache.recorded.test.ts index 5a38898c0..2b67a0a4f 100644 --- a/packages/llm/test/provider/openai-responses-cache.recorded.test.ts +++ b/packages/llm/test/provider/openai-responses-cache.recorded.test.ts @@ -29,9 +29,9 @@ const recorded = recordedTests({ provider: "openai", protocol: "openai-responses", requires: ["OPENAI_API_KEY"], - // Two identical requests in one cassette — match by recording order so the - // second call replays the cached-hit interaction, not the cold-miss one. - options: { dispatch: "sequential" }, + // Two identical requests in one cassette — replay walks the cassette in + // recording order so the second call replays the cached-hit interaction, + // not the cold-miss one. }) describe("OpenAI Responses cache recorded", () => { From 023e1c711eeb8b286dd2d1d3c842cce2cd895b1b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 13:05:41 -0400 Subject: [PATCH 019/188] refactor(llm): colocate per-type factories on their namespaces (#26799) --- packages/llm/AGENTS.md | 10 ++++-- packages/llm/src/llm.ts | 32 +++---------------- packages/llm/test/cache-policy.test.ts | 10 +++--- packages/llm/test/llm.test.ts | 28 ++++++++-------- .../anthropic-messages.recorded.test.ts | 10 +++--- .../test/provider/anthropic-messages.test.ts | 28 ++++++++-------- .../test/provider/bedrock-converse.test.ts | 32 +++++++++---------- packages/llm/test/provider/gemini.test.ts | 10 +++--- .../llm/test/provider/openai-chat.test.ts | 12 +++---- .../provider/openai-compatible-chat.test.ts | 8 ++--- .../test/provider/openai-responses.test.ts | 10 +++--- packages/llm/test/recorded-scenarios.ts | 6 ++-- packages/llm/test/tool-runtime.test.ts | 6 ++-- 13 files changed, 91 insertions(+), 111 deletions(-) diff --git a/packages/llm/AGENTS.md b/packages/llm/AGENTS.md index 8d51b5cfe..16a58fd86 100644 --- a/packages/llm/AGENTS.md +++ b/packages/llm/AGENTS.md @@ -8,6 +8,10 @@ - In `Effect.gen`, yield yieldable errors directly (`return yield* new MyError(...)`) instead of `Effect.fail(new MyError(...))`. - Use `Effect.void` instead of `Effect.succeed(undefined)` when the successful value is intentionally void. +## Conventions + +Per-type constructors live on the type's namespace, not as top-level re-exports. Use `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for the request-shaped call API: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.model`, `LLM.updateRequest`, `LLM.generateObject`. Two ways to construct the same thing is one too many. + ## Tests - Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers. @@ -166,12 +170,12 @@ If you find yourself copying a 3-to-5-line snippet between two protocols, lift i Tool loops are represented in common messages and events: ```ts -const call = LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }) -const result = LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }) +const call = ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } }) +const result = Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }) const followUp = LLM.request({ model, - messages: [LLM.user("Weather?"), LLM.assistant([call]), result], + messages: [Message.user("Weather?"), Message.assistant([call]), result], }) ``` diff --git a/packages/llm/src/llm.ts b/packages/llm/src/llm.ts index bca78c888..6f6728216 100644 --- a/packages/llm/src/llm.ts +++ b/packages/llm/src/llm.ts @@ -44,32 +44,8 @@ export type RequestInput = Omit< export const limits = modelLimits -export const text = Message.text - -export const system = SystemPart.make - -export const message = Message.make - -export const user = Message.user - -export const assistant = Message.assistant - export const model = modelRef -export const toolDefinition = ToolDefinition.make - -export const toolCall = ToolCallPart.make - -export const toolResult = ToolResultPart.make - -export const toolMessage = Message.tool - -export const toolChoiceName = ToolChoice.named - -export const toolChoice = ToolChoice.make - -export const generation = GenerationOptions.make - export const generate = LLMClient.generate export const stream = LLMClient.stream @@ -95,10 +71,10 @@ export const request = (input: RequestInput) => { return new LLMRequest({ ...rest, system: SystemPart.content(requestSystem), - messages: [...(messages?.map(message) ?? []), ...(prompt === undefined ? [] : [user(prompt)])], - tools: tools?.map(toolDefinition) ?? [], - toolChoice: requestToolChoice ? toolChoice(requestToolChoice) : undefined, - generation: requestGeneration === undefined ? undefined : generation(requestGeneration), + messages: [...(messages?.map(Message.make) ?? []), ...(prompt === undefined ? [] : [Message.user(prompt)])], + tools: tools?.map(ToolDefinition.make) ?? [], + toolChoice: requestToolChoice ? ToolChoice.make(requestToolChoice) : undefined, + generation: requestGeneration === undefined ? undefined : GenerationOptions.make(requestGeneration), providerOptions: requestProviderOptions, http: requestHttp === undefined ? undefined : HttpOptions.make(requestHttp), }) diff --git a/packages/llm/test/cache-policy.test.ts b/packages/llm/test/cache-policy.test.ts index e742ca5e6..bb65a5636 100644 --- a/packages/llm/test/cache-policy.test.ts +++ b/packages/llm/test/cache-policy.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" -import { CacheHint, LLM } from "../src" +import { CacheHint, LLM, Message } from "../src" import { LLMClient } from "../src/route" import * as AnthropicMessages from "../src/protocols/anthropic-messages" import * as BedrockConverse from "../src/protocols/bedrock-converse" @@ -59,7 +59,7 @@ describe("applyCachePolicy", () => { model: anthropicModel, system: "Sys A", tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], - messages: [LLM.user("first user"), LLM.assistant("assistant reply"), LLM.user("latest user message")], + messages: [Message.user("first user"), Message.assistant("assistant reply"), Message.user("latest user message")], cache: "auto", }), ) @@ -122,7 +122,7 @@ describe("applyCachePolicy", () => { model: bedrockModel, system: "Sys", tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], - messages: [LLM.user("first user"), LLM.assistant("reply"), LLM.user("latest user")], + messages: [Message.user("first user"), Message.assistant("reply"), Message.user("latest user")], cache: "auto", }), ) @@ -221,7 +221,7 @@ describe("applyCachePolicy", () => { const prepared = yield* LLMClient.prepare( LLM.request({ model: anthropicModel, - messages: [LLM.user("u1"), LLM.assistant("a1"), LLM.user("u2"), LLM.assistant("a2")], + messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")], cache: { messages: { tail: 2 } }, }), ) @@ -239,7 +239,7 @@ describe("applyCachePolicy", () => { const prepared = yield* LLMClient.prepare( LLM.request({ model: anthropicModel, - messages: [LLM.user("u1"), LLM.assistant("a1"), LLM.user("u2")], + messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")], cache: { messages: "latest-assistant" }, }), ) diff --git a/packages/llm/test/llm.test.ts b/packages/llm/test/llm.test.ts index e9ef58afa..c01fe33b2 100644 --- a/packages/llm/test/llm.test.ts +++ b/packages/llm/test/llm.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { LLM, LLMResponse } from "../src" -import { LLMRequest, Message, ModelRef, ToolChoice, ToolDefinition } from "../src/schema" +import { LLMRequest, Message, ModelRef, ToolCallPart, ToolChoice, ToolDefinition, ToolResultPart } from "../src/schema" describe("llm constructors", () => { test("builds canonical schema classes from ergonomic input", () => { @@ -28,7 +28,7 @@ describe("llm constructors", () => { }) const updated = LLM.updateRequest(base, { generation: { maxTokens: 20 }, - messages: [...base.messages, LLM.assistant("Hi.")], + messages: [...base.messages, Message.assistant("Hi.")], }) expect(updated).toBeInstanceOf(LLMRequest) @@ -70,7 +70,7 @@ describe("llm constructors", () => { model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }), prompt: "Say hello.", }) - const updated = LLMRequest.update(base, { messages: [...base.messages, LLM.assistant("Hi.")] }) + const updated = LLMRequest.update(base, { messages: [...base.messages, Message.assistant("Hi.")] }) expect(updated).toBeInstanceOf(LLMRequest) expect(updated.id).toBe("req_1") @@ -91,18 +91,18 @@ describe("llm constructors", () => { }) test("builds tool choices from names and tools", () => { - const tool = LLM.toolDefinition({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }) + const tool = ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }) expect(tool).toBeInstanceOf(ToolDefinition) - expect(LLM.toolChoice("lookup")).toEqual(new ToolChoice({ type: "tool", name: "lookup" })) - expect(LLM.toolChoiceName("required")).toEqual(new ToolChoice({ type: "tool", name: "required" })) - expect(LLM.toolChoice(tool)).toEqual(new ToolChoice({ type: "tool", name: "lookup" })) + expect(ToolChoice.make("lookup")).toEqual(new ToolChoice({ type: "tool", name: "lookup" })) + expect(ToolChoice.named("required")).toEqual(new ToolChoice({ type: "tool", name: "required" })) + expect(ToolChoice.make(tool)).toEqual(new ToolChoice({ type: "tool", name: "lookup" })) }) test("builds tool choice modes from reserved strings", () => { - expect(LLM.toolChoice("auto")).toEqual(new ToolChoice({ type: "auto" })) - expect(LLM.toolChoice("none")).toEqual(new ToolChoice({ type: "none" })) - expect(LLM.toolChoice("required")).toEqual(new ToolChoice({ type: "required" })) + expect(ToolChoice.make("auto")).toEqual(new ToolChoice({ type: "auto" })) + expect(ToolChoice.make("none")).toEqual(new ToolChoice({ type: "none" })) + expect(ToolChoice.make("required")).toEqual(new ToolChoice({ type: "required" })) expect( LLM.request({ model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }), @@ -113,11 +113,11 @@ describe("llm constructors", () => { }) test("builds assistant tool calls and tool result messages", () => { - const call = LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }) - const result = LLM.toolResult({ id: "call_1", name: "lookup", result: { temperature: 72 } }) + const call = ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } }) + const result = ToolResultPart.make({ id: "call_1", name: "lookup", result: { temperature: 72 } }) - expect(LLM.assistant([call]).content).toEqual([call]) - expect(LLM.toolMessage(result).content).toEqual([ + expect(Message.assistant([call]).content).toEqual([call]) + expect(Message.tool(result).content).toEqual([ { type: "tool-result", id: "call_1", name: "lookup", result: { type: "json", value: { temperature: 72 } } }, ]) }) diff --git a/packages/llm/test/provider/anthropic-messages.recorded.test.ts b/packages/llm/test/provider/anthropic-messages.recorded.test.ts index aa5b258d3..5fefae51d 100644 --- a/packages/llm/test/provider/anthropic-messages.recorded.test.ts +++ b/packages/llm/test/provider/anthropic-messages.recorded.test.ts @@ -1,7 +1,7 @@ import { Redactor } from "@opencode-ai/http-recorder" import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMError } from "../../src" +import { LLM, LLMError, Message, ToolCallPart } from "../../src" import { LLMClient } from "../../src/route" import * as AnthropicMessages from "../../src/protocols/anthropic-messages" import { weatherToolName } from "../recorded-scenarios" @@ -16,12 +16,12 @@ const malformedToolOrderRequest = LLM.request({ id: "recorded_anthropic_malformed_tool_order", model, messages: [ - LLM.assistant([ - LLM.toolCall({ id: "call_1", name: weatherToolName, input: { city: "Paris" } }), + Message.assistant([ + ToolCallPart.make({ id: "call_1", name: weatherToolName, input: { city: "Paris" } }), { type: "text", text: "I will check the weather." }, ]), - LLM.toolMessage({ id: "call_1", name: weatherToolName, result: { temperature: "72F" } }), - LLM.user("Use that result to answer briefly."), + Message.tool({ id: "call_1", name: weatherToolName, result: { temperature: "72F" } }), + Message.user("Use that result to answer briefly."), ], tools: [{ name: weatherToolName, description: "Get weather", inputSchema: { type: "object", properties: {} } }], }) diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index a867d1659..0df3541d5 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { CacheHint, LLM, LLMError, Usage } from "../../src" +import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src" import { LLMClient } from "../../src/route" import * as AnthropicMessages from "../../src/protocols/anthropic-messages" import { it } from "../lib/effect" @@ -47,9 +47,9 @@ describe("Anthropic Messages route", () => { id: "req_tool_result", model, messages: [ - LLM.user("What is the weather?"), - LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), - LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + Message.user("What is the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), ], cache: "none", }), @@ -77,7 +77,7 @@ describe("Anthropic Messages route", () => { LLM.request({ model, messages: [ - LLM.assistant([ + Message.assistant([ { type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } }, ]), ], @@ -304,8 +304,8 @@ describe("Anthropic Messages route", () => { id: "req_round_trip", model, messages: [ - LLM.user("Search for something."), - LLM.assistant([ + Message.user("Search for something."), + Message.assistant([ { type: "tool-call", id: "srvtoolu_abc", @@ -322,7 +322,7 @@ describe("Anthropic Messages route", () => { }, { type: "text", text: "Found it." }, ]), - LLM.user("Thanks."), + Message.user("Thanks."), ], }), ) @@ -355,7 +355,7 @@ describe("Anthropic Messages route", () => { id: "req_unknown_server_tool", model, messages: [ - LLM.assistant([ + Message.assistant([ { type: "tool-result", id: "srvtoolu_abc", @@ -378,7 +378,7 @@ describe("Anthropic Messages route", () => { LLM.request({ id: "req_media", model, - messages: [LLM.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], }), ).pipe(Effect.flip) @@ -416,9 +416,9 @@ describe("Anthropic Messages route", () => { }, ], messages: [ - LLM.user("What's the weather?"), - LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]), - LLM.toolMessage({ + Message.user("What's the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), + Message.tool({ id: "call_1", name: "lookup", result: { temp: 72 }, @@ -501,7 +501,7 @@ describe("Anthropic Messages route", () => { }, ], system: [{ type: "text", text: "system-tail", cache: hint }], - messages: [LLM.user([{ type: "text", text: "message-tail", cache: hint }])], + messages: [Message.user([{ type: "text", text: "message-tail", cache: hint }])], }), ) diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index 208b56527..7d1ad3f30 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -2,7 +2,7 @@ import { EventStreamCodec } from "@smithy/eventstream-codec" import { fromUtf8, toUtf8 } from "@smithy/util-utf8" import { describe, expect } from "bun:test" import { Effect } from "effect" -import { CacheHint, LLM } from "../../src" +import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src" import { LLMClient } from "../../src/route" import * as BedrockConverse from "../../src/protocols/bedrock-converse" import { it } from "../lib/effect" @@ -94,7 +94,7 @@ describe("Bedrock Converse route", () => { inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, }, ], - toolChoice: LLM.toolChoice({ type: "required" }), + toolChoice: ToolChoice.make({ type: "required" }), }), ) @@ -124,9 +124,9 @@ describe("Bedrock Converse route", () => { id: "req_history", model, messages: [ - LLM.user("What is the weather?"), - LLM.assistant([LLM.toolCall({ id: "tool_1", name: "lookup", input: { query: "weather" } })]), - LLM.toolMessage({ id: "tool_1", name: "lookup", result: { forecast: "sunny" } }), + Message.user("What is the weather?"), + Message.assistant([ToolCallPart.make({ id: "tool_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "tool_1", name: "lookup", result: { forecast: "sunny" } }), ], cache: "none", }), @@ -294,8 +294,8 @@ describe("Bedrock Converse route", () => { model, system: [{ type: "text", text: "System prefix.", cache }], messages: [ - LLM.user([{ type: "text", text: "User prefix.", cache }]), - LLM.assistant([{ type: "text", text: "Assistant prefix.", cache }]), + Message.user([{ type: "text", text: "User prefix.", cache }]), + Message.assistant([{ type: "text", text: "Assistant prefix.", cache }]), ], generation: { maxTokens: 16, temperature: 0 }, }), @@ -335,7 +335,7 @@ describe("Bedrock Converse route", () => { id: "req_image", model, messages: [ - LLM.user([ + Message.user([ { type: "text", text: "What is in this image?" }, { type: "media", mediaType: "image/png", data: "AAAA" }, { type: "media", mediaType: "image/jpeg", data: "BBBB" }, @@ -371,7 +371,7 @@ describe("Bedrock Converse route", () => { LLM.request({ id: "req_image_bytes", model, - messages: [LLM.user([{ type: "media", mediaType: "image/png", data: new Uint8Array([1, 2, 3, 4, 5]) }])], + messages: [Message.user([{ type: "media", mediaType: "image/png", data: new Uint8Array([1, 2, 3, 4, 5]) }])], }), ) @@ -394,7 +394,7 @@ describe("Bedrock Converse route", () => { id: "req_doc", model, messages: [ - LLM.user([ + Message.user([ { type: "media", mediaType: "application/pdf", data: "PDFDATA", filename: "report.pdf" }, { type: "media", mediaType: "text/csv", data: "CSVDATA" }, ]), @@ -424,7 +424,7 @@ describe("Bedrock Converse route", () => { LLM.request({ id: "req_bad_image", model, - messages: [LLM.user([{ type: "media", mediaType: "image/svg+xml", data: "x" }])], + messages: [Message.user([{ type: "media", mediaType: "image/svg+xml", data: "x" }])], }), ).pipe(Effect.flip) @@ -438,7 +438,7 @@ describe("Bedrock Converse route", () => { LLM.request({ id: "req_bad_doc", model, - messages: [LLM.user([{ type: "media", mediaType: "application/x-tar", data: "x", filename: "a.tar" }])], + messages: [Message.user([{ type: "media", mediaType: "application/x-tar", data: "x", filename: "a.tar" }])], }), ).pipe(Effect.flip) @@ -471,9 +471,9 @@ describe("Bedrock Converse route", () => { model, tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }], messages: [ - LLM.user("What's the weather?"), - LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]), - LLM.toolMessage({ id: "call_1", name: "lookup", result: { temp: 72 }, cache }), + Message.user("What's the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), + Message.tool({ id: "call_1", name: "lookup", result: { temp: 72 }, cache }), ], cache: "none", }), @@ -583,7 +583,7 @@ describe("Bedrock Converse recorded", () => { system: "Call tools exactly as requested.", prompt: "Call get_weather with city exactly Paris.", tools: [weatherTool], - toolChoice: LLM.toolChoice(weatherTool), + toolChoice: ToolChoice.make(weatherTool), cache: "none", generation: { maxTokens: 80, temperature: 0 }, }), diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index e0b3864a2..ea4eadc49 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMError, Usage } from "../../src" +import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src" import { LLMClient } from "../../src/route" import * as Gemini from "../../src/protocols/gemini" import { it } from "../lib/effect" @@ -49,12 +49,12 @@ describe("Gemini route", () => { ], toolChoice: { type: "tool", name: "lookup" }, messages: [ - LLM.user([ + Message.user([ { type: "text", text: "What is in this image?" }, { type: "media", mediaType: "image/png", data: "AAECAw==" }, ]), - LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), - LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), ], }), ) @@ -353,7 +353,7 @@ describe("Gemini route", () => { LLM.request({ id: "req_media", model, - messages: [LLM.assistant({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + messages: [Message.assistant({ type: "media", mediaType: "image/png", data: "AAECAw==" })], }), ).pipe(Effect.flip) diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 2c692dcd7..9c8142263 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Schema, Stream } from "effect" import { HttpClientRequest } from "effect/unstable/http" -import { LLM, LLMError, Usage } from "../../src" +import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src" import * as Azure from "../../src/providers/azure" import * as OpenAI from "../../src/providers/openai" import * as OpenAIChat from "../../src/protocols/openai-chat" @@ -149,9 +149,9 @@ describe("OpenAI Chat route", () => { id: "req_tool_result", model, messages: [ - LLM.user("What is the weather?"), - LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), - LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + Message.user("What is the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), ], }), ) @@ -185,7 +185,7 @@ describe("OpenAI Chat route", () => { LLM.request({ id: "req_media", model, - messages: [LLM.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], }), ).pipe(Effect.flip) @@ -199,7 +199,7 @@ describe("OpenAI Chat route", () => { LLM.request({ id: "req_reasoning", model, - messages: [LLM.assistant({ type: "reasoning", text: "hidden" })], + messages: [Message.assistant({ type: "reasoning", text: "hidden" })], }), ).pipe(Effect.flip) diff --git a/packages/llm/test/provider/openai-compatible-chat.test.ts b/packages/llm/test/provider/openai-compatible-chat.test.ts index 627e6ef4a..7759ff720 100644 --- a/packages/llm/test/provider/openai-compatible-chat.test.ts +++ b/packages/llm/test/provider/openai-compatible-chat.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Schema } from "effect" import { HttpClientRequest } from "effect/unstable/http" -import { LLM } from "../../src" +import { LLM, Message, ToolCallPart } from "../../src" import { LLMClient } from "../../src/route" import * as OpenAICompatible from "../../src/providers/openai-compatible" import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat" @@ -157,9 +157,9 @@ describe("OpenAI-compatible Chat route", () => { ], toolChoice: "lookup", messages: [ - LLM.user("What is the weather?"), - LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), - LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + Message.user("What is the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), ], }), ) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 2319857ed..da9dbd82c 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { ConfigProvider, Effect, Layer, Stream } from "effect" import { Headers, HttpClientRequest } from "effect/unstable/http" -import { LLM, LLMError, Usage } from "../../src" +import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src" import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route" import * as Azure from "../../src/providers/azure" import * as OpenAI from "../../src/providers/openai" @@ -251,9 +251,9 @@ describe("OpenAI Responses route", () => { id: "req_tool_result", model, messages: [ - LLM.user("What is the weather?"), - LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), - LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + Message.user("What is the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), ], }), ) @@ -508,7 +508,7 @@ describe("OpenAI Responses route", () => { LLM.request({ id: "req_media", model, - messages: [LLM.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], }), ).pipe(Effect.flip) diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts index 127a444a1..bdba8580f 100644 --- a/packages/llm/test/recorded-scenarios.ts +++ b/packages/llm/test/recorded-scenarios.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect, Schema, Stream } from "effect" -import { LLM, LLMEvent, LLMResponse, type LLMRequest, type ModelRef } from "../src" +import { LLM, LLMEvent, LLMResponse, ToolChoice, ToolDefinition, type LLMRequest, type ModelRef } from "../src" import { LLMClient } from "../src/route" import { tool } from "../src/tool" @@ -18,7 +18,7 @@ export const LARGE_CACHEABLE_SYSTEM = (() => { return sentence.repeat(250) })() -export const weatherTool = LLM.toolDefinition({ +export const weatherTool = ToolDefinition.make({ name: weatherToolName, description: "Get current weather for a city.", inputSchema: { @@ -70,7 +70,7 @@ export const weatherToolRequest = (input: { system: "Call tools exactly as requested.", prompt: "Call get_weather with city exactly Paris.", tools: [weatherTool], - toolChoice: LLM.toolChoice(weatherTool), + toolChoice: ToolChoice.make(weatherTool), cache: "none", generation: input.temperature === false diff --git a/packages/llm/test/tool-runtime.test.ts b/packages/llm/test/tool-runtime.test.ts index 7251dee8a..8f4221784 100644 --- a/packages/llm/test/tool-runtime.test.ts +++ b/packages/llm/test/tool-runtime.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Schema, Stream } from "effect" -import { LLM, LLMEvent, LLMRequest, LLMResponse } from "../src" +import { GenerationOptions, LLM, LLMEvent, LLMRequest, LLMResponse, ToolChoice } from "../src" import { LLMClient } from "../src/route" import * as AnthropicMessages from "../src/protocols/anthropic-messages" import * as OpenAIChat from "../src/protocols/openai-chat" @@ -78,8 +78,8 @@ describe("LLMClient tools", () => { yield* TestToolRuntime.runTools({ request: LLMRequest.update(baseRequest, { - generation: LLM.generation({ maxTokens: 50 }), - toolChoice: LLM.toolChoice("auto"), + generation: GenerationOptions.make({ maxTokens: 50 }), + toolChoice: ToolChoice.make("auto"), }), tools: { get_weather }, }).pipe(Stream.runCollect, Effect.provide(layer)) From 25b12ed754adb6e23997010ac30582ed0cf936bf Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 11 May 2026 17:06:57 +0000 Subject: [PATCH 020/188] chore: generate --- packages/llm/test/cache-policy.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/llm/test/cache-policy.test.ts b/packages/llm/test/cache-policy.test.ts index bb65a5636..ac700b58f 100644 --- a/packages/llm/test/cache-policy.test.ts +++ b/packages/llm/test/cache-policy.test.ts @@ -59,7 +59,11 @@ describe("applyCachePolicy", () => { model: anthropicModel, system: "Sys A", tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], - messages: [Message.user("first user"), Message.assistant("assistant reply"), Message.user("latest user message")], + messages: [ + Message.user("first user"), + Message.assistant("assistant reply"), + Message.user("latest user message"), + ], cache: "auto", }), ) From df386bd6512aac7020ca788fc909cb7e7ccd61d2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 13:13:41 -0400 Subject: [PATCH 021/188] feat(skill): enable customize-opencode by default, link full schema (#26899) --- packages/core/src/flag/flag.ts | 11 ----- packages/opencode/src/skill/index.ts | 12 +++--- .../src/skill/prompt/customize-opencode.md | 43 ++++++++++++++----- packages/opencode/test/preload.ts | 5 --- packages/opencode/test/skill/skill.test.ts | 20 ++++----- 5 files changed, 48 insertions(+), 43 deletions(-) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 3fe765575..175c723c5 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -1,5 +1,4 @@ import { Config } from "effect" -import { InstallationChannel } from "../installation/version" function truthy(key: string) { const value = process.env[key]?.toLowerCase() @@ -11,13 +10,6 @@ function falsy(key: string) { return value === "false" || value === "0" } -// Channels where new experiments default to ON (unstable / internal users). -// Stable channels (`prod`, `latest`) stay opt-in. -const UNSTABLE_CHANNELS = new Set(["dev", "beta", "local"]) -function unstableDefault(key: string) { - return truthy(key) || (!falsy(key) && UNSTABLE_CHANNELS.has(InstallationChannel)) -} - function number(key: string) { const value = process.env[key] if (!value) return undefined @@ -56,9 +48,6 @@ export const Flag = { OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"), OPENCODE_DISABLE_CLAUDE_CODE_SKILLS, OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"), - // Default-on for dev/beta/local; opt-in for stable. Set - // OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL=false to force off, =true to force on. - OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"), OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"], OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"], OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"], diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 01bffdb02..e532efa3d 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -242,13 +242,11 @@ export const layer = Layer.effect( const s: State = { skills: {}, dirs: new Set() } // Register the built-in skill BEFORE disk discovery so a user-disk // skill with the same name can override it. - if (Flag.OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL) { - s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { - name: CUSTOMIZE_OPENCODE_SKILL_NAME, - description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, - location: "", - content: CUSTOMIZE_OPENCODE_SKILL_BODY, - } + s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { + name: CUSTOMIZE_OPENCODE_SKILL_NAME, + description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, + location: "", + content: CUSTOMIZE_OPENCODE_SKILL_BODY, } yield* loadSkills(s, yield* InstanceState.get(discovered), bus) return s diff --git a/packages/opencode/src/skill/prompt/customize-opencode.md b/packages/opencode/src/skill/prompt/customize-opencode.md index 6158aae08..744690b15 100644 --- a/packages/opencode/src/skill/prompt/customize-opencode.md +++ b/packages/opencode/src/skill/prompt/customize-opencode.md @@ -1,17 +1,39 @@ # Customizing opencode opencode validates its own config strictly and refuses to start when a field -is wrong. The shapes below are the accepted shapes. When in doubt, fetch -`https://opencode.ai/config.json` (the JSON Schema) and validate against it. +is wrong. The shapes below cover the common surface area, but they are a +**summary, not the source of truth**. -Every `opencode.json` should declare `"$schema": "https://opencode.ai/config.json"` -so the user's editor catches mistakes as they type. +## Full schema reference + +The authoritative list of every config option — with field types, enums, +defaults, and descriptions — lives in the published JSON Schema: + +**** + +If a field is not documented in this skill, or you need to confirm an exact +shape before writing config, **fetch that URL and read the schema directly** +rather than guessing. opencode hard-fails on invalid config, so the cost of a +wrong shape is a broken startup. + +Independently, every `opencode.json` should declare +`"$schema": "https://opencode.ai/config.json"` so the user's editor catches +mistakes as they type. + +## Applying changes + +Config is loaded once when opencode starts and is not hot-reloaded. After +saving changes to `opencode.json`, an agent file, a skill, a plugin, or any +other config-time file, **tell the user to quit and restart opencode** for +the changes to take effect. The running session will keep using the +already-loaded config until then. ## Where files live @@ -343,12 +365,13 @@ When a user's config is broken and opencode won't start, these env vars help: ## When proposing edits - Validate against the schema before writing. If you are unsure of a field's - exact shape, fetch `https://opencode.ai/config.json` rather than guessing. + exact shape, or the field is not covered in this skill, fetch + `https://opencode.ai/config.json` and read the schema rather than guessing. - Preserve `$schema` and any existing fields the user did not ask to change. - For agent, skill, and plugin definitions, prefer creating new files in the correct location over inlining everything in `opencode.json`. - If the user's existing config is malformed, point them at the env-var escape - hatch above so they can edit from inside opencode without breaking their + hatches above so they can edit from inside opencode without breaking their session. -- opencode hard-fails on invalid config by design. There is no graceful - degradation, so get the shape right the first time. +- After saving any config change, remind the user to quit and restart opencode + — running sessions keep using the already-loaded config. diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 1ba0554d3..b408f7ef1 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -35,11 +35,6 @@ process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json") process.env["OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"] = "true" -// Tests assert exact skill counts from disk discovery; the built-in -// customize-opencode skill is opt-in for stable channels and on by default -// for unstable channels (including "local" where CI runs). Disable it here -// so disk-discovery tests aren't off-by-one. -process.env["OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"] = "false" // Set test home directory to isolate tests from user's actual home directory // This prevents tests from picking up real user configs/skills from ~/.claude/skills diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index d73750b08..969014e6b 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -63,7 +63,7 @@ Instructions here. ) const skill = yield* Skill.Service - const list = yield* skill.all() + const list = (yield* skill.all()).filter((s) => s.location !== "") expect(list.length).toBe(1) const item = list.find((x) => x.name === "test-skill") expect(item).toBeDefined() @@ -133,7 +133,7 @@ description: Second test skill. ) const skill = yield* Skill.Service - const list = yield* skill.all() + const list = (yield* skill.all()).filter((s) => s.location !== "") expect(list.length).toBe(2) expect(list.find((x) => x.name === "skill-one")).toBeDefined() expect(list.find((x) => x.name === "skill-two")).toBeDefined() @@ -157,7 +157,7 @@ Just some content without YAML frontmatter. ) const skill = yield* Skill.Service - expect(yield* skill.all()).toEqual([]) + expect((yield* skill.all()).filter((s) => s.location !== "")).toEqual([]) }), { git: true }, ), @@ -182,7 +182,7 @@ Instructions here. ) const skill = yield* Skill.Service - const list = yield* skill.all() + const list = (yield* skill.all()).filter((s) => s.location !== "") expect(list.length).toBe(1) const item = list.find((x) => x.name === "manual-skill") expect(item).toBeDefined() @@ -212,7 +212,7 @@ description: A skill in the .claude/skills directory. ) const skill = yield* Skill.Service - const list = yield* skill.all() + const list = (yield* skill.all()).filter((s) => s.location !== "") expect(list.length).toBe(1) const item = list.find((x) => x.name === "claude-skill") expect(item).toBeDefined() @@ -235,7 +235,7 @@ description: A skill in the .claude/skills directory. yield* Effect.promise(() => createGlobalSkill(tmp.path)) yield* Effect.gen(function* () { const skill = yield* Skill.Service - const list = yield* skill.all() + const list = (yield* skill.all()).filter((s) => s.location !== "") expect(list.length).toBe(1) expect(list[0].name).toBe("global-test-skill") expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.") @@ -251,7 +251,7 @@ description: A skill in the .claude/skills directory. () => Effect.gen(function* () { const skill = yield* Skill.Service - expect(yield* skill.all()).toEqual([]) + expect((yield* skill.all()).filter((s) => s.location !== "")).toEqual([]) }), { git: true }, ), @@ -275,7 +275,7 @@ description: A skill in the .agents/skills directory. ) const skill = yield* Skill.Service - const list = yield* skill.all() + const list = (yield* skill.all()).filter((s) => s.location !== "") expect(list.length).toBe(1) const item = list.find((x) => x.name === "agent-skill") expect(item).toBeDefined() @@ -314,7 +314,7 @@ This skill is loaded from the global home directory. yield* Effect.gen(function* () { const skill = yield* Skill.Service - const list = yield* skill.all() + const list = (yield* skill.all()).filter((s) => s.location !== "") expect(list.length).toBe(1) expect(list[0].name).toBe("global-agent-skill") expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.") @@ -355,7 +355,7 @@ description: A skill in the .agents/skills directory. ) const skill = yield* Skill.Service - const list = yield* skill.all() + const list = (yield* skill.all()).filter((s) => s.location !== "") expect(list.length).toBe(2) expect(list.find((x) => x.name === "claude-skill")).toBeDefined() expect(list.find((x) => x.name === "agent-skill")).toBeDefined() From 12583b18f0c7f169b7636b24844c17ba76388ded Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 11 May 2026 22:46:27 +0530 Subject: [PATCH 022/188] feat(tui): pin, quick-switch, and cycle recent sessions (#26858) --- packages/core/src/flag/flag.ts | 2 + packages/opencode/src/cli/cmd/tui/app.tsx | 51 ++++- .../cmd/tui/component/dialog-session-list.tsx | 131 ++++++++---- .../src/cli/cmd/tui/config/keybind.ts | 26 +++ .../src/cli/cmd/tui/context/local.tsx | 192 +++++++++++++++++- .../tui/feature-plugins/home/tips-view.tsx | 9 + 6 files changed, 373 insertions(+), 38 deletions(-) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 175c723c5..73ab3b055 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -85,6 +85,8 @@ export const Flag = { OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), + OPENCODE_EXPERIMENTAL_SESSION_SWITCHING: + OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SESSION_SWITCHING"), // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index cc2afd1cd..d7f2cd14b 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -74,6 +74,17 @@ const appBindingCommands = [ "command.palette.show", "session.list", "session.new", + "session.cycle_recent", + "session.cycle_recent_reverse", + "session.quick_switch.1", + "session.quick_switch.2", + "session.quick_switch.3", + "session.quick_switch.4", + "session.quick_switch.5", + "session.quick_switch.6", + "session.quick_switch.7", + "session.quick_switch.8", + "session.quick_switch.9", "model.list", "model.cycle_recent", "model.cycle_recent_reverse", @@ -462,6 +473,37 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.clear() }, }, + ...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING + ? [ + { + name: "session.cycle_recent", + title: "Cycle to previous recent session", + category: "Session", + hidden: true, + run: () => { + local.session.cycleRecent(1) + }, + }, + { + name: "session.cycle_recent_reverse", + title: "Cycle to next recent session", + category: "Session", + hidden: true, + run: () => { + local.session.cycleRecent(-1) + }, + }, + ...Array.from({ length: 9 }, (_, i) => ({ + name: `session.quick_switch.${i + 1}`, + title: `Switch to session in quick slot ${i + 1}`, + category: "Session", + hidden: true, + run: () => { + local.session.quickSwitch(i + 1) + }, + })), + ] + : []), { name: "model.list", title: "Switch model", @@ -776,7 +818,14 @@ function App(props: { onSnapshot?: () => Promise }) { useBindings(() => ({ enabled: command.matcher, - bindings: tuiConfig.keybinds.gather("app", appBindingCommands), + bindings: tuiConfig.keybinds.gather( + "app", + Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING + ? appBindingCommands + : appBindingCommands.filter( + (c) => !c.startsWith("session.cycle_recent") && !c.startsWith("session.quick_switch"), + ), + ), })) useBindings(() => ({ diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 35c966937..1dd33106d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -7,6 +7,7 @@ import { Locale } from "@/util/locale" import { useProject } from "@tui/context/project" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" +import { useLocal } from "../context/local" import { Flag } from "@opencode-ai/core/flag/flag" import { DialogSessionRename } from "./dialog-session-rename" import { createDebouncedSignal } from "../util/signal" @@ -25,6 +26,7 @@ export function DialogSessionList() { const project = useProject() const { theme } = useTheme() const sdk = useSDK() + const local = useLocal() const toast = useToast() const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) @@ -128,7 +130,10 @@ export function DialogSessionList() { const [browseOrder] = createSignal(orderByRecency(sync.data.session)) + const RECENT_LIMIT = 5 + const options = createMemo(() => { + const enabled = Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING const today = new Date().toDateString() const sessionMap = new Map( sessions() @@ -139,46 +144,74 @@ export function DialogSessionList() { const searchResult = searchResults() const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder() - return displayOrder - .map((id) => sessionMap.get(id)) - .filter((x) => x !== undefined) - .map((x) => { - const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined + const dismissed = enabled ? new Set(local.session.dismissedRecent()) : new Set() + const pinned = enabled ? local.session.pinned().filter((id) => sessionMap.has(id)) : [] + const pinnedSet = new Set(pinned) + const slotByID = enabled + ? new Map(local.session.slots().map((id, i) => [id, i + 1])) + : new Map() - let footer: JSX.Element | string = "" - if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { - if (x.workspaceID) { - footer = workspace ? ( - - ) : ( - - ) - } - } else { - footer = Locale.time(x.time.updated) - } + const recent = enabled + ? displayOrder.filter((id) => !pinnedSet.has(id) && !dismissed.has(id)).slice(0, RECENT_LIMIT) + : [] + const recentSet = new Set(recent) - const date = new Date(x.time.updated) - let category = date.toDateString() - if (category === today) { - category = "Today" - } - const isDeleting = toDelete() === x.id - const status = sync.data.session_status?.[x.id] - const isWorking = status?.type === "busy" || status?.type === "retry" - return { - title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title, - bg: isDeleting ? theme.error : undefined, - value: x.id, - category, - footer, - gutter: isWorking ? () => : undefined, + function buildOption(id: string, category: string) { + const x = sessionMap.get(id) + if (!x) return undefined + const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined + + let footer: JSX.Element | string = "" + if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + if (x.workspaceID) { + footer = workspace ? ( + + ) : ( + + ) } + } else { + footer = Locale.time(x.time.updated) + } + + const isDeleting = toDelete() === x.id + const status = sync.data.session_status?.[x.id] + const isWorking = status?.type === "busy" || status?.type === "retry" + const slot = slotByID.get(x.id) + const gutter = isWorking + ? () => + : slot !== undefined + ? () => {slot} + : undefined + return { + title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title, + bg: isDeleting ? theme.error : undefined, + value: x.id, + category, + footer, + gutter, + } + } + + const remaining = displayOrder + .filter((id) => !pinnedSet.has(id) && !recentSet.has(id)) + .map((id) => { + const x = sessionMap.get(id) + if (!x) return undefined + const label = new Date(x.time.updated).toDateString() + return buildOption(id, label === today ? "Today" : label) }) + .filter((x) => x !== undefined) + + return [ + ...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), + ...recent.map((id) => buildOption(id, "Recent")).filter((x) => x !== undefined), + ...remaining, + ] }) onMount(() => { @@ -203,6 +236,32 @@ export function DialogSessionList() { dialog.clear() }} actions={[ + ...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING + ? [ + { + command: "session.pin.toggle", + title: "pin/unpin", + onTrigger: (option: { value: string }) => { + local.session.togglePin(option.value) + }, + }, + { + command: "session.toggle.recent", + title: "toggle recent", + onTrigger: (option: { value: string }) => { + if (local.session.isPinned(option.value)) { + toast.show({ + variant: "info", + message: "Unpin the session first to toggle it in Recent", + duration: 3000, + }) + return + } + local.session.toggleRecent(option.value) + }, + }, + ] + : []), { command: "session.delete", title: "delete", diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts index 46a48e18e..b20c87f30 100644 --- a/packages/opencode/src/cli/cmd/tui/config/keybind.ts +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -80,6 +80,19 @@ const Definitions = { session_child_cycle: keybind("right", "Go to next child session"), session_child_cycle_reverse: keybind("left", "Go to previous child session"), session_parent: keybind("up", "Go to parent session"), + session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"), + session_toggle_recent: keybind("ctrl+h", "Show or hide session in the Recent group"), + session_cycle_recent: keybind("]", "Cycle to the previous recent session"), + session_cycle_recent_reverse: keybind("[", "Cycle to the next recent session"), + session_quick_switch_1: keybind("1", "Switch to session in quick slot 1"), + session_quick_switch_2: keybind("2", "Switch to session in quick slot 2"), + session_quick_switch_3: keybind("3", "Switch to session in quick slot 3"), + session_quick_switch_4: keybind("4", "Switch to session in quick slot 4"), + session_quick_switch_5: keybind("5", "Switch to session in quick slot 5"), + session_quick_switch_6: keybind("6", "Switch to session in quick slot 6"), + session_quick_switch_7: keybind("7", "Switch to session in quick slot 7"), + session_quick_switch_8: keybind("8", "Switch to session in quick slot 8"), + session_quick_switch_9: keybind("9", "Switch to session in quick slot 9"), stash_delete: keybind("ctrl+d", "Delete stash entry"), model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"), @@ -257,6 +270,19 @@ export const CommandMap = { session_child_cycle: "session.child.next", session_child_cycle_reverse: "session.child.previous", session_parent: "session.parent", + session_pin_toggle: "session.pin.toggle", + session_toggle_recent: "session.toggle.recent", + session_cycle_recent: "session.cycle_recent", + session_cycle_recent_reverse: "session.cycle_recent_reverse", + session_quick_switch_1: "session.quick_switch.1", + session_quick_switch_2: "session.quick_switch.2", + session_quick_switch_3: "session.quick_switch.3", + session_quick_switch_4: "session.quick_switch.4", + session_quick_switch_5: "session.quick_switch.5", + session_quick_switch_6: "session.quick_switch.6", + session_quick_switch_7: "session.quick_switch.7", + session_quick_switch_8: "session.quick_switch.8", + session_quick_switch_9: "session.quick_switch.9", stash_delete: "stash.delete", model_provider_list: "model.dialog.provider", model_favorite_toggle: "model.dialog.favorite", diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 2958b573d..fc2226315 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -1,11 +1,14 @@ import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" -import { batch, createEffect, createMemo } from "solid-js" +import { batch, createEffect, createMemo, on } from "solid-js" import { useSync } from "@tui/context/sync" import { useTheme } from "@tui/context/theme" +import { useRoute } from "@tui/context/route" +import { useEvent } from "@tui/context/event" import { uniqueBy } from "remeda" import path from "path" import { Global } from "@opencode-ai/core/global" +import { Flag } from "@opencode-ai/core/flag/flag" import { iife } from "@/util/iife" import { useToast } from "../ui/toast" import { useArgs } from "./args" @@ -380,6 +383,192 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } }) + const session = iife(() => { + const [sessionStore, setSessionStore] = createStore<{ + ready: boolean + pinned: string[] + dismissedRecent: string[] + recentOrder: string[] + }>({ + ready: false, + pinned: [], + dismissedRecent: [], + recentOrder: [], + }) + + const filePath = path.join(Global.Path.state, "session.json") + const state = { + pending: false, + } + + function save() { + if (!sessionStore.ready) { + state.pending = true + return + } + state.pending = false + void Filesystem.writeJson(filePath, { + pinned: sessionStore.pinned, + dismissedRecent: sessionStore.dismissedRecent, + recentOrder: sessionStore.recentOrder, + }) + } + + Filesystem.readJson(filePath) + .then((x: any) => { + if (Array.isArray(x.pinned)) setSessionStore("pinned", x.pinned) + if (Array.isArray(x.dismissedRecent)) setSessionStore("dismissedRecent", x.dismissedRecent) + if (Array.isArray(x.recentOrder)) setSessionStore("recentOrder", x.recentOrder) + }) + .catch(() => {}) + .finally(() => { + setSessionStore("ready", true) + if (state.pending) save() + }) + + const route = useRoute() + const event = useEvent() + let cycling = false + + const slots = createMemo(() => { + const rootSessions = sync.data.session.filter((x) => x.parentID === undefined) + const existing = new Set(rootSessions.map((x) => x.id)) + const dismissed = new Set(sessionStore.dismissedRecent) + const pins = sessionStore.pinned.filter((id) => existing.has(id)) + const pinnedSet = new Set(pins) + const recent = rootSessions + .filter((x) => !pinnedSet.has(x.id) && !dismissed.has(x.id)) + .toSorted((a, b) => b.time.updated - a.time.updated) + .map((x) => x.id) + return [...pins, ...recent].slice(0, 9) + }) + + function prune(sessionID: string) { + batch(() => { + if (sessionStore.pinned.includes(sessionID)) { + setSessionStore( + "pinned", + sessionStore.pinned.filter((x) => x !== sessionID), + ) + } + if (sessionStore.dismissedRecent.includes(sessionID)) { + setSessionStore( + "dismissedRecent", + sessionStore.dismissedRecent.filter((x) => x !== sessionID), + ) + } + if (sessionStore.recentOrder.includes(sessionID)) { + setSessionStore( + "recentOrder", + sessionStore.recentOrder.filter((x) => x !== sessionID), + ) + } + save() + }) + } + + event.on("session.deleted", (evt) => { + prune(evt.properties.info.id) + }) + + if (Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING) { + createEffect( + on( + () => (sessionStore.ready && route.data.type === "session" ? route.data.sessionID : undefined), + (sessionID) => { + if (!sessionID) return + if (cycling) { + cycling = false + return + } + const filtered = sessionStore.recentOrder.filter((x) => x !== sessionID) + const next = [sessionID, ...filtered].slice(0, 20) + setSessionStore("recentOrder", next) + save() + }, + ), + ) + } + + return { + get ready() { + return sessionStore.ready + }, + pinned() { + return sessionStore.pinned + }, + dismissedRecent() { + return sessionStore.dismissedRecent + }, + recentOrder() { + return sessionStore.recentOrder + }, + slots, + isPinned(sessionID: string) { + return sessionStore.pinned.includes(sessionID) + }, + isDismissed(sessionID: string) { + return sessionStore.dismissedRecent.includes(sessionID) + }, + togglePin(sessionID: string) { + batch(() => { + const exists = sessionStore.pinned.includes(sessionID) + const next = exists + ? sessionStore.pinned.filter((x) => x !== sessionID) + : [sessionID, ...sessionStore.pinned] + setSessionStore("pinned", next) + save() + }) + }, + toggleRecent(sessionID: string) { + batch(() => { + const exists = sessionStore.dismissedRecent.includes(sessionID) + const next = exists + ? sessionStore.dismissedRecent.filter((x) => x !== sessionID) + : [sessionID, ...sessionStore.dismissedRecent] + setSessionStore("dismissedRecent", next) + save() + }) + }, + quickSwitch(slot: number) { + const target = slots()[slot - 1] + if (!target) return + if (route.data.type === "session" && route.data.sessionID === target) return + route.navigate({ type: "session", sessionID: target }) + }, + cycleRecent(direction: 1 | -1) { + if (route.data.type !== "session") { + toast.show({ + variant: "info", + message: "Open a session first to cycle between recent sessions", + duration: 3000, + }) + return + } + const current = route.data.sessionID + const order = sessionStore.recentOrder.filter((id) => + sync.data.session.some((s) => s.id === id && s.parentID === undefined), + ) + if (order.length < 2) { + toast.show({ + variant: "info", + message: "No other recent sessions to cycle to", + duration: 3000, + }) + return + } + const index = order.indexOf(current) + if (index === -1) return + const next = index + direction + if (next < 0 || next >= order.length) return + const target = order[next] + if (!target || target === current) return + cycling = true + route.navigate({ type: "session", sessionID: target }) + }, + } + }) + const mcp = { isEnabled(name: string) { const status = sync.data.mcp[name] @@ -412,6 +601,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ model, agent, mcp, + session, } return result }, diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx index c7a7b211f..07a2844e9 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx @@ -1,5 +1,6 @@ import { createMemo, For } from "solid-js" import { DEFAULT_THEMES, useTheme } from "@tui/context/theme" +import { Flag } from "@opencode-ai/core/flag/flag" const themeCount = Object.keys(DEFAULT_THEMES).length const themeTip = `Use {highlight}/themes{/highlight} or {highlight}Ctrl+X T{/highlight} to switch between ${themeCount} built-in themes` @@ -66,6 +67,14 @@ const TIPS = [ themeTip, "Press {highlight}Ctrl+X N{/highlight} or {highlight}/new{/highlight} to start a fresh conversation session", "Use {highlight}/sessions{/highlight} or {highlight}Ctrl+X L{/highlight} to list and continue previous conversations", + ...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING + ? [ + "Press {highlight}Ctrl+F{/highlight} in the session list to pin a session so it stays at the top", + "Pinned and recent sessions are bound to {highlight}Ctrl+X 1{/highlight} through {highlight}Ctrl+X 9{/highlight} for one-press switching", + "Press {highlight}Ctrl+X ]{/highlight} / {highlight}Ctrl+X [{/highlight} to cycle through recently visited sessions", + "Press {highlight}Ctrl+H{/highlight} in the session list to show or hide a session in the Recent group", + ] + : []), "Run {highlight}/compact{/highlight} to summarize long sessions near context limits", "Press {highlight}Ctrl+X X{/highlight} or {highlight}/export{/highlight} to save the conversation as Markdown", "Press {highlight}Ctrl+X Y{/highlight} to copy the assistant's last message to clipboard", From cddab63808c394422aa519a01b07af52506e85d7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 11 May 2026 17:17:38 +0000 Subject: [PATCH 023/188] chore: generate --- packages/core/src/flag/flag.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 73ab3b055..f76d1aaf9 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -85,8 +85,7 @@ export const Flag = { OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), - OPENCODE_EXPERIMENTAL_SESSION_SWITCHING: - OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SESSION_SWITCHING"), + OPENCODE_EXPERIMENTAL_SESSION_SWITCHING: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SESSION_SWITCHING"), // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. From 6f2f759fbb7ecce6c28bac6ab0d72891370827ac Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 13:20:54 -0400 Subject: [PATCH 024/188] Clean up post-Hono references (#26903) --- bun.lock | 6 - packages/console/function/package.json | 2 - packages/opencode/specs/effect/errors.md | 15 +- packages/opencode/specs/effect/routes.md | 27 +- packages/opencode/specs/effect/schema.md | 40 +- .../opencode/specs/effect/server-package.md | 694 ++---------------- .../instance/httpapi/handlers/provider.ts | 2 +- .../routes/instance/httpapi/handlers/pty.ts | 2 +- .../instance/httpapi/handlers/session.ts | 2 +- .../httpapi/middleware/compression.ts | 2 +- .../server/routes/instance/httpapi/public.ts | 8 +- .../test/project/instance-bootstrap.test.ts | 4 +- .../test/server/httpapi-config.test.ts | 2 +- .../test/server/httpapi-experimental.test.ts | 9 +- .../test/server/httpapi-instance.test.ts | 5 +- specs/v2/todo.md | 6 +- 16 files changed, 90 insertions(+), 736 deletions(-) diff --git a/bun.lock b/bun.lock index c3758e232..37df44b0b 100644 --- a/bun.lock +++ b/bun.lock @@ -152,12 +152,10 @@ "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", "@ai-sdk/openai-compatible": "2.0.37", - "@hono/zod-validator": "catalog:", "@openauthjs/openauth": "0.0.0-20250322224806", "@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-resource": "workspace:*", "ai": "catalog:", - "hono": "catalog:", "zod": "catalog:", }, "devDependencies": { @@ -1235,8 +1233,6 @@ "@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="], - "@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="], - "@ibm/plex": ["@ibm/plex@6.4.1", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-fnsipQywHt3zWvsnlyYKMikcVI7E2fEwpiPnIHFqlbByXVfQfANAAeJk1IV4mNnxhppUIDlhU0TzwYwL++Rn2g=="], "@ibm/telemetry-js": ["@ibm/telemetry-js@1.11.0", "", { "bin": { "ibmtelemetry": "dist/collect.js" } }, "sha512-RO/9j+URJnSfseWg9ZkEX9p+a3Ousd33DBU7rOafoZB08RqdzxFVYJ2/iM50dkBuD0o7WX7GYt1sLbNgCoE+pA=="], @@ -5469,8 +5465,6 @@ "@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "@hono/zod-validator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 41487f845..5c1d1ba22 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -20,12 +20,10 @@ "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", "@ai-sdk/openai-compatible": "2.0.37", - "@hono/zod-validator": "catalog:", "@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-resource": "workspace:*", "@openauthjs/openauth": "0.0.0-20250322224806", "ai": "catalog:", - "hono": "catalog:", "zod": "catalog:" } } diff --git a/packages/opencode/specs/effect/errors.md b/packages/opencode/specs/effect/errors.md index 746e65869..e19199ef4 100644 --- a/packages/opencode/specs/effect/errors.md +++ b/packages/opencode/specs/effect/errors.md @@ -23,8 +23,9 @@ contracts. - Some services already use `Schema.TaggedErrorClass`, for example `Account`, `Auth`, `Permission`, `Question`, `Installation`, and parts of `Workspace`. -- Legacy Hono error handling recognizes `NamedError`, `Session.BusyError`, and a - few name-based cases, then emits the legacy `{ name, data }` JSON body. +- The temporary HttpApi compatibility middleware recognizes `NamedError`, + `Session.BusyError`, and a few name-based cases, then emits the legacy + `{ name, data }` JSON body. - Effect `HttpApi` only knows how to encode errors that are declared on the endpoint, group, or middleware. Undeclared expected errors become defects and eventually fall through to generic HTTP handling. @@ -127,7 +128,7 @@ Create an HttpApi-local error module, likely That module should provide: - Legacy-compatible public schemas for `{ name, data }` error bodies that must - remain SDK-compatible during the Hono migration. + remain SDK-compatible while route groups declare typed errors. - Small constructors or mapping helpers for common API errors such as not found, bad request, conflict, and unknown internal errors. - Route-group-specific adapters only when they encode domain-specific public @@ -173,7 +174,7 @@ Add the `httpapi/errors.ts` module before converting route groups. - Define a legacy `{ name, data }` body helper for SDK-compatible errors. - Define `UnknownError` for generic internal failures with a safe public message. - Define `BadRequestError` and `NotFoundError` equivalents only if the actual - wire body must match the legacy Hono SDK surface. + wire body must match the existing SDK surface. - Put the HTTP status on the public schema with `HttpApiSchema.status(...)` or `{ httpApiStatus: code }`; do not keep a separate name-to-status table. - Keep conversion helpers pure and small. They should not inspect `Cause` or @@ -238,7 +239,7 @@ Suggested route order: 2. `experimental` worktree mutations. 3. `provider` auth and model selection errors. 4. `mcp` OAuth and connection errors. -5. Remaining route groups as Hono deletion work progresses. +5. Remaining route groups as typed error contracts are declared. ### 6. Remove Defect Recovery @@ -286,8 +287,8 @@ For HttpApi conversions: errors. - Add a regression test that the temporary middleware is no longer needed for the migrated route. -- Keep bridge/parity tests aligned with legacy Hono behavior until Hono is - deleted or the SDK contract intentionally changes. +- Keep compatibility tests aligned with the existing SDK contract until the + public error shape intentionally changes. ## Verification Commands diff --git a/packages/opencode/specs/effect/routes.md b/packages/opencode/specs/effect/routes.md index 3bf7e1b55..8066bda34 100644 --- a/packages/opencode/specs/effect/routes.md +++ b/packages/opencode/specs/effect/routes.md @@ -39,26 +39,19 @@ This eliminates multiple `runPromise` round-trips and lets handlers compose natu ## Current route files -Current instance route files live under `src/server/routes/instance`. - -Files that are already mostly on the intended service-yielding shape: - -- [x] `server/routes/instance/question.ts` — handlers yield `Question.Service` -- [x] `server/routes/instance/provider.ts` — handlers yield `Provider.Service`, `ProviderAuth.Service`, and `Config.Service` -- [x] `server/routes/instance/permission.ts` — handlers yield `Permission.Service` -- [x] `server/routes/instance/mcp.ts` — handlers mostly yield `MCP.Service` -- [x] `server/routes/instance/pty.ts` — handlers yield `Pty.Service` +Current instance route files live under `src/server/routes/instance/httpapi`. +Most handlers already yield stable services at route-layer construction and then +close over those services in endpoint implementations. Files still worth tracking here: -- [ ] `server/routes/instance/session.ts` — still the heaviest mixed file; many handlers are composed, but the file still mixes patterns and has direct `Bus.publish(...)` / `Session.list(...)` usage -- [ ] `server/routes/instance/index.ts` — mostly converted, but still has direct `Instance.dispose()` / `Instance.*` reads for `/instance/dispose` and `/path` -- [ ] `server/routes/instance/file.ts` — most handlers yield services, but `/find` still passes `Instance.directory` directly into ripgrep and `/find/symbol` is still stubbed -- [ ] `server/routes/instance/experimental.ts` — mixed state; many handlers are composed, but some still rely on `runRequest(...)` or direct `Instance.project` reads -- [ ] `server/routes/instance/middleware.ts` — still enters the instance via `Instance.provide(...)` -- [ ] `server/routes/global.ts` — still uses `Instance.disposeAll()` and remains partly outside the fully-composed style +- [ ] `handlers/session.ts` — still the heaviest mixed file; some paths keep compatibility translations and direct event publication +- [ ] `handlers/experimental.ts` — mixed state; some handlers still rely on request-local context reads +- [ ] `middleware/*` — still contains compatibility policy for auth, compression, errors, instance context, and workspace routing +- [ ] `public.ts` — still owns SDK/OpenAPI compatibility translation shims +- [ ] raw route modules — WebSocket and catch-all routes should stay explicit and avoid rebuilding stable layers per request ## Notes -- Route conversion is now less about facade removal and more about removing the remaining direct `Instance.*` reads, `Instance.provide(...)` boundaries, and small Promise-style bridges inside route files. -- `jsonRequest(...)` / `runRequest(...)` already provide a good intermediate shape for many handlers. The remaining cleanup is mostly consistency work in the heavier files. +- Route conversion is now less about backend migration and more about removing the remaining direct `Instance.*` reads, request-local service plumbing, and OpenAPI compatibility shims. +- Prefer route-layer service capture over rebuilding or providing stable layers inside individual handlers. diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index e20605c3b..20b3e70e7 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -305,38 +305,18 @@ emitted JSON Schema must stay byte-identical. ### HTTP route boundaries -Every file in `src/server/routes/` uses hono-openapi with zod validators for -route inputs/outputs. Migrating these individually is the last step; most -will switch to `.zod` derived from the Schema-migrated domain types above, -which means touching them is largely mechanical once the domain side is -done. +The server route tree now lives under `src/server/routes/instance/httpapi` and +uses Effect HttpApi contracts for request and response schemas. Remaining schema +work is no longer a Hono route migration; it is compatibility cleanup around +derived `.zod` statics, OpenAPI translation shims, and route groups that still +need explicit SDK-visible error contracts. -- [ ] `src/server/error.ts` -- [x] `src/server/event.ts` -- [x] `src/server/projectors.ts` -- [ ] `src/server/routes/control/index.ts` -- [ ] `src/server/routes/control/workspace.ts` -- [ ] `src/server/routes/global.ts` -- [ ] `src/server/routes/instance/index.ts` -- [ ] `src/server/routes/instance/config.ts` -- [ ] `src/server/routes/instance/event.ts` -- [ ] `src/server/routes/instance/experimental.ts` -- [ ] `src/server/routes/instance/file.ts` -- [ ] `src/server/routes/instance/mcp.ts` -- [ ] `src/server/routes/instance/permission.ts` -- [ ] `src/server/routes/instance/project.ts` -- [ ] `src/server/routes/instance/provider.ts` -- [ ] `src/server/routes/instance/pty.ts` -- [ ] `src/server/routes/instance/question.ts` -- [ ] `src/server/routes/instance/session.ts` -- [ ] `src/server/routes/instance/sync.ts` -- [ ] `src/server/routes/instance/tui.ts` +Good follow-up targets: -The bigger prize for this group is the `@effect/platform` HTTP migration -described in `specs/effect/http-api.md`. Once that lands, every one of -these files changes shape entirely (`HttpApi.endpoint(...)` and friends), -so the Schema-first domain types become a prerequisite rather than a -sibling task. +- shrink `public.ts` legacy OpenAPI translation shims one SDK-compatible slice at a time +- replace production `.zod.safeParse(...)` call sites with Effect Schema decoders +- remove derived `.zod` statics after their production consumers are gone +- declare route-group errors directly instead of relying on compatibility middleware ### Everything else diff --git a/packages/opencode/specs/effect/server-package.md b/packages/opencode/specs/effect/server-package.md index 06e89c18d..036472337 100644 --- a/packages/opencode/specs/effect/server-package.md +++ b/packages/opencode/specs/effect/server-package.md @@ -1,668 +1,58 @@ -# Server package extraction +# Server Package Extraction -Practical reference for extracting a future `packages/server` from the current `packages/opencode` monolith while `packages/core` is still being migrated to Effect. +Practical reference for a future `packages/server` split after the opencode +server moved to the Effect HttpApi backend. -This document is intentionally execution-oriented. +## Current State -It should give an agent enough context to land one incremental PR at a time without needing to rediscover the package strategy, route migration rules, or current constraints. +- The server still lives in `packages/opencode`. +- The runtime and app layer are centralized in `src/effect/app-runtime.ts` and + `src/effect/run-service.ts`. +- The route tree lives under `src/server/routes/instance/httpapi` and is hosted + from `src/server/server.ts`. +- OpenAPI generation is based on the HttpApi contract plus compatibility + translation in `src/server/routes/instance/httpapi/public.ts`. +- There is no standalone `packages/server` workspace yet. -## Goal - -Create `packages/server` as the home for: - -- HTTP contract definitions -- HTTP handler implementations -- OpenAPI generation -- eventual embeddable server APIs for Node apps - -Do this without blocking on the full `packages/core` extraction. - -## Future state +## Future State Target package layout: -- `packages/core` - all opencode services, Effect-first source of truth -- `packages/server` - opencode server, with separate contract and implementation, still producing `openapi.json` -- `packages/cli` - TUI + CLI entrypoints -- `packages/sdk` - generated from the server OpenAPI spec, may add higher-level wrappers -- `packages/plugin` - generated or semi-hand-rolled non-Effect package built from core plugin definitions +- `packages/core` - shared domain services and schemas +- `packages/server` - HTTP contracts, handlers, OpenAPI generation, and an + embeddable server API +- `packages/cli` - TUI and CLI entrypoints +- `packages/sdk` - generated from the server OpenAPI spec +- `packages/plugin` - plugin authoring surface -Desired user stories: +## Extraction Rule -- import from `core` and build a custom agent or app-specific runtime -- import from `server` and embed the full opencode server into an existing Node app -- spawn the CLI and talk to the server through that boundary +Do not create a package cycle. -## Current state +Until enough shared service code lives outside `packages/opencode`, a future +`packages/server` should either: -Everything still lives in `packages/opencode`. +- own pure HttpApi contracts only, or +- accept host-provided services/layers/callbacks from `packages/opencode` -Important current facts: +It should not import `packages/opencode` services while `packages/opencode` +imports it to host routes. -- there is no `packages/core` or `packages/cli` workspace yet -- there is no `packages/server` workspace yet on this branch -- the main host server is still Hono-based in `src/server/server.ts` -- current OpenAPI generation is Hono-based through `Server.openapi()` and `cli/cmd/generate.ts` -- the Effect runtime and app layer are centralized in `src/effect/app-runtime.ts` and `src/effect/run-service.ts` -- there are already bridged Effect `HttpApi` slices under `src/server/routes/instance/httpapi/*` -- those slices are mounted into the Hono server behind `OPENCODE_EXPERIMENTAL_HTTPAPI` -- the bridge currently covers `question`, `permission`, `provider`, partial `config`, and partial `project` routes +## Suggested PR Sequence -This means the package split should start from an extraction path, not from greenfield package ownership. +1. Keep shrinking OpenAPI compatibility shims in `httpapi/public.ts`. +2. Move stable domain schemas into shared packages only when they no longer + depend on opencode-local runtime modules. +3. Extract pure HttpApi contract modules into `packages/server` once the contract + can compile without importing `packages/opencode` implementation details. +4. Extract handler factories after their service dependencies can be supplied by + a host layer instead of imported directly. +5. Move server hosting last, after package ownership is clear. -## Structural reference +## Non-Goals -Use `anomalyco/opentunnel` as the structural reference for `packages/server`. - -The important pattern there is: - -- `packages/core` owns services and domain schemas -- `packages/server/src/definition/*` owns pure `HttpApi` contracts -- `packages/server/src/api/*` owns `HttpApiBuilder.group(...)` implementations and server-side middleware wiring -- `packages/server/src/index.ts` becomes the composition root only after the server package really owns runtime hosting - -Relevant `opentunnel` files: - -- `packages/server/src/definition/index.ts` -- `packages/server/src/definition/tunnel.ts` -- `packages/server/src/api/index.ts` -- `packages/server/src/api/tunnel.ts` -- `packages/server/src/api/client.ts` -- `packages/server/src/index.ts` - -The intended direction here is the same, but the current `opencode` package split is earlier in the migration. - -That means: - -- we should follow the same `definition` and `api` naming -- we should keep contract and implementation as separate modules from the start -- we should postpone the runtime composition root until `packages/core` exists enough to support it cleanly - -## Key decision - -Start `packages/server` as a contract and implementation package only. - -Do not make it the runtime host yet. - -Why: - -- `packages/core` does not exist yet -- the current server host still lives in `packages/opencode` -- moving host ownership immediately would force a large package and runtime shuffle while Effect service extraction is still in flight -- if `packages/server` imports services from `packages/opencode` while `packages/opencode` imports `packages/server` to host routes, we create a package cycle immediately - -Short version: - -1. create `packages/server` -2. move pure `HttpApi` contracts there -3. move handler factories there -4. keep `packages/opencode` as the temporary Hono host -5. merge `packages/server` OpenAPI with the legacy Hono OpenAPI during the transition -6. move server hosting later, after `packages/core` exists enough - -## Dependency rule - -Phase 1 rule: - -- `packages/server` must not import from `packages/opencode` - -Allowed in phase 1: - -- `packages/opencode` imports `packages/server` -- `packages/server` accepts host-provided services, layers, or callbacks as inputs -- `packages/server` may temporarily own transport-local placeholder schemas when a canonical shared schema does not exist yet - -Future rule after `packages/core` exists: - -- `packages/server` imports from `packages/core` -- `packages/cli` imports from `packages/server` and `packages/core` -- `packages/opencode` shrinks or disappears as package responsibilities are fully split - -## HttpApi model - -Use Effect v4 `HttpApi` as the source of truth for migrated HTTP routes. - -Important properties from the current `effect` / `effect-smol` model: - -- `HttpApi`, `HttpApiGroup`, and `HttpApiEndpoint` are pure contract definitions -- handlers are implemented separately with `HttpApiBuilder.group(...)` -- OpenAPI can be generated from the contract alone -- auth and middleware can later be modeled with `HttpApiMiddleware.Service` -- SSE and websocket routes are not good first-wave `HttpApi` targets - -This package split should preserve that separation explicitly. - -Default shape for migrated routes: - -- contract lives in `packages/server/src/definition/*` -- implementation lives in `packages/server/src/api/*` -- host mounting stays outside for now - -## OpenAPI rule - -During the transition there is still one spec artifact. - -Default rule: - -- `packages/server` generates OpenAPI from `HttpApi` contract -- `packages/opencode` keeps generating legacy OpenAPI from Hono routes -- the temporary exported server spec is a merged document -- `packages/sdk` continues consuming one `openapi.json` - -Merge safety rules: - -- fail on duplicate `path + method` -- fail on duplicate `operationId` -- prefer explicit summary, description, and operation ids on all new `HttpApi` endpoints - -Practical implication: - -- do not make the SDK consume two specs -- do not switch SDK generation to `packages/server` only until enough of the route surface has moved - -## Package shape - -Minimum viable `packages/server`: - -- `src/index.ts` -- `src/definition/index.ts` -- `src/definition/api.ts` -- `src/definition/question.ts` -- `src/api/index.ts` -- `src/api/question.ts` -- `src/openapi.ts` -- `src/bridge/hono.ts` -- `src/types.ts` - -Later additions, once there is enough real contract surface: - -- `src/api/client.ts` -- runtime composition in `src/index.ts` - -Suggested initial exports: - -- `api` -- `openapi` -- `questionApi` -- `makeQuestionHandler` - -Phase 1 responsibilities: - -- own pure API contracts -- own handler factories for migrated slices -- own contract-generated OpenAPI -- expose host adapters needed by `packages/opencode` - -Phase 1 non-goals: - -- do not own `listen()` -- do not own adapter selection -- do not own global server middleware -- do not own websocket or SSE transport -- do not own process bootstrapping for CLI entrypoints - -## Current source inventory - -These files matter for the first phase. - -Current host and route composition: - -- `src/server/server.ts` -- `src/server/control/index.ts` -- `src/server/routes/instance/index.ts` -- `src/server/middleware.ts` -- `src/server/adapter.bun.ts` -- `src/server/adapter.node.ts` - -Current bridged `HttpApi` slices: - -- `src/server/routes/instance/httpapi/question.ts` -- `src/server/routes/instance/httpapi/permission.ts` -- `src/server/routes/instance/httpapi/provider.ts` -- `src/server/routes/instance/httpapi/config.ts` -- `src/server/routes/instance/httpapi/project.ts` -- `src/server/routes/instance/httpapi/server.ts` - -Current OpenAPI flow: - -- `src/server/server.ts` via `Server.openapi()` -- `src/cli/cmd/generate.ts` -- `packages/sdk/js/script/build.ts` - -Current runtime and service layer: - -- `src/effect/app-runtime.ts` -- `src/effect/run-service.ts` - -## Ownership rules - -Move first into `packages/server`: - -- the experimental `question` `HttpApi` slice -- future `provider` and `config` JSON read slices -- any new `HttpApi` route groups -- transport-local OpenAPI generation for migrated routes - -Keep in `packages/opencode` for now: - -- `src/server/server.ts` -- `src/server/control/index.ts` -- `src/server/routes/**/*.ts` -- `src/server/middleware.ts` -- `src/server/adapter.*.ts` -- `src/effect/app-runtime.ts` -- `src/effect/run-service.ts` -- all Effect services until they move to `packages/core` - -## Placeholder schema rule - -`packages/core` is allowed to lag behind. - -Until shared canonical schemas move to `packages/core`: - -- prefer importing existing Effect Schema DTOs from current locations when practical -- if a route only needs a transport-local type and moving the canonical schema would create unrelated churn, allow a temporary server-local placeholder schema -- if a placeholder is introduced, leave a short note so it does not become permanent - -The default rule from `schema.md` still applies: - -- Effect Schema owns the type -- `.zod` is compatibility only -- avoid parallel hand-written Zod and Effect definitions for the same migrated route shape - -## Host boundary rule - -Until host ownership moves: - -- auth stays at the outer Hono app level -- compression stays at the outer Hono app level -- CORS stays at the outer Hono app level -- instance and workspace lookup stay at the current middleware layer -- `packages/server` handlers should assume the host already provided the right request context -- do not redesign host middleware just to land the package split - -This matches the current guidance in `http-api.md`: - -- keep auth outside the first parallel `HttpApi` slices -- keep instance lookup outside the first parallel `HttpApi` slices -- keep the first migrations transport-focused and semantics-preserving - -## Route selection rules - -Good early migration targets: - -- `question` -- `provider` auth read endpoint -- `config` providers read endpoint -- small read-only instance routes - -Bad early migration targets: - -- `session` -- `event` -- `pty` -- most `global` streaming or process-heavy routes -- anything requiring websocket upgrade handling -- anything that mixes many mutations and streaming in one file - -## First vertical slice - -The first slice for the package split is still the existing `question` `HttpApi` group. - -Why `question` first: - -- it already exists as an experimental `HttpApi` slice -- it already follows the desired contract and implementation split in one file -- it is already mounted through the current Hono host -- it is JSON-only -- it has low blast radius - -Use the first slice to prove: - -- package boundary -- contract and implementation split -- host mounting from `packages/opencode` -- merged OpenAPI output -- test ergonomics for future slices - -Do not broaden scope in the first slice. - -## Incremental migration order - -Use small PRs. - -Each PR should be easy to review, easy to revert, and should not mix extraction work with unrelated service refactors. - -### PR 1. Create `packages/server` - -Scope: - -- add the new workspace package -- add package manifest and tsconfig -- add empty `src/index.ts`, `src/definition/api.ts`, `src/definition/index.ts`, `src/api/index.ts`, `src/openapi.ts`, and supporting scaffolding - -Rules: - -- no production behavior changes -- no host server changes yet -- no imports from `packages/opencode` inside `packages/server` -- prefer `opentunnel`-style naming from the start: `definition` for contracts, `api` for implementations - -Done means: - -- `packages/server` typechecks -- the workspace can import it -- the package boundary is in place for follow-up PRs - -### PR 2. Move the experimental question contract - -Scope: - -- extract the pure `HttpApi` contract from `src/server/routes/instance/httpapi/question.ts` -- place it in `packages/server/src/definition/question.ts` -- aggregate it in `packages/server/src/definition/api.ts` -- generate OpenAPI in `packages/server/src/openapi.ts` - -Rules: - -- contract only in this PR -- no handler movement yet if that keeps the diff simpler -- keep operation ids and docs metadata stable - -Done means: - -- question contract lives in `packages/server` -- OpenAPI can be generated from contract alone -- no runtime behavior changes yet - -### PR 3. Move the experimental question handler factory - -Scope: - -- extract the question `HttpApiBuilder.group(...)` implementation into `packages/server/src/api/question.ts` -- expose it as a factory that accepts host-provided dependencies or wiring -- add a small Hono bridge in `packages/server/src/bridge/hono.ts` if needed - -Rules: - -- `packages/server` must still not import from `packages/opencode` -- handler code should stay thin and service-delegating -- do not redesign the question service itself in this PR - -Done means: - -- `packages/server` can produce the experimental question handler -- the package still stays cycle-free - -### PR 4. Mount `packages/server` question from `packages/opencode` - -Scope: - -- replace local experimental question route wiring in `packages/opencode` -- keep the same mount path: -- `/question` -- `/question/:requestID/reply` -- `/question/:requestID/reject` - -Rules: - -- no behavior change -- preserve existing docs path -- preserve current request and response shapes - -Done means: - -- existing question `HttpApi` test still passes -- runtime behavior is unchanged -- the current host server is now consuming `packages/server` - -### PR 5. Merge legacy and contract OpenAPI - -Scope: - -- keep `Server.openapi()` as the temporary spec entrypoint -- generate legacy Hono spec -- generate `packages/server` contract spec -- merge them into one document -- keep `cli/cmd/generate.ts` and `packages/sdk/js/script/build.ts` consuming one spec - -Rules: - -- fail loudly on duplicate `path + method` -- fail loudly on duplicate `operationId` -- do not silently overwrite one source with the other - -Done means: - -- one merged spec is produced -- migrated question paths can come from `packages/server` -- existing SDK generation path still works - -### PR 6. Add merged OpenAPI coverage - -Scope: - -- add one test for merged OpenAPI -- assert both a legacy Hono route and a migrated `HttpApi` route exist - -Rules: - -- test the merged document, not just the `packages/server` contract spec in isolation -- pick one stable legacy route and one stable migrated route - -Done means: - -- the merged-spec path is covered -- future route migrations have a guardrail - -### PR 7. Migrate `GET /provider/auth` - -Scope: - -- add `GET /provider/auth` as the next `HttpApi` slice in `packages/server` -- mount it in parallel from `packages/opencode` - -Why this route: - -- JSON-only -- simple service delegation -- small response shape -- already listed as the best next `provider` candidate in `http-api.md` - -Done means: - -- route works through the current host -- route appears in merged OpenAPI -- no semantic change to provider auth behavior - -### PR 8. Migrate `GET /config/providers` - -Scope: - -- add `GET /config/providers` as a `HttpApi` slice in `packages/server` -- mount it in parallel from `packages/opencode` - -Why this route: - -- JSON-only -- read-only -- low transport complexity -- already listed as the best next `config` candidate in `http-api.md` - -Done means: - -- route works unchanged -- route appears in merged OpenAPI - -### PR 9+. Migrate small read-only instance routes - -Candidate order: - -1. `GET /path` -2. `GET /vcs` -3. `GET /vcs/diff` -4. `GET /command` -5. `GET /agent` -6. `GET /skill` - -Rules: - -- one or two endpoints per PR -- prefer read-only routes first -- keep outer middleware unchanged -- keep business logic in the existing service layer - -Done means for each PR: - -- contract lives in `packages/server` -- handler lives in `packages/server` -- route is mounted from the current host -- route appears in merged OpenAPI -- behavior remains unchanged - -### Later PR. Move host ownership into `packages/server` - -Only start this after there is enough `packages/core` surface to depend on directly. - -Scope: - -- move server composition into `packages/server` -- add embeddable APIs such as `createServer(...)`, `listen(...)`, or `createApp(...)` -- move adapter selection and server startup out of `packages/opencode` - -Rules: - -- do not start this while `packages/server` still depends on `packages/opencode` -- do not mix this with route migration PRs - -Done means: - -- `packages/server` can be embedded in another Node app -- `packages/cli` can depend on `packages/server` -- host logic no longer lives in `packages/opencode` - -## PR sizing rule - -Every migration PR should satisfy all of these: - -- one route group or one to two endpoints -- no unrelated service refactor -- no auth redesign -- no middleware redesign -- OpenAPI updated -- at least one route test or spec test added or updated - -## Done means for a migrated route group - -A route group migration is complete only when: - -1. the `HttpApi` contract lives in `packages/server` -2. handler implementation lives in `packages/server` -3. the route is mounted from the current host in `packages/opencode` -4. the route appears in merged OpenAPI -5. request and response schemas are Effect Schema-first or clearly temporary placeholders -6. existing behavior remains unchanged -7. the route has straightforward test coverage - -## Validation expectations - -For package-split PRs, validate the smallest useful thing. - -Typical validation for the first waves: - -- `bun typecheck` in the touched package directory or directories -- the relevant server / route coverage for the migrated slice -- merged OpenAPI coverage if the PR touches spec generation - -Do not run tests from repo root. - -## Main risks - -### Package cycle - -This is the biggest risk. - -Bad state: - -- `packages/server` imports services or runtime from `packages/opencode` -- `packages/opencode` imports route definitions or handlers from `packages/server` - -Avoid by: - -- keeping phase-1 `packages/server` free of `packages/opencode` imports -- using factories and host-provided wiring instead of direct service imports - -### Spec drift - -During the transition there are two route-definition sources. - -Avoid by: - -- one merged spec -- collision checks -- explicit `operationId`s -- merged OpenAPI tests - -### Middleware mismatch - -Current auth, compression, CORS, and instance selection are Hono-centered. - -Avoid by: - -- leaving them where they are during the first wave -- not trying to solve `HttpApiMiddleware.Service` globally in the package-split PRs - -### Core lag - -`packages/core` will not be ready everywhere. - -Avoid by: - -- allowing small transport-local placeholder schemas where necessary -- keeping those placeholders clearly temporary -- not blocking the server extraction on full schema movement - -### Scope creep - -The first vertical slice is easy to overload. - -Avoid by: - -- proving the package boundary first -- not mixing package creation, route migration, host redesign, and core extraction in the same change - -## Non-goals for the first wave - -- do not replace all Hono routes at once -- do not migrate SSE or websocket routes first -- do not redesign auth -- do not redesign instance lookup -- do not wait for full `packages/core` before starting `packages/server` -- do not change SDK generation to consume multiple specs - -## Checklist - -- [x] create `packages/server` -- [x] add package-level exports for contract and OpenAPI -- [ ] extract `question` contract into `packages/server` -- [ ] extract `question` handler factory into `packages/server` -- [ ] mount `question` from `packages/opencode` -- [ ] merge legacy and contract OpenAPI into one document -- [ ] add merged-spec coverage -- [ ] migrate `GET /provider/auth` -- [ ] migrate `GET /config/providers` -- [ ] migrate small read-only instance routes one or two at a time -- [ ] move host ownership into `packages/server` only after `packages/core` is ready enough -- [ ] split `packages/cli` after server and core boundaries are stable - -## Rule of thumb - -The fastest correct path is: - -1. establish `packages/server` as the contract-first boundary -2. keep `packages/opencode` as the temporary host -3. migrate a few safe JSON routes -4. keep one merged OpenAPI document -5. move actual host ownership only after `packages/core` can support it cleanly - -If a proposed PR would make `packages/server` import from `packages/opencode`, stop and restructure the boundary first. +- Do not revive the old dual-backend migration shape. +- Do not split server hosting before service dependencies have a clean package + boundary. +- Do not switch SDK generation to a new package until generated output is known + to remain compatible. diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index 7027e666c..b9d5b5af1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -61,7 +61,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe( Effect.mapError(() => new HttpApiError.BadRequest({})), ) - // Match legacy Hono behavior: when authorize() resolves without a + // Match legacy route behavior: when authorize() resolves without a // result (e.g. no further redirect), serialize as JSON `null` instead // of an empty body so clients can `.json()` parse the response. const result = yield* authorize({ params: ctx.params, payload }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index 369ca91d0..f4d6adb93 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -153,7 +153,7 @@ export const ptyConnectRoute = HttpRouter.use((router) => return HttpServerResponse.empty() } - // No `pending[]`-style early-frame buffer (the legacy Hono handler had one). + // No `pending[]`-style early-frame buffer (the legacy handler had one). // `request.upgrade` returns a Socket without running the WS handshake; the // handshake fires inside `socket.runRaw` below, AFTER `pty.connect` resolves // and the message callback is registered. The client therefore can't fire diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 9230a6fe5..99645f3da 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -234,7 +234,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", // share/unshare errors aren't all client-induced — storage and network // failures from SessionShare are real possibilities. Map to a typed 500 - // (matches the legacy Hono path which routed any failure through + // (matches the legacy route behavior which routed any failure through // ErrorMiddleware → NamedError.Unknown 500) instead of blanket-mapping // every failure to a 400 BadRequest. const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) { diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts index 9dc9bc01e..9187bfea3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts @@ -2,7 +2,7 @@ import { deflateSync, gzipSync } from "node:zlib" import { Effect } from "effect" import { HttpBody, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -// Mirror of Hono's compressible content-type set so wire behavior matches. +// Keep the server's compressible content-type set stable across HTTP backend changes. const COMPRESSIBLE_CONTENT_TYPE_REGEX = /^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 460a2be7a..12d3791ec 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -111,8 +111,8 @@ function matchLegacyOpenApi(input: Record) { const operation = item[method] if (!operation) continue if (operation.requestBody) { - // Hono's generated OpenAPI never marked request bodies as required. Keep - // that SDK surface stable during the HttpApi migration. + // The legacy OpenAPI surface never marked request bodies as required. + // Keep that SDK surface stable while the HttpApi spec is tightened. delete operation.requestBody.required const body = operation.requestBody.content?.["application/json"] if (body?.schema) body.schema = stripOptionalNull(structuredClone(body.schema)) @@ -146,8 +146,8 @@ function matchLegacyOpenApi(input: Record) { if (content.schema) content.schema = stripOptionalNull(structuredClone(content.schema)) } } - // Hono applied auth as runtime middleware outside OpenAPI metadata, so the - // legacy SDK did not expose auth schemes or generated 401 error unions. + // Auth is still runtime middleware outside the public OpenAPI metadata, so + // the SDK should not expose auth schemes or generated 401 error unions. delete operation.security delete operation.responses?.["401"] normalizeLegacyErrorResponses(operation) diff --git a/packages/opencode/test/project/instance-bootstrap.test.ts b/packages/opencode/test/project/instance-bootstrap.test.ts index baad8df59..71521a765 100644 --- a/packages/opencode/test/project/instance-bootstrap.test.ts +++ b/packages/opencode/test/project/instance-bootstrap.test.ts @@ -13,9 +13,7 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" // bodies deliberately avoid Plugin/config directly. The marker only // appears if InstanceBootstrap ran at the instance boundary. // -// The Hono variant of this check lived alongside these tests and is -// going away with the Hono backend. The boundaries below are backend- -// agnostic and stay. +// The boundaries below are transport-agnostic and stay. afterEach(async () => { await disposeAllInstances() diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index 509a067d0..26c0fe03e 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -25,7 +25,7 @@ afterEach(async () => { }) describe("config HttpApi", () => { - test("serves config update through Hono bridge", async () => { + test("serves config update through the default server app", async () => { await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) const disposed = waitDisposed(tmp.path) diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 0b8d8051b..383442e00 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" @@ -41,7 +40,7 @@ afterEach(async () => { }) describe("experimental HttpApi", () => { - test("serves read-only experimental endpoints through Hono bridge", async () => { + test("serves read-only experimental endpoints through the default server app", async () => { await using tmp = await tmpdir({ config: { formatter: false, @@ -94,7 +93,7 @@ describe("experimental HttpApi", () => { expect(await resources.json()).toEqual({}) }) - test("serves Console org switch through Hono bridge", async () => { + test("serves Console org switch through the default server app", async () => { await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) Database.Client() .$client.prepare( @@ -120,7 +119,7 @@ describe("experimental HttpApi", () => { expect(await switched.json()).toBe(true) }) - test("serves global session list through Hono bridge", async () => { + test("serves global session list through the default server app", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const first = await WithInstance.provide({ @@ -157,7 +156,7 @@ describe("experimental HttpApi", () => { expect(((await next.json()) as Session.GlobalInfo[]).map((session) => session.id)).toContain(first.id) }) - testWorktreeMutations("serves worktree mutations through Hono bridge", async () => { + testWorktreeMutations("serves worktree mutations through the default server app", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 946de2835..5ec4acb87 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -11,7 +11,7 @@ import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/se import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { HEADER as FenceHeader } from "../../src/server/shared/fence" import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" // Flip the experimental workspaces flag so SyncEvent.run actually writes to @@ -35,8 +35,7 @@ const testStateLayer = Layer.effectDiscard( // Mount the production HttpApi route tree on a real Node HTTP server bound to // 127.0.0.1:0 and a fetch-based HttpClient that prepends the server URL. This -// keeps the test wired through the same route layer production uses, without -// going through Server.Default()/Hono. +// keeps the test wired directly through the same route layer production uses. const servedRoutes: Layer.Layer = HttpRouter.serve( ExperimentalHttpApiServer.routes, { disableListenLog: true, disableLogger: true }, diff --git a/specs/v2/todo.md b/specs/v2/todo.md index 77c650e55..ca38931f8 100644 --- a/specs/v2/todo.md +++ b/specs/v2/todo.md @@ -2,9 +2,11 @@ ok we need to work towards a launch of v2 so we can get out of this rebuild phase -## Kill Hono - Kit +## Post-Hono cleanup - Kit -Hono needs to go away so zod can go away. this is almost done +The opencode server has moved to the Effect HttpApi backend. Remaining work is +mostly cleanup: delete compatibility shims, shrink Zod surfaces, and simplify +test harnesses that used to compare Hono and HttpApi behavior. ## New Data Mode - Dax From c7e084c32ca74284b75d6e11e1add0f8a51b99e9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 13:22:31 -0400 Subject: [PATCH 025/188] Simplify single-backend HttpApi exerciser (#26906) --- .../test/server/httpapi-exercise/backend.ts | 23 ++++++------------ .../test/server/httpapi-exercise/runner.ts | 24 +++++++++---------- .../test/server/httpapi-exercise/types.ts | 1 - 3 files changed, 19 insertions(+), 29 deletions(-) diff --git a/packages/opencode/test/server/httpapi-exercise/backend.ts b/packages/opencode/test/server/httpapi-exercise/backend.ts index fac5f699c..b306401cc 100644 --- a/packages/opencode/test/server/httpapi-exercise/backend.ts +++ b/packages/opencode/test/server/httpapi-exercise/backend.ts @@ -2,7 +2,7 @@ import { ConfigProvider, Effect, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" import { parse } from "./assertions" import { runtime, type Runtime } from "./runtime" -import type { ActiveScenario, Backend, BackendApp, CallResult, CaptureMode, SeededContext } from "./types" +import type { ActiveScenario, BackendApp, CallResult, CaptureMode, SeededContext } from "./types" type CallOptions = { auth?: { @@ -11,27 +11,18 @@ type CallOptions = { } } -export function call( - backend: Backend, - scenario: ActiveScenario, - ctx: SeededContext, - options: CallOptions = {}, -) { +export function call(scenario: ActiveScenario, ctx: SeededContext, options: CallOptions = {}) { return Effect.promise(async () => - capture(await app(await runtime(), backend, options).request(toRequest(scenario, ctx)), scenario.capture), + capture(await app(await runtime(), options).request(toRequest(scenario, ctx)), scenario.capture), ) } -export function callAuthProbe( - backend: Backend, - scenario: ActiveScenario, - credentials: "missing" | "valid" = "missing", -) { +export function callAuthProbe(scenario: ActiveScenario, credentials: "missing" | "valid" = "missing") { return Effect.promise(async () => { const controller = new AbortController() return Promise.race([ Promise.resolve( - app(await runtime(), backend, { auth: { password: "secret" } }).request( + app(await runtime(), { auth: { password: "secret" } }).request( toAuthProbeRequest(scenario, credentials, controller.signal), ), ).then((response) => capture(response, scenario.capture)), @@ -51,10 +42,10 @@ export function callAuthProbe( const appCache: Partial> = {} -function app(modules: Runtime, backend: Backend, options: CallOptions) { +function app(modules: Runtime, options: CallOptions) { const username = options.auth?.username const password = options.auth?.password - const cacheKey = `${backend}:${username ?? ""}:${password ?? ""}` + const cacheKey = `${username ?? ""}:${password ?? ""}` if (appCache[cacheKey]) return appCache[cacheKey] const handler = HttpRouter.toWebHandler( diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index 2b3f720c8..bc246dbed 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -30,28 +30,28 @@ function runActive(options: Options, scenario: ActiveScenario) { return withContext(options, scenario, "shared", (ctx) => Effect.gen(function* () { - yield* trace(options, scenario, "effect request start") - const effect = yield* call("effect", scenario, ctx) - yield* trace(options, scenario, `effect response ${effect.status}`) - yield* trace(options, scenario, "effect expect start") - yield* scenario.expect(ctx, ctx.state, effect) - yield* trace(options, scenario, "effect expect done") + yield* trace(options, scenario, "request start") + const result = yield* call(scenario, ctx) + yield* trace(options, scenario, `response ${result.status}`) + yield* trace(options, scenario, "expect start") + yield* scenario.expect(ctx, ctx.state, result) + yield* trace(options, scenario, "expect done") }), ) } function runAuth(scenario: ActiveScenario) { return Effect.gen(function* () { - const effect = yield* callAuthProbe("effect", scenario, "missing") + const result = yield* callAuthProbe(scenario, "missing") if (scenario.auth === "protected") { - if (effect.status !== 401) throw new Error(`effect auth expected 401, got ${effect.status}`) - const effectAuthed = yield* callAuthProbe("effect", scenario, "valid") - if (effectAuthed.status === 401) throw new Error("effect auth rejected valid credentials") + if (result.status !== 401) throw new Error(`auth expected 401, got ${result.status}`) + const authed = yield* callAuthProbe(scenario, "valid") + if (authed.status === 401) throw new Error("auth rejected valid credentials") return } - if (effect.status === 401) throw new Error("effect auth expected public access, got 401") - if (effect.timedOut) throw new Error("effect auth expected public access, probe timed out") + if (result.status === 401) throw new Error("auth expected public access, got 401") + if (result.timedOut) throw new Error("auth expected public access, probe timed out") }) } diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index a0466d7b7..e1fe93ba7 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -11,7 +11,6 @@ export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const export type Method = (typeof Methods)[number] export type OpenApiMethod = (typeof OpenApiMethods)[number] export type Mode = "effect" | "coverage" | "auth" -export type Backend = "effect" export type Comparison = "none" | "status" | "json" export type CaptureMode = "full" | "stream" export type AuthPolicy = "protected" | "public" | "public-bypass" | "ticket-bypass" From 8d9b9719bedadb5289c678eb61c4c4b5fdd1681f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 13:22:59 -0400 Subject: [PATCH 026/188] Drop unused small ID Zod statics (#26908) --- packages/opencode/src/permission/schema.ts | 3 --- packages/opencode/src/project/schema.ts | 2 -- packages/opencode/src/pty/schema.ts | 2 -- packages/opencode/src/question/schema.ts | 3 --- packages/opencode/src/sync/schema.ts | 2 -- packages/opencode/test/session/schema-decoding.test.ts | 2 +- 6 files changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index f7c6e2c5b..58ef0a8a7 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -1,7 +1,6 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod } from "@opencode-ai/core/effect-zod" import { Newtype } from "@opencode-ai/core/schema" export class PermissionID extends Newtype()( @@ -11,6 +10,4 @@ export class PermissionID extends Newtype()( static ascending(id?: string): PermissionID { return this.make(Identifier.ascending("permission", id)) } - - static readonly zod = zod(this) } diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts index c6cff94fd..e511a75ff 100644 --- a/packages/opencode/src/project/schema.ts +++ b/packages/opencode/src/project/schema.ts @@ -1,6 +1,5 @@ import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID")) @@ -10,6 +9,5 @@ export type ProjectID = typeof projectIdSchema.Type export const ProjectID = projectIdSchema.pipe( withStatics((schema: typeof projectIdSchema) => ({ global: schema.make("global"), - zod: zod(schema), })), ) diff --git a/packages/opencode/src/pty/schema.ts b/packages/opencode/src/pty/schema.ts index fadb0457e..c86ae8c73 100644 --- a/packages/opencode/src/pty/schema.ts +++ b/packages/opencode/src/pty/schema.ts @@ -1,7 +1,6 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID")) @@ -11,6 +10,5 @@ export type PtyID = typeof ptyIdSchema.Type export const PtyID = ptyIdSchema.pipe( withStatics((schema: typeof ptyIdSchema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)), - zod: zod(schema), })), ) diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index 1856c94bc..2574594a2 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -1,13 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod } from "@opencode-ai/core/effect-zod" import { Newtype } from "@opencode-ai/core/schema" export class QuestionID extends Newtype()("QuestionID", Schema.String.check(Schema.isStartsWith("que"))) { static ascending(id?: string): QuestionID { return this.make(Identifier.ascending("question", id)) } - - static readonly zod = zod(this) } diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts index dde2e53d1..4ff9d2fc3 100644 --- a/packages/opencode/src/sync/schema.ts +++ b/packages/opencode/src/sync/schema.ts @@ -1,13 +1,11 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" export const EventID = Schema.String.check(Schema.isStartsWith("evt")).pipe( Schema.brand("EventID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("event", id)), - zod: zod(s), })), ) diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index 67c438a38..a65137a2f 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -27,7 +27,7 @@ const sessionID = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3 const sessionIDChild = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L") const messageID = Schema.decodeUnknownSync(MessageID)("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M") const partID = Schema.decodeUnknownSync(PartID)("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N") -const projectID = ProjectID.zod.parse("proj-alpha") +const projectID = ProjectID.make("proj-alpha") const workspaceID = Schema.decodeUnknownSync(WorkspaceID)("wrk-primary") function decodeUnknown(schema: S) { From 0bced8ec966878477641ccbe4666cdb274667a44 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 11 May 2026 17:32:56 +0000 Subject: [PATCH 027/188] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 4244e0c0e..9127bb6d3 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=", - "aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=", - "aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=", - "x86_64-darwin": "sha256-1MZC1fadRoY4lhkmjlcUQTLYH9Q8pDI1bxd5f94f1xU=" + "x86_64-linux": "sha256-2rWGrfNuMLayiouyK65Bu7cSiE0WZIyckU/PChZeYLU=", + "aarch64-linux": "sha256-yF+0UfYCb1isfuAg/KVTJcNxK2yt2gz0o7h8F8WK994=", + "aarch64-darwin": "sha256-KU2/HtyKOAKqJUnqgEyCe5XhYnoUZxqlQtW814yFZKs=", + "x86_64-darwin": "sha256-D/RD/HjMMg2r1HD/glnG+y7DnVEE7guA/J4oiYOAqqc=" } } From 45adfedd6412b173a9a13092ee6914c98d1b2107 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 16:10:58 -0400 Subject: [PATCH 028/188] Drop unused domain Zod statics (#26927) --- packages/opencode/src/agent/agent.ts | 7 ++----- packages/opencode/src/file/index.ts | 15 ++++----------- packages/opencode/src/file/ripgrep.ts | 5 ++--- packages/opencode/src/format/index.ts | 6 +----- packages/opencode/src/pty/index.ts | 11 ++++------- packages/opencode/src/skill/index.ts | 4 +--- packages/opencode/src/snapshot/index.ts | 9 ++------- 7 files changed, 16 insertions(+), 41 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 777f6e6d1..d96e508c9 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -24,8 +24,7 @@ import { Effect, Context, Layer, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics, type DeepMutable } from "@opencode-ai/core/schema" +import { type DeepMutable } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ name: Schema.String, @@ -47,9 +46,7 @@ export const Info = Schema.Struct({ prompt: Schema.optional(Schema.String), options: Schema.Record(Schema.String, Schema.Unknown), steps: Schema.optional(Schema.Finite), -}) - .annotate({ identifier: "Agent" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Agent" }) export type Info = DeepMutable> export interface Interface { diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 52f2b8486..b951a4d7a 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -14,17 +14,14 @@ import { containsPath } from "../project/instance-context" import * as Log from "@opencode-ai/core/util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" -import { zod } from "@opencode-ai/core/effect-zod" -import { NonNegativeInt, type DeepMutable, withStatics } from "@opencode-ai/core/schema" +import { NonNegativeInt, type DeepMutable } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ path: Schema.String, added: NonNegativeInt, removed: NonNegativeInt, status: Schema.Literals(["added", "deleted", "modified"]), -}) - .annotate({ identifier: "File" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "File" }) export type Info = DeepMutable> export const Node = Schema.Struct({ @@ -33,9 +30,7 @@ export const Node = Schema.Struct({ absolute: Schema.String, type: Schema.Literals(["file", "directory"]), ignored: Schema.Boolean, -}) - .annotate({ identifier: "FileNode" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "FileNode" }) export type Node = DeepMutable> const Hunk = Schema.Struct({ @@ -62,9 +57,7 @@ export const Content = Schema.Struct({ patch: Schema.optional(Patch), encoding: Schema.optional(Schema.Literal("base64")), mimeType: Schema.optional(Schema.String), -}) - .annotate({ identifier: "FileContent" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "FileContent" }) export type Content = DeepMutable> export const Event = { diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 8459dd9ac..aae794c1a 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -11,8 +11,7 @@ import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process" import { which } from "@/util/which" -import { zod } from "@opencode-ai/core/effect-zod" -import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" const log = Log.create({ service: "ripgrep" }) const VERSION = "15.1.0" @@ -69,7 +68,7 @@ export const SearchMatch = Schema.Struct({ end: NonNegativeInt, }), ), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export const Match = Schema.Struct({ type: Schema.Literal("match"), diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index c9ab433f1..b6eb9dfd0 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -7,8 +7,6 @@ import { mergeDeep } from "remeda" import { Config } from "@/config/config" import * as Log from "@opencode-ai/core/util/log" import * as Formatter from "./formatter" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "format" }) @@ -16,9 +14,7 @@ export const Status = Schema.Struct({ name: Schema.String, extensions: Schema.Array(Schema.String), enabled: Schema.Boolean, -}) - .annotate({ identifier: "FormatterStatus" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "FormatterStatus" }) export type Status = Schema.Schema.Type export interface Interface { diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 85e0840cb..6f18856fd 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -10,8 +10,7 @@ import type { Proc } from "#pty" import * as Log from "@opencode-ai/core/util/log" import { PtyID } from "./schema" import { Effect, Layer, Context, Schema, Types } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { NonNegativeInt, PositiveInt, withStatics } from "@opencode-ai/core/schema" +import { NonNegativeInt, PositiveInt } from "@opencode-ai/core/schema" const log = Log.create({ service: "pty" }) @@ -62,9 +61,7 @@ export const Info = Schema.Struct({ cwd: Schema.String, status: Schema.Literals(["running", "exited"]), pid: PositiveInt, -}) - .annotate({ identifier: "Pty" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Pty" }) export type Info = Types.DeepMutable> @@ -74,7 +71,7 @@ export const CreateInput = Schema.Struct({ cwd: Schema.optional(Schema.String), title: Schema.optional(Schema.String), env: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type CreateInput = Types.DeepMutable> @@ -86,7 +83,7 @@ export const UpdateInput = Schema.Struct({ cols: PositiveInt, }), ), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type UpdateInput = Types.DeepMutable> diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index e532efa3d..a0cc383d0 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -2,8 +2,6 @@ import path from "path" import { pathToFileURL } from "url" import z from "zod" import { Effect, Layer, Context, Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" import { NamedError } from "@opencode-ai/core/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" @@ -40,7 +38,7 @@ export const Info = Schema.Struct({ description: Schema.optional(Schema.String), location: Schema.String, content: Schema.String, -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type Info = Schema.Schema.Type export const InvalidError = NamedError.create( diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 848a067c3..51fd267d5 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -2,7 +2,6 @@ import { Cause, Duration, Effect, Layer, Schedule, Schema, Semaphore, Context, S import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { formatPatch, structuredPatch } from "diff" import path from "path" -import z from "zod" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -10,13 +9,11 @@ import { Hash } from "@opencode-ai/core/util/hash" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" -import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" -import { zod } from "@opencode-ai/core/effect-zod" export const Patch = Schema.Struct({ hash: Schema.String, files: Schema.mutable(Schema.Array(Schema.String)), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type Patch = typeof Patch.Type export const FileDiff = Schema.Struct({ @@ -28,9 +25,7 @@ export const FileDiff = Schema.Struct({ additions: Schema.Finite, deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), -}) - .annotate({ identifier: "SnapshotFileDiff" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "SnapshotFileDiff" }) export type FileDiff = typeof FileDiff.Type const log = Log.create({ service: "snapshot" }) From c060c436b6f1447be09116f36b77633e6eb7d89d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 16:11:11 -0400 Subject: [PATCH 029/188] Drop LSP config Zod statics (#26920) --- packages/opencode/src/config/lsp.ts | 8 +++----- packages/opencode/test/config/lsp.test.ts | 18 ------------------ 2 files changed, 3 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts index accfbee3b..ea7328a80 100644 --- a/packages/opencode/src/config/lsp.ts +++ b/packages/opencode/src/config/lsp.ts @@ -1,13 +1,11 @@ export * as ConfigLSP from "./lsp" import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" import * as LSPServer from "../lsp/server" export const Disabled = Schema.Struct({ disabled: Schema.Literal(true), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}).pipe((schema) => schema) export const Entry = Schema.Union([ Disabled, @@ -18,7 +16,7 @@ export const Entry = Schema.Union([ env: Schema.optional(Schema.Record(Schema.String, Schema.String)), initialization: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }), -]).pipe(withStatics((s) => ({ zod: zod(s) }))) +]).pipe((schema) => schema) /** * For custom (non-builtin) LSP server entries, `extensions` is required so the @@ -40,6 +38,6 @@ export const requiresExtensionsForCustomServers = Schema.makeFilter< export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]) .check(requiresExtensionsForCustomServers) - .pipe(withStatics((s) => ({ zod: zod(s) }))) + .pipe((schema) => schema) export type Info = Schema.Schema.Type diff --git a/packages/opencode/test/config/lsp.test.ts b/packages/opencode/test/config/lsp.test.ts index 1d24fe124..ff0048a19 100644 --- a/packages/opencode/test/config/lsp.test.ts +++ b/packages/opencode/test/config/lsp.test.ts @@ -7,10 +7,6 @@ import { ConfigLSP } from "../../src/config/lsp" // the server should attach to. Builtin server IDs and explicitly disabled // entries are exempt. // -// Both validation paths must honor this rule: -// - `Schema.decodeUnknownSync(ConfigLSP.Info)` (Effect layer) -// - `ConfigLSP.Info.zod.parse(...)` (derived Zod) -// // `typescript` is a builtin server id (see src/lsp/server.ts). describe("ConfigLSP.Info refinement", () => { const decodeEffect = Schema.decodeUnknownSync(ConfigLSP.Info) @@ -19,14 +15,11 @@ describe("ConfigLSP.Info refinement", () => { test("true and false pass (top-level toggle)", () => { expect(decodeEffect(true)).toBe(true) expect(decodeEffect(false)).toBe(false) - expect(ConfigLSP.Info.zod.parse(true)).toBe(true) - expect(ConfigLSP.Info.zod.parse(false)).toBe(false) }) test("builtin server with no extensions passes", () => { const input = { typescript: { command: ["typescript-language-server", "--stdio"] } } expect(decodeEffect(input)).toEqual(input) - expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) }) test("custom server WITH extensions passes", () => { @@ -34,13 +27,11 @@ describe("ConfigLSP.Info refinement", () => { "my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] }, } expect(decodeEffect(input)).toEqual(input) - expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) }) test("disabled custom server passes (no extensions needed)", () => { const input = { "my-lsp": { disabled: true as const } } expect(decodeEffect(input)).toEqual(input) - expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) }) test("mix of builtin and custom with extensions passes", () => { @@ -49,7 +40,6 @@ describe("ConfigLSP.Info refinement", () => { "my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] }, } expect(decodeEffect(input)).toEqual(input) - expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) }) }) @@ -60,19 +50,12 @@ describe("ConfigLSP.Info refinement", () => { expect(() => decodeEffect({ "my-lsp": { command: ["my-lsp-bin"] } })).toThrow(expectedMessage) }) - test("custom server WITHOUT extensions fails via derived Zod", () => { - const result = ConfigLSP.Info.zod.safeParse({ "my-lsp": { command: ["my-lsp-bin"] } }) - expect(result.success).toBe(false) - expect(result.error!.issues.some((i) => i.message === expectedMessage)).toBe(true) - }) - test("custom server with empty extensions array fails (extensions must be non-empty-truthy)", () => { // Boolean(['']) is true, so a non-empty array of strings is fine. // Boolean([]) is also true in JS, so empty arrays are accepted by the // refinement. This test documents current behavior. const input = { "my-lsp": { command: ["my-lsp-bin"], extensions: [] } } expect(decodeEffect(input)).toEqual(input) - expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) }) test("custom server without extensions mixed with a valid builtin still fails", () => { @@ -81,7 +64,6 @@ describe("ConfigLSP.Info refinement", () => { "my-lsp": { command: ["my-lsp-bin"] }, } expect(() => decodeEffect(input)).toThrow(expectedMessage) - expect(ConfigLSP.Info.zod.safeParse(input).success).toBe(false) }) }) }) From 4d9eb6c320429d0d70ee91419ada9a7c86d7a817 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 16:11:25 -0400 Subject: [PATCH 030/188] Validate structured output tests with Effect Schema (#26919) --- packages/opencode/src/session/message-v2.ts | 23 ++----- .../test/session/structured-output.test.ts | 65 ++++++++++--------- 2 files changed, 42 insertions(+), 46 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index e3539021b..25ca27460 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -64,24 +64,19 @@ export const ContextOverflowError = namedSchemaError("ContextOverflowError", { export class OutputFormatText extends Schema.Class("OutputFormatText")({ type: Schema.Literal("text"), -}) { - static readonly zod = zod(this) -} +}) {} export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ type: Schema.Literal("json_schema"), schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), -}) { - static readonly zod = zod(this) -} +}) {} -const _Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ +export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ discriminator: "type", identifier: "OutputFormat", }) -export const Format = Object.assign(_Format, { zod: zod(_Format) }) -export type OutputFormat = Schema.Schema.Type +export type OutputFormat = Schema.Schema.Type const partBase = { id: PartID, @@ -381,7 +376,7 @@ export const User = Schema.Struct({ time: Schema.Struct({ created: NonNegativeInt, }), - format: Schema.optional(_Format), + format: Schema.optional(Format), summary: Schema.optional( Schema.Struct({ title: Schema.optional(Schema.String), @@ -397,9 +392,7 @@ export const User = Schema.Struct({ }), system: Schema.optional(Schema.String), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), -}) - .annotate({ identifier: "UserMessage" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "UserMessage" }) export type User = Types.DeepMutable> export const Part = Schema.Union([ @@ -550,9 +543,7 @@ export const Assistant = Schema.Struct({ structured: Schema.optional(Schema.Any), variant: Schema.optional(Schema.String), finish: Schema.optional(Schema.String), -}) - .annotate({ identifier: "AssistantMessage" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "AssistantMessage" }) export type Assistant = Omit>, "error"> & { error?: AssistantError } diff --git a/packages/opencode/test/session/structured-output.test.ts b/packages/opencode/test/session/structured-output.test.ts index c734a182a..806c57483 100644 --- a/packages/opencode/test/session/structured-output.test.ts +++ b/packages/opencode/test/session/structured-output.test.ts @@ -1,60 +1,65 @@ import { describe, expect, test } from "bun:test" +import { Exit, Schema } from "effect" import { MessageV2 } from "../../src/session/message-v2" import { SessionPrompt } from "../../src/session/prompt" import { SessionID, MessageID } from "../../src/session/schema" +const decodeFormat = Schema.decodeUnknownExit(MessageV2.Format) +const decodeUser = Schema.decodeUnknownExit(MessageV2.User) +const decodeAssistant = Schema.decodeUnknownExit(MessageV2.Assistant) + describe("structured-output.OutputFormat", () => { test("parses text format", () => { - const result = MessageV2.Format.zod.safeParse({ type: "text" }) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.type).toBe("text") + const result = decodeFormat({ type: "text" }) + expect(Exit.isSuccess(result)).toBe(true) + if (Exit.isSuccess(result)) { + expect(result.value.type).toBe("text") } }) test("parses json_schema format with defaults", () => { - const result = MessageV2.Format.zod.safeParse({ + const result = decodeFormat({ type: "json_schema", schema: { type: "object", properties: { name: { type: "string" } } }, }) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.type).toBe("json_schema") - if (result.data.type === "json_schema") { - expect(result.data.retryCount).toBe(2) // default value + expect(Exit.isSuccess(result)).toBe(true) + if (Exit.isSuccess(result)) { + expect(result.value.type).toBe("json_schema") + if (result.value.type === "json_schema") { + expect(result.value.retryCount).toBe(2) // default value } } }) test("parses json_schema format with custom retryCount", () => { - const result = MessageV2.Format.zod.safeParse({ + const result = decodeFormat({ type: "json_schema", schema: { type: "object" }, retryCount: 5, }) - expect(result.success).toBe(true) - if (result.success && result.data.type === "json_schema") { - expect(result.data.retryCount).toBe(5) + expect(Exit.isSuccess(result)).toBe(true) + if (Exit.isSuccess(result) && result.value.type === "json_schema") { + expect(result.value.retryCount).toBe(5) } }) test("rejects invalid type", () => { - const result = MessageV2.Format.zod.safeParse({ type: "invalid" }) - expect(result.success).toBe(false) + const result = decodeFormat({ type: "invalid" }) + expect(Exit.isFailure(result)).toBe(true) }) test("rejects json_schema without schema", () => { - const result = MessageV2.Format.zod.safeParse({ type: "json_schema" }) - expect(result.success).toBe(false) + const result = decodeFormat({ type: "json_schema" }) + expect(Exit.isFailure(result)).toBe(true) }) test("rejects negative retryCount", () => { - const result = MessageV2.Format.zod.safeParse({ + const result = decodeFormat({ type: "json_schema", schema: { type: "object" }, retryCount: -1, }) - expect(result.success).toBe(false) + expect(Exit.isFailure(result)).toBe(true) }) }) @@ -95,7 +100,7 @@ describe("structured-output.StructuredOutputError", () => { describe("structured-output.UserMessage", () => { test("user message accepts outputFormat", () => { - const result = MessageV2.User.zod.safeParse({ + const result = decodeUser({ id: MessageID.ascending(), sessionID: SessionID.descending(), role: "user", @@ -107,11 +112,11 @@ describe("structured-output.UserMessage", () => { schema: { type: "object" }, }, }) - expect(result.success).toBe(true) + expect(Exit.isSuccess(result)).toBe(true) }) test("user message works without outputFormat (optional)", () => { - const result = MessageV2.User.zod.safeParse({ + const result = decodeUser({ id: MessageID.ascending(), sessionID: SessionID.descending(), role: "user", @@ -119,7 +124,7 @@ describe("structured-output.UserMessage", () => { agent: "default", model: { providerID: "anthropic", modelID: "claude-3" }, }) - expect(result.success).toBe(true) + expect(Exit.isSuccess(result)).toBe(true) }) }) @@ -140,19 +145,19 @@ describe("structured-output.AssistantMessage", () => { } test("assistant message accepts structured", () => { - const result = MessageV2.Assistant.zod.safeParse({ + const result = decodeAssistant({ ...baseAssistantMessage, structured: { company: "Anthropic", founded: 2021 }, }) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.structured).toEqual({ company: "Anthropic", founded: 2021 }) + expect(Exit.isSuccess(result)).toBe(true) + if (Exit.isSuccess(result)) { + expect(result.value.structured).toEqual({ company: "Anthropic", founded: 2021 }) } }) test("assistant message works without structured_output (optional)", () => { - const result = MessageV2.Assistant.zod.safeParse(baseAssistantMessage) - expect(result.success).toBe(true) + const result = decodeAssistant(baseAssistantMessage) + expect(Exit.isSuccess(result)).toBe(true) }) }) From 42a0453945cf203d5c03c4f46bba92368de59dfd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 16:12:31 -0400 Subject: [PATCH 031/188] Drop small session Zod statics (#26921) --- packages/opencode/src/session/revert.ts | 4 +--- packages/opencode/src/session/status.ts | 8 ++------ packages/opencode/src/session/todo.ts | 7 +------ packages/opencode/test/session/schema-decoding.test.ts | 7 ------- 4 files changed, 4 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 12c81180e..ef9089c94 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -4,8 +4,6 @@ import { Snapshot } from "../snapshot" import { Storage } from "@/storage/storage" import { SyncEvent } from "../sync" import * as Log from "@opencode-ai/core/util/log" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID, MessageID, PartID } from "./schema" @@ -18,7 +16,7 @@ export const RevertInput = Schema.Struct({ sessionID: SessionID, messageID: MessageID, partID: Schema.optional(PartID), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type RevertInput = Schema.Schema.Type export interface Interface { diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 1dd36ec53..089559e2c 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -2,10 +2,8 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" import { SessionID } from "./schema" -import { zod } from "@opencode-ai/core/effect-zod" -import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { Effect, Layer, Context, Schema } from "effect" -import z from "zod" export const Info = Schema.Union([ Schema.Struct({ @@ -30,9 +28,7 @@ export const Info = Schema.Union([ Schema.Struct({ type: Schema.Literal("busy"), }), -]) - .annotate({ identifier: "SessionStatus" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +]).annotate({ identifier: "SessionStatus" }) export type Info = Schema.Schema.Type export const Event = { diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 9b7daf7f0..005b3b7c4 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -1,10 +1,7 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { SessionID } from "./schema" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" import { Effect, Layer, Context, Schema } from "effect" -import z from "zod" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" import { asc } from "drizzle-orm" @@ -16,9 +13,7 @@ export const Info = Schema.Struct({ description: "Current status of the task: pending, in_progress, completed, cancelled", }), priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }), -}) - .annotate({ identifier: "Todo" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Todo" }) export type Info = Schema.Schema.Type export const Event = { diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index a65137a2f..4422c5b71 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -221,14 +221,11 @@ describe("SessionRevert.RevertInput", () => { test("messageID is required, partID is optional", () => { const withPart = { sessionID, messageID, partID } expect(decode(withPart)).toEqual(withPart) - expect(SessionRevert.RevertInput.zod.parse(withPart)).toEqual(withPart) const noPart = { sessionID, messageID } expect(decode(noPart)).toEqual(noPart) - expect(SessionRevert.RevertInput.zod.parse(noPart)).toEqual(noPart) expect(() => decode({ sessionID })).toThrow() - expect(() => SessionRevert.RevertInput.zod.parse({ sessionID })).toThrow() }) }) @@ -247,7 +244,6 @@ describe("SessionStatus.Info", () => { test("idle / busy discriminators", () => { expect(decode({ type: "idle" })).toEqual({ type: "idle" }) expect(decode({ type: "busy" })).toEqual({ type: "busy" }) - expect(SessionStatus.Info.zod.parse({ type: "idle" })).toEqual({ type: "idle" }) }) test("retry carries attempt/message/action/next", () => { @@ -266,12 +262,10 @@ describe("SessionStatus.Info", () => { next: 500, } expect(decode(input)).toEqual(input) - expect(SessionStatus.Info.zod.parse(input)).toEqual(input) }) test("rejects unknown type", () => { expect(() => decode({ type: "bogus" })).toThrow() - expect(() => SessionStatus.Info.zod.parse({ type: "bogus" })).toThrow() }) }) @@ -281,7 +275,6 @@ describe("Todo.Info", () => { test("three-field round-trip", () => { const input = { content: "do a thing", status: "pending", priority: "high" } expect(decode(input)).toEqual(input) - expect(Todo.Info.zod.parse(input)).toEqual(input) }) }) From 9067218b74874bdffd3a53142c6b2d0ff65bb479 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 11 May 2026 16:22:25 -0400 Subject: [PATCH 032/188] fix(core): always start worktrees as detached (#26931) --- packages/app/src/pages/layout.tsx | 2 +- .../src/control-plane/adapters/worktree.ts | 8 +-- packages/opencode/src/control-plane/types.ts | 6 +- packages/opencode/src/worktree/index.ts | 49 +++++++++------ .../opencode/test/project/worktree.test.ts | 63 ++++++++++++++++--- packages/sdk/js/src/v2/gen/types.gen.ts | 12 ++-- packages/sdk/openapi.json | 8 +-- 7 files changed, 101 insertions(+), 47 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index a08372649..45fcc6ee2 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1934,7 +1934,7 @@ export default function Layout(props: ParentProps) { if (!created?.directory) return - setWorkspaceName(created.directory, created.branch, project.id, created.branch) + setWorkspaceName(created.directory, created.branch ?? getFilename(created.directory), project.id, created.branch) const local = project.worktree const key = pathKey(created.directory) diff --git a/packages/opencode/src/control-plane/adapters/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts index 605d114ac..1c85d125a 100644 --- a/packages/opencode/src/control-plane/adapters/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -22,11 +22,10 @@ export const WorktreeAdapter: WorkspaceAdapter = { description: "Create a git worktree", async configure(info) { const { AppRuntime, Worktree } = await loadWorktree() - const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo())) + const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true }))) return { ...info, name: next.name, - branch: next.branch, directory: next.directory, } }, @@ -38,7 +37,7 @@ export const WorktreeAdapter: WorkspaceAdapter = { svc.createFromInfo({ name: config.name, directory: config.directory, - branch: config.branch ?? config.name, + ...(config.branch ? { branch: config.branch } : {}), }), ), ) @@ -48,9 +47,8 @@ export const WorktreeAdapter: WorkspaceAdapter = { return (await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.list()))).map((info) => ({ type: "worktree", name: info.name, - branch: info.branch ?? null, + branch: info.branch, directory: info.directory, - extra: null, projectID: Instance.project.id, })) }, diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index e78d728e0..e55ae2194 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -7,9 +7,9 @@ export const WorkspaceInfo = Schema.Struct({ id: WorkspaceID, type: Schema.String, name: Schema.String, - branch: Schema.NullOr(Schema.String), - directory: Schema.NullOr(Schema.String), - extra: Schema.NullOr(Schema.Unknown), + branch: Schema.optional(Schema.NullOr(Schema.String)), + directory: Schema.optional(Schema.NullOr(Schema.String)), + extra: Schema.optional(Schema.NullOr(Schema.Unknown)), projectID: ProjectID, }).annotate({ identifier: "Workspace" }) export type WorkspaceInfo = DeepMutable> diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index a6599debd..439f36e0a 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -29,7 +29,7 @@ export const Event = { "worktree.ready", Schema.Struct({ name: Schema.String, - branch: Schema.String, + branch: Schema.optional(Schema.String), }), ), Failed: BusEvent.define( @@ -42,7 +42,7 @@ export const Event = { export const Info = Schema.Struct({ name: Schema.String, - branch: Schema.String, + branch: Schema.optional(Schema.String), directory: Schema.String, }).annotate({ identifier: "Worktree" }) export type Info = Schema.Schema.Type @@ -143,7 +143,7 @@ function failedRemoves(...chunks: string[]) { // --------------------------------------------------------------------------- export interface Interface { - readonly makeWorktreeInfo: (name?: string) => Effect.Effect + readonly makeWorktreeInfo: (options?: { name?: string; detached?: boolean }) => Effect.Effect readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect readonly create: (input?: CreateInput) => Effect.Effect readonly list: () => Effect.Effect<(Omit & { branch?: string })[]> @@ -194,25 +194,34 @@ export const layer: Layer.Layer< ) const MAX_NAME_ATTEMPTS = 26 - const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) { + const candidate = Effect.fn("Worktree.candidate")(function* (input: { + root: string + name?: string + detached?: boolean + }) { const ctx = yield* InstanceState.context for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) { - const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create() - const branch = `opencode/${name}` - const directory = pathSvc.join(root, name) + const name = input.name ? (attempt === 0 ? input.name : `${input.name}-${Slug.create()}`) : Slug.create() + const branch = input.detached ? undefined : `opencode/${name}` + const directory = pathSvc.join(input.root, name) if (yield* fs.exists(directory).pipe(Effect.orDie)) continue - const ref = `refs/heads/${branch}` - const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree }) - if (branchCheck.code === 0) continue + if (branch) { + const ref = `refs/heads/${branch}` + const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree }) + if (branchCheck.code === 0) continue + } - return { name, branch, directory } + return { name, directory, ...(branch ? { branch } : {}) } } throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" }) }) - const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) { + const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (input?: { + name?: string + detached?: boolean + }) { const ctx = yield* InstanceState.context if (ctx.project.vcs !== "git") { throw new NotGitError({ message: "Worktrees are only supported for git projects" }) @@ -221,15 +230,17 @@ export const layer: Layer.Layer< const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id) yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie) - const base = name ? slugify(name) : "" - return yield* candidate(root, base || undefined) + return yield* candidate({ root, name: input?.name ? slugify(input.name) : "", detached: input?.detached }) }) const setup = Effect.fnUntraced(function* (info: Info) { const ctx = yield* InstanceState.context - const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], { - cwd: ctx.worktree, - }) + const created = yield* git( + info.branch + ? ["worktree", "add", "--no-checkout", "-b", info.branch, info.directory] + : ["worktree", "add", "--no-checkout", "--detach", info.directory, "HEAD"], + { cwd: ctx.worktree }, + ) if (created.code !== 0) { throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" }) } @@ -280,7 +291,7 @@ export const layer: Layer.Layer< workspace: workspaceID, payload: { type: Event.Ready.type, - properties: { name: info.name, branch: info.branch }, + properties: { name: info.name, ...(info.branch ? { branch: info.branch } : {}) }, }, }) @@ -296,7 +307,7 @@ export const layer: Layer.Layer< }) const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) { - const info = yield* makeWorktreeInfo(input?.name) + const info = yield* makeWorktreeInfo({ name: input?.name }) yield* createFromInfo(info, input?.startCommand) return info }) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 4f0ead54e..b1b9d22b7 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -21,13 +21,13 @@ function normalize(input: string) { async function waitReady() { const { GlobalBus } = await import("../../src/bus/global") - return await new Promise<{ name: string; branch: string }>((resolve, reject) => { + return await new Promise<{ name: string; branch?: string }>((resolve, reject) => { const timer = setTimeout(() => { GlobalBus.off("event", on) reject(new Error("timed out waiting for worktree.ready")) }, 10_000) - function on(evt: { directory?: string; payload: { type: string; properties: { name: string; branch: string } } }) { + function on(evt: { directory?: string; payload: { type: string; properties: { name: string; branch?: string } } }) { if (evt.payload.type !== Worktree.Event.Ready.type) return clearTimeout(timer) GlobalBus.off("event", on) @@ -63,7 +63,7 @@ describe("Worktree", () => { () => Effect.gen(function* () { const svc = yield* Worktree.Service - const info = yield* svc.makeWorktreeInfo("my-feature") + const info = yield* svc.makeWorktreeInfo({ name: "my-feature" }) expect(info.name).toBe("my-feature") expect(info.branch).toBe("opencode/my-feature") @@ -77,7 +77,7 @@ describe("Worktree", () => { () => Effect.gen(function* () { const svc = yield* Worktree.Service - const info = yield* svc.makeWorktreeInfo("My Feature Branch!") + const info = yield* svc.makeWorktreeInfo({ name: "My Feature Branch!" }) expect(info.name).toBe("my-feature-branch") }), @@ -85,6 +85,22 @@ describe("Worktree", () => { ), ) + it.live("omits branch for detached info", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const svc = yield* Worktree.Service + yield* Effect.promise(() => $`git branch opencode/my-feature`.cwd(dir).quiet()) + + const info = yield* svc.makeWorktreeInfo({ name: "my-feature", detached: true }) + + expect(info.name).toBe("my-feature") + expect(info.branch).toBeUndefined() + }), + { git: true }, + ), + ) + it.live("throws NotGitError for non-git directories", () => provideTmpdirInstance(() => Effect.gen(function* () { @@ -96,6 +112,35 @@ describe("Worktree", () => { }), ), ) + + wintest("creates detached git worktree when info has no branch", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const svc = yield* Worktree.Service + const info = yield* svc.makeWorktreeInfo({ name: "detached-test", detached: true }) + const ready = waitReady() + yield* svc.createFromInfo(info) + + const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text()) + const normalizedList = normalize(list) + const normalizedDir = normalize(info.directory) + expect(normalizedList).toContain(normalizedDir) + + const branch = yield* Effect.promise(() => + $`git symbolic-ref -q --short HEAD`.cwd(info.directory).quiet().nothrow(), + ) + expect(branch.exitCode).not.toBe(0) + + const props = yield* Effect.promise(() => ready) + expect(props.name).toBe(info.name) + expect(props.branch).toBeUndefined() + + yield* svc.remove({ directory: info.directory }) + }), + { git: true }, + ), + ) }) describe("create + remove lifecycle", () => { @@ -107,7 +152,7 @@ describe("Worktree", () => { const info = yield* svc.create() expect(info.name).toBeDefined() - expect(info.branch).toStartWith("opencode/") + expect(info.branch ?? "").toStartWith("opencode/") expect(info.directory).toBeDefined() yield* Effect.promise(() => Bun.sleep(1000)) @@ -128,7 +173,7 @@ describe("Worktree", () => { const info = yield* svc.create() expect(info.name).toBeDefined() - expect(info.branch).toStartWith("opencode/") + expect(info.branch ?? "").toStartWith("opencode/") const text = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text()) const next = yield* Effect.promise(() => fs.realpath(info.directory).catch(() => info.directory)) @@ -183,7 +228,7 @@ describe("Worktree", () => { (dir) => Effect.gen(function* () { const svc = yield* Worktree.Service - const info = yield* svc.makeWorktreeInfo("from-info-test") + const info = yield* svc.makeWorktreeInfo({ name: "from-info-test" }) const ready = waitReady() yield* svc.createFromInfo(info) @@ -216,10 +261,10 @@ describe("Worktree", () => { const list = yield* svc.list() const directory = yield* Effect.promise(() => fs.realpath(target).catch(() => target)) - expect(list).toContainEqual({ + expect(list.map((item) => ({ ...item, directory: normalize(item.directory) }))).toContainEqual({ name: path.basename(parent), branch, - directory: directory.toLowerCase(), + directory: normalize(directory), }) yield* svc.remove({ directory: target }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index da80645ad..c7a479f5a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1398,7 +1398,7 @@ export type WorktreeCreateInput = { export type Worktree = { name: string - branch: string + branch?: string directory: string } @@ -1795,9 +1795,9 @@ export type Workspace = { id: string type: string name: string - branch: string | null - directory: string | null - extra: unknown | null + branch?: string | null + directory?: string | null + extra?: unknown | null projectID: string timeUsed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } @@ -2566,7 +2566,7 @@ export type EventWorktreeReady = { type: "worktree.ready" properties: { name: string - branch: string + branch?: string } } @@ -6772,7 +6772,7 @@ export type ExperimentalWorkspaceCreateData = { body?: { id?: string type: string - branch: string | null + branch?: string | null extra?: unknown | null } path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index df0427f45..3d452cc9c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8540,7 +8540,7 @@ ] } }, - "required": ["type", "branch"], + "required": ["type"], "additionalProperties": false } } @@ -12803,7 +12803,7 @@ "type": "string" } }, - "required": ["name", "branch", "directory"], + "required": ["name", "directory"], "additionalProperties": false }, "WorktreeRemoveInput": { @@ -14027,7 +14027,7 @@ ] } }, - "required": ["id", "type", "name", "branch", "directory", "extra", "projectID", "timeUsed"], + "required": ["id", "type", "name", "projectID", "timeUsed"], "additionalProperties": false }, "WorkspaceWarpError": { @@ -16580,7 +16580,7 @@ "type": "string" } }, - "required": ["name", "branch"], + "required": ["name"], "additionalProperties": false } }, From cc95197d723fa9e1c5db0990ebfbe8ab846ef8d8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 16:49:08 -0400 Subject: [PATCH 033/188] Drop prompt input Zod statics (#26923) --- packages/opencode/src/session/prompt.ts | 12 +++---- packages/opencode/src/session/session.ts | 31 +++++++------------ .../test/server/global-session-list.test.ts | 4 +-- .../test/session/schema-decoding.test.ts | 21 ------------- 4 files changed, 16 insertions(+), 52 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4950be084..2de4bbd30 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -46,8 +46,6 @@ import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" import * as EffectLogger from "@opencode-ai/core/effect/logger" import { InstanceState } from "@/effect/instance-state" import { TaskTool, type TaskPromptOps } from "@/tool/task" @@ -2054,14 +2052,12 @@ export const PromptInput = Schema.Struct({ MessageV2.SubtaskPartInput, ]).annotate({ discriminator: "type" }), ), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type PromptInput = Schema.Schema.Type export class LoopInput extends Schema.Class("SessionPrompt.LoopInput")({ sessionID: SessionID, -}) { - static readonly zod = zod(this) -} +}) {} export const ShellInput = Schema.Struct({ sessionID: SessionID, @@ -2069,7 +2065,7 @@ export const ShellInput = Schema.Struct({ agent: Schema.String, model: Schema.optional(ModelRef), command: Schema.String, -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type ShellInput = Schema.Schema.Type export const CommandInput = Schema.Struct({ @@ -2097,7 +2093,7 @@ export const CommandInput = Schema.Struct({ ]).annotate({ discriminator: "type" }), ), ), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type CommandInput = Schema.Schema.Type /** @internal Exported for testing */ diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index f50f8750b..92b4329e6 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -37,8 +37,7 @@ import type { Provider } from "@/provider/provider" import { Permission } from "@/permission" import { Global } from "@opencode-ai/core/global" import { Effect, Layer, Option, Context, Schema, Types } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" +import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema" const log = Log.create({ service: "session" }) @@ -193,26 +192,20 @@ export const Info = Schema.Struct({ time: Time, permission: optionalOmitUndefined(Permission.Ruleset), revert: optionalOmitUndefined(Revert), -}) - .annotate({ identifier: "Session" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Session" }) export type Info = Types.DeepMutable> export const ProjectInfo = Schema.Struct({ id: ProjectID, name: optionalOmitUndefined(Schema.String), worktree: Schema.String, -}) - .annotate({ identifier: "ProjectSummary" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "ProjectSummary" }) export type ProjectInfo = Types.DeepMutable> export const GlobalInfo = Schema.Struct({ ...Info.fields, project: Schema.NullOr(ProjectInfo), -}) - .annotate({ identifier: "GlobalSession" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "GlobalSession" }) export type GlobalInfo = Types.DeepMutable> export const CreateInput = Schema.optional( @@ -224,36 +217,34 @@ export const CreateInput = Schema.optional( permission: Schema.optional(Permission.Ruleset), workspaceID: Schema.optional(WorkspaceID), }), -).pipe(withStatics((s) => ({ zod: zod(s) }))) +) export type CreateInput = Types.DeepMutable> export const ForkInput = Schema.Struct({ sessionID: SessionID, messageID: Schema.optional(MessageID), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export const GetInput = SessionID export const ChildrenInput = SessionID export const RemoveInput = SessionID -export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema.String }).pipe( - withStatics((s) => ({ zod: zod(s) })), -) +export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema.String }) export const SetArchivedInput = Schema.Struct({ sessionID: SessionID, time: Schema.optional(ArchivedTimestamp), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export const SetPermissionInput = Schema.Struct({ sessionID: SessionID, permission: Permission.Ruleset, -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export const SetRevertInput = Schema.Struct({ sessionID: SessionID, revert: Schema.optional(Revert), summary: Schema.optional(Summary), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export const MessagesInput = Schema.Struct({ sessionID: SessionID, limit: Schema.optional(NonNegativeInt), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type ListInput = { directory?: string scope?: "project" diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index 936808951..04348e5c0 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -1,7 +1,5 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" -import z from "zod" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Project } from "@/project/project" import { Session as SessionNs } from "@/session/session" @@ -19,7 +17,7 @@ const svc = { create(input?: SessionNs.CreateInput) { return run(SessionNs.Service.use((svc) => svc.create(input))) }, - setArchived(input: z.output) { + setArchived(input: typeof SessionNs.SetArchivedInput.Type) { return run(SessionNs.Service.use((svc) => svc.setArchived(input))) }, } diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index 4422c5b71..c0201a475 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -49,7 +49,6 @@ describe("Session.Info", () => { time: { created: 1, updated: 2 }, } expect(decode(input)).toEqual(input) - expect(Session.Info.zod.parse(input)).toEqual(input) }) test("round-trips every optional field", () => { @@ -80,7 +79,6 @@ describe("Session.Info", () => { }, } expect(decode(input)).toEqual(input) - expect(Session.Info.zod.parse(input)).toEqual(input) }) test("accepts migrated summary diffs without file details", () => { @@ -100,19 +98,16 @@ describe("Session.Info", () => { time: { created: 1, updated: 2 }, } expect(decode(input)).toEqual(input) - expect(Session.Info.zod.parse(input)).toEqual(input) }) test("rejects unbranded session id", () => { const bad = { id: "not-a-session-id" } as unknown expect(() => decode(bad)).toThrow() - expect(() => Session.Info.zod.parse(bad)).toThrow() }) test("rejects missing required fields", () => { const bad = { id: sessionID } as unknown expect(() => decode(bad)).toThrow() - expect(() => Session.Info.zod.parse(bad)).toThrow() }) }) @@ -124,8 +119,6 @@ describe("Session.ProjectInfo", () => { const withName = { ...noName, name: "alpha" } expect(decode(noName)).toEqual(noName) expect(decode(withName)).toEqual(withName) - expect(Session.ProjectInfo.zod.parse(noName)).toEqual(noName) - expect(Session.ProjectInfo.zod.parse(withName)).toEqual(withName) }) }) @@ -144,7 +137,6 @@ describe("Session.GlobalInfo", () => { project: null, } expect(decode(input)).toEqual(input) - expect(Session.GlobalInfo.zod.parse(input)).toEqual(input) }) test("accepts populated project", () => { @@ -159,7 +151,6 @@ describe("Session.GlobalInfo", () => { project: { id: projectID, worktree: "/tmp/wt", name: "alpha" }, } expect(decode(input)).toEqual(input) - expect(Session.GlobalInfo.zod.parse(input)).toEqual(input) }) }) @@ -167,7 +158,6 @@ describe("Session input schemas", () => { test("CreateInput accepts undefined and populated forms", () => { const decode = decodeUnknown(Session.CreateInput) expect(decode(undefined)).toBeUndefined() - expect(Session.CreateInput.zod.parse(undefined)).toBeUndefined() const populated = { parentID: sessionID, @@ -176,23 +166,19 @@ describe("Session input schemas", () => { workspaceID, } expect(decode(populated)).toEqual(populated) - expect(Session.CreateInput.zod.parse(populated)).toEqual(populated) }) test("ForkInput round-trips", () => { const decode = decodeUnknown(Session.ForkInput) const input = { sessionID, messageID } expect(decode(input)).toEqual(input) - expect(Session.ForkInput.zod.parse(input)).toEqual(input) // messageID is optional const bare = { sessionID } expect(decode(bare)).toEqual(bare) - expect(Session.ForkInput.zod.parse(bare)).toEqual(bare) }) test("SetTitleInput rejects missing title", () => { expect(() => decodeUnknown(Session.SetTitleInput)({ sessionID })).toThrow() - expect(() => Session.SetTitleInput.zod.parse({ sessionID })).toThrow() }) test("SetArchivedInput accepts both with and without time", () => { @@ -282,7 +268,6 @@ describe("SessionPrompt input schemas", () => { test("LoopInput is just sessionID", () => { const decode = decodeUnknown(SessionPrompt.LoopInput) expect(decode({ sessionID })).toEqual({ sessionID }) - expect(SessionPrompt.LoopInput.zod.parse({ sessionID } as unknown)).toEqual({ sessionID }) }) test("ShellInput requires agent + command", () => { @@ -290,7 +275,6 @@ describe("SessionPrompt input schemas", () => { const expected = { sessionID, agent: "build", command: "echo hi" } const input: unknown = expected expect(decode(input)).toEqual(expected) - expect(SessionPrompt.ShellInput.zod.parse(input as unknown)).toEqual(expected) expect(() => decode({ sessionID })).toThrow() }) @@ -308,9 +292,6 @@ describe("SessionPrompt input schemas", () => { expect(decoded.parts).toHaveLength(2) expect(decoded.parts[0]).toMatchObject({ type: "text", text: "hello" }) expect(decoded.parts[1]).toMatchObject({ type: "file", mime: "image/png" }) - - const viaZod = SessionPrompt.PromptInput.zod.parse(input) - expect(viaZod.parts).toHaveLength(2) }) test("PromptInput rejects unknown part type", () => { @@ -320,7 +301,6 @@ describe("SessionPrompt input schemas", () => { parts: [{ type: "nonsense", payload: 42 }], } expect(() => decode(bad)).toThrow() - expect(() => SessionPrompt.PromptInput.zod.parse(bad)).toThrow() }) test("CommandInput round-trips core fields", () => { @@ -332,6 +312,5 @@ describe("SessionPrompt input schemas", () => { } const input: unknown = expected expect(decode(input)).toEqual(expected) - expect(SessionPrompt.CommandInput.zod.parse(input)).toEqual(expected) }) }) From fe7ca3421e22a0ac89718211d9932417417e398d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 16:49:22 -0400 Subject: [PATCH 034/188] Drop Config Info Zod static (#26933) --- packages/opencode/script/schema.ts | 3 ++- packages/opencode/src/config/config.ts | 23 ++----------------- packages/opencode/test/config/config.test.ts | 2 +- .../opencode/test/session/compaction.test.ts | 4 ++-- 4 files changed, 7 insertions(+), 25 deletions(-) diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index b1a587075..eaa678524 100755 --- a/packages/opencode/script/schema.ts +++ b/packages/opencode/script/schema.ts @@ -2,6 +2,7 @@ import { z } from "zod" import { Config } from "@/config/config" +import { zodObject } from "@opencode-ai/core/effect-zod" import { TuiConfig } from "../src/cli/cmd/tui/config/tui" function generate(schema: z.ZodType) { @@ -55,7 +56,7 @@ const configFile = process.argv[2] const tuiFile = process.argv[3] console.log(configFile) -await Bun.write(configFile, JSON.stringify(generate(Config.Info.zod), null, 2)) +await Bun.write(configFile, JSON.stringify(generate(zodObject(Config.Info).strict().meta({ ref: "Config" })), null, 2)) if (tuiFile) { console.log(tuiFile) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index c05d562c9..2e282c232 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -22,8 +22,7 @@ import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { containsPath } from "../project/instance-context" -import { zod } from "@opencode-ai/core/effect-zod" -import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema" +import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema" import { ConfigAgent } from "./agent" import { ConfigAttachment } from "./attachment" import { ConfigCommand } from "./command" @@ -112,8 +111,6 @@ async function resolveLoadedPlugins( return config } -export const Server = ConfigServer.Server.zod -export const Layout = ConfigLayout.Layout.zod export type Layout = ConfigLayout.Layout const LogLevelRef = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({ @@ -121,14 +118,6 @@ const LogLevelRef = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate description: "Log level", }) -// The Effect Schema is the canonical source of truth. The `.zod` compatibility -// surface is derived from it so plugin/SDK Zod consumers keep working without -// a parallel hand-maintained Zod definition. -// -// The walker emits `z.object({...})` which is non-strict by default. Config -// historically uses `.strict()` (additionalProperties: false in openapi.json), -// so layer that on after derivation. Re-apply the Config ref afterward -// since `.strict()` strips the walker's meta annotation. export const Info = Schema.Struct({ $schema: Schema.optional(Schema.String).annotate({ description: "JSON schema reference for configuration validation", @@ -301,15 +290,7 @@ export const Info = Schema.Struct({ }), }), ), -}) - .annotate({ identifier: "Config" }) - .pipe( - withStatics((s) => ({ - zod: (zod(s) as unknown as z.ZodObject).strict().meta({ ref: "Config" }) as unknown as z.ZodType< - DeepMutable> - >, - })), - ) +}).annotate({ identifier: "Config" }) // Uses the shared `DeepMutable` from `@opencode-ai/core/schema`. See the definition // there for why the local variant is needed over `Types.DeepMutable` from diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index bbe585237..5f49a3f3a 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -251,7 +251,7 @@ test("updates global config and omits empty shell key in jsonc", async () => { const file = path.join(tmp.path, "opencode.jsonc") const writtenConfig = await Filesystem.readText(file) - const parsed = ConfigParse.schema(Config.Info.zod, ConfigParse.jsonc(writtenConfig, file), file) + const parsed = ConfigParse.effectSchema(Config.Info, ConfigParse.jsonc(writtenConfig, file), file) expect(writtenConfig).not.toContain('"shell"') expect(parsed.shell).toBeUndefined() expect(parsed.model).toBe("test/model") diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 13400d79c..c7f349d5c 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, mock, test } from "bun:test" import { APICallError } from "ai" -import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect" import * as Stream from "effect/Stream" import { Bus } from "../../src/bus" import { Config } from "@/config/config" @@ -211,7 +211,7 @@ function layer(result: "continue" | "compact") { } function cfg(compaction?: Config.Info["compaction"]) { - const base = Config.Info.zod.parse({}) + const base = Schema.decodeUnknownSync(Config.Info)({}) as Config.Info return TestConfig.layer({ get: () => Effect.succeed({ ...base, compaction }), }) From d3caac527061f427d25f30d1359912c6ca07b1b7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 17:09:57 -0400 Subject: [PATCH 035/188] chore(deps): upgrade effect to 4.0.0-beta.65 (#26934) --- bun.lock | 14 +++++++------- package.json | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index 37df44b0b..4268e5fb7 100644 --- a/bun.lock +++ b/bun.lock @@ -684,8 +684,8 @@ }, "catalog": { "@cloudflare/workers-types": "4.20251008.0", - "@effect/opentelemetry": "4.0.0-beta.57", - "@effect/platform-node": "4.0.0-beta.57", + "@effect/opentelemetry": "4.0.0-beta.65", + "@effect/platform-node": "4.0.0-beta.65", "@hono/zod-validator": "0.4.2", "@kobalte/core": "0.13.11", "@lydell/node-pty": "1.2.0-beta.10", @@ -718,7 +718,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.59", + "effect": "4.0.0-beta.65", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -1079,11 +1079,11 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], - "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.57", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.57" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-gdjZPEP0QQg4qmI1vd+443kheeQZKytrjJIzCJncy6ZEpyk/SfrqeStLqLXdTRcms3IB0ls0vOV7KNq7YmBRVA=="], + "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.65", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.65" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-0CD2fSsXrDM7FP2WFkbGJO1DwMqWR3UKHh6oBDXPHAPA+RsJSKoh3pLQsbQfldLuKnhOy87Bv0v9r9IdrIHCQw=="], - "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.57", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.57", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.57", "ioredis": "^5.7.0" } }, "sha512-la0xxPSAYOsY0d+uVxEBxok3jYB31iPQmIaZZRUj2SNWqcGGHJc6KorKtI8guqSLuv9FGZ255kBWXRbG6hMeeg=="], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.65", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.65", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.65", "ioredis": "^5.7.0" } }, "sha512-QQy3KRcMwP0TngQdfQGl2u1zp03B7k7DuF5SNS8aZhD0dDBpKZpCwFad1ODY5qdY3ycPgMwBwKRRK7y/aw0C9w=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.57", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.57" } }, "sha512-C976X6f+qHUtLSqcqImuCrjhAHnJV17NC2RvvybsAuDfkyIWU4MyiO2XwgiBeijeNupyr1M/KPKnyjtkNxV9Hw=="], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.65", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.65" } }, "sha512-3rY8F3WLEax6Hj08GI/OvDIH+KqjfxH7RM2bAMfgR75NgRmwDtny1P49PtPkoRjH5dcdtThThtsvE4X9OTZkpQ=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], @@ -3037,7 +3037,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="], + "effect": ["effect@4.0.0-beta.65", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], diff --git a/package.json b/package.json index 5faf8be92..6d82864d6 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,8 @@ "packages/slack" ], "catalog": { - "@effect/opentelemetry": "4.0.0-beta.57", - "@effect/platform-node": "4.0.0-beta.57", + "@effect/opentelemetry": "4.0.0-beta.65", + "@effect/platform-node": "4.0.0-beta.65", "@npmcli/arborist": "9.4.0", "@types/bun": "1.3.12", "@types/cross-spawn": "6.0.6", @@ -55,7 +55,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.59", + "effect": "4.0.0-beta.65", "ai": "6.0.168", "cross-spawn": "7.0.6", "hono": "4.10.7", From fd65d29dcc3e09a6b9314890ae1fc5e9cfaef61d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 17:14:18 -0400 Subject: [PATCH 036/188] Drop unused opencode Zod statics (#26935) --- packages/opencode/src/auth/index.ts | 6 +- .../opencode/src/cli/cmd/tui/config/tui.ts | 7 +- packages/opencode/src/command/index.ts | 7 +- packages/opencode/src/config/agent.ts | 9 +- packages/opencode/src/config/attachment.ts | 11 +- packages/opencode/src/config/config.ts | 6 +- packages/opencode/src/config/console-state.ts | 5 +- packages/opencode/src/config/formatter.ts | 8 +- packages/opencode/src/config/layout.ts | 6 +- packages/opencode/src/config/mcp.ts | 19 +-- packages/opencode/src/config/model-id.ts | 5 +- packages/opencode/src/config/parse.ts | 16 +-- packages/opencode/src/config/permission.ts | 22 +--- packages/opencode/src/config/plugin.ts | 2 +- packages/opencode/src/config/provider.ts | 9 +- packages/opencode/src/config/reference.ts | 6 +- packages/opencode/src/config/server.ts | 7 +- packages/opencode/src/config/skills.ts | 4 +- packages/opencode/src/lsp/lsp.ts | 20 +-- packages/opencode/src/mcp/index.ts | 10 +- packages/opencode/src/permission/index.ts | 36 ++--- packages/opencode/src/project/project.ts | 11 +- packages/opencode/src/project/vcs.ts | 20 +-- packages/opencode/src/provider/auth.ts | 17 +-- packages/opencode/src/provider/provider.ts | 15 +-- packages/opencode/src/provider/schema.ts | 8 +- packages/opencode/src/question/index.ts | 30 +---- packages/opencode/src/session/message-v2.ts | 123 +++++------------- packages/opencode/src/session/message.ts | 54 +++----- packages/opencode/src/session/summary.ts | 4 +- packages/opencode/src/sync/README.md | 20 +-- packages/opencode/src/tool/schema.ts | 2 - packages/opencode/test/config/config.test.ts | 18 +-- .../test/session/schema-decoding.test.ts | 7 +- 34 files changed, 161 insertions(+), 389 deletions(-) diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index f7c631935..9d30ea142 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,6 +1,5 @@ import path from "path" import { Effect, Layer, Record, Result, Schema, Context } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" import { NonNegativeInt } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -32,9 +31,8 @@ export class WellKnown extends Schema.Class("WellKnownAuth")({ token: Schema.String, }) {} -const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" }) -export const Info = Object.assign(_Info, { zod: zod(_Info) }) -export type Info = Schema.Schema.Type +export const Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" }) +export type Info = Schema.Schema.Type export class AuthError extends Schema.TaggedErrorClass()("AuthError", { message: Schema.String, diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 14d991816..d7409cd2d 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -5,6 +5,7 @@ import { createBindingLookup } from "@opentui/keymap/extras" import { mergeDeep, unique } from "remeda" import { Context, Effect, Fiber, Layer } from "effect" import { ConfigParse } from "@/config/parse" +import { InvalidError } from "@/config/error" import * as ConfigPaths from "@/config/paths" import { migrateTuiConfig } from "./tui-migrate" import { KeymapLeaderTimeoutDefault, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema" @@ -91,10 +92,12 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: if (!isRecord(data)) return {} as Info // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json // (mirroring the old opencode.json shape) still get their settings applied. - const validated = ConfigParse.schema(Info, normalize(data), configFilepath) + const parsed = Info.safeParse(normalize(data)) + if (!parsed.success) throw new InvalidError({ path: configFilepath, issues: parsed.error.issues }) + const validated = parsed.data return yield* resolvePlugins(validated, configFilepath) }).pipe( - // catchCause (not tapErrorCause + orElseSucceed) because ConfigParse.jsonc/.schema + // catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation // can sync-throw — those become defects, which orElseSucceed wouldn't catch. Effect.catchCause((cause) => Effect.sync(() => { diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index e26c4068b..54cfe4fcc 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -5,8 +5,7 @@ import type { InstanceContext } from "@/project/instance" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" +import { ZodOverride } from "@opencode-ai/core/effect-zod" import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" @@ -39,9 +38,7 @@ export const Info = Schema.Struct({ template: Schema.Unknown.annotate({ [ZodOverride]: z.promise(z.string()).or(z.string()) }), subtask: Schema.optional(Schema.Boolean), hints: Schema.Array(Schema.String), -}) - .annotate({ identifier: "Command" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Command" }) // for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it export type Info = Omit, "template"> & { template: Promise | string } diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 94c8d8fe0..a6719e867 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -2,8 +2,7 @@ export * as ConfigAgent from "./agent" import { Exit, Schema, SchemaGetter } from "effect" import { Bus } from "@/bus" -import { zod } from "@opencode-ai/core/effect-zod" -import { PositiveInt, withStatics } from "@opencode-ai/core/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" import { Glob } from "@opencode-ai/core/util/glob" @@ -102,9 +101,7 @@ export const Info = AgentSchema.pipe( decode: SchemaGetter.transform(normalize), encode: SchemaGetter.passthrough({ strict: false }), }), -) - .annotate({ identifier: "AgentConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +).annotate({ identifier: "AgentConfig" }) export type Info = Schema.Schema.Type export async function load(dir: string) { @@ -134,7 +131,7 @@ export async function load(dir: string) { ...md.data, prompt: md.content.trim(), } - result[config.name] = ConfigParse.effectSchema(Info, config, item) + result[config.name] = ConfigParse.schema(Info, config, item) } return result } diff --git a/packages/opencode/src/config/attachment.ts b/packages/opencode/src/config/attachment.ts index 7af429afd..a5fc59973 100644 --- a/packages/opencode/src/config/attachment.ts +++ b/packages/opencode/src/config/attachment.ts @@ -1,8 +1,7 @@ export * as ConfigAttachment from "./attachment" import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { PositiveInt, withStatics } from "@opencode-ai/core/schema" +import { PositiveInt } from "@opencode-ai/core/schema" export const Image = Schema.Struct({ auto_resize: Schema.optional(Schema.Boolean).annotate({ @@ -17,14 +16,10 @@ export const Image = Schema.Struct({ max_base64_bytes: Schema.optional(PositiveInt).annotate({ description: "Maximum base64 payload bytes for an image attachment (default: 4718592)", }), -}) - .annotate({ identifier: "ImageAttachmentConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "ImageAttachmentConfig" }) export type Image = Schema.Schema.Type export const Info = Schema.Struct({ image: Schema.optional(Image).annotate({ description: "Image attachment configuration" }), -}) - .annotate({ identifier: "AttachmentConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "AttachmentConfig" }) export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 2e282c232..4b10665ac 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -388,7 +388,7 @@ export const layer = Layer.effect( ), ) const parsed = ConfigParse.jsonc(expanded, source) - const data = ConfigParse.effectSchema(Info, normalizeLoadedConfig(parsed, source), source) + const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source) if (!("path" in options)) return data yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)) @@ -786,7 +786,7 @@ export const layer = Layer.effect( let next: Info let changed: boolean if (!file.endsWith(".jsonc")) { - const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file) + const existing = ConfigParse.schema(Info, ConfigParse.jsonc(before, file), file) const merged = mergeDeep(writable(existing), patch) const serialized = JSON.stringify(merged, null, 2) changed = serialized !== before @@ -794,7 +794,7 @@ export const layer = Layer.effect( next = merged } else { const updated = patchJsonc(before, patch) - next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file) + next = ConfigParse.schema(Info, ConfigParse.jsonc(updated, file), file) changed = updated !== before if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) } diff --git a/packages/opencode/src/config/console-state.ts b/packages/opencode/src/config/console-state.ts index 485e33416..d52a14840 100644 --- a/packages/opencode/src/config/console-state.ts +++ b/packages/opencode/src/config/console-state.ts @@ -1,14 +1,11 @@ import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" import { NonNegativeInt } from "@opencode-ai/core/schema" export class ConsoleState extends Schema.Class("ConsoleState")({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), activeOrgName: Schema.optional(Schema.String), switchableOrgCount: NonNegativeInt, -}) { - static readonly zod = zod(this) -} +}) {} export const emptyConsoleState: ConsoleState = ConsoleState.make({ consoleManagedProviders: [], diff --git a/packages/opencode/src/config/formatter.ts b/packages/opencode/src/config/formatter.ts index 222a75005..7539fe4a7 100644 --- a/packages/opencode/src/config/formatter.ts +++ b/packages/opencode/src/config/formatter.ts @@ -1,17 +1,13 @@ export * as ConfigFormatter from "./formatter" import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" export const Entry = Schema.Struct({ disabled: Schema.optional(Schema.Boolean), command: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), environment: Schema.optional(Schema.Record(Schema.String, Schema.String)), extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) -export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe( - withStatics((s) => ({ zod: zod(s) })), -) +export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]) export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/config/layout.ts b/packages/opencode/src/config/layout.ts index a5299ea95..3ac63576d 100644 --- a/packages/opencode/src/config/layout.ts +++ b/packages/opencode/src/config/layout.ts @@ -1,10 +1,6 @@ import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" -export const Layout = Schema.Literals(["auto", "stretch"]) - .annotate({ identifier: "LayoutConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Layout = Schema.Literals(["auto", "stretch"]).annotate({ identifier: "LayoutConfig" }) export type Layout = Schema.Schema.Type export * as ConfigLayout from "./layout" diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index bb4fd88f0..cf170b95f 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -1,6 +1,5 @@ import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { PositiveInt, withStatics } from "@opencode-ai/core/schema" +import { PositiveInt } from "@opencode-ai/core/schema" export const Local = Schema.Struct({ type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), @@ -16,9 +15,7 @@ export const Local = Schema.Struct({ timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), -}) - .annotate({ identifier: "McpLocalConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "McpLocalConfig" }) export type Local = Schema.Schema.Type export const OAuth = Schema.Struct({ @@ -32,9 +29,7 @@ export const OAuth = Schema.Struct({ redirectUri: Schema.optional(Schema.String).annotate({ description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", }), -}) - .annotate({ identifier: "McpOAuthConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "McpOAuthConfig" }) export type OAuth = Schema.Schema.Type export const Remote = Schema.Struct({ @@ -52,14 +47,10 @@ export const Remote = Schema.Struct({ timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), -}) - .annotate({ identifier: "McpRemoteConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "McpRemoteConfig" }) export type Remote = Schema.Schema.Type -export const Info = Schema.Union([Local, Remote]) - .annotate({ discriminator: "type" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Info = Schema.Union([Local, Remote]).annotate({ discriminator: "type" }) export type Info = Schema.Schema.Type export * as ConfigMCP from "./mcp" diff --git a/packages/opencode/src/config/model-id.ts b/packages/opencode/src/config/model-id.ts index 26fa2e0b3..6cba3ecd2 100644 --- a/packages/opencode/src/config/model-id.ts +++ b/packages/opencode/src/config/model-id.ts @@ -1,7 +1,6 @@ import { Schema } from "effect" import z from "zod" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" +import { ZodOverride } from "@opencode-ai/core/effect-zod" // The original Zod schema carried an external $ref pointing at the models.dev // JSON schema. That external reference is not a named SDK component — it is a @@ -9,6 +8,6 @@ import { withStatics } from "@opencode-ai/core/schema" // from AST metadata. Preserve the exact original Zod via ZodOverride. export const ConfigModelID = Schema.String.annotate({ [ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type ConfigModelID = Schema.Schema.Type diff --git a/packages/opencode/src/config/parse.ts b/packages/opencode/src/config/parse.ts index f964ed4e1..d4048cf17 100644 --- a/packages/opencode/src/config/parse.ts +++ b/packages/opencode/src/config/parse.ts @@ -2,12 +2,10 @@ export * as ConfigParse from "./parse" import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser" import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect" -import z from "zod" +import type z from "zod" import type { DeepMutable } from "@opencode-ai/core/schema" import { InvalidError, JsonError } from "./error" -type ZodSchema = z.ZodType - export function jsonc(text: string, filepath: string): unknown { const errors: JsoncParseError[] = [] const data = parseJsoncImpl(text, errors, { allowTrailingComma: true }) @@ -35,17 +33,7 @@ export function jsonc(text: string, filepath: string): unknown { return data } -export function schema(schema: ZodSchema, data: unknown, source: string): T { - const parsed = schema.safeParse(data) - if (parsed.success) return parsed.data - - throw new InvalidError({ - path: source, - issues: parsed.error.issues, - }) -} - -export function effectSchema>( +export function schema>( schema: S, data: unknown, source: string, diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index 8c5f85499..a04b404e8 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -1,21 +1,13 @@ export * as ConfigPermission from "./permission" import { Schema, SchemaGetter } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" -export const Action = Schema.Literals(["ask", "allow", "deny"]) - .annotate({ identifier: "PermissionActionConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" }) export type Action = Schema.Schema.Type -export const Object = Schema.Record(Schema.String, Action) - .annotate({ identifier: "PermissionObjectConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Object = Schema.Record(Schema.String, Action).annotate({ identifier: "PermissionObjectConfig" }) export type Object = Schema.Schema.Type -export const Rule = Schema.Union([Action, Object]) - .annotate({ identifier: "PermissionRuleConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Rule = Schema.Union([Action, Object]).annotate({ identifier: "PermissionRuleConfig" }) export type Rule = Schema.Schema.Type // Known permission keys get explicit types in the Effect schema for generated @@ -62,12 +54,6 @@ export const Info = InputSchema.pipe( // of the same rules. encode: SchemaGetter.passthrough({ strict: false }), }), -) - .annotate({ identifier: "PermissionConfig" }) - .pipe( - // Walker already emits the decodeTo transform into the derived zod (see - // `encoded()` in effect-zod.ts), so just expose that directly. - withStatics((s) => ({ zod: zod(s) })), - ) +).annotate({ identifier: "PermissionConfig" }) type _Info = Schema.Schema.Type export type Info = { -readonly [K in keyof _Info]: _Info[K] } diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index b1e3ec6f4..c70442427 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -6,7 +6,7 @@ import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" import path from "path" -export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Options = Schema.Record(Schema.String, Schema.Unknown) export type Options = Schema.Schema.Type // Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options. diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index af9aac696..5635512ce 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -1,6 +1,5 @@ import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { PositiveInt, withStatics } from "@opencode-ai/core/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { ModelStatus } from "@/provider/model-status" export const Model = Schema.Struct({ @@ -67,7 +66,7 @@ export const Model = Schema.Struct({ ), ).annotate({ description: "Variant-specific configuration" }), ), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export const Info = Schema.Struct({ api: Schema.optional(Schema.String), @@ -106,9 +105,7 @@ export const Info = Schema.Struct({ ), ), models: Schema.optional(Schema.Record(Schema.String, Model)), -}) - .annotate({ identifier: "ProviderConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "ProviderConfig" }) export type Info = Schema.Schema.Type export * as ConfigProvider from "./provider" diff --git a/packages/opencode/src/config/reference.ts b/packages/opencode/src/config/reference.ts index 36a8faff7..b3dec491a 100644 --- a/packages/opencode/src/config/reference.ts +++ b/packages/opencode/src/config/reference.ts @@ -1,8 +1,6 @@ export * as ConfigReference from "./reference" import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" const Git = Schema.Struct({ repository: Schema.String.annotate({ @@ -21,7 +19,5 @@ const Local = Schema.Struct({ export const Entry = Schema.Union([Schema.String, Git, Local]).annotate({ identifier: "ReferenceConfigEntry" }) -export const Info = Schema.Record(Schema.String, Entry) - .annotate({ identifier: "ReferenceConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Info = Schema.Record(Schema.String, Entry).annotate({ identifier: "ReferenceConfig" }) export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/config/server.ts b/packages/opencode/src/config/server.ts index 159ba0ce5..642adbe51 100644 --- a/packages/opencode/src/config/server.ts +++ b/packages/opencode/src/config/server.ts @@ -1,6 +1,5 @@ import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { PositiveInt, withStatics } from "@opencode-ai/core/schema" +import { PositiveInt } from "@opencode-ai/core/schema" export const Server = Schema.Struct({ port: Schema.optional(PositiveInt).annotate({ @@ -14,9 +13,7 @@ export const Server = Schema.Struct({ cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Additional domains to allow for CORS", }), -}) - .annotate({ identifier: "ServerConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "ServerConfig" }) export type Server = Schema.Schema.Type export * as ConfigServer from "./server" diff --git a/packages/opencode/src/config/skills.ts b/packages/opencode/src/config/skills.ts index f707e922e..38c0017d0 100644 --- a/packages/opencode/src/config/skills.ts +++ b/packages/opencode/src/config/skills.ts @@ -1,6 +1,4 @@ import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ paths: Schema.optional(Schema.Array(Schema.String)).annotate({ @@ -9,7 +7,7 @@ export const Info = Schema.Struct({ urls: Schema.optional(Schema.Array(Schema.String)).annotate({ description: "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)", }), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index a647dc099..12ce5f581 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -13,8 +13,8 @@ import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { containsPath } from "@/project/instance-context" -import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt } from "@opencode-ai/core/schema" +import { ZodOverride } from "@opencode-ai/core/effect-zod" const log = Log.create({ service: "lsp" }) @@ -30,9 +30,7 @@ const Position = Schema.Struct({ export const Range = Schema.Struct({ start: Position, end: Position, -}) - .annotate({ identifier: "Range" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Range" }) export type Range = typeof Range.Type export const Symbol = Schema.Struct({ @@ -42,9 +40,7 @@ export const Symbol = Schema.Struct({ uri: Schema.String, range: Range, }), -}) - .annotate({ identifier: "Symbol" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Symbol" }) export type Symbol = typeof Symbol.Type export const DocumentSymbol = Schema.Struct({ @@ -53,9 +49,7 @@ export const DocumentSymbol = Schema.Struct({ kind: NonNegativeInt, range: Range, selectionRange: Range, -}) - .annotate({ identifier: "DocumentSymbol" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "DocumentSymbol" }) export type DocumentSymbol = typeof DocumentSymbol.Type export const Status = Schema.Struct({ @@ -65,9 +59,7 @@ export const Status = Schema.Struct({ status: Schema.Literals(["connected", "error"]).annotate({ [ZodOverride]: z.union([z.literal("connected"), z.literal("error")]), }), -}) - .annotate({ identifier: "LSPStatus" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "LSPStatus" }) export type Status = typeof Status.Type enum SymbolKind { diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index ed74c648a..db43412f7 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -31,8 +31,6 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { zod as effectZod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "mcp" }) const DEFAULT_TIMEOUT = 30_000 @@ -52,9 +50,7 @@ export const Resource = Schema.Struct({ description: Schema.optional(Schema.String), mimeType: Schema.optional(Schema.String), client: Schema.String, -}) - .annotate({ identifier: "McpResource" }) - .pipe(withStatics((s) => ({ zod: effectZod(s) }))) +}).annotate({ identifier: "McpResource" }) export type Resource = Schema.Schema.Type export const ToolsChanged = BusEvent.define( @@ -104,9 +100,7 @@ export const Status = Schema.Union([ StatusFailed, StatusNeedsAuth, StatusNeedsClientRegistration, -]) - .annotate({ identifier: "MCPStatus", discriminator: "status" }) - .pipe(withStatics((s) => ({ zod: effectZod(s) }))) +]).annotate({ identifier: "MCPStatus", discriminator: "status" }) export type Status = Schema.Schema.Type // Store transports for OAuth servers to allow finishing auth diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index f4bd2e2cc..2f0813aff 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -7,9 +7,7 @@ import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" -import { zod } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@opencode-ai/core/schema" import { Wildcard } from "@/util/wildcard" import { Deferred, Effect, Layer, Schema, Context } from "effect" import os from "os" @@ -18,23 +16,17 @@ import { PermissionID } from "./schema" const log = Log.create({ service: "permission" }) -export const Action = Schema.Literals(["allow", "deny", "ask"]) - .annotate({ identifier: "PermissionAction" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" }) export type Action = Schema.Schema.Type export const Rule = Schema.Struct({ permission: Schema.String, pattern: Schema.String, action: Action, -}) - .annotate({ identifier: "PermissionRule" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "PermissionRule" }) export type Rule = Schema.Schema.Type -export const Ruleset = Schema.mutable(Schema.Array(Rule)) - .annotate({ identifier: "PermissionRuleset" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Ruleset = Schema.mutable(Schema.Array(Rule)).annotate({ identifier: "PermissionRuleset" }) export type Ruleset = Schema.Schema.Type export class Request extends Schema.Class("PermissionRequest")({ @@ -50,11 +42,9 @@ export class Request extends Schema.Class("PermissionRequest")({ callID: Schema.String, }), ), -}) { - static readonly zod = zod(this) -} +}) {} -export const Reply = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Reply = Schema.Literals(["once", "always", "reject"]) export type Reply = Schema.Schema.Type const reply = { @@ -62,17 +52,13 @@ const reply = { message: Schema.optional(Schema.String), } -export const ReplyBody = Schema.Struct(reply) - .annotate({ identifier: "PermissionReplyBody" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +export const ReplyBody = Schema.Struct(reply).annotate({ identifier: "PermissionReplyBody" }) export type ReplyBody = Schema.Schema.Type export class Approval extends Schema.Class("PermissionApproval")({ projectID: ProjectID, patterns: Schema.Array(Schema.String), -}) { - static readonly zod = zod(this) -} +}) {} export const Event = { Asked: BusEvent.define("permission.asked", Request), @@ -114,17 +100,13 @@ export const AskInput = Schema.Struct({ ...Request.fields, id: Schema.optional(PermissionID), ruleset: Ruleset, -}) - .annotate({ identifier: "PermissionAskInput" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "PermissionAskInput" }) export type AskInput = Schema.Schema.Type export const ReplyInput = Schema.Struct({ requestID: PermissionID, ...reply, -}) - .annotate({ identifier: "PermissionReplyInput" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "PermissionReplyInput" }) export type ReplyInput = Schema.Schema.Type export interface Interface { diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 91d272ea6..643685539 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -17,8 +17,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { zod } from "@opencode-ai/core/effect-zod" -import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" +import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema" import { serviceUse } from "@/effect/service-use" const log = Log.create({ service: "project" }) @@ -52,9 +51,7 @@ export const Info = Schema.Struct({ commands: optionalOmitUndefined(ProjectCommands), time: ProjectTime, sandboxes: Schema.Array(Schema.String), -}) - .annotate({ identifier: "Project" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Project" }) export type Info = Types.DeepMutable> export const Event = { @@ -100,9 +97,7 @@ export const UpdatePayload = Schema.Struct({ name: Schema.optional(Schema.String), icon: Schema.optional(ProjectIcon), commands: Schema.optional(ProjectCommands), -}) - .annotate({ identifier: "ProjectUpdateInput" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "ProjectUpdateInput" }) export type UpdatePayload = Types.DeepMutable> // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 092444c44..5a477e02b 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -6,8 +6,6 @@ import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" -import { zod, zodObject } from "@opencode-ai/core/effect-zod" -import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "vcs" }) const PATCH_CONTEXT_LINES = 2_147_483_647 @@ -208,7 +206,7 @@ const track = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: return yield* diffAgainstRef(git, cwd, ref) }) -export const Mode = Schema.Literals(["git", "branch"]).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Mode = Schema.Literals(["git", "branch"]) export type Mode = Schema.Schema.Type export const Event = { @@ -223,9 +221,7 @@ export const Event = { export const Info = Schema.Struct({ branch: Schema.optional(Schema.String), default_branch: Schema.optional(Schema.String), -}) - .annotate({ identifier: "VcsInfo" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "VcsInfo" }) export type Info = Schema.Schema.Type export const FileDiff = Schema.Struct({ @@ -237,9 +233,7 @@ export const FileDiff = Schema.Struct({ additions: Schema.Finite, deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), -}) - .annotate({ identifier: "VcsFileDiff" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "VcsFileDiff" }) export type FileDiff = Schema.Schema.Type export const FileStatus = Schema.Struct({ @@ -247,19 +241,17 @@ export const FileStatus = Schema.Struct({ additions: Schema.Finite, deletions: Schema.Finite, status: Schema.Literals(["added", "deleted", "modified"]), -}) - .annotate({ identifier: "VcsFileStatus" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "VcsFileStatus" }) export type FileStatus = Schema.Schema.Type export const ApplyInput = Schema.Struct({ patch: Schema.String, -}).pipe(withStatics((s) => ({ zod: zod(s), zodObject: zodObject(s) }))) +}) export type ApplyInput = Schema.Schema.Type export const ApplyResult = Schema.Struct({ applied: Schema.Boolean, -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type ApplyResult = Schema.Schema.Type export class PatchApplyError extends Schema.TaggedErrorClass()("VcsPatchApplyError", { diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 135df6fec..42b94ffcc 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,9 +1,8 @@ import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin" import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" -import { zod } from "@opencode-ai/core/effect-zod" import { namedSchemaError } from "@/util/named-schema-error" -import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" +import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { Plugin } from "../plugin" import { ProviderID } from "./schema" import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" @@ -42,31 +41,27 @@ export class Method extends Schema.Class("ProviderAuthMethod")({ type: Schema.Literals(["oauth", "api"]), label: Schema.String, prompts: optionalOmitUndefined(Schema.Array(Prompt)), -}) { - static readonly zod = zod(this) -} +}) {} -export const Methods = Schema.Record(Schema.String, Schema.Array(Method)).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Methods = Schema.Record(Schema.String, Schema.Array(Method)) export type Methods = typeof Methods.Type export class Authorization extends Schema.Class("ProviderAuthAuthorization")({ url: Schema.String, method: Schema.Literals(["auto", "code"]), instructions: Schema.String, -}) { - static readonly zod = zod(this) -} +}) {} export const AuthorizeInput = Schema.Struct({ method: Schema.Finite.annotate({ description: "Auth method index" }), inputs: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ description: "Prompt inputs" }), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type AuthorizeInput = Schema.Schema.Type export const CallbackInput = Schema.Struct({ method: Schema.Finite.annotate({ description: "Auth method index" }), code: Schema.optional(Schema.String).annotate({ description: "OAuth authorization code" }), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type CallbackInput = Schema.Schema.Type export const OauthMissing = namedSchemaError("ProviderAuthOauthMissing", { providerID: ProviderID }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c27b69b6a..236f14de7 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -13,7 +13,6 @@ import { Auth } from "../auth" import { Env } from "../env" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Flag } from "@opencode-ai/core/flag/flag" -import { zod } from "@opencode-ai/core/effect-zod" import { namedSchemaError } from "@/util/named-schema-error" import { iife } from "@/util/iife" import { Global } from "@opencode-ai/core/global" @@ -24,7 +23,7 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { isRecord } from "@/util/record" -import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" +import { optionalOmitUndefined } from "@opencode-ai/core/schema" import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" @@ -903,9 +902,7 @@ export const Model = Schema.Struct({ headers: Schema.Record(Schema.String, Schema.String), release_date: Schema.String, variants: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))), -}) - .annotate({ identifier: "Model" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Model" }) export type Model = Types.DeepMutable> export const Info = Schema.Struct({ @@ -916,9 +913,7 @@ export const Info = Schema.Struct({ key: optionalOmitUndefined(Schema.String), options: Schema.Record(Schema.String, Schema.Any), models: Schema.Record(Schema.String, Model), -}) - .annotate({ identifier: "Provider" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Provider" }) export type Info = Types.DeepMutable> const DefaultModelIDs = Schema.Record(Schema.String, Schema.String) @@ -927,13 +922,13 @@ export const ListResult = Schema.Struct({ all: Schema.Array(Info), default: DefaultModelIDs, connected: Schema.Array(Schema.String), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type ListResult = Types.DeepMutable> export const ConfigProvidersResult = Schema.Struct({ providers: Schema.Array(Info), default: DefaultModelIDs, -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type ConfigProvidersResult = Types.DeepMutable> export function toPublicInfo(provider: Info): Info { diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index 757b70f3f..db05b4784 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -1,6 +1,5 @@ import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID")) @@ -9,7 +8,6 @@ export type ProviderID = typeof providerIdSchema.Type export const ProviderID = providerIdSchema.pipe( withStatics((schema: typeof providerIdSchema) => ({ - zod: zod(schema), // Well-known providers opencode: schema.make("opencode"), anthropic: schema.make("anthropic"), @@ -29,8 +27,4 @@ const modelIdSchema = Schema.String.pipe(Schema.brand("ModelID")) export type ModelID = typeof modelIdSchema.Type -export const ModelID = modelIdSchema.pipe( - withStatics((schema: typeof modelIdSchema) => ({ - zod: zod(schema), - })), -) +export const ModelID = modelIdSchema diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index c041462ad..94182f1a2 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -3,9 +3,7 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { SessionID, MessageID } from "@/session/schema" -import { zod } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@opencode-ai/core/schema" import { QuestionID } from "./schema" const log = Log.create({ service: "question" }) @@ -19,9 +17,7 @@ export class Option extends Schema.Class
0.1, }} From 061efc6cf260762677bdbac9261725a2da4fdbea Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 20:30:23 -0400 Subject: [PATCH 046/188] Fix run JSON output draining (#26955) --- packages/opencode/src/cli/cmd/run.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index bca89c3ca..7011b51eb 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -719,6 +719,7 @@ export const RunCommand = effectCmd({ } } } + return error } const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root) const client = args.attach ? attachSDK(cwd) : sdk @@ -730,10 +731,7 @@ export const RunCommand = effectCmd({ if (!args.interactive) { const events = await client.event.subscribe() - loop(client, events).catch((e) => { - console.error(e) - process.exit(1) - }) + const completed = loop(client, events) if (args.command) { await client.session.command({ @@ -744,6 +742,8 @@ export const RunCommand = effectCmd({ arguments: message, variant: args.variant, }) + const error = await completed + if (error) process.exitCode = 1 return } @@ -755,6 +755,8 @@ export const RunCommand = effectCmd({ variant: args.variant, parts: [...files, { type: "text", text: message }], }) + const error = await completed + if (error) process.exitCode = 1 return } From ec9584177fc7a00b9f02c74ee4fde7850cee4825 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 20:32:27 -0400 Subject: [PATCH 047/188] docs(test): plan Effect test migration (#26954) --- .../opencode/test/EFFECT_TEST_MIGRATION.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 packages/opencode/test/EFFECT_TEST_MIGRATION.md diff --git a/packages/opencode/test/EFFECT_TEST_MIGRATION.md b/packages/opencode/test/EFFECT_TEST_MIGRATION.md new file mode 100644 index 000000000..20472a631 --- /dev/null +++ b/packages/opencode/test/EFFECT_TEST_MIGRATION.md @@ -0,0 +1,215 @@ +# Effect Test Migration Plan + +This document describes how to move opencode tests out of Promise-land and into the shared `testEffect` pattern. + +## Target Pattern + +Every test file that exercises Effect services should have one local runner near the top: + +```ts +const it = testEffect(layer) +``` + +Then each test should use one of the runner methods: + +```ts +it.effect("pure service behavior", () => + Effect.gen(function* () { + const service = yield* SomeService.Service + expect(yield* service.run()).toEqual("ok") + }), +) + +it.instance("instance-local behavior", () => + Effect.gen(function* () { + const test = yield* TestInstance + // test.directory is a scoped temp opencode instance + }), +) + +it.live("live filesystem or process behavior", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + // real clock / fs / git / process work + }), +) +``` + +Use `it.effect` for pure Effect code that should run with `TestClock` and `TestConsole`. +Use `it.instance` when the test needs one scoped opencode instance. +Use `it.live` when the test depends on real time, filesystem mtimes, git, child processes, servers, file watchers, or OS behavior. + +## Anti-Patterns To Remove + +Avoid these in tests that already target Effect services: + +- `test(..., async () => Effect.runPromise(...))` +- local `run(...)`, `load(...)`, `svc(...)`, or `runtime.runPromise(...)` wrappers that only provide a layer +- `tmpdir()` plus `WithInstance.provide(...)` in Promise test bodies +- custom `ManagedRuntime.make(...)` in test files +- Promise `try/catch` around Effect failures +- `Promise.withResolvers`, `Bun.sleep`, or `setTimeout` for synchronization when `Deferred`, `Fiber`, or `Effect.sleep` can express the same behavior + +Promise helpers are acceptable at the boundary for non-Effect APIs, but they should be yielded from an Effect body with `Effect.promise(...)` rather than becoming the test harness. + +## Layer Rules + +Compose tests from open service layers, not closed `defaultLayer` graphs when a dependency needs replacing. + +Good: + +```ts +const layer = Config.layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(AuthTest.empty), + Layer.provide(AccountTest.empty), + Layer.provide(NpmTest.noop), +) +``` + +Avoid using a fully closed layer and hoping to override an inner dependency later. Once `Agent.defaultLayer` has already provided `Config.defaultLayer`, tests cannot cleanly swap the `Npm.Service` used by that config layer. + +Prefer small reusable fake boundary layers in `test/fake/*`: + +```ts +AuthTest.empty +AccountTest.empty +NpmTest.noop +SkillTest.empty +ProviderTest.fake().layer +``` + +Do not add generic test-layer builders until repeated local compositions prove the need. Shared fake boundary services are the first reusable unit. Pre-composed subtrees such as `AgentTest.withPlugins` should come later, only after the same graph appears in multiple files. + +## Fixture Rules + +Use Effect-aware fixtures from `test/fixture/fixture.ts`: + +- `TestInstance` inside `it.instance(...)` for the current temp instance path +- `tmpdirScoped(...)` inside `Effect.gen` for additional temp directories +- `provideInstance(dir)(effect)` when one test needs to switch instance context +- `provideTmpdirInstance((dir) => effect, options)` when a live test needs custom instance setup or multiple instance scopes +- `disposeAllInstances()` in `afterEach` only for integration tests that intentionally touch shared instance registries + +Use finalizers only as a temporary bridge for existing global mutations: + +```ts +yield* Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.MY_FLAG + process.env.MY_FLAG = "1" + return previous + }), + () => testBody, + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.MY_FLAG + else process.env.MY_FLAG = previous + }), +) +``` + +TODO: eliminate this pattern over time. Tests should not toggle process-global flags or env vars when the behavior can be modeled with services. Prefer moving flag/env reads behind injectable services such as `Config.Service`, `Env.Service`, or focused test layers, then provide the desired test value through the layer graph instead of mutating `process.env` or `Global.Path`. + +## Conversion Recipe + +1. Identify the real service under test and its open `*.layer`. +2. Build one top-level `layer` with real dependencies where they are relevant and `test/fake/*` layers at slow or external boundaries. +3. Replace local Promise wrappers with Effect helpers: + +```ts +const run = Effect.fn("MyTest.run")(function* (input: Input) { + const service = yield* MyService.Service + return yield* service.run(input) +}) +``` + +4. Convert `test(..., async () => { ... })` to `it.effect`, `it.instance`, or `it.live`. +5. Move `await` calls inside `Effect.gen` as `yield*` calls. +6. Replace `await using tmp = await tmpdir(...)` with `yield* tmpdirScoped(...)` when the temp directory is inside an Effect test. +7. Replace `WithInstance.provide({ directory, fn })` with `it.instance(...)`, `provideInstance(directory)(effect)`, or `provideTmpdirInstance(...)`. +8. Replace Promise failure assertions with Effect assertions: + +```ts +const exit = yield* run(input).pipe(Effect.exit) +expect(Exit.isFailure(exit)).toBe(true) +``` + +This is correct but still verbose. Track repeated assertion shapes during migration so we can add small test assertion helpers later instead of copying low-level `Exit` plumbing everywhere. + +9. Keep concurrency concurrent by using `Effect.forkScoped`, `Fiber.join`, `Deferred`, or `Effect.all(..., { concurrency: "unbounded" })` instead of serializing formerly parallel Promise work. +10. Run the focused test file and `bun typecheck` from `packages/opencode`. + +## Good Examples + +Use these files as models: + +- `test/tool/write.test.ts`: strong `it.instance` tests, top-level `testEffect(...)`, and Effect-native test helpers. +- `test/effect/instance-state.test.ts`: good `it.live` use for scoped directories, instance switching, reload/disposal, and concurrency. +- `test/bus/bus-effect.test.ts`: good `Deferred`, streams, and scoped fibers. +- `test/tool/truncation.test.ts`: good configured runners and concise live service tests. +- `test/tool/repo_clone.test.ts`: good live git integration while staying inside Effect fixtures. +- `test/server/httpapi-instance.test.ts`: good scoped integration layer setup and live HTTP assertions. +- `test/account/service.test.ts`: good service-level live tests, `Effect.flip`, typed errors, and fake HTTP clients. +- `test/agent/plugin-agent-regression.test.ts`: good example of open real service layers plus reusable fake boundary layers. + +## Current Promise-Land Hotspots + +Start with files that already exercise Effect services but still manually run Promises: + +- `test/config/config.test.ts`: many `Effect.runPromise`, `tmpdir()`, and `WithInstance.provide(...)` patterns despite already having `const it = testEffect(layer)`. +- `test/tool/shell.test.ts`: custom `ManagedRuntime`, Promise test helpers, and instance setup around shell execution. +- `test/tool/edit.test.ts`: manual runtime helpers and Promise concurrency patterns that should become fibers/deferreds. +- `test/session/messages-pagination.test.ts`: local Promise service facade over `Session.defaultLayer`. +- `test/snapshot/snapshot.test.ts`: Promise helper with `provideInstance` around snapshot operations. +- `test/file/index.test.ts`: Promise wrappers for `File.Service` plus repeated temp instance setup. +- `test/provider/provider.test.ts`: `AppRuntime.runPromise` helpers and mutable env/config setup. +- `test/project/vcs.test.ts`: Promise event waiting and `AppRuntime.runPromise` around VCS service calls. + +## Migration Order + +1. Convert one small file with straightforward service calls and no race behavior. +2. Convert `config.test.ts` incrementally by cluster, not in one PR. +3. Extract additional `test/fake/*` boundary layers only when a second test needs the same fake. +4. Convert files with concurrency or watchers after the simple files, preserving timing semantics with `Deferred` and fibers. +5. Leave pure non-Effect utility tests alone unless converting the underlying code to Effect. + +## Claimable Checklist + +Use this as a migration queue. Each checkbox should be safe for one agent or one PR unless the notes say otherwise. Agents should claim one item, convert only that file or cluster, run the focused test file, run `bun typecheck`, and update this checklist in the PR description or follow-up note. + +- [ ] `test/file/index.test.ts`: straightforward service wrapper cleanup. Replace local Promise helpers with Effect helpers and use `it.instance` / `it.live` around existing temp instance cases. +- [ ] `test/session/messages-pagination.test.ts`: convert the local `run(...)` / `svc(...)` facade to `testEffect(Session.defaultLayer...)` and direct service yields. Good early target. +- [ ] `test/snapshot/snapshot.test.ts`: convert snapshot operations to `it.live` with `tmpdirScoped` / `provideInstance`. Keep git/filesystem behavior live. +- [ ] `test/project/vcs.test.ts`: convert `AppRuntime.runPromise` service calls first. Leave event/watcher timing intact until the first Effect version is stable. +- [ ] `test/provider/provider.test.ts` cluster 1: convert provider service tests that only read config/env and do not mutate global state heavily. +- [ ] `test/provider/provider.test.ts` cluster 2: convert tests with env/config mutation after introducing or reusing service-backed test seams. +- [ ] `test/tool/shell.test.ts`: replace custom `ManagedRuntime` with `testEffect`, keep as `it.live`, and preserve process behavior. +- [ ] `test/tool/edit.test.ts` cluster 1: convert straightforward edit/read/write cases and remove manual runtime helpers. +- [ ] `test/tool/edit.test.ts` cluster 2: convert concurrency/race tests using `Deferred`, fibers, and `Effect.all` without serializing behavior. +- [ ] `test/config/config.test.ts` setup pass: replace inline fake layers with shared `test/fake/*` layers where possible and turn Promise helpers into Effect helpers. +- [ ] `test/config/config.test.ts` cluster 1: convert simple config load/merge tests that only need one instance. +- [ ] `test/config/config.test.ts` cluster 2: convert managed/global config tests that mutate `Global.Path` or managed config directories. Prefer service seams; use finalizers only as a bridge. +- [ ] `test/config/config.test.ts` cluster 3: convert plugin/dependency tests after ensuring `NpmTest.noop` or explicit fake NPM layers are used. +- [ ] `test/config/config.test.ts` cluster 4: convert remote/account/provider config tests after isolating auth/account/env dependencies through layers. +- [ ] Audit remaining `Effect.runPromise` in `packages/opencode/test/**/*.ts` and create follow-up checklist entries for any missed files. +- [ ] Audit remaining `WithInstance.provide` in `packages/opencode/test/**/*.ts` and convert cases that can use `it.instance` or `provideInstance` inside Effect. +- [ ] Audit repeated `Exit` / `Cause` assertion shapes and propose `test/lib/effect-assert.ts` helpers if at least three files repeat the same pattern. + +Parallelization notes: + +- The first four items are mostly independent and good for parallel agents. +- `provider.test.ts`, `tool/edit.test.ts`, and `config.test.ts` should be split by cluster so agents do not edit the same file concurrently. +- Any new fake boundary layer under `test/fake/*` should be small and independently useful. Do not add a fake just for one assertion unless it removes a real external dependency. +- Do not combine assertion-helper design with file migrations. First collect repeated shapes, then add helpers in a separate pass. + +## Effectified Test Rough Edges + +Track patterns that are technically Effect-native but still too noisy. These should become a second cleanup pass after the Promise-land migration is underway. + +- Failure assertions against `Exit` / `Cause` are often verbose. Consider helpers such as `expectEffectFailure(effect)`, `expectTaggedError(effect, Tag)`, or custom Bun matchers if the same shapes repeat. +- Some tests still need `Effect.promise(...)` around Node/Bun filesystem helpers. Prefer Effect platform services when the surrounding code already uses them, but do not block migrations on perfect filesystem abstraction. +- Scoped global mutation with `process.env`, `Global.Path`, or flags should disappear behind injectable services over time. +- Layer composition can be noisy when a test needs a real service subtree plus fake boundaries. Keep extracting small `test/fake/*` boundary layers before inventing larger builders. +- Concurrency tests can become harder to read after replacing Promise resolvers with `Deferred` and fibers. Look for repeated patterns that deserve named helpers. From 8015ff7ca5c17e272181a915be1c5cec0b26e88b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 00:33:34 +0000 Subject: [PATCH 048/188] chore: generate --- .../opencode/test/EFFECT_TEST_MIGRATION.md | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/opencode/test/EFFECT_TEST_MIGRATION.md b/packages/opencode/test/EFFECT_TEST_MIGRATION.md index 20472a631..60cd33264 100644 --- a/packages/opencode/test/EFFECT_TEST_MIGRATION.md +++ b/packages/opencode/test/EFFECT_TEST_MIGRATION.md @@ -95,19 +95,20 @@ Use Effect-aware fixtures from `test/fixture/fixture.ts`: Use finalizers only as a temporary bridge for existing global mutations: ```ts -yield* Effect.acquireUseRelease( - Effect.sync(() => { - const previous = process.env.MY_FLAG - process.env.MY_FLAG = "1" - return previous - }), - () => testBody, - (previous) => +yield * + Effect.acquireUseRelease( Effect.sync(() => { - if (previous === undefined) delete process.env.MY_FLAG - else process.env.MY_FLAG = previous + const previous = process.env.MY_FLAG + process.env.MY_FLAG = "1" + return previous }), -) + () => testBody, + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.MY_FLAG + else process.env.MY_FLAG = previous + }), + ) ``` TODO: eliminate this pattern over time. Tests should not toggle process-global flags or env vars when the behavior can be modeled with services. Prefer moving flag/env reads behind injectable services such as `Config.Service`, `Env.Service`, or focused test layers, then provide the desired test value through the layer graph instead of mutating `process.env` or `Global.Path`. @@ -132,7 +133,7 @@ const run = Effect.fn("MyTest.run")(function* (input: Input) { 8. Replace Promise failure assertions with Effect assertions: ```ts -const exit = yield* run(input).pipe(Effect.exit) +const exit = yield * run(input).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) ``` From fbd52ca2f4fcbeb23f070b8a010cda40129f207b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 20:39:31 -0400 Subject: [PATCH 049/188] test(file): migrate file tests to Effect runner (#26959) --- packages/opencode/test/file/index.test.ts | 1416 ++++++++++----------- 1 file changed, 651 insertions(+), 765 deletions(-) diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index cdd2e211c..925084140 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -1,557 +1,489 @@ -import { afterEach, describe, test, expect } from "bun:test" +import { afterEach, describe, expect } from "bun:test" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { $ } from "bun" -import { Effect } from "effect" +import { Cause, Effect, Exit, Layer } from "effect" import path from "path" import fs from "fs/promises" import { File } from "../../src/file" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" -import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, TestInstance, withTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" afterEach(async () => { await disposeAllInstances() }) -const init = () => run(File.Service.use((svc) => svc.init())) -const run = (eff: Effect.Effect) => - Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer)))) -const status = () => run(File.Service.use((svc) => svc.status())) -const read = (file: string) => run(File.Service.use((svc) => svc.read(file))) -const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir))) -const search = (input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) => - run(File.Service.use((svc) => svc.search(input))) +const it = testEffect(Layer.mergeAll(File.defaultLayer, AppFileSystem.defaultLayer)) + +const init = Effect.fn("FileTest.init")(function* () { + const file = yield* File.Service + return yield* file.init() +}) + +const status = Effect.fn("FileTest.status")(function* () { + const file = yield* File.Service + return yield* file.status() +}) + +const read = Effect.fn("FileTest.read")(function* (input: string) { + const file = yield* File.Service + return yield* file.read(input) +}) + +const list = Effect.fn("FileTest.list")(function* (dir?: string) { + const file = yield* File.Service + return yield* file.list(dir) +}) + +const search = Effect.fn("FileTest.search")(function* (input: { + query: string + limit?: number + dirs?: boolean + type?: "file" | "directory" +}) { + const file = yield* File.Service + return yield* file.search(input) +}) + +const gitAddAll = (directory: string) => Effect.promise(() => $`git add .`.cwd(directory).quiet()) +const gitCommit = (directory: string, message: string) => + Effect.promise(() => $`git commit -m ${message}`.cwd(directory).quiet()) + +const failureMessage = (self: Effect.Effect) => + Effect.gen(function* () { + const exit = yield* self.pipe(Effect.exit) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + return error instanceof Error ? error.message : String(error) + } + throw new Error("expected effect to fail") + }) + +const setupSearchableRepo = Effect.fn("FileTest.setupSearchableRepo")(function* (directory: string) { + const fsys = yield* AppFileSystem.Service + yield* fsys.writeWithDirs(path.join(directory, "index.ts"), "code") + yield* fsys.writeWithDirs(path.join(directory, "utils.ts"), "utils") + yield* fsys.writeWithDirs(path.join(directory, "readme.md"), "readme") + yield* fsys.writeWithDirs(path.join(directory, "src", "main.ts"), "main") + yield* fsys.writeWithDirs(path.join(directory, ".hidden", "secret.ts"), "secret") +}) describe("file/index Filesystem patterns", () => { describe("read() - text content", () => { - test("reads text file via Filesystem.readText()", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.txt") - await fs.writeFile(filepath, "Hello World", "utf-8") + it.instance("reads text file via Filesystem.readText()", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "Hello World", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("test.txt") - expect(result.type).toBe("text") - expect(result.content).toBe("Hello World") - }, - }) - }) + const result = yield* read("test.txt") + expect(result.type).toBe("text") + expect(result.content).toBe("Hello World") + }), + ) - test("reads with Filesystem.exists() check", async () => { - await using tmp = await tmpdir() + it.instance("reads with Filesystem.exists() check", () => + Effect.gen(function* () { + const result = yield* read("nonexistent.txt") + expect(result.type).toBe("text") + expect(result.content).toBe("") + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - // Non-existent file should return empty content - const result = await read("nonexistent.txt") - expect(result.type).toBe("text") - expect(result.content).toBe("") - }, - }) - }) + it.instance("trims whitespace from text content", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => + fs.writeFile(path.join(test.directory, "test.txt"), " content with spaces \n\n", "utf-8"), + ) - test("trims whitespace from text content", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.txt") - await fs.writeFile(filepath, " content with spaces \n\n", "utf-8") + const result = yield* read("test.txt") + expect(result.content).toBe("content with spaces") + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("test.txt") - expect(result.content).toBe("content with spaces") - }, - }) - }) + it.instance("handles empty text file", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "empty.txt"), "", "utf-8")) - test("handles empty text file", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "empty.txt") - await fs.writeFile(filepath, "", "utf-8") + const result = yield* read("empty.txt") + expect(result.type).toBe("text") + expect(result.content).toBe("") + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("empty.txt") - expect(result.type).toBe("text") - expect(result.content).toBe("") - }, - }) - }) + it.instance("handles multi-line text files", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "multiline.txt"), "line1\nline2\nline3", "utf-8")) - test("handles multi-line text files", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "multiline.txt") - await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("multiline.txt") - expect(result.content).toBe("line1\nline2\nline3") - }, - }) - }) + const result = yield* read("multiline.txt") + expect(result.content).toBe("line1\nline2\nline3") + }), + ) }) describe("read() - binary content", () => { - test("reads binary file via Filesystem.readArrayBuffer()", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "image.png") - const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - await fs.writeFile(filepath, binaryContent) + it.instance("reads binary file via Filesystem.readArrayBuffer()", () => + Effect.gen(function* () { + const test = yield* TestInstance + const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "image.png"), binaryContent)) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("image.png") - expect(result.type).toBe("text") // Images return as text with base64 encoding - expect(result.encoding).toBe("base64") - expect(result.mimeType).toBe("image/png") - expect(result.content).toBe(binaryContent.toString("base64")) - }, - }) - }) + const result = yield* read("image.png") + expect(result.type).toBe("text") + expect(result.encoding).toBe("base64") + expect(result.mimeType).toBe("image/png") + expect(result.content).toBe(binaryContent.toString("base64")) + }), + ) - test("returns empty for binary non-image files", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "binary.so") - await fs.writeFile(filepath, Buffer.from([0x7f, 0x45, 0x4c, 0x46]), "binary") + it.instance("returns empty for binary non-image files", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "binary.so"), Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("binary.so") - expect(result.type).toBe("binary") - expect(result.content).toBe("") - }, - }) - }) + const result = yield* read("binary.so") + expect(result.type).toBe("binary") + expect(result.content).toBe("") + }), + ) }) describe("read() - Filesystem.mimeType()", () => { - test("detects MIME type via Filesystem.mimeType()", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.json") - await fs.writeFile(filepath, '{"key": "value"}', "utf-8") + it.instance("detects MIME type via Filesystem.mimeType()", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "test.json") + yield* Effect.promise(() => fs.writeFile(filepath, '{"key": "value"}', "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - expect(await Filesystem.mimeType(filepath)).toContain("application/json") + expect(yield* Effect.promise(() => Filesystem.mimeType(filepath))).toContain("application/json") - const result = await read("test.json") - expect(result.type).toBe("text") - }, - }) - }) + const result = yield* read("test.json") + expect(result.type).toBe("text") + }), + ) - test("handles various image MIME types", async () => { - await using tmp = await tmpdir() - const testCases = [ - { ext: "jpg", mime: "image/jpeg" }, - { ext: "png", mime: "image/png" }, - { ext: "gif", mime: "image/gif" }, - { ext: "webp", mime: "image/webp" }, - ] + it.instance("handles various image MIME types", () => + Effect.gen(function* () { + const test = yield* TestInstance + const testCases = [ + { ext: "jpg", mime: "image/jpeg" }, + { ext: "png", mime: "image/png" }, + { ext: "gif", mime: "image/gif" }, + { ext: "webp", mime: "image/webp" }, + ] - for (const { ext, mime } of testCases) { - const filepath = path.join(tmp.path, `test.${ext}`) - await fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]), "binary") - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - expect(await Filesystem.mimeType(filepath)).toContain(mime) - }, - }) - } - }) + for (const testCase of testCases) { + const filepath = path.join(test.directory, `test.${testCase.ext}`) + yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]))) + expect(yield* Effect.promise(() => Filesystem.mimeType(filepath))).toContain(testCase.mime) + } + }), + ) }) describe("list() - Filesystem.exists() and readText()", () => { - test("reads .gitignore via Filesystem.exists() and readText()", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance( + "reads .gitignore via Filesystem.exists() and readText()", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const gitignorePath = path.join(test.directory, ".gitignore") + yield* Effect.promise(() => fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const gitignorePath = path.join(tmp.path, ".gitignore") - await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8") + expect(yield* Effect.promise(() => Filesystem.exists(gitignorePath))).toBe(true) + expect(yield* Effect.promise(() => Filesystem.readText(gitignorePath))).toContain("node_modules") + }), + { git: true }, + ) - // This is used internally in list() - expect(await Filesystem.exists(gitignorePath)).toBe(true) + it.instance( + "reads .ignore file similarly", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const ignorePath = path.join(test.directory, ".ignore") + yield* Effect.promise(() => fs.writeFile(ignorePath, "*.log\n.env\n", "utf-8")) - const content = await Filesystem.readText(gitignorePath) - expect(content).toContain("node_modules") - }, - }) - }) + expect(yield* Effect.promise(() => Filesystem.exists(ignorePath))).toBe(true) + expect(yield* Effect.promise(() => Filesystem.readText(ignorePath))).toContain("*.log") + }), + { git: true }, + ) - test("reads .ignore file similarly", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance( + "handles missing .gitignore gracefully", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const gitignorePath = path.join(test.directory, ".gitignore") + expect(yield* Effect.promise(() => Filesystem.exists(gitignorePath))).toBe(false) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const ignorePath = path.join(tmp.path, ".ignore") - await fs.writeFile(ignorePath, "*.log\n.env\n", "utf-8") - - expect(await Filesystem.exists(ignorePath)).toBe(true) - expect(await Filesystem.readText(ignorePath)).toContain("*.log") - }, - }) - }) - - test("handles missing .gitignore gracefully", async () => { - await using tmp = await tmpdir({ git: true }) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const gitignorePath = path.join(tmp.path, ".gitignore") - expect(await Filesystem.exists(gitignorePath)).toBe(false) - - // list() should still work - const nodes = await list() + const nodes = yield* list() expect(Array.isArray(nodes)).toBe(true) - }, - }) - }) + }), + { git: true }, + ) }) describe("File.changed() - Filesystem.readText() for untracked files", () => { - test("reads untracked files via Filesystem.readText()", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance( + "reads untracked files via Filesystem.readText()", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const untrackedPath = path.join(test.directory, "untracked.txt") + yield* Effect.promise(() => fs.writeFile(untrackedPath, "new content\nwith multiple lines", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const untrackedPath = path.join(tmp.path, "untracked.txt") - await fs.writeFile(untrackedPath, "new content\nwith multiple lines", "utf-8") - - // This is how File.changed() reads untracked files - const content = await Filesystem.readText(untrackedPath) - const lines = content.split("\n").length - expect(lines).toBe(2) - }, - }) - }) + const content = yield* Effect.promise(() => Filesystem.readText(untrackedPath)) + expect(content.split("\n").length).toBe(2) + }), + { git: true }, + ) }) describe("Error handling", () => { - test("handles errors gracefully in Filesystem.readText()", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "readonly.txt") - await fs.writeFile(filepath, "content", "utf-8") + it.instance("handles errors gracefully in Filesystem.readText()", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "readonly.txt"), "content", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const nonExistentPath = path.join(tmp.path, "does-not-exist.txt") - // Filesystem.readText() on non-existent file throws - await expect(Filesystem.readText(nonExistentPath)).rejects.toThrow() + const nonExistentPath = path.join(test.directory, "does-not-exist.txt") + expect(Exit.isFailure(yield* Effect.promise(() => Filesystem.readText(nonExistentPath)).pipe(Effect.exit))).toBe(true) - // But read() handles this gracefully - const result = await read("does-not-exist.txt") - expect(result.content).toBe("") - }, - }) - }) + const result = yield* read("does-not-exist.txt") + expect(result.content).toBe("") + }), + ) - test("handles errors in Filesystem.readArrayBuffer()", async () => { - await using tmp = await tmpdir() + it.instance("handles errors in Filesystem.readArrayBuffer()", () => + Effect.gen(function* () { + const test = yield* TestInstance + const nonExistentPath = path.join(test.directory, "does-not-exist.bin") + const buffer = yield* Effect.promise(() => Filesystem.readArrayBuffer(nonExistentPath).catch(() => new ArrayBuffer(0))) + expect(buffer.byteLength).toBe(0) + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const nonExistentPath = path.join(tmp.path, "does-not-exist.bin") - const buffer = await Filesystem.readArrayBuffer(nonExistentPath).catch(() => new ArrayBuffer(0)) - expect(buffer.byteLength).toBe(0) - }, - }) - }) - - test("returns empty array buffer on error for images", async () => { - await using tmp = await tmpdir() - const _filepath = path.join(tmp.path, "broken.png") - // Don't create the file - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - // read() handles missing images gracefully - const result = await read("broken.png") - expect(result.type).toBe("text") - expect(result.content).toBe("") - }, - }) - }) + it.instance("returns empty array buffer on error for images", () => + Effect.gen(function* () { + const result = yield* read("broken.png") + expect(result.type).toBe("text") + expect(result.content).toBe("") + }), + ) }) describe("shouldEncode() logic", () => { - test("treats .ts files as text", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.ts") - await fs.writeFile(filepath, "export const value = 1", "utf-8") + it.instance("treats .ts files as text", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.ts"), "export const value = 1", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("test.ts") - expect(result.type).toBe("text") - expect(result.content).toBe("export const value = 1") - }, - }) - }) + const result = yield* read("test.ts") + expect(result.type).toBe("text") + expect(result.content).toBe("export const value = 1") + }), + ) - test("treats .mts files as text", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.mts") - await fs.writeFile(filepath, "export const value = 1", "utf-8") + it.instance("treats .mts files as text", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.mts"), "export const value = 1", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("test.mts") - expect(result.type).toBe("text") - expect(result.content).toBe("export const value = 1") - }, - }) - }) + const result = yield* read("test.mts") + expect(result.type).toBe("text") + expect(result.content).toBe("export const value = 1") + }), + ) - test("treats .sh files as text", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.sh") - await fs.writeFile(filepath, "#!/usr/bin/env bash\necho hello", "utf-8") + it.instance("treats .sh files as text", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.sh"), "#!/usr/bin/env bash\necho hello", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("test.sh") - expect(result.type).toBe("text") - expect(result.content).toBe("#!/usr/bin/env bash\necho hello") - }, - }) - }) + const result = yield* read("test.sh") + expect(result.type).toBe("text") + expect(result.content).toBe("#!/usr/bin/env bash\necho hello") + }), + ) - test("treats Dockerfile as text", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "Dockerfile") - await fs.writeFile(filepath, "FROM alpine:3.20", "utf-8") + it.instance("treats Dockerfile as text", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "Dockerfile"), "FROM alpine:3.20", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("Dockerfile") - expect(result.type).toBe("text") - expect(result.content).toBe("FROM alpine:3.20") - }, - }) - }) + const result = yield* read("Dockerfile") + expect(result.type).toBe("text") + expect(result.content).toBe("FROM alpine:3.20") + }), + ) - test("returns encoding info for text files", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.txt") - await fs.writeFile(filepath, "simple text", "utf-8") + it.instance("returns encoding info for text files", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "simple text", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("test.txt") - expect(result.encoding).toBeUndefined() - expect(result.type).toBe("text") - }, - }) - }) + const result = yield* read("test.txt") + expect(result.encoding).toBeUndefined() + expect(result.type).toBe("text") + }), + ) - test("returns base64 encoding for images", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.jpg") - await fs.writeFile(filepath, Buffer.from([0xff, 0xd8, 0xff, 0xe0]), "binary") + it.instance("returns base64 encoding for images", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.jpg"), Buffer.from([0xff, 0xd8, 0xff, 0xe0]))) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("test.jpg") - expect(result.encoding).toBe("base64") - expect(result.mimeType).toBe("image/jpeg") - }, - }) - }) + const result = yield* read("test.jpg") + expect(result.encoding).toBe("base64") + expect(result.mimeType).toBe("image/jpeg") + }), + ) }) describe("Path security", () => { - test("throws for paths outside project directory", async () => { - await using tmp = await tmpdir() + it.instance("throws for paths outside project directory", () => + Effect.gen(function* () { + expect(yield* failureMessage(read("../outside.txt"))).toContain("Access denied") + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await expect(read("../outside.txt")).rejects.toThrow("Access denied") - }, - }) - }) - - test("throws for paths outside project directory", async () => { - await using tmp = await tmpdir() - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await expect(read("../outside.txt")).rejects.toThrow("Access denied") - }, - }) - }) + it.instance("throws for paths outside project directory", () => + Effect.gen(function* () { + expect(yield* failureMessage(read("../outside.txt"))).toContain("Access denied") + }), + ) }) describe("status()", () => { - test("detects modified file", async () => { - await using tmp = await tmpdir({ git: true }) - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "original\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit -m "add file"`.cwd(tmp.path).quiet() - await fs.writeFile(filepath, "modified\nextra line\n", "utf-8") + it.instance( + "detects modified file", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "original\n", "utf-8")) + yield* gitAddAll(test.directory) + yield* gitCommit(test.directory, "add file") + yield* Effect.promise(() => fs.writeFile(filepath, "modified\nextra line\n", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await status() - const entry = result.find((f) => f.path === "file.txt") + const result = yield* status() + const entry = result.find((file) => file.path === "file.txt") expect(entry).toBeDefined() expect(entry!.status).toBe("modified") expect(entry!.added).toBeGreaterThan(0) expect(entry!.removed).toBeGreaterThan(0) - }, - }) - }) + }), + { git: true }, + ) - test("detects untracked file as added", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "new.txt"), "line1\nline2\nline3\n", "utf-8") + it.instance( + "detects untracked file as added", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "new.txt"), "line1\nline2\nline3\n", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await status() - const entry = result.find((f) => f.path === "new.txt") + const result = yield* status() + const entry = result.find((file) => file.path === "new.txt") expect(entry).toBeDefined() expect(entry!.status).toBe("added") - expect(entry!.added).toBe(4) // 3 lines + trailing newline splits to 4 + expect(entry!.added).toBe(4) expect(entry!.removed).toBe(0) - }, - }) - }) + }), + { git: true }, + ) - test("detects deleted file", async () => { - await using tmp = await tmpdir({ git: true }) - const filepath = path.join(tmp.path, "gone.txt") - await fs.writeFile(filepath, "content\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit -m "add file"`.cwd(tmp.path).quiet() - await fs.rm(filepath) + it.instance( + "detects deleted file", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "gone.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "content\n", "utf-8")) + yield* gitAddAll(test.directory) + yield* gitCommit(test.directory, "add file") + yield* Effect.promise(() => fs.rm(filepath)) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await status() - // Deleted files appear in both numstat (as "modified") and diff-filter=D (as "deleted") - const entries = result.filter((f) => f.path === "gone.txt") - expect(entries.some((e) => e.status === "deleted")).toBe(true) - }, - }) - }) + const result = yield* status() + const entries = result.filter((file) => file.path === "gone.txt") + expect(entries.some((entry) => entry.status === "deleted")).toBe(true) + }), + { git: true }, + ) - test("detects mixed changes", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "keep.txt"), "keep\n", "utf-8") - await fs.writeFile(path.join(tmp.path, "remove.txt"), "remove\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit -m "initial"`.cwd(tmp.path).quiet() + it.instance( + "detects mixed changes", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "keep\n", "utf-8")) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "remove.txt"), "remove\n", "utf-8")) + yield* gitAddAll(test.directory) + yield* gitCommit(test.directory, "initial") - // Modify one, delete one, add one - await fs.writeFile(path.join(tmp.path, "keep.txt"), "changed\n", "utf-8") - await fs.rm(path.join(tmp.path, "remove.txt")) - await fs.writeFile(path.join(tmp.path, "brand-new.txt"), "hello\n", "utf-8") + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "changed\n", "utf-8")) + yield* Effect.promise(() => fs.rm(path.join(test.directory, "remove.txt"))) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "brand-new.txt"), "hello\n", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await status() - expect(result.some((f) => f.path === "keep.txt" && f.status === "modified")).toBe(true) - expect(result.some((f) => f.path === "remove.txt" && f.status === "deleted")).toBe(true) - expect(result.some((f) => f.path === "brand-new.txt" && f.status === "added")).toBe(true) - }, - }) - }) + const result = yield* status() + expect(result.some((file) => file.path === "keep.txt" && file.status === "modified")).toBe(true) + expect(result.some((file) => file.path === "remove.txt" && file.status === "deleted")).toBe(true) + expect(result.some((file) => file.path === "brand-new.txt" && file.status === "added")).toBe(true) + }), + { git: true }, + ) - test("returns empty for non-git project", async () => { - await using tmp = await tmpdir() + it.instance("returns empty for non-git project", () => + Effect.gen(function* () { + expect(yield* status()).toEqual([]) + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await status() - expect(result).toEqual([]) - }, - }) - }) + it.instance( + "returns empty for clean repo", + () => + Effect.gen(function* () { + expect(yield* status()).toEqual([]) + }), + { git: true }, + ) - test("returns empty for clean repo", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance( + "parses binary numstat as 0", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "data.bin") + yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from(Array.from({ length: 256 }, (_, index) => index)))) + yield* gitAddAll(test.directory) + yield* gitCommit(test.directory, "add binary") + yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256)))) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await status() - expect(result).toEqual([]) - }, - }) - }) - - test("parses binary numstat as 0", async () => { - await using tmp = await tmpdir({ git: true }) - const filepath = path.join(tmp.path, "data.bin") - // Write content with null bytes so git treats it as binary - const binaryData = Buffer.alloc(256) - for (let i = 0; i < 256; i++) binaryData[i] = i - await fs.writeFile(filepath, binaryData) - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit -m "add binary"`.cwd(tmp.path).quiet() - // Modify the binary - const modified = Buffer.alloc(512) - for (let i = 0; i < 512; i++) modified[i] = i % 256 - await fs.writeFile(filepath, modified) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await status() - const entry = result.find((f) => f.path === "data.bin") + const result = yield* status() + const entry = result.find((file) => file.path === "data.bin") expect(entry).toBeDefined() expect(entry!.status).toBe("modified") expect(entry!.added).toBe(0) expect(entry!.removed).toBe(0) - }, - }) - }) + }), + { git: true }, + ) }) describe("list()", () => { - test("returns files and directories with correct shape", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.mkdir(path.join(tmp.path, "subdir")) - await fs.writeFile(path.join(tmp.path, "file.txt"), "content", "utf-8") - await fs.writeFile(path.join(tmp.path, "subdir", "nested.txt"), "nested", "utf-8") + it.instance( + "returns files and directories with correct shape", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "subdir"))) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "content", "utf-8")) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "subdir", "nested.txt"), "nested", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const nodes = await list() + const nodes = yield* list() expect(nodes.length).toBeGreaterThanOrEqual(2) for (const node of nodes) { expect(node).toHaveProperty("name") @@ -561,289 +493,260 @@ describe("file/index Filesystem patterns", () => { expect(node).toHaveProperty("ignored") expect(["file", "directory"]).toContain(node.type) } - }, - }) - }) + }), + { git: true }, + ) - test("sorts directories before files, alphabetical within each", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.mkdir(path.join(tmp.path, "beta")) - await fs.mkdir(path.join(tmp.path, "alpha")) - await fs.writeFile(path.join(tmp.path, "zz.txt"), "", "utf-8") - await fs.writeFile(path.join(tmp.path, "aa.txt"), "", "utf-8") + it.instance( + "sorts directories before files, alphabetical within each", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "beta"))) + yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "alpha"))) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "zz.txt"), "", "utf-8")) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "aa.txt"), "", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const nodes = await list() - const dirs = nodes.filter((n) => n.type === "directory") - const files = nodes.filter((n) => n.type === "file") - // Dirs come first - const firstFile = nodes.findIndex((n) => n.type === "file") - const lastDir = nodes.findLastIndex((n) => n.type === "directory") + const nodes = yield* list() + const dirs = nodes.filter((node) => node.type === "directory") + const files = nodes.filter((node) => node.type === "file") + const firstFile = nodes.findIndex((node) => node.type === "file") + const lastDir = nodes.findLastIndex((node) => node.type === "directory") if (lastDir >= 0 && firstFile >= 0) { expect(lastDir).toBeLessThan(firstFile) } - // Alphabetical within dirs - expect(dirs.map((d) => d.name)).toEqual(dirs.map((d) => d.name).toSorted()) - // Alphabetical within files - expect(files.map((f) => f.name)).toEqual(files.map((f) => f.name).toSorted()) - }, - }) - }) + expect(dirs.map((dir) => dir.name)).toEqual(dirs.map((dir) => dir.name).toSorted()) + expect(files.map((file) => file.name)).toEqual(files.map((file) => file.name).toSorted()) + }), + { git: true }, + ) - test("excludes .git and .DS_Store", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, ".DS_Store"), "", "utf-8") - await fs.writeFile(path.join(tmp.path, "visible.txt"), "", "utf-8") + it.instance( + "excludes .git and .DS_Store", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".DS_Store"), "", "utf-8")) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "visible.txt"), "", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const nodes = await list() - const names = nodes.map((n) => n.name) + const names = (yield* list()).map((node) => node.name) expect(names).not.toContain(".git") expect(names).not.toContain(".DS_Store") expect(names).toContain("visible.txt") - }, - }) - }) + }), + { git: true }, + ) - test("marks gitignored files as ignored", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, ".gitignore"), "*.log\nbuild/\n", "utf-8") - await fs.writeFile(path.join(tmp.path, "app.log"), "log data", "utf-8") - await fs.writeFile(path.join(tmp.path, "main.ts"), "code", "utf-8") - await fs.mkdir(path.join(tmp.path, "build")) + it.instance( + "marks gitignored files as ignored", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".gitignore"), "*.log\nbuild/\n", "utf-8")) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "app.log"), "log data", "utf-8")) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "main.ts"), "code", "utf-8")) + yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "build"))) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const nodes = await list() - const logNode = nodes.find((n) => n.name === "app.log") - const tsNode = nodes.find((n) => n.name === "main.ts") - const buildNode = nodes.find((n) => n.name === "build") - expect(logNode?.ignored).toBe(true) - expect(tsNode?.ignored).toBe(false) - expect(buildNode?.ignored).toBe(true) - }, - }) - }) + const nodes = yield* list() + expect(nodes.find((node) => node.name === "app.log")?.ignored).toBe(true) + expect(nodes.find((node) => node.name === "main.ts")?.ignored).toBe(false) + expect(nodes.find((node) => node.name === "build")?.ignored).toBe(true) + }), + { git: true }, + ) - test("lists subdirectory contents", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.mkdir(path.join(tmp.path, "sub")) - await fs.writeFile(path.join(tmp.path, "sub", "a.txt"), "", "utf-8") - await fs.writeFile(path.join(tmp.path, "sub", "b.txt"), "", "utf-8") + it.instance( + "lists subdirectory contents", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "sub"))) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "a.txt"), "", "utf-8")) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "b.txt"), "", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const nodes = await list("sub") + const nodes = yield* list("sub") expect(nodes.length).toBe(2) - expect(nodes.map((n) => n.name).sort()).toEqual(["a.txt", "b.txt"]) - // Paths should be relative to project root (normalize for Windows) + expect(nodes.map((node) => node.name).sort()).toEqual(["a.txt", "b.txt"]) expect(nodes[0].path.replaceAll("\\", "/").startsWith("sub/")).toBe(true) - }, - }) - }) + }), + { git: true }, + ) - test("throws for paths outside project directory", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance( + "throws for paths outside project directory", + () => + Effect.gen(function* () { + expect(yield* failureMessage(list("../outside"))).toContain("Access denied") + }), + { git: true }, + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await expect(list("../outside")).rejects.toThrow("Access denied") - }, - }) - }) + it.instance("works without git", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "hi", "utf-8")) - test("works without git", async () => { - await using tmp = await tmpdir() - await fs.writeFile(path.join(tmp.path, "file.txt"), "hi", "utf-8") - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const nodes = await list() - expect(nodes.length).toBeGreaterThanOrEqual(1) - // Without git, ignored should be false for all - for (const node of nodes) { - expect(node.ignored).toBe(false) - } - }, - }) - }) + const nodes = yield* list() + expect(nodes.length).toBeGreaterThanOrEqual(1) + for (const node of nodes) { + expect(node.ignored).toBe(false) + } + }), + ) }) describe("search()", () => { - async function setupSearchableRepo() { - const tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8") - await fs.writeFile(path.join(tmp.path, "utils.ts"), "utils", "utf-8") - await fs.writeFile(path.join(tmp.path, "readme.md"), "readme", "utf-8") - await fs.mkdir(path.join(tmp.path, "src")) - await fs.mkdir(path.join(tmp.path, ".hidden")) - await fs.writeFile(path.join(tmp.path, "src", "main.ts"), "main", "utf-8") - await fs.writeFile(path.join(tmp.path, ".hidden", "secret.ts"), "secret", "utf-8") - return tmp - } + it.instance( + "empty query returns files", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* setupSearchableRepo(test.directory) + yield* init() - test("empty query returns files", async () => { - await using tmp = await setupSearchableRepo() - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - - const result = await search({ query: "", type: "file" }) + const result = yield* search({ query: "", type: "file" }) expect(result.length).toBeGreaterThan(0) - }, - }) - }) + }), + { git: true }, + ) - test("search works before explicit init", async () => { - await using tmp = await setupSearchableRepo() + it.instance( + "search works before explicit init", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* setupSearchableRepo(test.directory) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await search({ query: "main", type: "file" }) - expect(result.some((f) => f.includes("main"))).toBe(true) - }, - }) - }) + const result = yield* search({ query: "main", type: "file" }) + expect(result.some((file) => file.includes("main"))).toBe(true) + }), + { git: true }, + ) - test("empty query returns dirs sorted with hidden last", async () => { - await using tmp = await setupSearchableRepo() + it.instance( + "empty query returns dirs sorted with hidden last", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* setupSearchableRepo(test.directory) + yield* init() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - - const result = await search({ query: "", type: "directory" }) + const result = yield* search({ query: "", type: "directory" }) expect(result.length).toBeGreaterThan(0) - // Find first hidden dir index - const firstHidden = result.findIndex((d) => d.split("/").some((p) => p.startsWith(".") && p.length > 1)) - const lastVisible = result.findLastIndex((d) => !d.split("/").some((p) => p.startsWith(".") && p.length > 1)) + const firstHidden = result.findIndex((dir) => dir.split("/").some((part) => part.startsWith(".") && part.length > 1)) + const lastVisible = result.findLastIndex((dir) => !dir.split("/").some((part) => part.startsWith(".") && part.length > 1)) if (firstHidden >= 0 && lastVisible >= 0) { expect(firstHidden).toBeGreaterThan(lastVisible) } - }, - }) - }) + }), + { git: true }, + ) - test("fuzzy matches file names", async () => { - await using tmp = await setupSearchableRepo() + it.instance( + "fuzzy matches file names", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* setupSearchableRepo(test.directory) + yield* init() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() + const result = yield* search({ query: "main", type: "file" }) + expect(result.some((file) => file.includes("main"))).toBe(true) + }), + { git: true }, + ) - const result = await search({ query: "main", type: "file" }) - expect(result.some((f) => f.includes("main"))).toBe(true) - }, - }) - }) + it.instance( + "type filter returns only files", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* setupSearchableRepo(test.directory) + yield* init() - test("type filter returns only files", async () => { - await using tmp = await setupSearchableRepo() - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - - const result = await search({ query: "", type: "file" }) - // Files don't end with / - for (const f of result) { - expect(f.endsWith("/")).toBe(false) + const result = yield* search({ query: "", type: "file" }) + for (const file of result) { + expect(file.endsWith("/")).toBe(false) } - }, - }) - }) + }), + { git: true }, + ) - test("type filter returns only directories", async () => { - await using tmp = await setupSearchableRepo() + it.instance( + "type filter returns only directories", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* setupSearchableRepo(test.directory) + yield* init() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - - const result = await search({ query: "", type: "directory" }) - // Directories end with / - for (const d of result) { - expect(d.endsWith("/")).toBe(true) + const result = yield* search({ query: "", type: "directory" }) + for (const dir of result) { + expect(dir.endsWith("/")).toBe(true) } - }, - }) - }) + }), + { git: true }, + ) - test("respects limit", async () => { - await using tmp = await setupSearchableRepo() + it.instance( + "respects limit", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* setupSearchableRepo(test.directory) + yield* init() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - - const result = await search({ query: "", type: "file", limit: 2 }) + const result = yield* search({ query: "", type: "file", limit: 2 }) expect(result.length).toBeLessThanOrEqual(2) - }, - }) - }) + }), + { git: true }, + ) - test("query starting with dot prefers hidden files", async () => { - await using tmp = await setupSearchableRepo() + it.instance( + "query starting with dot prefers hidden files", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* setupSearchableRepo(test.directory) + yield* init() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - - const result = await search({ query: ".hidden", type: "directory" }) + const result = yield* search({ query: ".hidden", type: "directory" }) expect(result.length).toBeGreaterThan(0) expect(result[0]).toContain(".hidden") - }, - }) - }) + }), + { git: true }, + ) - test("search refreshes after init when files change", async () => { - await using tmp = await setupSearchableRepo() + it.instance( + "search refreshes after init when files change", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* setupSearchableRepo(test.directory) + yield* init() + expect(yield* search({ query: "fresh", type: "file" })).toEqual([]) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - expect(await search({ query: "fresh", type: "file" })).toEqual([]) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "fresh.ts"), "fresh", "utf-8")) - await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8") - - const result = await search({ query: "fresh", type: "file" }) - expect(result).toContain("fresh.ts") - }, - }) - }) + expect(yield* search({ query: "fresh", type: "file" })).toContain("fresh.ts") + }), + { git: true }, + ) }) describe("read() - diff/patch", () => { - test("returns diff and patch for modified tracked file", async () => { - await using tmp = await tmpdir({ git: true }) - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "original content\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit -m "add file"`.cwd(tmp.path).quiet() - await fs.writeFile(filepath, "modified content\n", "utf-8") + it.instance( + "returns diff and patch for modified tracked file", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "original content\n", "utf-8")) + yield* gitAddAll(test.directory) + yield* gitCommit(test.directory, "add file") + yield* Effect.promise(() => fs.writeFile(filepath, "modified content\n", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("file.txt") + const result = yield* read("file.txt") expect(result.type).toBe("text") expect(result.content).toBe("modified content") expect(result.diff).toBeDefined() @@ -851,107 +754,90 @@ describe("file/index Filesystem patterns", () => { expect(result.diff).toContain("modified content") expect(result.patch).toBeDefined() expect(result.patch!.hunks.length).toBeGreaterThan(0) - }, - }) - }) + }), + { git: true }, + ) - test("returns diff for staged changes", async () => { - await using tmp = await tmpdir({ git: true }) - const filepath = path.join(tmp.path, "staged.txt") - await fs.writeFile(filepath, "before\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit -m "add file"`.cwd(tmp.path).quiet() - await fs.writeFile(filepath, "after\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() + it.instance( + "returns diff for staged changes", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "staged.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "before\n", "utf-8")) + yield* gitAddAll(test.directory) + yield* gitCommit(test.directory, "add file") + yield* Effect.promise(() => fs.writeFile(filepath, "after\n", "utf-8")) + yield* gitAddAll(test.directory) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("staged.txt") + const result = yield* read("staged.txt") expect(result.diff).toBeDefined() expect(result.patch).toBeDefined() - }, - }) - }) + }), + { git: true }, + ) - test("returns no diff for unmodified file", async () => { - await using tmp = await tmpdir({ git: true }) - const filepath = path.join(tmp.path, "clean.txt") - await fs.writeFile(filepath, "unchanged\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit -m "add file"`.cwd(tmp.path).quiet() + it.instance( + "returns no diff for unmodified file", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "clean.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "unchanged\n", "utf-8")) + yield* gitAddAll(test.directory) + yield* gitCommit(test.directory, "add file") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("clean.txt") + const result = yield* read("clean.txt") expect(result.type).toBe("text") expect(result.content).toBe("unchanged") expect(result.diff).toBeUndefined() expect(result.patch).toBeUndefined() - }, - }) - }) + }), + { git: true }, + ) }) describe("InstanceState isolation", () => { - test("two directories get independent file caches", async () => { - await using one = await tmpdir({ git: true }) - await using two = await tmpdir({ git: true }) - await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8") - await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8") + it.instance( + "two directories get independent file caches", + () => + Effect.gen(function* () { + const one = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(one.directory, "a.ts"), "one", "utf-8")) + yield* init() + expect(yield* search({ query: "a.ts", type: "file" })).toContain("a.ts") + expect(yield* search({ query: "b.ts", type: "file" })).not.toContain("b.ts") - await WithInstance.provide({ - directory: one.path, - fn: async () => { - await init() - const results = await search({ query: "a.ts", type: "file" }) - expect(results).toContain("a.ts") - const results2 = await search({ query: "b.ts", type: "file" }) - expect(results2).not.toContain("b.ts") - }, - }) + yield* Effect.gen(function* () { + const two = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(two.directory, "b.ts"), "two", "utf-8")) + yield* init() + expect(yield* search({ query: "b.ts", type: "file" })).toContain("b.ts") + expect(yield* search({ query: "a.ts", type: "file" })).not.toContain("a.ts") + }).pipe(withTmpdirInstance({ git: true })) + }), + { git: true }, + ) - await WithInstance.provide({ - directory: two.path, - fn: async () => { - await init() - const results = await search({ query: "b.ts", type: "file" }) - expect(results).toContain("b.ts") - const results2 = await search({ query: "a.ts", type: "file" }) - expect(results2).not.toContain("a.ts") - }, - }) - }) + it.instance( + "disposal gives fresh state on next access", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "before.ts"), "before", "utf-8")) + yield* init() + expect(yield* search({ query: "before", type: "file" })).toContain("before.ts") - test("disposal gives fresh state on next access", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8") + yield* Effect.promise(() => disposeAllInstances()) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - const results = await search({ query: "before", type: "file" }) - expect(results).toContain("before.ts") - }, - }) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "after.ts"), "after", "utf-8")) + yield* Effect.promise(() => fs.rm(path.join(test.directory, "before.ts"))) - await disposeAllInstances() - - await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8") - await fs.rm(path.join(tmp.path, "before.ts")) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - const results = await search({ query: "after", type: "file" }) - expect(results).toContain("after.ts") - const stale = await search({ query: "before", type: "file" }) - expect(stale).not.toContain("before.ts") - }, - }) - }) + yield* init() + expect(yield* search({ query: "after", type: "file" })).toContain("after.ts") + expect(yield* search({ query: "before", type: "file" })).not.toContain("before.ts") + }), + { git: true }, + ) }) }) From 44edb639c2c2b2874e7cdeb25e048ccad8be449c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 20:40:23 -0400 Subject: [PATCH 050/188] test(session): migrate message pagination to Effect runner (#26957) --- .../test/session/messages-pagination.test.ts | 1598 +++++++---------- 1 file changed, 678 insertions(+), 920 deletions(-) diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 86e1d85d0..49828a9b6 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -1,46 +1,41 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" -import path from "path" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import * as Log from "@opencode-ai/core/util/log" +import { testEffect } from "../lib/effect" -const root = path.join(__dirname, "../..") void Log.init({ print: false }) -function run(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) -} +const it = testEffect(SessionNs.defaultLayer) -const svc = { - ...SessionNs, - create(input?: SessionNs.CreateInput) { - return run(SessionNs.Service.use((svc) => svc.create(input))) - }, - remove(id: SessionID) { - return run(SessionNs.Service.use((svc) => svc.remove(id))) - }, - updateMessage(msg: T) { - return run(SessionNs.Service.use((svc) => svc.updateMessage(msg))) - }, - updatePart(part: T) { - return run(SessionNs.Service.use((svc) => svc.updatePart(part))) - }, - fork(input: { sessionID: SessionID; messageID?: MessageID }) { - return run(SessionNs.Service.use((svc) => svc.fork(input))) - }, -} +const withSession = ( + fn: (input: { session: SessionNs.Interface; sessionID: SessionID }) => Effect.Effect, +) => + Effect.acquireUseRelease( + Effect.gen(function* () { + const session = yield* SessionNs.Service + const created = yield* session.create({}) + return { session, sessionID: created.id } + }), + fn, + (input) => input.session.remove(input.sessionID).pipe(Effect.ignore), + ) -async function fill(sessionID: SessionID, count: number, time = (i: number) => Date.now() + i) { +// Helper functions using Effect.gen +const fill = Effect.fn("Test.fill")(function* ( + sessionID: SessionID, + count: number, + time = (i: number) => Date.now() + i, +) { + const session = yield* SessionNs.Service const ids = [] as MessageID[] for (let i = 0; i < count; i++) { const id = MessageID.ascending() ids.push(id) - await svc.updateMessage({ + yield* session.updateMessage({ id, sessionID, role: "user", @@ -50,7 +45,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D tools: {}, mode: "", } as unknown as MessageV2.Info) - await svc.updatePart({ + yield* session.updatePart({ id: PartID.ascending(), sessionID, messageID: id, @@ -59,11 +54,12 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D }) } return ids -} +}) -async function addUser(sessionID: SessionID, text?: string) { +const addUser = Effect.fn("Test.addUser")(function* (sessionID: SessionID, text?: string) { + const session = yield* SessionNs.Service const id = MessageID.ascending() - await svc.updateMessage({ + yield* session.updateMessage({ id, sessionID, role: "user", @@ -74,7 +70,7 @@ async function addUser(sessionID: SessionID, text?: string) { mode: "", } as unknown as MessageV2.Info) if (text) { - await svc.updatePart({ + yield* session.updatePart({ id: PartID.ascending(), sessionID, messageID: id, @@ -83,15 +79,16 @@ async function addUser(sessionID: SessionID, text?: string) { }) } return id -} +}) -async function addAssistant( +const addAssistant = Effect.fn("Test.addAssistant")(function* ( sessionID: SessionID, parentID: MessageID, opts?: { summary?: boolean; finish?: string; error?: MessageV2.Assistant["error"] }, ) { + const session = yield* SessionNs.Service const id = MessageID.ascending() - await svc.updateMessage({ + yield* session.updateMessage({ id, sessionID, role: "assistant", @@ -109,10 +106,15 @@ async function addAssistant( error: opts?.error, } as unknown as MessageV2.Info) return id -} +}) -async function addCompactionPart(sessionID: SessionID, messageID: MessageID, tailStartID?: MessageID) { - await svc.updatePart({ +const addCompactionPart = Effect.fn("Test.addCompactionPart")(function* ( + sessionID: SessionID, + messageID: MessageID, + tailStartID?: MessageID, +) { + const session = yield* SessionNs.Service + yield* session.updatePart({ id: PartID.ascending(), sessionID, messageID, @@ -120,933 +122,713 @@ async function addCompactionPart(sessionID: SessionID, messageID: MessageID, tai auto: true, tail_start_id: tailStartID, } as any) -} +}) describe("MessageV2.page", () => { - test("returns sync result", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - await fill(session.id, 2) + it.instance("returns sync result", () => + withSession(({ sessionID }) => Effect.gen(function* () { + yield* fill(sessionID, 2) - const result = MessageV2.page({ sessionID: session.id, limit: 10 }) - expect(result).toBeDefined() - expect(result.items).toBeArray() + const result = MessageV2.page({ sessionID, limit: 10 }) + expect(result).toBeDefined() + expect(result.items).toBeArray() + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("pages backward with opaque cursors", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 6) - test("pages backward with opaque cursors", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 6) + const a = MessageV2.page({ sessionID, limit: 2 }) + expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) + expect(a.items.every((item) => item.parts.length === 1)).toBe(true) + expect(a.more).toBe(true) + expect(a.cursor).toBeTruthy() - const a = MessageV2.page({ sessionID: session.id, limit: 2 }) - expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) - expect(a.items.every((item) => item.parts.length === 1)).toBe(true) - expect(a.more).toBe(true) - expect(a.cursor).toBeTruthy() + const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) + expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(-4, -2)) + expect(b.more).toBe(true) + expect(b.cursor).toBeTruthy() - const b = MessageV2.page({ sessionID: session.id, limit: 2, before: a.cursor! }) - expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(-4, -2)) - expect(b.more).toBe(true) - expect(b.cursor).toBeTruthy() + const c = MessageV2.page({ sessionID, limit: 2, before: b.cursor! }) + expect(c.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) + expect(c.more).toBe(false) + expect(c.cursor).toBeUndefined() + })), + ) - const c = MessageV2.page({ sessionID: session.id, limit: 2, before: b.cursor! }) - expect(c.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) - expect(c.more).toBe(false) - expect(c.cursor).toBeUndefined() + it.instance("returns items in chronological order within a page", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 4) - await svc.remove(session.id) - }, - }) - }) + const result = MessageV2.page({ sessionID, limit: 4 }) + expect(result.items.map((item) => item.info.id)).toEqual(ids) + })), + ) - test("returns items in chronological order within a page", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 4) + it.instance("returns empty items for session with no messages", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const result = MessageV2.page({ sessionID, limit: 10 }) + expect(result.items).toEqual([]) + expect(result.more).toBe(false) + expect(result.cursor).toBeUndefined() + })), + ) - const result = MessageV2.page({ sessionID: session.id, limit: 4 }) - expect(result.items.map((item) => item.info.id)).toEqual(ids) + it.instance("throws NotFoundError for non-existent session", () => + Effect.gen(function* () { + const fake = "non-existent-session" as SessionID + expect(() => MessageV2.page({ sessionID: fake, limit: 10 })).toThrow("NotFoundError") + }), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("handles exact limit boundary", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 3) - test("returns empty items for session with no messages", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + const result = MessageV2.page({ sessionID, limit: 3 }) + expect(result.items.map((item) => item.info.id)).toEqual(ids) + expect(result.more).toBe(false) + expect(result.cursor).toBeUndefined() + })), + ) - const result = MessageV2.page({ sessionID: session.id, limit: 10 }) - expect(result.items).toEqual([]) - expect(result.more).toBe(false) - expect(result.cursor).toBeUndefined() + it.instance("limit of 1 returns single newest message", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 5) - await svc.remove(session.id) - }, - }) - }) + const result = MessageV2.page({ sessionID, limit: 1 }) + expect(result.items).toHaveLength(1) + expect(result.items[0].info.id).toBe(ids[ids.length - 1]) + expect(result.more).toBe(true) + })), + ) - test("throws NotFoundError for non-existent session", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const fake = "non-existent-session" as SessionID - expect(() => MessageV2.page({ sessionID: fake, limit: 10 })).toThrow("NotFoundError") - }, - }) - }) + it.instance("hydrates multiple parts per message", () => + withSession(({ session, sessionID }) => Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - test("handles exact limit boundary", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 3) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text: "extra", + }) - const result = MessageV2.page({ sessionID: session.id, limit: 3 }) - expect(result.items.map((item) => item.info.id)).toEqual(ids) - expect(result.more).toBe(false) - expect(result.cursor).toBeUndefined() + const result = MessageV2.page({ sessionID, limit: 10 }) + expect(result.items).toHaveLength(1) + expect(result.items[0].parts).toHaveLength(2) + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("accepts cursors from fractional timestamps", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 4, (i: number) => 1000.5 + i) - test("limit of 1 returns single newest message", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 5) + const a = MessageV2.page({ sessionID, limit: 2 }) + const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) - const result = MessageV2.page({ sessionID: session.id, limit: 1 }) - expect(result.items).toHaveLength(1) - expect(result.items[0].info.id).toBe(ids[ids.length - 1]) - expect(result.more).toBe(true) + expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) + expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("messages with same timestamp are ordered by id", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 4, () => 1000) - test("hydrates multiple parts per message", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const [id] = await fill(session.id, 1) + const a = MessageV2.page({ sessionID, limit: 2 }) + expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) + expect(a.more).toBe(true) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: id, - type: "text", - text: "extra", - }) + const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) + expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) + expect(b.more).toBe(false) + })), + ) - const result = MessageV2.page({ sessionID: session.id, limit: 10 }) - expect(result.items).toHaveLength(1) - expect(result.items[0].parts).toHaveLength(2) + it.instance("does not return messages from other sessions", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const a = yield* session.create({}) + const b = yield* session.create({}) + yield* fill(a.id, 3) + yield* fill(b.id, 2) - await svc.remove(session.id) - }, - }) - }) + const resultA = MessageV2.page({ sessionID: a.id, limit: 10 }) + const resultB = MessageV2.page({ sessionID: b.id, limit: 10 }) + expect(resultA.items).toHaveLength(3) + expect(resultB.items).toHaveLength(2) + expect(resultA.items.every((item) => item.info.sessionID === a.id)).toBe(true) + expect(resultB.items.every((item) => item.info.sessionID === b.id)).toBe(true) - test("accepts cursors from fractional timestamps", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 4, (i) => 1000.5 + i) + yield* session.remove(a.id) + yield* session.remove(b.id) + }), + ) - const a = MessageV2.page({ sessionID: session.id, limit: 2 }) - const b = MessageV2.page({ sessionID: session.id, limit: 2, before: a.cursor! }) + it.instance("large limit returns all messages without cursor", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 10) - expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) - expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) - - await svc.remove(session.id) - }, - }) - }) - - test("messages with same timestamp are ordered by id", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 4, () => 1000) - - const a = MessageV2.page({ sessionID: session.id, limit: 2 }) - expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) - expect(a.more).toBe(true) - - const b = MessageV2.page({ sessionID: session.id, limit: 2, before: a.cursor! }) - expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) - expect(b.more).toBe(false) - - await svc.remove(session.id) - }, - }) - }) - - test("does not return messages from other sessions", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const a = await svc.create({}) - const b = await svc.create({}) - await fill(a.id, 3) - await fill(b.id, 2) - - const resultA = MessageV2.page({ sessionID: a.id, limit: 10 }) - const resultB = MessageV2.page({ sessionID: b.id, limit: 10 }) - expect(resultA.items).toHaveLength(3) - expect(resultB.items).toHaveLength(2) - expect(resultA.items.every((item) => item.info.sessionID === a.id)).toBe(true) - expect(resultB.items.every((item) => item.info.sessionID === b.id)).toBe(true) - - await svc.remove(a.id) - await svc.remove(b.id) - }, - }) - }) - - test("large limit returns all messages without cursor", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 10) - - const result = MessageV2.page({ sessionID: session.id, limit: 100 }) - expect(result.items).toHaveLength(10) - expect(result.items.map((item) => item.info.id)).toEqual(ids) - expect(result.more).toBe(false) - expect(result.cursor).toBeUndefined() - - await svc.remove(session.id) - }, - }) - }) + const result = MessageV2.page({ sessionID, limit: 100 }) + expect(result.items).toHaveLength(10) + expect(result.items.map((item) => item.info.id)).toEqual(ids) + expect(result.more).toBe(false) + expect(result.cursor).toBeUndefined() + })), + ) }) describe("MessageV2.stream", () => { - test("yields items newest first", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 5) + it.instance("yields items newest first", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 5) - const items = Array.from(MessageV2.stream(session.id)) - expect(items.map((item) => item.info.id)).toEqual(ids.slice().reverse()) + const items = Array.from(MessageV2.stream(sessionID)) + expect(items.map((item) => item.info.id)).toEqual(ids.slice().reverse()) + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("yields nothing for empty session", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const items = Array.from(MessageV2.stream(sessionID)) + expect(items).toHaveLength(0) + })), + ) - test("yields nothing for empty session", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + it.instance("yields single message", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 1) - const items = Array.from(MessageV2.stream(session.id)) - expect(items).toHaveLength(0) + const items = Array.from(MessageV2.stream(sessionID)) + expect(items).toHaveLength(1) + expect(items[0].info.id).toBe(ids[0]) + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("hydrates parts for each yielded message", () => + withSession(({ sessionID }) => Effect.gen(function* () { + yield* fill(sessionID, 3) - test("yields single message", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 1) + const items = Array.from(MessageV2.stream(sessionID)) + for (const item of items) { + expect(item.parts).toHaveLength(1) + expect(item.parts[0].type).toBe("text") + } + })), + ) - const items = Array.from(MessageV2.stream(session.id)) - expect(items).toHaveLength(1) - expect(items[0].info.id).toBe(ids[0]) + it.instance("handles sets exceeding internal page size", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 60) - await svc.remove(session.id) - }, - }) - }) + const items = Array.from(MessageV2.stream(sessionID)) + expect(items).toHaveLength(60) + expect(items[0].info.id).toBe(ids[ids.length - 1]) + expect(items[59].info.id).toBe(ids[0]) + })), + ) - test("hydrates parts for each yielded message", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - await fill(session.id, 3) + it.instance("is a sync generator", () => + withSession(({ sessionID }) => Effect.gen(function* () { + yield* fill(sessionID, 1) - const items = Array.from(MessageV2.stream(session.id)) - for (const item of items) { - expect(item.parts).toHaveLength(1) - expect(item.parts[0].type).toBe("text") - } - - await svc.remove(session.id) - }, - }) - }) - - test("handles sets exceeding internal page size", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 60) - - const items = Array.from(MessageV2.stream(session.id)) - expect(items).toHaveLength(60) - expect(items[0].info.id).toBe(ids[ids.length - 1]) - expect(items[59].info.id).toBe(ids[0]) - - await svc.remove(session.id) - }, - }) - }) - - test("is a sync generator", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - await fill(session.id, 1) - - const gen = MessageV2.stream(session.id) - const first = gen.next() - // sync generator returns { value, done } directly, not a Promise - expect(first).toHaveProperty("value") - expect(first).toHaveProperty("done") - expect(first.done).toBe(false) - - await svc.remove(session.id) - }, - }) - }) + const gen = MessageV2.stream(sessionID) + const first = gen.next() + // sync generator returns { value, done } directly, not a Promise + expect(first).toHaveProperty("value") + expect(first).toHaveProperty("done") + expect(first.done).toBe(false) + })), + ) }) describe("MessageV2.parts", () => { - test("returns parts for a message", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const [id] = await fill(session.id, 1) + it.instance("returns parts for a message", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - const result = MessageV2.parts(id) - expect(result).toHaveLength(1) - expect(result[0].type).toBe("text") - expect((result[0] as MessageV2.TextPart).text).toBe("m0") + const result = MessageV2.parts(id) + expect(result).toHaveLength(1) + expect(result[0].type).toBe("text") + expect((result[0] as MessageV2.TextPart).text).toBe("m0") + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("returns empty array for message with no parts", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const id = yield* addUser(sessionID) - test("returns empty array for message with no parts", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const id = await addUser(session.id) + const result = MessageV2.parts(id) + expect(result).toEqual([]) + })), + ) - const result = MessageV2.parts(id) - expect(result).toEqual([]) + it.instance("returns multiple parts in order", () => + withSession(({ session, sessionID }) => Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - await svc.remove(session.id) - }, - }) - }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text: "second", + }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text: "third", + }) - test("returns multiple parts in order", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const [id] = await fill(session.id, 1) + const result = MessageV2.parts(id) + expect(result).toHaveLength(3) + expect((result[0] as MessageV2.TextPart).text).toBe("m0") + expect((result[1] as MessageV2.TextPart).text).toBe("second") + expect((result[2] as MessageV2.TextPart).text).toBe("third") + })), + ) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: id, - type: "text", - text: "second", - }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: id, - type: "text", - text: "third", - }) + it.instance("returns empty for non-existent message id", () => + Effect.gen(function* () { + yield* SessionNs.Service + const result = MessageV2.parts(MessageID.ascending()) + expect(result).toEqual([]) + }), + ) - const result = MessageV2.parts(id) - expect(result).toHaveLength(3) - expect((result[0] as MessageV2.TextPart).text).toBe("m0") - expect((result[1] as MessageV2.TextPart).text).toBe("second") - expect((result[2] as MessageV2.TextPart).text).toBe("third") + it.instance("parts contain sessionID and messageID", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - await svc.remove(session.id) - }, - }) - }) - - test("returns empty for non-existent message id", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - await svc.create({}) - const result = MessageV2.parts(MessageID.ascending()) - expect(result).toEqual([]) - }, - }) - }) - - test("parts contain sessionID and messageID", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const [id] = await fill(session.id, 1) - - const result = MessageV2.parts(id) - expect(result[0].sessionID).toBe(session.id) - expect(result[0].messageID).toBe(id) - - await svc.remove(session.id) - }, - }) - }) + const result = MessageV2.parts(id) + expect(result[0].sessionID).toBe(sessionID) + expect(result[0].messageID).toBe(id) + })), + ) }) describe("MessageV2.get", () => { - test("returns message with hydrated parts", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const [id] = await fill(session.id, 1) + it.instance("returns message with hydrated parts", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - const result = MessageV2.get({ sessionID: session.id, messageID: id }) - expect(result.info.id).toBe(id) - expect(result.info.sessionID).toBe(session.id) - expect(result.info.role).toBe("user") - expect(result.parts).toHaveLength(1) - expect((result.parts[0] as MessageV2.TextPart).text).toBe("m0") + const result = MessageV2.get({ sessionID, messageID: id }) + expect(result.info.id).toBe(id) + expect(result.info.sessionID).toBe(sessionID) + expect(result.info.role).toBe("user") + expect(result.parts).toHaveLength(1) + expect((result.parts[0] as MessageV2.TextPart).text).toBe("m0") + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("throws NotFoundError for non-existent message", () => + withSession(({ sessionID }) => Effect.gen(function* () { + expect(() => MessageV2.get({ sessionID, messageID: MessageID.ascending() })).toThrow( + "NotFoundError", + ) + })), + ) - test("throws NotFoundError for non-existent message", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + it.instance("scopes by session id", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const a = yield* session.create({}) + const b = yield* session.create({}) + const [id] = yield* fill(a.id, 1) - expect(() => MessageV2.get({ sessionID: session.id, messageID: MessageID.ascending() })).toThrow( - "NotFoundError", - ) + expect(() => MessageV2.get({ sessionID: b.id, messageID: id })).toThrow("NotFoundError") + const result = MessageV2.get({ sessionID: a.id, messageID: id }) + expect(result.info.id).toBe(id) - await svc.remove(session.id) - }, - }) - }) + yield* session.remove(a.id) + yield* session.remove(b.id) + }), + ) - test("scopes by session id", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const a = await svc.create({}) - const b = await svc.create({}) - const [id] = await fill(a.id, 1) + it.instance("returns message with multiple parts", () => + withSession(({ session, sessionID }) => Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - expect(() => MessageV2.get({ sessionID: b.id, messageID: id })).toThrow("NotFoundError") - const result = MessageV2.get({ sessionID: a.id, messageID: id }) - expect(result.info.id).toBe(id) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text: "extra", + }) - await svc.remove(a.id) - await svc.remove(b.id) - }, - }) - }) + const result = MessageV2.get({ sessionID, messageID: id }) + expect(result.parts).toHaveLength(2) + })), + ) - test("returns message with multiple parts", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const [id] = await fill(session.id, 1) + it.instance("returns assistant message with correct role", () => + withSession(({ session, sessionID }) => Effect.gen(function* () { + const uid = yield* addUser(sessionID, "hello") + const aid = yield* addAssistant(sessionID, uid) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: id, - type: "text", - text: "extra", - }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: aid, + type: "text", + text: "response", + }) - const result = MessageV2.get({ sessionID: session.id, messageID: id }) - expect(result.parts).toHaveLength(2) + const result = MessageV2.get({ sessionID, messageID: aid }) + expect(result.info.role).toBe("assistant") + expect(result.parts).toHaveLength(1) + expect((result.parts[0] as MessageV2.TextPart).text).toBe("response") + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("returns message with zero parts", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const id = yield* addUser(sessionID) - test("returns assistant message with correct role", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const uid = await addUser(session.id, "hello") - const aid = await addAssistant(session.id, uid) - - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: aid, - type: "text", - text: "response", - }) - - const result = MessageV2.get({ sessionID: session.id, messageID: aid }) - expect(result.info.role).toBe("assistant") - expect(result.parts).toHaveLength(1) - expect((result.parts[0] as MessageV2.TextPart).text).toBe("response") - - await svc.remove(session.id) - }, - }) - }) - - test("returns message with zero parts", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const id = await addUser(session.id) - - const result = MessageV2.get({ sessionID: session.id, messageID: id }) - expect(result.info.id).toBe(id) - expect(result.parts).toEqual([]) - - await svc.remove(session.id) - }, - }) - }) + const result = MessageV2.get({ sessionID, messageID: id }) + expect(result.info.id).toBe(id) + expect(result.parts).toEqual([]) + })), + ) }) describe("MessageV2.filterCompacted", () => { - test("returns all messages when no compaction", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 5) + it.instance("returns all messages when no compaction", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 5) - const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result).toHaveLength(5) - // reversed from newest-first to chronological - expect(result.map((item) => item.info.id)).toEqual(ids) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + expect(result).toHaveLength(5) + // reversed from newest-first to chronological + expect(result.map((item) => item.info.id)).toEqual(ids) + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("stops at compaction boundary and returns chronological order", () => + withSession(({ session, sessionID }) => Effect.gen(function* () { + // Chronological: u1(+compaction part), a1(summary, parentID=u1), u2, a2 + // Stream (newest first): a2, u2, a1(adds u1 to completed), u1(in completed + compaction) -> break + const u1 = yield* addUser(sessionID, "first question") + const a1 = yield* addAssistant(sessionID, u1, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a1, + type: "text", + text: "summary", + }) + yield* addCompactionPart(sessionID, u1) - test("stops at compaction boundary and returns chronological order", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + const u2 = yield* addUser(sessionID, "new question") + const a2 = yield* addAssistant(sessionID, u2) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a2, + type: "text", + text: "new response", + }) - // Chronological: u1(+compaction part), a1(summary, parentID=u1), u2, a2 - // Stream (newest first): a2, u2, a1(adds u1 to completed), u1(in completed + compaction) -> break - const u1 = await addUser(session.id, "first question") - const a1 = await addAssistant(session.id, u1, { summary: true, finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a1, - type: "text", - text: "summary", - }) - await addCompactionPart(session.id, u1) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + // Includes compaction boundary: u1, a1, u2, a2 + expect(result[0].info.id).toBe(u1) + expect(result.length).toBe(4) + })), + ) - const u2 = await addUser(session.id, "new question") - const a2 = await addAssistant(session.id, u2) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a2, - type: "text", - text: "new response", - }) - - const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - // Includes compaction boundary: u1, a1, u2, a2 - expect(result[0].info.id).toBe(u1) - expect(result.length).toBe(4) - - await svc.remove(session.id) - }, - }) - }) - - test("handles empty iterable", () => { + it.live("handles empty iterable", () => Effect.sync(() => { const result = MessageV2.filterCompacted([]) expect(result).toEqual([]) - }) + })) - test("does not break on compaction part without matching summary", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + it.instance("does not break on compaction part without matching summary", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "hello") + yield* addCompactionPart(sessionID, u1) + yield* addUser(sessionID, "world") - const u1 = await addUser(session.id, "hello") - await addCompactionPart(session.id, u1) - await addUser(session.id, "world") + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + expect(result).toHaveLength(2) + })), + ) - const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result).toHaveLength(2) + it.instance("skips assistant with error even if marked as summary", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "hello") + yield* addCompactionPart(sessionID, u1) - await svc.remove(session.id) - }, - }) - }) + const error = new MessageV2.APIError({ + message: "boom", + isRetryable: true, + }).toObject() as MessageV2.Assistant["error"] + yield* addAssistant(sessionID, u1, { summary: true, finish: "end_turn", error }) + yield* addUser(sessionID, "retry") - test("skips assistant with error even if marked as summary", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + // Error assistant doesn't add to completed, so compaction boundary never triggers + expect(result).toHaveLength(3) + })), + ) - const u1 = await addUser(session.id, "hello") - await addCompactionPart(session.id, u1) + it.instance("skips assistant without finish even if marked as summary", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "hello") + yield* addCompactionPart(sessionID, u1) - const error = new MessageV2.APIError({ - message: "boom", - isRetryable: true, - }).toObject() as MessageV2.Assistant["error"] - await addAssistant(session.id, u1, { summary: true, finish: "end_turn", error }) - await addUser(session.id, "retry") + // summary=true but no finish + yield* addAssistant(sessionID, u1, { summary: true }) + yield* addUser(sessionID, "next") - const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - // Error assistant doesn't add to completed, so compaction boundary never triggers - expect(result).toHaveLength(3) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + expect(result).toHaveLength(3) + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("ignores original tail when compaction stores tail_start_id", () => + withSession(({ session, sessionID }) => Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "first") + const a1 = yield* addAssistant(sessionID, u1, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a1, + type: "text", + text: "first reply", + }) - test("skips assistant without finish even if marked as summary", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + const u2 = yield* addUser(sessionID, "second") + const a2 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a2, + type: "text", + text: "second reply", + }) - const u1 = await addUser(session.id, "hello") - await addCompactionPart(session.id, u1) + const c1 = yield* addUser(sessionID) + yield* addCompactionPart(sessionID, c1, u2) + const s1 = yield* addAssistant(sessionID, c1, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: s1, + type: "text", + text: "summary", + }) - // summary=true but no finish - await addAssistant(session.id, u1, { summary: true }) - await addUser(session.id, "next") + const u3 = yield* addUser(sessionID, "third") + const a3 = yield* addAssistant(sessionID, u3, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a3, + type: "text", + text: "third reply", + }) - const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result).toHaveLength(3) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - await svc.remove(session.id) - }, - }) - }) + expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) + })), + ) - test("ignores original tail when compaction stores tail_start_id", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + it.instance("fork keeps legacy tail_start_id without replaying the tail", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const created = yield* session.create({}) - const u1 = await addUser(session.id, "first") - const a1 = await addAssistant(session.id, u1, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a1, - type: "text", - text: "first reply", - }) + const u1 = yield* addUser(created.id, "first") + const a1 = yield* addAssistant(created.id, u1, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID: created.id, + messageID: a1, + type: "text", + text: "first reply", + }) - const u2 = await addUser(session.id, "second") - const a2 = await addAssistant(session.id, u2, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a2, - type: "text", - text: "second reply", - }) + const u2 = yield* addUser(created.id, "second") + const a2 = yield* addAssistant(created.id, u2, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID: created.id, + messageID: a2, + type: "text", + text: "second reply", + }) - const c1 = await addUser(session.id) - await addCompactionPart(session.id, c1, u2) - const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: s1, - type: "text", - text: "summary", - }) + const c1 = yield* addUser(created.id) + yield* addCompactionPart(created.id, c1, u2) + const s1 = yield* addAssistant(created.id, c1, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID: created.id, + messageID: s1, + type: "text", + text: "summary", + }) - const u3 = await addUser(session.id, "third") - const a3 = await addAssistant(session.id, u3, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a3, - type: "text", - text: "third reply", - }) + const u3 = yield* addUser(created.id, "third") + const a3 = yield* addAssistant(created.id, u3, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID: created.id, + messageID: a3, + type: "text", + text: "third reply", + }) - const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) + const parentFiltered = MessageV2.filterCompacted(MessageV2.stream(created.id)) + expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) - expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) + const forked = yield* session.fork({ sessionID: created.id }) + const childFiltered = MessageV2.filterCompacted(MessageV2.stream(forked.id)) + expect(childFiltered).toHaveLength(parentFiltered.length) - await svc.remove(session.id) - }, - }) - }) + const tailPart = childFiltered.flatMap((m) => m.parts).find((p) => p.type === "compaction") + expect(tailPart?.type).toBe("compaction") + if (!tailPart || tailPart.type !== "compaction") throw new Error("Expected forked compaction part") + expect(tailPart.tail_start_id).toBeDefined() + expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(false) - test("fork keeps legacy tail_start_id without replaying the tail", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + yield* session.remove(forked.id) + yield* session.remove(created.id) + }), + ) - const u1 = await addUser(session.id, "first") - const a1 = await addAssistant(session.id, u1, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a1, - type: "text", - text: "first reply", - }) + it.instance("does not replay an assistant tail when compaction starts inside a turn", () => + withSession(({ session, sessionID }) => Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "first") + const a1 = yield* addAssistant(sessionID, u1, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a1, + type: "text", + text: "first reply", + }) - const u2 = await addUser(session.id, "second") - const a2 = await addAssistant(session.id, u2, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a2, - type: "text", - text: "second reply", - }) + const u2 = yield* addUser(sessionID, "second") + const a2 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a2, + type: "text", + text: "second reply", + }) + const a3 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a3, + type: "text", + text: "tail reply", + }) - const c1 = await addUser(session.id) - await addCompactionPart(session.id, c1, u2) - const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: s1, - type: "text", - text: "summary", - }) + const c1 = yield* addUser(sessionID) + yield* addCompactionPart(sessionID, c1, a3) + const s1 = yield* addAssistant(sessionID, c1, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: s1, + type: "text", + text: "summary", + }) - const u3 = await addUser(session.id, "third") - const a3 = await addAssistant(session.id, u3, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a3, - type: "text", - text: "third reply", - }) + const u3 = yield* addUser(sessionID, "third") + const a4 = yield* addAssistant(sessionID, u3, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a4, + type: "text", + text: "third reply", + }) - const parentFiltered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - const forked = await svc.fork({ sessionID: session.id }) - const childFiltered = MessageV2.filterCompacted(MessageV2.stream(forked.id)) - expect(childFiltered).toHaveLength(parentFiltered.length) + expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a4]) + })), + ) - const tailPart = childFiltered.flatMap((m) => m.parts).find((p) => p.type === "compaction") - expect(tailPart?.type).toBe("compaction") - if (!tailPart || tailPart.type !== "compaction") throw new Error("Expected forked compaction part") - expect(tailPart.tail_start_id).toBeDefined() - expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(false) + it.instance("prefers latest compaction boundary when repeated compactions exist", () => + withSession(({ session, sessionID }) => Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "first") + const a1 = yield* addAssistant(sessionID, u1, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a1, + type: "text", + text: "first reply", + }) - await svc.remove(forked.id) - await svc.remove(session.id) - }, - }) - }) + const u2 = yield* addUser(sessionID, "second") + const a2 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a2, + type: "text", + text: "second reply", + }) - test("does not replay an assistant tail when compaction starts inside a turn", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + const c1 = yield* addUser(sessionID) + yield* addCompactionPart(sessionID, c1, u2) + const s1 = yield* addAssistant(sessionID, c1, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: s1, + type: "text", + text: "summary one", + }) - const u1 = await addUser(session.id, "first") - const a1 = await addAssistant(session.id, u1, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a1, - type: "text", - text: "first reply", - }) + const u3 = yield* addUser(sessionID, "third") + const a3 = yield* addAssistant(sessionID, u3, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a3, + type: "text", + text: "third reply", + }) - const u2 = await addUser(session.id, "second") - const a2 = await addAssistant(session.id, u2, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a2, - type: "text", - text: "second reply", - }) - const a3 = await addAssistant(session.id, u2, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a3, - type: "text", - text: "tail reply", - }) + const c2 = yield* addUser(sessionID) + yield* addCompactionPart(sessionID, c2, u3) + const s2 = yield* addAssistant(sessionID, c2, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: s2, + type: "text", + text: "summary two", + }) - const c1 = await addUser(session.id) - await addCompactionPart(session.id, c1, a3) - const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: s1, - type: "text", - text: "summary", - }) + const u4 = yield* addUser(sessionID, "fourth") + const a4 = yield* addAssistant(sessionID, u4, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a4, + type: "text", + text: "fourth reply", + }) - const u3 = await addUser(session.id, "third") - const a4 = await addAssistant(session.id, u3, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a4, - type: "text", - text: "third reply", - }) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - - expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a4]) - - await svc.remove(session.id) - }, - }) - }) - - test("prefers latest compaction boundary when repeated compactions exist", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - - const u1 = await addUser(session.id, "first") - const a1 = await addAssistant(session.id, u1, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a1, - type: "text", - text: "first reply", - }) - - const u2 = await addUser(session.id, "second") - const a2 = await addAssistant(session.id, u2, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a2, - type: "text", - text: "second reply", - }) - - const c1 = await addUser(session.id) - await addCompactionPart(session.id, c1, u2) - const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: s1, - type: "text", - text: "summary one", - }) - - const u3 = await addUser(session.id, "third") - const a3 = await addAssistant(session.id, u3, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a3, - type: "text", - text: "third reply", - }) - - const c2 = await addUser(session.id) - await addCompactionPart(session.id, c2, u3) - const s2 = await addAssistant(session.id, c2, { summary: true, finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: s2, - type: "text", - text: "summary two", - }) - - const u4 = await addUser(session.id, "fourth") - const a4 = await addAssistant(session.id, u4, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a4, - type: "text", - text: "fourth reply", - }) - - const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - - expect(result.map((item) => item.info.id)).toEqual([c2, s2, u4, a4]) - - await svc.remove(session.id) - }, - }) - }) + expect(result.map((item) => item.info.id)).toEqual([c2, s2, u4, a4]) + })), + ) test("works with array input", () => { // filterCompacted accepts any Iterable, not just generators @@ -1093,82 +875,58 @@ describe("MessageV2.cursor", () => { }) describe("MessageV2 consistency", () => { - test("page hydration matches get for each message", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - await fill(session.id, 3) + it.instance("page hydration matches get for each message", () => + withSession(({ sessionID }) => Effect.gen(function* () { + yield* fill(sessionID, 3) - const paged = MessageV2.page({ sessionID: session.id, limit: 10 }) - for (const item of paged.items) { - const got = MessageV2.get({ sessionID: session.id, messageID: item.info.id as MessageID }) - expect(got.info).toEqual(item.info) - expect(got.parts).toEqual(item.parts) + const paged = MessageV2.page({ sessionID, limit: 10 }) + for (const item of paged.items) { + const got = MessageV2.get({ sessionID, messageID: item.info.id as MessageID }) + expect(got.info).toEqual(item.info) + expect(got.parts).toEqual(item.parts) + } + })), + ) + + it.instance("parts from get match standalone parts call", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) + + const got = MessageV2.get({ sessionID, messageID: id }) + const standalone = MessageV2.parts(id) + expect(got.parts).toEqual(standalone) + })), + ) + + it.instance("stream collects same messages as exhaustive page iteration", () => + withSession(({ sessionID }) => Effect.gen(function* () { + yield* fill(sessionID, 7) + + const streamed = Array.from(MessageV2.stream(sessionID)) + + const paged = [] as MessageV2.WithParts[] + let cursor: string | undefined + while (true) { + const result = MessageV2.page({ sessionID, limit: 3, before: cursor }) + for (let i = result.items.length - 1; i >= 0; i--) { + paged.push(result.items[i]) } + if (!result.more || !result.cursor) break + cursor = result.cursor + } - await svc.remove(session.id) - }, - }) - }) + expect(streamed.map((m) => m.info.id)).toEqual(paged.map((m) => m.info.id)) + })), + ) - test("parts from get match standalone parts call", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const [id] = await fill(session.id, 1) + it.instance("filterCompacted of full stream returns same as Array.from when no compaction", () => + withSession(({ sessionID }) => Effect.gen(function* () { + yield* fill(sessionID, 4) - const got = MessageV2.get({ sessionID: session.id, messageID: id }) - const standalone = MessageV2.parts(id) - expect(got.parts).toEqual(standalone) + const filtered = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const all = Array.from(MessageV2.stream(sessionID)).reverse() - await svc.remove(session.id) - }, - }) - }) - - test("stream collects same messages as exhaustive page iteration", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - await fill(session.id, 7) - - const streamed = Array.from(MessageV2.stream(session.id)) - - const paged = [] as MessageV2.WithParts[] - let cursor: string | undefined - while (true) { - const result = MessageV2.page({ sessionID: session.id, limit: 3, before: cursor }) - for (let i = result.items.length - 1; i >= 0; i--) { - paged.push(result.items[i]) - } - if (!result.more || !result.cursor) break - cursor = result.cursor - } - - expect(streamed.map((m) => m.info.id)).toEqual(paged.map((m) => m.info.id)) - - await svc.remove(session.id) - }, - }) - }) - - test("filterCompacted of full stream returns same as Array.from when no compaction", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - await fill(session.id, 4) - - const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - const all = Array.from(MessageV2.stream(session.id)).reverse() - - expect(filtered.map((m) => m.info.id)).toEqual(all.map((m) => m.info.id)) - - await svc.remove(session.id) - }, - }) - }) + expect(filtered.map((m) => m.info.id)).toEqual(all.map((m) => m.info.id)) + })), + ) }) From e0e9414cbdc32d3bd08c8c4ea239fb9e80091c8b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 00:41:30 +0000 Subject: [PATCH 051/188] chore: generate --- packages/opencode/test/file/index.test.ts | 56 +- .../test/session/messages-pagination.test.ts | 998 ++++++++++-------- 2 files changed, 578 insertions(+), 476 deletions(-) diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 925084140..8b48fff5e 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -115,7 +115,9 @@ describe("file/index Filesystem patterns", () => { it.instance("handles multi-line text files", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "multiline.txt"), "line1\nline2\nline3", "utf-8")) + yield* Effect.promise(() => + fs.writeFile(path.join(test.directory, "multiline.txt"), "line1\nline2\nline3", "utf-8"), + ) const result = yield* read("multiline.txt") expect(result.content).toBe("line1\nline2\nline3") @@ -141,7 +143,9 @@ describe("file/index Filesystem patterns", () => { it.instance("returns empty for binary non-image files", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "binary.so"), Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) + yield* Effect.promise(() => + fs.writeFile(path.join(test.directory, "binary.so"), Buffer.from([0x7f, 0x45, 0x4c, 0x46])), + ) const result = yield* read("binary.so") expect(result.type).toBe("binary") @@ -250,7 +254,9 @@ describe("file/index Filesystem patterns", () => { yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "readonly.txt"), "content", "utf-8")) const nonExistentPath = path.join(test.directory, "does-not-exist.txt") - expect(Exit.isFailure(yield* Effect.promise(() => Filesystem.readText(nonExistentPath)).pipe(Effect.exit))).toBe(true) + expect( + Exit.isFailure(yield* Effect.promise(() => Filesystem.readText(nonExistentPath)).pipe(Effect.exit)), + ).toBe(true) const result = yield* read("does-not-exist.txt") expect(result.content).toBe("") @@ -261,7 +267,9 @@ describe("file/index Filesystem patterns", () => { Effect.gen(function* () { const test = yield* TestInstance const nonExistentPath = path.join(test.directory, "does-not-exist.bin") - const buffer = yield* Effect.promise(() => Filesystem.readArrayBuffer(nonExistentPath).catch(() => new ArrayBuffer(0))) + const buffer = yield* Effect.promise(() => + Filesystem.readArrayBuffer(nonExistentPath).catch(() => new ArrayBuffer(0)), + ) expect(buffer.byteLength).toBe(0) }), ) @@ -279,7 +287,9 @@ describe("file/index Filesystem patterns", () => { it.instance("treats .ts files as text", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.ts"), "export const value = 1", "utf-8")) + yield* Effect.promise(() => + fs.writeFile(path.join(test.directory, "test.ts"), "export const value = 1", "utf-8"), + ) const result = yield* read("test.ts") expect(result.type).toBe("text") @@ -290,7 +300,9 @@ describe("file/index Filesystem patterns", () => { it.instance("treats .mts files as text", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.mts"), "export const value = 1", "utf-8")) + yield* Effect.promise(() => + fs.writeFile(path.join(test.directory, "test.mts"), "export const value = 1", "utf-8"), + ) const result = yield* read("test.mts") expect(result.type).toBe("text") @@ -301,7 +313,9 @@ describe("file/index Filesystem patterns", () => { it.instance("treats .sh files as text", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.sh"), "#!/usr/bin/env bash\necho hello", "utf-8")) + yield* Effect.promise(() => + fs.writeFile(path.join(test.directory, "test.sh"), "#!/usr/bin/env bash\necho hello", "utf-8"), + ) const result = yield* read("test.sh") expect(result.type).toBe("text") @@ -334,7 +348,9 @@ describe("file/index Filesystem patterns", () => { it.instance("returns base64 encoding for images", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.jpg"), Buffer.from([0xff, 0xd8, 0xff, 0xe0]))) + yield* Effect.promise(() => + fs.writeFile(path.join(test.directory, "test.jpg"), Buffer.from([0xff, 0xd8, 0xff, 0xe0])), + ) const result = yield* read("test.jpg") expect(result.encoding).toBe("base64") @@ -384,7 +400,9 @@ describe("file/index Filesystem patterns", () => { () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "new.txt"), "line1\nline2\nline3\n", "utf-8")) + yield* Effect.promise(() => + fs.writeFile(path.join(test.directory, "new.txt"), "line1\nline2\nline3\n", "utf-8"), + ) const result = yield* status() const entry = result.find((file) => file.path === "new.txt") @@ -457,10 +475,14 @@ describe("file/index Filesystem patterns", () => { Effect.gen(function* () { const test = yield* TestInstance const filepath = path.join(test.directory, "data.bin") - yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from(Array.from({ length: 256 }, (_, index) => index)))) + yield* Effect.promise(() => + fs.writeFile(filepath, Buffer.from(Array.from({ length: 256 }, (_, index) => index))), + ) yield* gitAddAll(test.directory) yield* gitCommit(test.directory, "add binary") - yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256)))) + yield* Effect.promise(() => + fs.writeFile(filepath, Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256))), + ) const result = yield* status() const entry = result.find((file) => file.path === "data.bin") @@ -481,7 +503,9 @@ describe("file/index Filesystem patterns", () => { const test = yield* TestInstance yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "subdir"))) yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "content", "utf-8")) - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "subdir", "nested.txt"), "nested", "utf-8")) + yield* Effect.promise(() => + fs.writeFile(path.join(test.directory, "subdir", "nested.txt"), "nested", "utf-8"), + ) const nodes = yield* list() expect(nodes.length).toBeGreaterThanOrEqual(2) @@ -633,8 +657,12 @@ describe("file/index Filesystem patterns", () => { const result = yield* search({ query: "", type: "directory" }) expect(result.length).toBeGreaterThan(0) - const firstHidden = result.findIndex((dir) => dir.split("/").some((part) => part.startsWith(".") && part.length > 1)) - const lastVisible = result.findLastIndex((dir) => !dir.split("/").some((part) => part.startsWith(".") && part.length > 1)) + const firstHidden = result.findIndex((dir) => + dir.split("/").some((part) => part.startsWith(".") && part.length > 1), + ) + const lastVisible = result.findLastIndex( + (dir) => !dir.split("/").some((part) => part.startsWith(".") && part.length > 1), + ) if (firstHidden >= 0 && lastVisible >= 0) { expect(firstHidden).toBeGreaterThan(lastVisible) } diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 49828a9b6..e1714a901 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -126,53 +126,61 @@ const addCompactionPart = Effect.fn("Test.addCompactionPart")(function* ( describe("MessageV2.page", () => { it.instance("returns sync result", () => - withSession(({ sessionID }) => Effect.gen(function* () { - yield* fill(sessionID, 2) + withSession(({ sessionID }) => + Effect.gen(function* () { + yield* fill(sessionID, 2) - const result = MessageV2.page({ sessionID, limit: 10 }) - expect(result).toBeDefined() - expect(result.items).toBeArray() - })), + const result = MessageV2.page({ sessionID, limit: 10 }) + expect(result).toBeDefined() + expect(result.items).toBeArray() + }), + ), ) it.instance("pages backward with opaque cursors", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 6) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 6) - const a = MessageV2.page({ sessionID, limit: 2 }) - expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) - expect(a.items.every((item) => item.parts.length === 1)).toBe(true) - expect(a.more).toBe(true) - expect(a.cursor).toBeTruthy() + const a = MessageV2.page({ sessionID, limit: 2 }) + expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) + expect(a.items.every((item) => item.parts.length === 1)).toBe(true) + expect(a.more).toBe(true) + expect(a.cursor).toBeTruthy() - const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) - expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(-4, -2)) - expect(b.more).toBe(true) - expect(b.cursor).toBeTruthy() + const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) + expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(-4, -2)) + expect(b.more).toBe(true) + expect(b.cursor).toBeTruthy() - const c = MessageV2.page({ sessionID, limit: 2, before: b.cursor! }) - expect(c.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) - expect(c.more).toBe(false) - expect(c.cursor).toBeUndefined() - })), + const c = MessageV2.page({ sessionID, limit: 2, before: b.cursor! }) + expect(c.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) + expect(c.more).toBe(false) + expect(c.cursor).toBeUndefined() + }), + ), ) it.instance("returns items in chronological order within a page", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 4) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 4) - const result = MessageV2.page({ sessionID, limit: 4 }) - expect(result.items.map((item) => item.info.id)).toEqual(ids) - })), + const result = MessageV2.page({ sessionID, limit: 4 }) + expect(result.items.map((item) => item.info.id)).toEqual(ids) + }), + ), ) it.instance("returns empty items for session with no messages", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const result = MessageV2.page({ sessionID, limit: 10 }) - expect(result.items).toEqual([]) - expect(result.more).toBe(false) - expect(result.cursor).toBeUndefined() - })), + withSession(({ sessionID }) => + Effect.gen(function* () { + const result = MessageV2.page({ sessionID, limit: 10 }) + expect(result.items).toEqual([]) + expect(result.more).toBe(false) + expect(result.cursor).toBeUndefined() + }), + ), ) it.instance("throws NotFoundError for non-existent session", () => @@ -183,69 +191,79 @@ describe("MessageV2.page", () => { ) it.instance("handles exact limit boundary", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 3) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 3) - const result = MessageV2.page({ sessionID, limit: 3 }) - expect(result.items.map((item) => item.info.id)).toEqual(ids) - expect(result.more).toBe(false) - expect(result.cursor).toBeUndefined() - })), + const result = MessageV2.page({ sessionID, limit: 3 }) + expect(result.items.map((item) => item.info.id)).toEqual(ids) + expect(result.more).toBe(false) + expect(result.cursor).toBeUndefined() + }), + ), ) it.instance("limit of 1 returns single newest message", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 5) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 5) - const result = MessageV2.page({ sessionID, limit: 1 }) - expect(result.items).toHaveLength(1) - expect(result.items[0].info.id).toBe(ids[ids.length - 1]) - expect(result.more).toBe(true) - })), + const result = MessageV2.page({ sessionID, limit: 1 }) + expect(result.items).toHaveLength(1) + expect(result.items[0].info.id).toBe(ids[ids.length - 1]) + expect(result.more).toBe(true) + }), + ), ) it.instance("hydrates multiple parts per message", () => - withSession(({ session, sessionID }) => Effect.gen(function* () { - const [id] = yield* fill(sessionID, 1) + withSession(({ session, sessionID }) => + Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: id, - type: "text", - text: "extra", - }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text: "extra", + }) - const result = MessageV2.page({ sessionID, limit: 10 }) - expect(result.items).toHaveLength(1) - expect(result.items[0].parts).toHaveLength(2) - })), + const result = MessageV2.page({ sessionID, limit: 10 }) + expect(result.items).toHaveLength(1) + expect(result.items[0].parts).toHaveLength(2) + }), + ), ) it.instance("accepts cursors from fractional timestamps", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 4, (i: number) => 1000.5 + i) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 4, (i: number) => 1000.5 + i) - const a = MessageV2.page({ sessionID, limit: 2 }) - const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) + const a = MessageV2.page({ sessionID, limit: 2 }) + const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) - expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) - expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) - })), + expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) + expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) + }), + ), ) it.instance("messages with same timestamp are ordered by id", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 4, () => 1000) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 4, () => 1000) - const a = MessageV2.page({ sessionID, limit: 2 }) - expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) - expect(a.more).toBe(true) + const a = MessageV2.page({ sessionID, limit: 2 }) + expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) + expect(a.more).toBe(true) - const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) - expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) - expect(b.more).toBe(false) - })), + const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) + expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) + expect(b.more).toBe(false) + }), + ), ) it.instance("does not return messages from other sessions", () => @@ -269,128 +287,148 @@ describe("MessageV2.page", () => { ) it.instance("large limit returns all messages without cursor", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 10) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 10) - const result = MessageV2.page({ sessionID, limit: 100 }) - expect(result.items).toHaveLength(10) - expect(result.items.map((item) => item.info.id)).toEqual(ids) - expect(result.more).toBe(false) - expect(result.cursor).toBeUndefined() - })), + const result = MessageV2.page({ sessionID, limit: 100 }) + expect(result.items).toHaveLength(10) + expect(result.items.map((item) => item.info.id)).toEqual(ids) + expect(result.more).toBe(false) + expect(result.cursor).toBeUndefined() + }), + ), ) }) describe("MessageV2.stream", () => { it.instance("yields items newest first", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 5) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 5) - const items = Array.from(MessageV2.stream(sessionID)) - expect(items.map((item) => item.info.id)).toEqual(ids.slice().reverse()) - })), + const items = Array.from(MessageV2.stream(sessionID)) + expect(items.map((item) => item.info.id)).toEqual(ids.slice().reverse()) + }), + ), ) it.instance("yields nothing for empty session", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const items = Array.from(MessageV2.stream(sessionID)) - expect(items).toHaveLength(0) - })), + withSession(({ sessionID }) => + Effect.gen(function* () { + const items = Array.from(MessageV2.stream(sessionID)) + expect(items).toHaveLength(0) + }), + ), ) it.instance("yields single message", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 1) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 1) - const items = Array.from(MessageV2.stream(sessionID)) - expect(items).toHaveLength(1) - expect(items[0].info.id).toBe(ids[0]) - })), + const items = Array.from(MessageV2.stream(sessionID)) + expect(items).toHaveLength(1) + expect(items[0].info.id).toBe(ids[0]) + }), + ), ) it.instance("hydrates parts for each yielded message", () => - withSession(({ sessionID }) => Effect.gen(function* () { - yield* fill(sessionID, 3) + withSession(({ sessionID }) => + Effect.gen(function* () { + yield* fill(sessionID, 3) - const items = Array.from(MessageV2.stream(sessionID)) - for (const item of items) { - expect(item.parts).toHaveLength(1) - expect(item.parts[0].type).toBe("text") - } - })), + const items = Array.from(MessageV2.stream(sessionID)) + for (const item of items) { + expect(item.parts).toHaveLength(1) + expect(item.parts[0].type).toBe("text") + } + }), + ), ) it.instance("handles sets exceeding internal page size", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 60) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 60) - const items = Array.from(MessageV2.stream(sessionID)) - expect(items).toHaveLength(60) - expect(items[0].info.id).toBe(ids[ids.length - 1]) - expect(items[59].info.id).toBe(ids[0]) - })), + const items = Array.from(MessageV2.stream(sessionID)) + expect(items).toHaveLength(60) + expect(items[0].info.id).toBe(ids[ids.length - 1]) + expect(items[59].info.id).toBe(ids[0]) + }), + ), ) it.instance("is a sync generator", () => - withSession(({ sessionID }) => Effect.gen(function* () { - yield* fill(sessionID, 1) + withSession(({ sessionID }) => + Effect.gen(function* () { + yield* fill(sessionID, 1) - const gen = MessageV2.stream(sessionID) - const first = gen.next() - // sync generator returns { value, done } directly, not a Promise - expect(first).toHaveProperty("value") - expect(first).toHaveProperty("done") - expect(first.done).toBe(false) - })), + const gen = MessageV2.stream(sessionID) + const first = gen.next() + // sync generator returns { value, done } directly, not a Promise + expect(first).toHaveProperty("value") + expect(first).toHaveProperty("done") + expect(first.done).toBe(false) + }), + ), ) }) describe("MessageV2.parts", () => { it.instance("returns parts for a message", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const [id] = yield* fill(sessionID, 1) + withSession(({ sessionID }) => + Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - const result = MessageV2.parts(id) - expect(result).toHaveLength(1) - expect(result[0].type).toBe("text") - expect((result[0] as MessageV2.TextPart).text).toBe("m0") - })), + const result = MessageV2.parts(id) + expect(result).toHaveLength(1) + expect(result[0].type).toBe("text") + expect((result[0] as MessageV2.TextPart).text).toBe("m0") + }), + ), ) it.instance("returns empty array for message with no parts", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const id = yield* addUser(sessionID) + withSession(({ sessionID }) => + Effect.gen(function* () { + const id = yield* addUser(sessionID) - const result = MessageV2.parts(id) - expect(result).toEqual([]) - })), + const result = MessageV2.parts(id) + expect(result).toEqual([]) + }), + ), ) it.instance("returns multiple parts in order", () => - withSession(({ session, sessionID }) => Effect.gen(function* () { - const [id] = yield* fill(sessionID, 1) + withSession(({ session, sessionID }) => + Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: id, - type: "text", - text: "second", - }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: id, - type: "text", - text: "third", - }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text: "second", + }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text: "third", + }) - const result = MessageV2.parts(id) - expect(result).toHaveLength(3) - expect((result[0] as MessageV2.TextPart).text).toBe("m0") - expect((result[1] as MessageV2.TextPart).text).toBe("second") - expect((result[2] as MessageV2.TextPart).text).toBe("third") - })), + const result = MessageV2.parts(id) + expect(result).toHaveLength(3) + expect((result[0] as MessageV2.TextPart).text).toBe("m0") + expect((result[1] as MessageV2.TextPart).text).toBe("second") + expect((result[2] as MessageV2.TextPart).text).toBe("third") + }), + ), ) it.instance("returns empty for non-existent message id", () => @@ -402,36 +440,40 @@ describe("MessageV2.parts", () => { ) it.instance("parts contain sessionID and messageID", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const [id] = yield* fill(sessionID, 1) + withSession(({ sessionID }) => + Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - const result = MessageV2.parts(id) - expect(result[0].sessionID).toBe(sessionID) - expect(result[0].messageID).toBe(id) - })), + const result = MessageV2.parts(id) + expect(result[0].sessionID).toBe(sessionID) + expect(result[0].messageID).toBe(id) + }), + ), ) }) describe("MessageV2.get", () => { it.instance("returns message with hydrated parts", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const [id] = yield* fill(sessionID, 1) + withSession(({ sessionID }) => + Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - const result = MessageV2.get({ sessionID, messageID: id }) - expect(result.info.id).toBe(id) - expect(result.info.sessionID).toBe(sessionID) - expect(result.info.role).toBe("user") - expect(result.parts).toHaveLength(1) - expect((result.parts[0] as MessageV2.TextPart).text).toBe("m0") - })), + const result = MessageV2.get({ sessionID, messageID: id }) + expect(result.info.id).toBe(id) + expect(result.info.sessionID).toBe(sessionID) + expect(result.info.role).toBe("user") + expect(result.parts).toHaveLength(1) + expect((result.parts[0] as MessageV2.TextPart).text).toBe("m0") + }), + ), ) it.instance("throws NotFoundError for non-existent message", () => - withSession(({ sessionID }) => Effect.gen(function* () { - expect(() => MessageV2.get({ sessionID, messageID: MessageID.ascending() })).toThrow( - "NotFoundError", - ) - })), + withSession(({ sessionID }) => + Effect.gen(function* () { + expect(() => MessageV2.get({ sessionID, messageID: MessageID.ascending() })).toThrow("NotFoundError") + }), + ), ) it.instance("scopes by session id", () => @@ -451,192 +493,212 @@ describe("MessageV2.get", () => { ) it.instance("returns message with multiple parts", () => - withSession(({ session, sessionID }) => Effect.gen(function* () { - const [id] = yield* fill(sessionID, 1) + withSession(({ session, sessionID }) => + Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: id, - type: "text", - text: "extra", - }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text: "extra", + }) - const result = MessageV2.get({ sessionID, messageID: id }) - expect(result.parts).toHaveLength(2) - })), + const result = MessageV2.get({ sessionID, messageID: id }) + expect(result.parts).toHaveLength(2) + }), + ), ) it.instance("returns assistant message with correct role", () => - withSession(({ session, sessionID }) => Effect.gen(function* () { - const uid = yield* addUser(sessionID, "hello") - const aid = yield* addAssistant(sessionID, uid) + withSession(({ session, sessionID }) => + Effect.gen(function* () { + const uid = yield* addUser(sessionID, "hello") + const aid = yield* addAssistant(sessionID, uid) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: aid, - type: "text", - text: "response", - }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: aid, + type: "text", + text: "response", + }) - const result = MessageV2.get({ sessionID, messageID: aid }) - expect(result.info.role).toBe("assistant") - expect(result.parts).toHaveLength(1) - expect((result.parts[0] as MessageV2.TextPart).text).toBe("response") - })), + const result = MessageV2.get({ sessionID, messageID: aid }) + expect(result.info.role).toBe("assistant") + expect(result.parts).toHaveLength(1) + expect((result.parts[0] as MessageV2.TextPart).text).toBe("response") + }), + ), ) it.instance("returns message with zero parts", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const id = yield* addUser(sessionID) + withSession(({ sessionID }) => + Effect.gen(function* () { + const id = yield* addUser(sessionID) - const result = MessageV2.get({ sessionID, messageID: id }) - expect(result.info.id).toBe(id) - expect(result.parts).toEqual([]) - })), + const result = MessageV2.get({ sessionID, messageID: id }) + expect(result.info.id).toBe(id) + expect(result.parts).toEqual([]) + }), + ), ) }) describe("MessageV2.filterCompacted", () => { it.instance("returns all messages when no compaction", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 5) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 5) - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - expect(result).toHaveLength(5) - // reversed from newest-first to chronological - expect(result.map((item) => item.info.id)).toEqual(ids) - })), + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + expect(result).toHaveLength(5) + // reversed from newest-first to chronological + expect(result.map((item) => item.info.id)).toEqual(ids) + }), + ), ) it.instance("stops at compaction boundary and returns chronological order", () => - withSession(({ session, sessionID }) => Effect.gen(function* () { - // Chronological: u1(+compaction part), a1(summary, parentID=u1), u2, a2 - // Stream (newest first): a2, u2, a1(adds u1 to completed), u1(in completed + compaction) -> break - const u1 = yield* addUser(sessionID, "first question") - const a1 = yield* addAssistant(sessionID, u1, { summary: true, finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a1, - type: "text", - text: "summary", - }) - yield* addCompactionPart(sessionID, u1) + withSession(({ session, sessionID }) => + Effect.gen(function* () { + // Chronological: u1(+compaction part), a1(summary, parentID=u1), u2, a2 + // Stream (newest first): a2, u2, a1(adds u1 to completed), u1(in completed + compaction) -> break + const u1 = yield* addUser(sessionID, "first question") + const a1 = yield* addAssistant(sessionID, u1, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a1, + type: "text", + text: "summary", + }) + yield* addCompactionPart(sessionID, u1) - const u2 = yield* addUser(sessionID, "new question") - const a2 = yield* addAssistant(sessionID, u2) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a2, - type: "text", - text: "new response", - }) + const u2 = yield* addUser(sessionID, "new question") + const a2 = yield* addAssistant(sessionID, u2) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a2, + type: "text", + text: "new response", + }) - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - // Includes compaction boundary: u1, a1, u2, a2 - expect(result[0].info.id).toBe(u1) - expect(result.length).toBe(4) - })), + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + // Includes compaction boundary: u1, a1, u2, a2 + expect(result[0].info.id).toBe(u1) + expect(result.length).toBe(4) + }), + ), ) - it.live("handles empty iterable", () => Effect.sync(() => { - const result = MessageV2.filterCompacted([]) - expect(result).toEqual([]) - })) + it.live("handles empty iterable", () => + Effect.sync(() => { + const result = MessageV2.filterCompacted([]) + expect(result).toEqual([]) + }), + ) it.instance("does not break on compaction part without matching summary", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const u1 = yield* addUser(sessionID, "hello") - yield* addCompactionPart(sessionID, u1) - yield* addUser(sessionID, "world") + withSession(({ sessionID }) => + Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "hello") + yield* addCompactionPart(sessionID, u1) + yield* addUser(sessionID, "world") - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - expect(result).toHaveLength(2) - })), + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + expect(result).toHaveLength(2) + }), + ), ) it.instance("skips assistant with error even if marked as summary", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const u1 = yield* addUser(sessionID, "hello") - yield* addCompactionPart(sessionID, u1) + withSession(({ sessionID }) => + Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "hello") + yield* addCompactionPart(sessionID, u1) - const error = new MessageV2.APIError({ - message: "boom", - isRetryable: true, - }).toObject() as MessageV2.Assistant["error"] - yield* addAssistant(sessionID, u1, { summary: true, finish: "end_turn", error }) - yield* addUser(sessionID, "retry") + const error = new MessageV2.APIError({ + message: "boom", + isRetryable: true, + }).toObject() as MessageV2.Assistant["error"] + yield* addAssistant(sessionID, u1, { summary: true, finish: "end_turn", error }) + yield* addUser(sessionID, "retry") - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - // Error assistant doesn't add to completed, so compaction boundary never triggers - expect(result).toHaveLength(3) - })), + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + // Error assistant doesn't add to completed, so compaction boundary never triggers + expect(result).toHaveLength(3) + }), + ), ) it.instance("skips assistant without finish even if marked as summary", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const u1 = yield* addUser(sessionID, "hello") - yield* addCompactionPart(sessionID, u1) + withSession(({ sessionID }) => + Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "hello") + yield* addCompactionPart(sessionID, u1) - // summary=true but no finish - yield* addAssistant(sessionID, u1, { summary: true }) - yield* addUser(sessionID, "next") + // summary=true but no finish + yield* addAssistant(sessionID, u1, { summary: true }) + yield* addUser(sessionID, "next") - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - expect(result).toHaveLength(3) - })), + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + expect(result).toHaveLength(3) + }), + ), ) it.instance("ignores original tail when compaction stores tail_start_id", () => - withSession(({ session, sessionID }) => Effect.gen(function* () { - const u1 = yield* addUser(sessionID, "first") - const a1 = yield* addAssistant(sessionID, u1, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a1, - type: "text", - text: "first reply", - }) + withSession(({ session, sessionID }) => + Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "first") + const a1 = yield* addAssistant(sessionID, u1, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a1, + type: "text", + text: "first reply", + }) - const u2 = yield* addUser(sessionID, "second") - const a2 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a2, - type: "text", - text: "second reply", - }) + const u2 = yield* addUser(sessionID, "second") + const a2 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a2, + type: "text", + text: "second reply", + }) - const c1 = yield* addUser(sessionID) - yield* addCompactionPart(sessionID, c1, u2) - const s1 = yield* addAssistant(sessionID, c1, { summary: true, finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: s1, - type: "text", - text: "summary", - }) + const c1 = yield* addUser(sessionID) + yield* addCompactionPart(sessionID, c1, u2) + const s1 = yield* addAssistant(sessionID, c1, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: s1, + type: "text", + text: "summary", + }) - const u3 = yield* addUser(sessionID, "third") - const a3 = yield* addAssistant(sessionID, u3, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a3, - type: "text", - text: "third reply", - }) + const u3 = yield* addUser(sessionID, "third") + const a3 = yield* addAssistant(sessionID, u3, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a3, + type: "text", + text: "third reply", + }) - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) - })), + expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) + }), + ), ) it.instance("fork keeps legacy tail_start_id without replaying the tail", () => @@ -704,130 +766,134 @@ describe("MessageV2.filterCompacted", () => { ) it.instance("does not replay an assistant tail when compaction starts inside a turn", () => - withSession(({ session, sessionID }) => Effect.gen(function* () { - const u1 = yield* addUser(sessionID, "first") - const a1 = yield* addAssistant(sessionID, u1, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a1, - type: "text", - text: "first reply", - }) + withSession(({ session, sessionID }) => + Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "first") + const a1 = yield* addAssistant(sessionID, u1, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a1, + type: "text", + text: "first reply", + }) - const u2 = yield* addUser(sessionID, "second") - const a2 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a2, - type: "text", - text: "second reply", - }) - const a3 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a3, - type: "text", - text: "tail reply", - }) + const u2 = yield* addUser(sessionID, "second") + const a2 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a2, + type: "text", + text: "second reply", + }) + const a3 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a3, + type: "text", + text: "tail reply", + }) - const c1 = yield* addUser(sessionID) - yield* addCompactionPart(sessionID, c1, a3) - const s1 = yield* addAssistant(sessionID, c1, { summary: true, finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: s1, - type: "text", - text: "summary", - }) + const c1 = yield* addUser(sessionID) + yield* addCompactionPart(sessionID, c1, a3) + const s1 = yield* addAssistant(sessionID, c1, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: s1, + type: "text", + text: "summary", + }) - const u3 = yield* addUser(sessionID, "third") - const a4 = yield* addAssistant(sessionID, u3, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a4, - type: "text", - text: "third reply", - }) + const u3 = yield* addUser(sessionID, "third") + const a4 = yield* addAssistant(sessionID, u3, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a4, + type: "text", + text: "third reply", + }) - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a4]) - })), + expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a4]) + }), + ), ) it.instance("prefers latest compaction boundary when repeated compactions exist", () => - withSession(({ session, sessionID }) => Effect.gen(function* () { - const u1 = yield* addUser(sessionID, "first") - const a1 = yield* addAssistant(sessionID, u1, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a1, - type: "text", - text: "first reply", - }) + withSession(({ session, sessionID }) => + Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "first") + const a1 = yield* addAssistant(sessionID, u1, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a1, + type: "text", + text: "first reply", + }) - const u2 = yield* addUser(sessionID, "second") - const a2 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a2, - type: "text", - text: "second reply", - }) + const u2 = yield* addUser(sessionID, "second") + const a2 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a2, + type: "text", + text: "second reply", + }) - const c1 = yield* addUser(sessionID) - yield* addCompactionPart(sessionID, c1, u2) - const s1 = yield* addAssistant(sessionID, c1, { summary: true, finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: s1, - type: "text", - text: "summary one", - }) + const c1 = yield* addUser(sessionID) + yield* addCompactionPart(sessionID, c1, u2) + const s1 = yield* addAssistant(sessionID, c1, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: s1, + type: "text", + text: "summary one", + }) - const u3 = yield* addUser(sessionID, "third") - const a3 = yield* addAssistant(sessionID, u3, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a3, - type: "text", - text: "third reply", - }) + const u3 = yield* addUser(sessionID, "third") + const a3 = yield* addAssistant(sessionID, u3, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a3, + type: "text", + text: "third reply", + }) - const c2 = yield* addUser(sessionID) - yield* addCompactionPart(sessionID, c2, u3) - const s2 = yield* addAssistant(sessionID, c2, { summary: true, finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: s2, - type: "text", - text: "summary two", - }) + const c2 = yield* addUser(sessionID) + yield* addCompactionPart(sessionID, c2, u3) + const s2 = yield* addAssistant(sessionID, c2, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: s2, + type: "text", + text: "summary two", + }) - const u4 = yield* addUser(sessionID, "fourth") - const a4 = yield* addAssistant(sessionID, u4, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a4, - type: "text", - text: "fourth reply", - }) + const u4 = yield* addUser(sessionID, "fourth") + const a4 = yield* addAssistant(sessionID, u4, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a4, + type: "text", + text: "fourth reply", + }) - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - expect(result.map((item) => item.info.id)).toEqual([c2, s2, u4, a4]) - })), + expect(result.map((item) => item.info.id)).toEqual([c2, s2, u4, a4]) + }), + ), ) test("works with array input", () => { @@ -876,57 +942,65 @@ describe("MessageV2.cursor", () => { describe("MessageV2 consistency", () => { it.instance("page hydration matches get for each message", () => - withSession(({ sessionID }) => Effect.gen(function* () { - yield* fill(sessionID, 3) + withSession(({ sessionID }) => + Effect.gen(function* () { + yield* fill(sessionID, 3) - const paged = MessageV2.page({ sessionID, limit: 10 }) - for (const item of paged.items) { - const got = MessageV2.get({ sessionID, messageID: item.info.id as MessageID }) - expect(got.info).toEqual(item.info) - expect(got.parts).toEqual(item.parts) - } - })), + const paged = MessageV2.page({ sessionID, limit: 10 }) + for (const item of paged.items) { + const got = MessageV2.get({ sessionID, messageID: item.info.id as MessageID }) + expect(got.info).toEqual(item.info) + expect(got.parts).toEqual(item.parts) + } + }), + ), ) it.instance("parts from get match standalone parts call", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const [id] = yield* fill(sessionID, 1) + withSession(({ sessionID }) => + Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - const got = MessageV2.get({ sessionID, messageID: id }) - const standalone = MessageV2.parts(id) - expect(got.parts).toEqual(standalone) - })), + const got = MessageV2.get({ sessionID, messageID: id }) + const standalone = MessageV2.parts(id) + expect(got.parts).toEqual(standalone) + }), + ), ) it.instance("stream collects same messages as exhaustive page iteration", () => - withSession(({ sessionID }) => Effect.gen(function* () { - yield* fill(sessionID, 7) + withSession(({ sessionID }) => + Effect.gen(function* () { + yield* fill(sessionID, 7) - const streamed = Array.from(MessageV2.stream(sessionID)) + const streamed = Array.from(MessageV2.stream(sessionID)) - const paged = [] as MessageV2.WithParts[] - let cursor: string | undefined - while (true) { - const result = MessageV2.page({ sessionID, limit: 3, before: cursor }) - for (let i = result.items.length - 1; i >= 0; i--) { - paged.push(result.items[i]) + const paged = [] as MessageV2.WithParts[] + let cursor: string | undefined + while (true) { + const result = MessageV2.page({ sessionID, limit: 3, before: cursor }) + for (let i = result.items.length - 1; i >= 0; i--) { + paged.push(result.items[i]) + } + if (!result.more || !result.cursor) break + cursor = result.cursor } - if (!result.more || !result.cursor) break - cursor = result.cursor - } - expect(streamed.map((m) => m.info.id)).toEqual(paged.map((m) => m.info.id)) - })), + expect(streamed.map((m) => m.info.id)).toEqual(paged.map((m) => m.info.id)) + }), + ), ) it.instance("filterCompacted of full stream returns same as Array.from when no compaction", () => - withSession(({ sessionID }) => Effect.gen(function* () { - yield* fill(sessionID, 4) + withSession(({ sessionID }) => + Effect.gen(function* () { + yield* fill(sessionID, 4) - const filtered = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - const all = Array.from(MessageV2.stream(sessionID)).reverse() + const filtered = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const all = Array.from(MessageV2.stream(sessionID)).reverse() - expect(filtered.map((m) => m.info.id)).toEqual(all.map((m) => m.info.id)) - })), + expect(filtered.map((m) => m.info.id)).toEqual(all.map((m) => m.info.id)) + }), + ), ) }) From 5773d43cbf356b2ede8e9c000ae5f8bfbf017e75 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 11 May 2026 19:50:35 -0500 Subject: [PATCH 052/188] ci: GitHub Actions dependencies (#26962) --- .github/actions/setup-bun/action.yml | 4 +- .../actions/setup-git-committer/action.yml | 2 +- .github/workflows/beta.yml | 2 +- .github/workflows/close-issues.yml | 4 +- .github/workflows/close-stale-prs.yml | 2 +- .github/workflows/compliance-close.yml | 2 +- .github/workflows/containers.yml | 8 +-- .github/workflows/deploy.yml | 4 +- .github/workflows/docs-locale-sync.yml | 2 +- .github/workflows/docs-update.yml | 4 +- .github/workflows/duplicate-issues.yml | 4 +- .github/workflows/generate.yml | 2 +- .github/workflows/nix-eval.yml | 4 +- .github/workflows/nix-hashes.yml | 10 ++-- .github/workflows/notify-discord.yml | 2 +- .github/workflows/opencode.yml | 4 +- .github/workflows/pr-management.yml | 4 +- .github/workflows/pr-standards.yml | 4 +- .github/workflows/publish-github-action.yml | 2 +- .github/workflows/publish-vscode.yml | 2 +- .github/workflows/publish.yml | 52 +++++++++---------- .github/workflows/release-github-action.yml | 2 +- .github/workflows/review.yml | 2 +- .github/workflows/stats.yml | 2 +- .github/workflows/storybook.yml | 2 +- .github/workflows/sync-zed-extension.yml | 2 +- .github/workflows/test.yml | 18 +++---- .github/workflows/triage.yml | 2 +- .github/workflows/typecheck.yml | 2 +- 29 files changed, 78 insertions(+), 78 deletions(-) diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 9859174a2..35f42462b 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -23,7 +23,7 @@ runs: fi - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }} bun-download-url: ${{ steps.bun-url.outputs.url }} @@ -34,7 +34,7 @@ runs: run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT" - name: Cache Bun dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ steps.cache.outputs.dir }} key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} diff --git a/.github/actions/setup-git-committer/action.yml b/.github/actions/setup-git-committer/action.yml index 87d2f5d0d..65c974c6a 100644 --- a/.github/actions/setup-git-committer/action.yml +++ b/.github/actions/setup-git-committer/action.yml @@ -19,7 +19,7 @@ runs: steps: - name: Create app token id: apptoken - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 with: app-id: ${{ inputs.opencode-app-id }} private-key: ${{ inputs.opencode-app-secret }} diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index a7106667b..e93d5fbdb 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -13,7 +13,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 diff --git a/.github/workflows/close-issues.yml b/.github/workflows/close-issues.yml index 04b6ae7ac..b8a2e3f57 100644 --- a/.github/workflows/close-issues.yml +++ b/.github/workflows/close-issues.yml @@ -12,9 +12,9 @@ jobs: contents: read issues: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml index e0e571b46..3a0fa4b5c 100644 --- a/.github/workflows/close-stale-prs.yml +++ b/.github/workflows/close-stale-prs.yml @@ -21,7 +21,7 @@ jobs: timeout-minutes: 15 steps: - name: Close inactive PRs - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/compliance-close.yml b/.github/workflows/compliance-close.yml index c3bcf9f68..14e68701e 100644 --- a/.github/workflows/compliance-close.yml +++ b/.github/workflows/compliance-close.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Close non-compliant issues and PRs after 2 hours - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const { data: items } = await github.rest.issues.listForRepo({ diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml index c7df066d4..15bf07831 100644 --- a/.github/workflows/containers.yml +++ b/.github/workflows/containers.yml @@ -21,18 +21,18 @@ jobs: REGISTRY: ghcr.io/${{ github.repository_owner }} TAG: "24.04" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: ./.github/actions/setup-bun - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Login to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index abd8bafdd..7b4f53a98 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,11 +13,11 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: ./.github/actions/setup-bun - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" diff --git a/.github/workflows/docs-locale-sync.yml b/.github/workflows/docs-locale-sync.yml index 9689eee6d..5f921e8bb 100644 --- a/.github/workflows/docs-locale-sync.yml +++ b/.github/workflows/docs-locale-sync.yml @@ -16,7 +16,7 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/docs-update.yml b/.github/workflows/docs-update.yml index 900ad2b0c..4767dec53 100644 --- a/.github/workflows/docs-update.yml +++ b/.github/workflows/docs-update.yml @@ -18,7 +18,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 # Fetch full history to access commits @@ -43,7 +43,7 @@ jobs: - name: Run opencode if: steps.commits.outputs.has_commits == 'true' - uses: sst/opencode/github@latest + uses: sst/opencode/github@2c14fc5586fe0b88e5c04732d2e846769cc35671 # latest env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml index 6c1943fe7..4648a2d0c 100644 --- a/.github/workflows/duplicate-issues.yml +++ b/.github/workflows/duplicate-issues.yml @@ -13,7 +13,7 @@ jobs: issues: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 1 @@ -125,7 +125,7 @@ jobs: issues: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 1 diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index 706ab2989..324cfec02 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -13,7 +13,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/.github/workflows/nix-eval.yml b/.github/workflows/nix-eval.yml index c76b2c972..75332695a 100644 --- a/.github/workflows/nix-eval.yml +++ b/.github/workflows/nix-eval.yml @@ -20,10 +20,10 @@ jobs: timeout-minutes: 15 steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Nix - uses: nixbuild/nix-quick-install-action@v34 + uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 - name: Evaluate flake outputs (all systems) run: | diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index 6b5b3929a..085f8895c 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -41,10 +41,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Nix - uses: nixbuild/nix-quick-install-action@v34 + uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 - name: Compute node_modules hash id: hash @@ -72,7 +72,7 @@ jobs: echo "Computed hash for ${SYSTEM}: $HASH" - name: Upload hash - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: hash-${{ matrix.system }} path: hash.txt @@ -85,7 +85,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: persist-credentials: false fetch-depth: 0 @@ -102,7 +102,7 @@ jobs: git pull --rebase --autostash origin "$GITHUB_REF_NAME" - name: Download hash artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: path: hashes pattern: hash-* diff --git a/.github/workflows/notify-discord.yml b/.github/workflows/notify-discord.yml index b1d805360..0b2b1cde0 100644 --- a/.github/workflows/notify-discord.yml +++ b/.github/workflows/notify-discord.yml @@ -9,6 +9,6 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Send nicely-formatted embed to Discord - uses: SethCohen/github-releases-to-discord@v1 + uses: SethCohen/github-releases-to-discord@24d166886aee4646d448c8a389ff9e1ebcab3682 # v1.20.0 with: webhook_url: ${{ secrets.DISCORD_WEBHOOK }} diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 76e75fcae..3469c2191 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -21,12 +21,12 @@ jobs: issues: read steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: ./.github/actions/setup-bun - name: Run opencode - uses: anomalyco/opencode/github@latest + uses: anomalyco/opencode/github@2c14fc5586fe0b88e5c04732d2e846769cc35671 # latest env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_PERMISSION: '{"bash": "deny"}' diff --git a/.github/workflows/pr-management.yml b/.github/workflows/pr-management.yml index 35bd7ae36..b6aa4e589 100644 --- a/.github/workflows/pr-management.yml +++ b/.github/workflows/pr-management.yml @@ -12,7 +12,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 1 @@ -78,7 +78,7 @@ jobs: issues: write steps: - name: Add Contributor Label - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const isPR = !!context.payload.pull_request; diff --git a/.github/workflows/pr-standards.yml b/.github/workflows/pr-standards.yml index 1edbd5d06..06838089d 100644 --- a/.github/workflows/pr-standards.yml +++ b/.github/workflows/pr-standards.yml @@ -12,7 +12,7 @@ jobs: pull-requests: write steps: - name: Check PR standards - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const pr = context.payload.pull_request; @@ -159,7 +159,7 @@ jobs: pull-requests: write steps: - name: Check PR template compliance - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const pr = context.payload.pull_request; diff --git a/.github/workflows/publish-github-action.yml b/.github/workflows/publish-github-action.yml index d2789373a..e5ca91b56 100644 --- a/.github/workflows/publish-github-action.yml +++ b/.github/workflows/publish-github-action.yml @@ -16,7 +16,7 @@ jobs: publish: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: fetch-depth: 0 diff --git a/.github/workflows/publish-vscode.yml b/.github/workflows/publish-vscode.yml index f49a10578..00c7e2604 100644 --- a/.github/workflows/publish-vscode.yml +++ b/.github/workflows/publish-vscode.yml @@ -15,7 +15,7 @@ jobs: publish: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: fetch-depth: 0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5f7ee96b9..bef1e7029 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -35,7 +35,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 if: github.repository == 'anomalyco/opencode' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: fetch-depth: 0 @@ -72,7 +72,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 if: github.repository == 'anomalyco/opencode' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: fetch-tags: true @@ -95,14 +95,14 @@ jobs: GH_REPO: ${{ needs.version.outputs.repo }} GH_TOKEN: ${{ steps.committer.outputs.token }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: opencode-cli path: | packages/opencode/dist/opencode-darwin* packages/opencode/dist/opencode-linux* - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: opencode-cli-windows path: packages/opencode/dist/opencode-windows* @@ -123,9 +123,9 @@ jobs: AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }} AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: opencode-cli-windows path: packages/opencode/dist @@ -138,13 +138,13 @@ jobs: opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - name: Azure login - uses: azure/login@v2 + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 with: client-id: ${{ env.AZURE_CLIENT_ID }} tenant-id: ${{ env.AZURE_TENANT_ID }} subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} - - uses: azure/artifact-signing-action@v1 + - uses: azure/artifact-signing-action@b443cf8ea4124818d2ea9f043cba29fc3ec47b16 # v1.2.0 with: endpoint: ${{ env.AZURE_TRUSTED_SIGNING_ENDPOINT }} signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} @@ -201,7 +201,7 @@ jobs: --clobber ` --repo "${{ needs.version.outputs.repo }}" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: opencode-cli-signed-windows path: | @@ -249,9 +249,9 @@ jobs: platform_flag: --linux runs-on: ${{ matrix.settings.host }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - - uses: apple-actions/import-codesign-certs@v2 + - uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0 if: runner.os == 'macOS' with: keychain: build @@ -268,19 +268,19 @@ jobs: - name: Azure login if: runner.os == 'Windows' - uses: azure/login@v2 + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 with: client-id: ${{ env.AZURE_CLIENT_ID }} tenant-id: ${{ env.AZURE_TENANT_ID }} subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" - name: Cache apt packages if: contains(matrix.settings.host, 'ubuntu') - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/apt-cache key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-${{ hashFiles('.github/workflows/publish.yml') }} @@ -388,12 +388,12 @@ jobs: } } - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: opencode-desktop-${{ matrix.settings.target }} path: packages/desktop/dist/* - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: needs.version.outputs.release with: name: latest-yml-${{ matrix.settings.target }} @@ -408,44 +408,44 @@ jobs: if: always() && !failure() && !cancelled() runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: ./.github/actions/setup-bun - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" registry-url: "https://registry.npmjs.org" - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: opencode-cli path: packages/opencode/dist - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: opencode-cli-windows path: packages/opencode/dist - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: opencode-cli-signed-windows path: packages/opencode/dist - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 if: needs.version.outputs.release with: pattern: latest-yml-* @@ -459,7 +459,7 @@ jobs: opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - name: Cache apt packages (AUR) - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: /var/cache/apt/archives key: ${{ runner.os }}-apt-aur-${{ hashFiles('.github/workflows/publish.yml') }} diff --git a/.github/workflows/release-github-action.yml b/.github/workflows/release-github-action.yml index 3f5caa55c..4a1d7218b 100644 --- a/.github/workflows/release-github-action.yml +++ b/.github/workflows/release-github-action.yml @@ -16,7 +16,7 @@ jobs: release: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 2bd1f0c4a..00a4fba8c 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -25,7 +25,7 @@ jobs: fi - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 1 diff --git a/.github/workflows/stats.yml b/.github/workflows/stats.yml index 824733901..bc97cfcd7 100644 --- a/.github/workflows/stats.yml +++ b/.github/workflows/stats.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 6d143a8a2..1e652104d 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -29,7 +29,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/.github/workflows/sync-zed-extension.yml b/.github/workflows/sync-zed-extension.yml index f14487cde..6e4b44083 100644 --- a/.github/workflows/sync-zed-extension.yml +++ b/.github/workflows/sync-zed-extension.yml @@ -10,7 +10,7 @@ jobs: name: Release Zed Extension runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f226d3483..4a65b9927 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,12 +37,12 @@ jobs: shell: bash steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" @@ -55,7 +55,7 @@ jobs: git config --global user.name "opencode" - name: Cache Turbo - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: node_modules/.cache/turbo key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }} @@ -75,7 +75,7 @@ jobs: - name: Publish unit reports if: always() - uses: mikepenz/action-junit-report@v6 + uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0 with: report_paths: packages/*/.artifacts/unit/junit.xml check_name: "unit results (${{ matrix.settings.name }})" @@ -85,7 +85,7 @@ jobs: - name: Upload unit artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }} include-hidden-files: true @@ -111,12 +111,12 @@ jobs: shell: bash steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" @@ -131,7 +131,7 @@ jobs: - name: Cache Playwright browsers id: playwright-cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ github.workspace }}/.playwright-browsers key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium @@ -155,7 +155,7 @@ jobs: - name: Upload Playwright artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }} if-no-files-found: ignore diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index 99e7b5b34..27852a12c 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -12,7 +12,7 @@ jobs: issues: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 1 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index b247d24b4..fc9a52797 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -12,7 +12,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun uses: ./.github/actions/setup-bun From 0d9c5341846819db96b2a7e08f0357d6c24507fa Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 20:59:31 -0400 Subject: [PATCH 053/188] test(snapshot): migrate snapshot tests to Effect runner (#26964) --- .../opencode/test/snapshot/snapshot.test.ts | 2368 +++++++---------- 1 file changed, 977 insertions(+), 1391 deletions(-) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 99ddfe72d..fa167281b 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -1,13 +1,15 @@ -import { afterEach, test, expect } from "bun:test" +import { afterEach, expect } from "bun:test" import { $ } from "bun" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import fs from "fs/promises" import path from "path" -import { Effect } from "effect" +import { Effect, Fiber } from "effect" import { Snapshot } from "../../src/snapshot" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" -import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Snapshot.defaultLayer) // Git always outputs /-separated paths internally. Snapshot.patch() joins them // with path.join (which produces \ on Windows) then normalizes back to /. @@ -18,1515 +20,1099 @@ afterEach(async () => { await disposeAllInstances() }) -async function bootstrap() { - return tmpdir({ - git: true, - init: async (dir) => { - const unique = Math.random().toString(36).slice(2) - const aContent = `A${unique}` - const bContent = `B${unique}` - await Filesystem.write(`${dir}/a.txt`, aContent) - await Filesystem.write(`${dir}/b.txt`, bContent) - await $`git add .`.cwd(dir).quiet() - await $`git commit -m init`.cwd(dir).quiet() - return { - aContent, - bContent, - } - }, +const exec = (cwd: string, command: string[]) => + Effect.promise(async () => { + const proc = Bun.spawn(command, { cwd, stdout: "ignore", stderr: "pipe" }) + const code = await proc.exited + if (code !== 0) throw new Error(`${command.join(" ")} failed: ${await new Response(proc.stderr).text()}`) }) -} -function run(dir: string, body: (snapshot: Snapshot.Interface) => Effect.Effect) { - return Effect.runPromise( - Effect.gen(function* () { - const snapshot = yield* Snapshot.Service - return yield* body(snapshot) - }).pipe(provideInstance(dir), Effect.provide(Snapshot.defaultLayer)), +const write = (file: string, content: string | Uint8Array) => Effect.promise(() => Filesystem.write(file, content)) +const readText = (file: string) => Effect.promise(() => fs.readFile(file, "utf-8")) +const exists = (file: string) => + Effect.promise(() => + fs + .access(file) + .then(() => true) + .catch(() => false), ) -} +const mkdirp = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true })) +const rm = (file: string) => Effect.promise(() => fs.rm(file, { recursive: true, force: true })) -test("tracks deleted files correctly", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await $`rm ${tmp.path}/a.txt`.quiet() - - expect((await run(tmp.path, (snapshot) => snapshot.patch(before!))).files).toContain(fwd(tmp.path, "a.txt")) - }, - }) +const initialize = Effect.fn("SnapshotTest.initialize")(function* (dir: string) { + const unique = Math.random().toString(36).slice(2) + const aContent = `A${unique}` + const bContent = `B${unique}` + yield* write(`${dir}/a.txt`, aContent) + yield* write(`${dir}/b.txt`, bContent) + yield* exec(dir, ["git", "add", "."]) + yield* exec(dir, ["git", "commit", "-m", "init"]) + return { aContent, bContent } }) -test("revert should remove new files", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() +type Bootstrapped = { path: string; extra: { aContent: string; bContent: string } } - await Filesystem.write(`${tmp.path}/new.txt`, "NEW") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - - expect( - await fs - .access(`${tmp.path}/new.txt`) - .then(() => true) - .catch(() => false), - ).toBe(false) - }, - }) +const bootstrap = Effect.fn("SnapshotTest.bootstrap")(function* () { + const tmp = yield* TestInstance + return { path: tmp.directory, extra: yield* initialize(tmp.directory) } }) -test("revert in subdirectory", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await $`mkdir -p ${tmp.path}/sub`.quiet() - await Filesystem.write(`${tmp.path}/sub/file.txt`, "SUB") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - - expect( - await fs - .access(`${tmp.path}/sub/file.txt`) - .then(() => true) - .catch(() => false), - ).toBe(false) - // Note: revert currently only removes files, not directories - // The empty subdirectory will remain - }, +const withTrackedSnapshot = ( + fn: (input: { tmp: Bootstrapped; snapshot: Snapshot.Interface; before: string }) => Effect.Effect, +) => + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + return yield* fn({ tmp, snapshot, before: before! }) }) + +const bootstrapScoped = Effect.fn("SnapshotTest.bootstrapScoped")(function* () { + const dir = yield* tmpdirScoped({ git: true }).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)) + return { path: dir, extra: yield* initialize(dir) } }) -test("multiple file operations", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() +const scopedGitTmpdir = () => tmpdirScoped({ git: true }).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)) - await $`rm ${tmp.path}/a.txt`.quiet() - await Filesystem.write(`${tmp.path}/c.txt`, "C") - await $`mkdir -p ${tmp.path}/dir`.quiet() - await Filesystem.write(`${tmp.path}/dir/d.txt`, "D") - await Filesystem.write(`${tmp.path}/b.txt`, "MODIFIED") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - - expect(await fs.readFile(`${tmp.path}/a.txt`, "utf-8")).toBe(tmp.extra.aContent) - expect( - await fs - .access(`${tmp.path}/c.txt`) - .then(() => true) - .catch(() => false), - ).toBe(false) - // Note: revert currently only removes files, not directories - // The empty directory will remain - expect(await fs.readFile(`${tmp.path}/b.txt`, "utf-8")).toBe(tmp.extra.bContent) - }, +const cleanupWorktree = (repo: string, worktree: string, files: string[] = []) => + Effect.promise(async () => { + await $`git worktree remove --force ${worktree}`.cwd(repo).quiet().nothrow() + await fs.rm(worktree, { recursive: true, force: true }).catch(() => undefined) + await Promise.all(files.map((file) => fs.rm(file, { recursive: true, force: true }).catch(() => undefined))) }) -}) -test("empty directory handling", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() +const withGitConfigGlobal = (config: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.GIT_CONFIG_GLOBAL + process.env.GIT_CONFIG_GLOBAL = config + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous) process.env.GIT_CONFIG_GLOBAL = previous + else delete process.env.GIT_CONFIG_GLOBAL + }), + ) - await $`mkdir ${tmp.path}/empty`.quiet() +it.instance( + "tracks deleted files correctly", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* rm(`${tmp.path}/a.txt`) + expect((yield* snapshot.patch(before)).files).toContain(fwd(tmp.path, "a.txt")) + }), + ), + { git: true }, +) - expect((await run(tmp.path, (snapshot) => snapshot.patch(before!))).files.length).toBe(0) - }, - }) -}) +it.instance( + "revert should remove new files", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/new.txt`, "NEW") + const patch = yield* snapshot.patch(before) + yield* snapshot.revert([patch]) + expect(yield* exists(`${tmp.path}/new.txt`)).toBe(false) + }), + ), + { git: true }, +) -test("binary file handling", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() +it.instance( + "revert in subdirectory", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* mkdirp(`${tmp.path}/sub`) + yield* write(`${tmp.path}/sub/file.txt`, "SUB") + const patch = yield* snapshot.patch(before) + yield* snapshot.revert([patch]) + expect(yield* exists(`${tmp.path}/sub/file.txt`)).toBe(false) + }), + ), + { git: true }, +) - await Filesystem.write(`${tmp.path}/image.png`, new Uint8Array([0x89, 0x50, 0x4e, 0x47])) +it.instance( + "multiple file operations", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* rm(`${tmp.path}/a.txt`) + yield* write(`${tmp.path}/c.txt`, "C") + yield* mkdirp(`${tmp.path}/dir`) + yield* write(`${tmp.path}/dir/d.txt`, "D") + yield* write(`${tmp.path}/b.txt`, "MODIFIED") + const patch = yield* snapshot.patch(before) + yield* snapshot.revert([patch]) + expect(yield* readText(`${tmp.path}/a.txt`)).toBe(tmp.extra.aContent) + expect(yield* exists(`${tmp.path}/c.txt`)).toBe(false) + expect(yield* readText(`${tmp.path}/b.txt`)).toBe(tmp.extra.bContent) + }), + ), + { git: true }, +) - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) +it.instance( + "empty directory handling", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* mkdirp(`${tmp.path}/empty`) + expect((yield* snapshot.patch(before)).files.length).toBe(0) + }), + ), + { git: true }, +) + +it.instance( + "binary file handling", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/image.png`, new Uint8Array([0x89, 0x50, 0x4e, 0x47])) + const patch = yield* snapshot.patch(before) expect(patch.files).toContain(fwd(tmp.path, "image.png")) + yield* snapshot.revert([patch]) + expect(yield* exists(`${tmp.path}/image.png`)).toBe(false) + }), + ), + { git: true }, +) - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - expect( - await fs - .access(`${tmp.path}/image.png`) - .then(() => true) - .catch(() => false), - ).toBe(false) - }, - }) -}) +it.instance( + "symlink handling", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* Effect.promise(() => fs.symlink(`${tmp.path}/a.txt`, `${tmp.path}/link.txt`, "file")) + expect((yield* snapshot.patch(before)).files).toContain(fwd(tmp.path, "link.txt")) + }), + ), + { git: true }, +) -test("symlink handling", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() +it.instance( + "file under size limit handling", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024)) + expect((yield* snapshot.patch(before)).files).toContain(fwd(tmp.path, "large.txt")) + }), + ), + { git: true }, +) - await fs.symlink(`${tmp.path}/a.txt`, `${tmp.path}/link.txt`, "file") +it.instance( + "large added files are skipped", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/huge.txt`, new Uint8Array(2 * 1024 * 1024 + 1)) + expect((yield* snapshot.patch(before)).files).toEqual([]) + expect(yield* snapshot.diff(before)).toBe("") + expect(yield* snapshot.track()).toBe(before) + }), + ), + { git: true }, +) - expect((await run(tmp.path, (snapshot) => snapshot.patch(before!))).files).toContain(fwd(tmp.path, "link.txt")) - }, - }) -}) +it.instance( + "nested directory revert", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* mkdirp(`${tmp.path}/level1/level2/level3`) + yield* write(`${tmp.path}/level1/level2/level3/deep.txt`, "DEEP") + const patch = yield* snapshot.patch(before) + yield* snapshot.revert([patch]) + expect(yield* exists(`${tmp.path}/level1/level2/level3/deep.txt`)).toBe(false) + }), + ), + { git: true }, +) -test("file under size limit handling", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024)) - - expect((await run(tmp.path, (snapshot) => snapshot.patch(before!))).files).toContain(fwd(tmp.path, "large.txt")) - }, - }) -}) - -test("large added files are skipped", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/huge.txt`, new Uint8Array(2 * 1024 * 1024 + 1)) - - expect((await run(tmp.path, (snapshot) => snapshot.patch(before!))).files).toEqual([]) - expect(await run(tmp.path, (snapshot) => snapshot.diff(before!))).toBe("") - expect(await run(tmp.path, (snapshot) => snapshot.track())).toBe(before) - }, - }) -}) - -test("nested directory revert", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await $`mkdir -p ${tmp.path}/level1/level2/level3`.quiet() - await Filesystem.write(`${tmp.path}/level1/level2/level3/deep.txt`, "DEEP") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - - expect( - await fs - .access(`${tmp.path}/level1/level2/level3/deep.txt`) - .then(() => true) - .catch(() => false), - ).toBe(false) - }, - }) -}) - -test("special characters in filenames", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/file with spaces.txt`, "SPACES") - await Filesystem.write(`${tmp.path}/file-with-dashes.txt`, "DASHES") - await Filesystem.write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES") - - const files = (await run(tmp.path, (snapshot) => snapshot.patch(before!))).files +it.instance( + "special characters in filenames", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/file with spaces.txt`, "SPACES") + yield* write(`${tmp.path}/file-with-dashes.txt`, "DASHES") + yield* write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES") + const files = (yield* snapshot.patch(before)).files expect(files).toContain(fwd(tmp.path, "file with spaces.txt")) expect(files).toContain(fwd(tmp.path, "file-with-dashes.txt")) expect(files).toContain(fwd(tmp.path, "file_with_underscores.txt")) - }, - }) -}) + }), + ), + { git: true }, +) -test("revert with empty patches", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - // Should not crash with empty patches - expect(run(tmp.path, (snapshot) => snapshot.revert([]))).resolves.toBeUndefined() +it.instance( + "revert with empty patches", + Effect.gen(function* () { + yield* bootstrap() + const snapshot = yield* Snapshot.Service + yield* snapshot.revert([]) + yield* snapshot.revert([{ hash: "dummy", files: [] }]) + }), + { git: true }, +) - // Should not crash with patches that have empty file lists - expect(run(tmp.path, (snapshot) => snapshot.revert([{ hash: "dummy", files: [] }]))).resolves.toBeUndefined() - }, - }) -}) - -test("patch with invalid hash", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - // Create a change - await Filesystem.write(`${tmp.path}/test.txt`, "TEST") - - // Try to patch with invalid hash - should handle gracefully - const patch = await run(tmp.path, (snapshot) => snapshot.patch("invalid-hash-12345")) +it.instance( + "patch with invalid hash", + withTrackedSnapshot(({ tmp, snapshot }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/test.txt`, "TEST") + const patch = yield* snapshot.patch("invalid-hash-12345") expect(patch.files).toEqual([]) expect(patch.hash).toBe("invalid-hash-12345") - }, - }) -}) + }), + ), + { git: true }, +) -test("revert non-existent file", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - // Try to revert a file that doesn't exist in the snapshot - // This should not crash - expect( - run(tmp.path, (snapshot) => - snapshot.revert([ - { - hash: before!, - files: [`${tmp.path}/nonexistent.txt`], - }, - ]), - ), - ).resolves.toBeUndefined() - }, - }) -}) - -test("unicode filenames", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() +it.instance( + "revert non-existent file", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* snapshot.revert([{ hash: before, files: [`${tmp.path}/nonexistent.txt`] }]) + }), + ), + { git: true }, +) +it.instance( + "unicode filenames", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { const unicodeFiles = [ { path: fwd(tmp.path, "文件.txt"), content: "chinese content" }, { path: fwd(tmp.path, "🚀rocket.txt"), content: "emoji content" }, { path: fwd(tmp.path, "café.txt"), content: "accented content" }, { path: fwd(tmp.path, "файл.txt"), content: "cyrillic content" }, ] - - for (const file of unicodeFiles) { - await Filesystem.write(file.path, file.content) - } - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) + yield* Effect.all( + unicodeFiles.map((file) => write(file.path, file.content)), + { concurrency: "unbounded" }, + ) + const patch = yield* snapshot.patch(before) expect(patch.files.length).toBe(4) + for (const file of unicodeFiles) expect(patch.files).toContain(file.path) + yield* snapshot.revert([patch]) + for (const file of unicodeFiles) expect(yield* exists(file.path)).toBe(false) + }), + ), + { git: true }, +) - for (const file of unicodeFiles) { - expect(patch.files).toContain(file.path) - } +it.instance.skip( + "unicode filenames modification and restore", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + const chineseFile = fwd(tmp.path, "文件.txt") + const cyrillicFile = fwd(tmp.path, "файл.txt") + yield* write(chineseFile, "original chinese") + yield* write(cyrillicFile, "original cyrillic") + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(chineseFile, "modified chinese") + yield* write(cyrillicFile, "modified cyrillic") + const patch = yield* snapshot.patch(before!) + expect(patch.files).toContain(chineseFile) + expect(patch.files).toContain(cyrillicFile) + yield* snapshot.revert([patch]) + expect(yield* readText(chineseFile)).toBe("original chinese") + expect(yield* readText(cyrillicFile)).toBe("original cyrillic") + }), + { git: true }, +) - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - - for (const file of unicodeFiles) { - expect( - await fs - .access(file.path) - .then(() => true) - .catch(() => false), - ).toBe(false) - } - }, - }) -}) - -test.skip("unicode filenames modification and restore", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const chineseFile = fwd(tmp.path, "文件.txt") - const cyrillicFile = fwd(tmp.path, "файл.txt") - - await Filesystem.write(chineseFile, "original chinese") - await Filesystem.write(cyrillicFile, "original cyrillic") - - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(chineseFile, "modified chinese") - await Filesystem.write(cyrillicFile, "modified cyrillic") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - expect(patch.files).toContain(chineseFile) - expect(patch.files).toContain(cyrillicFile) - - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - - expect(await fs.readFile(chineseFile, "utf-8")).toBe("original chinese") - expect(await fs.readFile(cyrillicFile, "utf-8")).toBe("original cyrillic") - }, - }) -}) - -test("unicode filenames in subdirectories", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await $`mkdir -p "${tmp.path}/目录/подкаталог"`.quiet() +it.instance( + "unicode filenames in subdirectories", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* mkdirp(`${tmp.path}/目录/подкаталог`) const deepFile = fwd(tmp.path, "目录", "подкаталог", "文件.txt") - await Filesystem.write(deepFile, "deep unicode content") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) + yield* write(deepFile, "deep unicode content") + const patch = yield* snapshot.patch(before) expect(patch.files).toContain(deepFile) + yield* snapshot.revert([patch]) + expect(yield* exists(deepFile)).toBe(false) + }), + ), + { git: true }, +) - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - expect( - await fs - .access(deepFile) - .then(() => true) - .catch(() => false), - ).toBe(false) - }, - }) -}) - -test("very long filenames", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - const longName = "a".repeat(200) + ".txt" - const longFile = fwd(tmp.path, longName) - - await Filesystem.write(longFile, "long filename content") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) +it.instance( + "very long filenames", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + const longFile = fwd(tmp.path, `${"a".repeat(200)}.txt`) + yield* write(longFile, "long filename content") + const patch = yield* snapshot.patch(before) expect(patch.files).toContain(longFile) + yield* snapshot.revert([patch]) + expect(yield* exists(longFile)).toBe(false) + }), + ), + { git: true }, +) - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - expect( - await fs - .access(longFile) - .then(() => true) - .catch(() => false), - ).toBe(false) - }, - }) -}) - -test("hidden files", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/.hidden`, "hidden content") - await Filesystem.write(`${tmp.path}/.gitignore`, "*.log") - await Filesystem.write(`${tmp.path}/.config`, "config content") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) +it.instance( + "hidden files", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/.hidden`, "hidden content") + yield* write(`${tmp.path}/.gitignore`, "*.log") + yield* write(`${tmp.path}/.config`, "config content") + const patch = yield* snapshot.patch(before) expect(patch.files).toContain(fwd(tmp.path, ".hidden")) expect(patch.files).toContain(fwd(tmp.path, ".gitignore")) expect(patch.files).toContain(fwd(tmp.path, ".config")) - }, - }) -}) + }), + ), + { git: true }, +) -test("nested symlinks", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await $`mkdir -p ${tmp.path}/sub/dir`.quiet() - await Filesystem.write(`${tmp.path}/sub/dir/target.txt`, "target content") - await fs.symlink(`${tmp.path}/sub/dir/target.txt`, `${tmp.path}/sub/dir/link.txt`, "file") - await fs.symlink(`${tmp.path}/sub`, `${tmp.path}/sub-link`, "dir") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) +it.instance( + "nested symlinks", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* mkdirp(`${tmp.path}/sub/dir`) + yield* write(`${tmp.path}/sub/dir/target.txt`, "target content") + yield* Effect.promise(() => fs.symlink(`${tmp.path}/sub/dir/target.txt`, `${tmp.path}/sub/dir/link.txt`, "file")) + yield* Effect.promise(() => fs.symlink(`${tmp.path}/sub`, `${tmp.path}/sub-link`, "dir")) + const patch = yield* snapshot.patch(before) expect(patch.files).toContain(fwd(tmp.path, "sub", "dir", "link.txt")) expect(patch.files).toContain(fwd(tmp.path, "sub-link")) - }, - }) -}) + }), + ), + { git: true }, +) -test("file permissions and ownership changes", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "file permissions and ownership changes", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* Effect.promise(() => fs.chmod(`${tmp.path}/a.txt`, 0o600)) + yield* Effect.promise(() => fs.chmod(`${tmp.path}/a.txt`, 0o755)) + yield* Effect.promise(() => fs.chmod(`${tmp.path}/a.txt`, 0o644)) + expect((yield* snapshot.patch(before)).files.length).toBe(0) + }), + ), + { git: true }, +) + +it.instance( + "circular symlinks", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* Effect.promise(() => + fs.symlink(`${tmp.path}/circular`, `${tmp.path}/circular`, "dir").catch(() => undefined), + ) + expect((yield* snapshot.patch(before)).files.length).toBeGreaterThanOrEqual(0) + }), + ), + { git: true }, +) + +it.live( + "source project gitignore is respected - ignored files are not snapshotted", + Effect.gen(function* () { + const dir = yield* scopedGitTmpdir() + yield* write(`${dir}/.gitignore`, "*.ignored\nbuild/\nnode_modules/\n") + yield* write(`${dir}/tracked.txt`, "tracked content") + yield* write(`${dir}/ignored.ignored`, "ignored content") + yield* mkdirp(`${dir}/build`) + yield* write(`${dir}/build/output.js`, "build output") + yield* write(`${dir}/normal.js`, "normal js") + yield* exec(dir, ["git", "add", "."]) + yield* exec(dir, ["git", "commit", "-m", "init"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() expect(before).toBeTruthy() + yield* write(`${dir}/tracked.txt`, "modified tracked") + yield* write(`${dir}/new.ignored`, "new ignored") + yield* write(`${dir}/new-tracked.txt`, "new tracked") + yield* write(`${dir}/build/new-build.js`, "new build file") + const patch = yield* snapshot.patch(before!) + expect(patch.files).toContain(fwd(dir, "new-tracked.txt")) + expect(patch.files).toContain(fwd(dir, "tracked.txt")) + expect(patch.files).not.toContain(fwd(dir, "new.ignored")) + expect(patch.files).not.toContain(fwd(dir, "ignored.ignored")) + expect(patch.files).not.toContain(fwd(dir, "build/output.js")) + expect(patch.files).not.toContain(fwd(dir, "build/new-build.js")) + }).pipe(provideInstance(dir)) + }), +) - // Change permissions multiple times - await $`chmod 600 ${tmp.path}/a.txt`.quiet() - await $`chmod 755 ${tmp.path}/a.txt`.quiet() - await $`chmod 644 ${tmp.path}/a.txt`.quiet() - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - // Note: git doesn't track permission changes on existing files by default - // Only tracks executable bit when files are first added - expect(patch.files.length).toBe(0) - }, - }) -}) - -test("circular symlinks", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - // Create circular symlink - await fs.symlink(`${tmp.path}/circular`, `${tmp.path}/circular`, "dir").catch(() => {}) - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - expect(patch.files.length).toBeGreaterThanOrEqual(0) // Should not crash - }, - }) -}) - -test("source project gitignore is respected - ignored files are not snapshotted", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - // Create gitignore BEFORE any tracking - await Filesystem.write(`${dir}/.gitignore`, "*.ignored\nbuild/\nnode_modules/\n") - await Filesystem.write(`${dir}/tracked.txt`, "tracked content") - await Filesystem.write(`${dir}/ignored.ignored`, "ignored content") - await $`mkdir -p ${dir}/build`.quiet() - await Filesystem.write(`${dir}/build/output.js`, "build output") - await Filesystem.write(`${dir}/normal.js`, "normal js") - await $`git add .`.cwd(dir).quiet() - await $`git commit -m init`.cwd(dir).quiet() - }, - }) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - // Modify tracked files and create new ones - some ignored, some not - await Filesystem.write(`${tmp.path}/tracked.txt`, "modified tracked") - await Filesystem.write(`${tmp.path}/new.ignored`, "new ignored") - await Filesystem.write(`${tmp.path}/new-tracked.txt`, "new tracked") - await Filesystem.write(`${tmp.path}/build/new-build.js`, "new build file") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - - // Modified and new tracked files should be in snapshot - expect(patch.files).toContain(fwd(tmp.path, "new-tracked.txt")) - expect(patch.files).toContain(fwd(tmp.path, "tracked.txt")) - - // Ignored files should NOT be in snapshot - expect(patch.files).not.toContain(fwd(tmp.path, "new.ignored")) - expect(patch.files).not.toContain(fwd(tmp.path, "ignored.ignored")) - expect(patch.files).not.toContain(fwd(tmp.path, "build/output.js")) - expect(patch.files).not.toContain(fwd(tmp.path, "build/new-build.js")) - }, - }) -}) - -test("gitignore changes", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/.gitignore`, "*.ignored") - await Filesystem.write(`${tmp.path}/test.ignored`, "ignored content") - await Filesystem.write(`${tmp.path}/normal.txt`, "normal content") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - - // Should track gitignore itself +it.instance( + "gitignore changes", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/.gitignore`, "*.ignored") + yield* write(`${tmp.path}/test.ignored`, "ignored content") + yield* write(`${tmp.path}/normal.txt`, "normal content") + const patch = yield* snapshot.patch(before) expect(patch.files).toContain(fwd(tmp.path, ".gitignore")) - // Should track normal files expect(patch.files).toContain(fwd(tmp.path, "normal.txt")) - // Should not track ignored files (git won't see them) expect(patch.files).not.toContain(fwd(tmp.path, "test.ignored")) - }, - }) -}) + }), + ), + { git: true }, +) -test("files tracked in snapshot but now gitignored are filtered out", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - // First, create a file and snapshot it - await Filesystem.write(`${tmp.path}/later-ignored.txt`, "initial content") - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() +it.instance( + "files tracked in snapshot but now gitignored are filtered out", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + yield* write(`${tmp.path}/later-ignored.txt`, "initial content") + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${tmp.path}/later-ignored.txt`, "modified content") + yield* write(`${tmp.path}/.gitignore`, "later-ignored.txt\n") + yield* write(`${tmp.path}/still-tracked.txt`, "new tracked file") + const patch = yield* snapshot.patch(before!) + expect(patch.files).not.toContain(fwd(tmp.path, "later-ignored.txt")) + expect(patch.files).toContain(fwd(tmp.path, ".gitignore")) + expect(patch.files).toContain(fwd(tmp.path, "still-tracked.txt")) + }), + { git: true }, +) - // Modify the file (so it appears in diff-files) - await Filesystem.write(`${tmp.path}/later-ignored.txt`, "modified content") - - // Now add gitignore that would exclude this file - await Filesystem.write(`${tmp.path}/.gitignore`, "later-ignored.txt\n") - - // Also create another tracked file - await Filesystem.write(`${tmp.path}/still-tracked.txt`, "new tracked file") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - - // The file that is now gitignored should NOT appear, even though it was - // previously tracked and modified - expect(patch.files).not.toContain(fwd(tmp.path, "later-ignored.txt")) - - // The gitignore file itself should appear - expect(patch.files).toContain(fwd(tmp.path, ".gitignore")) - - // Other tracked files should appear - expect(patch.files).toContain(fwd(tmp.path, "still-tracked.txt")) - }, - }) -}) - -test("gitignore updated between track calls filters from diff", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - // a.txt is already committed from bootstrap - track it in snapshot - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - // Modify a.txt (so it appears in diff-files) - await Filesystem.write(`${tmp.path}/a.txt`, "modified content") - - // Now add gitignore that would exclude a.txt - await Filesystem.write(`${tmp.path}/.gitignore`, "a.txt\n") - - // Also modify b.txt which is not gitignored - await Filesystem.write(`${tmp.path}/b.txt`, "also modified") - - // Second track - should not include a.txt even though it changed - const after = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "gitignore updated between track calls filters from diff", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/a.txt`, "modified content") + yield* write(`${tmp.path}/.gitignore`, "a.txt\n") + yield* write(`${tmp.path}/b.txt`, "also modified") + const after = yield* snapshot.track() expect(after).toBeTruthy() - - // Verify a.txt is NOT in the diff between snapshots - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) + const diffs = yield* snapshot.diffFull(before, after!) expect(diffs.some((x) => x.file === "a.txt")).toBe(false) - - // But .gitignore should be in the diff expect(diffs.some((x) => x.file === ".gitignore")).toBe(true) - - // b.txt should be in the diff (not gitignored) expect(diffs.some((x) => x.file === "b.txt")).toBe(true) - }, - }) -}) - -test("git info exclude changes", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() + }), + ), + { git: true }, +) +it.instance( + "git info exclude changes", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { const file = `${tmp.path}/.git/info/exclude` - const text = await Bun.file(file).text() - await Bun.write(file, `${text.trimEnd()}\nignored.txt\n`) - await Bun.write(`${tmp.path}/ignored.txt`, "ignored content") - await Bun.write(`${tmp.path}/normal.txt`, "normal content") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) + yield* write(file, `${(yield* Effect.promise(() => Bun.file(file).text())).trimEnd()}\nignored.txt\n`) + yield* write(`${tmp.path}/ignored.txt`, "ignored content") + yield* write(`${tmp.path}/normal.txt`, "normal content") + const patch = yield* snapshot.patch(before) expect(patch.files).toContain(fwd(tmp.path, "normal.txt")) expect(patch.files).not.toContain(fwd(tmp.path, "ignored.txt")) - - const after = await run(tmp.path, (snapshot) => snapshot.track()) - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) + const after = yield* snapshot.track() + const diffs = yield* snapshot.diffFull(before, after!) expect(diffs.some((x) => x.file === "normal.txt")).toBe(true) expect(diffs.some((x) => x.file === "ignored.txt")).toBe(false) - }, - }) -}) + }), + ), + { git: true }, +) -test("git info exclude keeps global excludes", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const global = `${tmp.path}/global.ignore` - const config = `${tmp.path}/global.gitconfig` - await Bun.write(global, "global.tmp\n") - await Bun.write(config, `[core]\n\texcludesFile = ${global.replaceAll("\\", "/")}\n`) - - const prev = process.env.GIT_CONFIG_GLOBAL - process.env.GIT_CONFIG_GLOBAL = config - try { - const before = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "git info exclude keeps global excludes", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const global = `${tmp.path}/global.ignore` + const config = `${tmp.path}/global.gitconfig` + yield* write(global, "global.tmp\n") + yield* write(config, `[core]\n\texcludesFile = ${global.replaceAll("\\", "/")}\n`) + yield* withGitConfigGlobal( + config, + Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() expect(before).toBeTruthy() - const file = `${tmp.path}/.git/info/exclude` - const text = await Bun.file(file).text() - await Bun.write(file, `${text.trimEnd()}\ninfo.tmp\n`) - - await Bun.write(`${tmp.path}/global.tmp`, "global content") - await Bun.write(`${tmp.path}/info.tmp`, "info content") - await Bun.write(`${tmp.path}/normal.txt`, "normal content") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) + yield* write(file, `${(yield* Effect.promise(() => Bun.file(file).text())).trimEnd()}\ninfo.tmp\n`) + yield* write(`${tmp.path}/global.tmp`, "global content") + yield* write(`${tmp.path}/info.tmp`, "info content") + yield* write(`${tmp.path}/normal.txt`, "normal content") + const patch = yield* snapshot.patch(before!) expect(patch.files).toContain(fwd(tmp.path, "normal.txt")) expect(patch.files).not.toContain(fwd(tmp.path, "global.tmp")) expect(patch.files).not.toContain(fwd(tmp.path, "info.tmp")) - } finally { - if (prev) process.env.GIT_CONFIG_GLOBAL = prev - else delete process.env.GIT_CONFIG_GLOBAL - } - }, - }) -}) + }), + ) + }), + { git: true }, +) -test("concurrent file operations during patch", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - // Start creating files - const createPromise = (async () => { +it.instance( + "concurrent file operations during patch", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + const fiber = yield* Effect.gen(function* () { for (let i = 0; i < 10; i++) { - await Filesystem.write(`${tmp.path}/concurrent${i}.txt`, `concurrent${i}`) - // Small delay to simulate concurrent operations - await new Promise((resolve) => setTimeout(resolve, 1)) + yield* write(`${tmp.path}/concurrent${i}.txt`, `concurrent${i}`) + yield* Effect.sleep("1 millis") } - })() - - // Get patch while files are being created - const patchPromise = run(tmp.path, (snapshot) => snapshot.patch(before!)) - - await createPromise - const patch = await patchPromise - - // Should capture some or all of the concurrent files + }).pipe(Effect.forkScoped) + const patch = yield* snapshot.patch(before) + yield* Fiber.join(fiber) expect(patch.files.length).toBeGreaterThanOrEqual(0) - }, - }) -}) + }), + ), + { git: true }, +) -test("snapshot state isolation between projects", async () => { - // Test that different projects don't interfere with each other - await using tmp1 = await bootstrap() - await using tmp2 = await bootstrap() - - await WithInstance.provide({ - directory: tmp1.path, - fn: async () => { - const before1 = await run(tmp1.path, (snapshot) => snapshot.track()) - await Filesystem.write(`${tmp1.path}/project1.txt`, "project1 content") - const patch1 = await run(tmp1.path, (snapshot) => snapshot.patch(before1!)) +it.live( + "snapshot state isolation between projects", + Effect.gen(function* () { + const tmp1 = yield* bootstrapScoped() + const tmp2 = yield* bootstrapScoped() + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before1 = yield* snapshot.track() + yield* write(`${tmp1.path}/project1.txt`, "project1 content") + const patch1 = yield* snapshot.patch(before1!) expect(patch1.files).toContain(fwd(tmp1.path, "project1.txt")) - }, - }) - - await WithInstance.provide({ - directory: tmp2.path, - fn: async () => { - const before2 = await run(tmp2.path, (snapshot) => snapshot.track()) - await Filesystem.write(`${tmp2.path}/project2.txt`, "project2 content") - const patch2 = await run(tmp2.path, (snapshot) => snapshot.patch(before2!)) + }).pipe(provideInstance(tmp1.path)) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before2 = yield* snapshot.track() + yield* write(`${tmp2.path}/project2.txt`, "project2 content") + const patch2 = yield* snapshot.patch(before2!) expect(patch2.files).toContain(fwd(tmp2.path, "project2.txt")) + expect(patch2.files).not.toContain(fwd(tmp1.path, "project1.txt")) + }).pipe(provideInstance(tmp2.path)) + }), +) - // Ensure project1 files don't appear in project2 - expect(patch2.files).not.toContain(fwd(tmp1?.path ?? "", "project1.txt")) - }, - }) -}) - -test("patch detects changes in secondary worktree", async () => { - await using tmp = await bootstrap() - const worktreePath = `${tmp.path}-worktree` - await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() - - try { - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() - }, - }) - - await WithInstance.provide({ - directory: worktreePath, - fn: async () => { - const before = await run(worktreePath, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - const worktreeFile = fwd(worktreePath, "worktree.txt") - await Filesystem.write(worktreeFile, "worktree content") - - const patch = await run(worktreePath, (snapshot) => snapshot.patch(before!)) - expect(patch.files).toContain(worktreeFile) - }, - }) - } finally { - await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow() - await $`rm -rf ${worktreePath}`.quiet() - } -}) - -test("revert only removes files in invoking worktree", async () => { - await using tmp = await bootstrap() - const worktreePath = `${tmp.path}-worktree` - await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() - - try { - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() - }, - }) - const primaryFile = `${tmp.path}/worktree.txt` - await Filesystem.write(primaryFile, "primary content") - - await WithInstance.provide({ - directory: worktreePath, - fn: async () => { - const before = await run(worktreePath, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - const worktreeFile = fwd(worktreePath, "worktree.txt") - await Filesystem.write(worktreeFile, "worktree content") - - const patch = await run(worktreePath, (snapshot) => snapshot.patch(before!)) - await run(worktreePath, (snapshot) => snapshot.revert([patch])) - - expect( - await fs - .access(worktreeFile) - .then(() => true) - .catch(() => false), - ).toBe(false) - }, - }) - - expect(await fs.readFile(primaryFile, "utf-8")).toBe("primary content") - } finally { - await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow() - await $`rm -rf ${worktreePath}`.quiet() - await $`rm -f ${tmp.path}/worktree.txt`.quiet() - } -}) - -test("diff reports worktree-only/shared edits and ignores primary-only", async () => { - await using tmp = await bootstrap() - const worktreePath = `${tmp.path}-worktree` - await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() - - try { - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() - }, - }) - - await WithInstance.provide({ - directory: worktreePath, - fn: async () => { - const before = await run(worktreePath, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${worktreePath}/worktree-only.txt`, "worktree diff content") - await Filesystem.write(`${worktreePath}/shared.txt`, "worktree edit") - await Filesystem.write(`${tmp.path}/shared.txt`, "primary edit") - await Filesystem.write(`${tmp.path}/primary-only.txt`, "primary change") - - const diff = await run(worktreePath, (snapshot) => snapshot.diff(before!)) - expect(diff).toContain("worktree-only.txt") - expect(diff).toContain("shared.txt") - expect(diff).not.toContain("primary-only.txt") - }, - }) - } finally { - await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow() - await $`rm -rf ${worktreePath}`.quiet() - await $`rm -f ${tmp.path}/shared.txt`.quiet() - await $`rm -f ${tmp.path}/primary-only.txt`.quiet() - } -}) - -test("track with no changes returns same hash", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const hash1 = await run(tmp.path, (snapshot) => snapshot.track()) - expect(hash1).toBeTruthy() - - // Track again with no changes - const hash2 = await run(tmp.path, (snapshot) => snapshot.track()) - expect(hash2).toBe(hash1!) - - // Track again - const hash3 = await run(tmp.path, (snapshot) => snapshot.track()) - expect(hash3).toBe(hash1!) - }, - }) -}) - -test("diff function with various changes", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) +it.live( + "patch detects changes in secondary worktree", + Effect.gen(function* () { + const tmp = yield* bootstrapScoped() + const worktreePath = `${tmp.path}-worktree` + yield* exec(tmp.path, ["git", "worktree", "add", worktreePath, "HEAD"]) + yield* Effect.addFinalizer(() => cleanupWorktree(tmp.path, worktreePath)) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + expect(yield* snapshot.track()).toBeTruthy() + }).pipe(provideInstance(tmp.path)) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() expect(before).toBeTruthy() + const worktreeFile = fwd(worktreePath, "worktree.txt") + yield* write(worktreeFile, "worktree content") + expect((yield* snapshot.patch(before!)).files).toContain(worktreeFile) + }).pipe(provideInstance(worktreePath)) + }), +) - // Make various changes - await $`rm ${tmp.path}/a.txt`.quiet() - await Filesystem.write(`${tmp.path}/new.txt`, "new content") - await Filesystem.write(`${tmp.path}/b.txt`, "modified content") +it.live( + "revert only removes files in invoking worktree", + Effect.gen(function* () { + const tmp = yield* bootstrapScoped() + const worktreePath = `${tmp.path}-worktree` + const primaryFile = `${tmp.path}/worktree.txt` + yield* exec(tmp.path, ["git", "worktree", "add", worktreePath, "HEAD"]) + yield* Effect.addFinalizer(() => cleanupWorktree(tmp.path, worktreePath, [primaryFile])) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + expect(yield* snapshot.track()).toBeTruthy() + }).pipe(provideInstance(tmp.path)) + yield* write(primaryFile, "primary content") + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + const worktreeFile = fwd(worktreePath, "worktree.txt") + yield* write(worktreeFile, "worktree content") + const patch = yield* snapshot.patch(before!) + yield* snapshot.revert([patch]) + expect(yield* exists(worktreeFile)).toBe(false) + }).pipe(provideInstance(worktreePath)) + expect(yield* readText(primaryFile)).toBe("primary content") + }), +) - const diff = await run(tmp.path, (snapshot) => snapshot.diff(before!)) +it.live( + "diff reports worktree-only/shared edits and ignores primary-only", + Effect.gen(function* () { + const tmp = yield* bootstrapScoped() + const worktreePath = `${tmp.path}-worktree` + yield* exec(tmp.path, ["git", "worktree", "add", worktreePath, "HEAD"]) + yield* Effect.addFinalizer(() => + cleanupWorktree(tmp.path, worktreePath, [`${tmp.path}/shared.txt`, `${tmp.path}/primary-only.txt`]), + ) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + expect(yield* snapshot.track()).toBeTruthy() + }).pipe(provideInstance(tmp.path)) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${worktreePath}/worktree-only.txt`, "worktree diff content") + yield* write(`${worktreePath}/shared.txt`, "worktree edit") + yield* write(`${tmp.path}/shared.txt`, "primary edit") + yield* write(`${tmp.path}/primary-only.txt`, "primary change") + const diff = yield* snapshot.diff(before!) + expect(diff).toContain("worktree-only.txt") + expect(diff).toContain("shared.txt") + expect(diff).not.toContain("primary-only.txt") + }).pipe(provideInstance(worktreePath)) + }), +) + +it.instance( + "track with no changes returns same hash", + withTrackedSnapshot(({ snapshot, before }) => + Effect.gen(function* () { + expect(yield* snapshot.track()).toBe(before) + expect(yield* snapshot.track()).toBe(before) + }), + ), + { git: true }, +) + +it.instance( + "diff function with various changes", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* rm(`${tmp.path}/a.txt`) + yield* write(`${tmp.path}/new.txt`, "new content") + yield* write(`${tmp.path}/b.txt`, "modified content") + const diff = yield* snapshot.diff(before) expect(diff).toContain("a.txt") expect(diff).toContain("b.txt") expect(diff).toContain("new.txt") - }, - }) -}) + }), + ), + { git: true }, +) -test("restore function", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() +it.instance( + "restore function", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* rm(`${tmp.path}/a.txt`) + yield* write(`${tmp.path}/new.txt`, "new content") + yield* write(`${tmp.path}/b.txt`, "modified") + yield* snapshot.restore(before) + expect(yield* exists(`${tmp.path}/a.txt`)).toBe(true) + expect(yield* readText(`${tmp.path}/a.txt`)).toBe(tmp.extra.aContent) + expect(yield* exists(`${tmp.path}/new.txt`)).toBe(true) + expect(yield* readText(`${tmp.path}/b.txt`)).toBe(tmp.extra.bContent) + }), + ), + { git: true }, +) - // Make changes - await $`rm ${tmp.path}/a.txt`.quiet() - await Filesystem.write(`${tmp.path}/new.txt`, "new content") - await Filesystem.write(`${tmp.path}/b.txt`, "modified") +it.instance( + "revert should not delete files that existed but were deleted in snapshot", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + const snapshot1 = yield* snapshot.track() + expect(snapshot1).toBeTruthy() + yield* rm(`${tmp.path}/a.txt`) + const snapshot2 = yield* snapshot.track() + expect(snapshot2).toBeTruthy() + yield* write(`${tmp.path}/a.txt`, "recreated content") + const patch = yield* snapshot.patch(snapshot2!) + expect(patch.files).toContain(fwd(tmp.path, "a.txt")) + yield* snapshot.revert([patch]) + expect(yield* exists(`${tmp.path}/a.txt`)).toBe(false) + }), + { git: true }, +) - // Restore to original state - await run(tmp.path, (snapshot) => snapshot.restore(before!)) +it.instance( + "revert preserves file that existed in snapshot when deleted then recreated", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + yield* write(`${tmp.path}/existing.txt`, "original content") + const hash = yield* snapshot.track() + expect(hash).toBeTruthy() + yield* rm(`${tmp.path}/existing.txt`) + yield* write(`${tmp.path}/existing.txt`, "recreated") + yield* write(`${tmp.path}/newfile.txt`, "new") + const patch = yield* snapshot.patch(hash!) + expect(patch.files).toContain(fwd(tmp.path, "existing.txt")) + expect(patch.files).toContain(fwd(tmp.path, "newfile.txt")) + yield* snapshot.revert([patch]) + expect(yield* exists(`${tmp.path}/newfile.txt`)).toBe(false) + expect(yield* exists(`${tmp.path}/existing.txt`)).toBe(true) + expect(yield* readText(`${tmp.path}/existing.txt`)).toBe("original content") + }), + { git: true }, +) - expect( - await fs - .access(`${tmp.path}/a.txt`) - .then(() => true) - .catch(() => false), - ).toBe(true) - expect(await fs.readFile(`${tmp.path}/a.txt`, "utf-8")).toBe(tmp.extra.aContent) - expect( - await fs - .access(`${tmp.path}/new.txt`) - .then(() => true) - .catch(() => false), - ).toBe(true) // New files should remain - expect(await fs.readFile(`${tmp.path}/b.txt`, "utf-8")).toBe(tmp.extra.bContent) - }, - }) -}) +it.instance( + "diffFull sets status based on git change type", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + yield* write(`${tmp.path}/grow.txt`, "one\n") + yield* write(`${tmp.path}/trim.txt`, "line1\nline2\n") + yield* write(`${tmp.path}/delete.txt`, "gone") + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${tmp.path}/grow.txt`, "one\ntwo\n") + yield* write(`${tmp.path}/trim.txt`, "line1\n") + yield* rm(`${tmp.path}/delete.txt`) + yield* write(`${tmp.path}/added.txt`, "new") + const after = yield* snapshot.track() + expect(after).toBeTruthy() + const diffs = yield* snapshot.diffFull(before!, after!) + expect(diffs.length).toBe(4) + expect(diffs.find((d) => d.file === "added.txt")!.status).toBe("added") + expect(diffs.find((d) => d.file === "delete.txt")!.status).toBe("deleted") + const grow = diffs.find((d) => d.file === "grow.txt")! + expect(grow.status).toBe("modified") + expect(grow.additions).toBeGreaterThan(0) + expect(grow.deletions).toBe(0) + const trim = diffs.find((d) => d.file === "trim.txt")! + expect(trim.status).toBe("modified") + expect(trim.additions).toBe(0) + expect(trim.deletions).toBeGreaterThan(0) + }), + { git: true }, +) -test("revert should not delete files that existed but were deleted in snapshot", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const snapshot1 = await run(tmp.path, (snapshot) => snapshot.track()) - expect(snapshot1).toBeTruthy() - - await $`rm ${tmp.path}/a.txt`.quiet() - - const snapshot2 = await run(tmp.path, (snapshot) => snapshot.track()) - expect(snapshot2).toBeTruthy() - - await Filesystem.write(`${tmp.path}/a.txt`, "recreated content") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(snapshot2!)) - expect(patch.files).toContain(fwd(tmp.path, "a.txt")) - - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - - expect( - await fs - .access(`${tmp.path}/a.txt`) - .then(() => true) - .catch(() => false), - ).toBe(false) - }, - }) -}) - -test("revert preserves file that existed in snapshot when deleted then recreated", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await Filesystem.write(`${tmp.path}/existing.txt`, "original content") - - const hash = await run(tmp.path, (snapshot) => snapshot.track()) - expect(hash).toBeTruthy() - - await $`rm ${tmp.path}/existing.txt`.quiet() - await Filesystem.write(`${tmp.path}/existing.txt`, "recreated") - await Filesystem.write(`${tmp.path}/newfile.txt`, "new") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(hash!)) - expect(patch.files).toContain(fwd(tmp.path, "existing.txt")) - expect(patch.files).toContain(fwd(tmp.path, "newfile.txt")) - - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - - expect( - await fs - .access(`${tmp.path}/newfile.txt`) - .then(() => true) - .catch(() => false), - ).toBe(false) - expect( - await fs - .access(`${tmp.path}/existing.txt`) - .then(() => true) - .catch(() => false), - ).toBe(true) - expect(await fs.readFile(`${tmp.path}/existing.txt`, "utf-8")).toBe("original content") - }, - }) -}) - -test("diffFull sets status based on git change type", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await Filesystem.write(`${tmp.path}/grow.txt`, "one\n") - await Filesystem.write(`${tmp.path}/trim.txt`, "line1\nline2\n") - await Filesystem.write(`${tmp.path}/delete.txt`, "gone") - - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/grow.txt`, "one\ntwo\n") - await Filesystem.write(`${tmp.path}/trim.txt`, "line1\n") - await $`rm ${tmp.path}/delete.txt`.quiet() - await Filesystem.write(`${tmp.path}/added.txt`, "new") - - const after = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "diffFull with new file additions", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/new.txt`, "new content") + const after = yield* snapshot.track() expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) - expect(diffs.length).toBe(4) - - const added = diffs.find((d) => d.file === "added.txt") - expect(added).toBeDefined() - expect(added!.status).toBe("added") - - const deleted = diffs.find((d) => d.file === "delete.txt") - expect(deleted).toBeDefined() - expect(deleted!.status).toBe("deleted") - - const grow = diffs.find((d) => d.file === "grow.txt") - expect(grow).toBeDefined() - expect(grow!.status).toBe("modified") - expect(grow!.additions).toBeGreaterThan(0) - expect(grow!.deletions).toBe(0) - - const trim = diffs.find((d) => d.file === "trim.txt") - expect(trim).toBeDefined() - expect(trim!.status).toBe("modified") - expect(trim!.additions).toBe(0) - expect(trim!.deletions).toBeGreaterThan(0) - }, - }) -}) - -test("diffFull with new file additions", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/new.txt`, "new content") - - const after = await run(tmp.path, (snapshot) => snapshot.track()) - expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) + const diffs = yield* snapshot.diffFull(before, after!) expect(diffs.length).toBe(1) + expect(diffs[0].file).toBe("new.txt") + expect(diffs[0].patch).toContain("+new content") + expect(diffs[0].additions).toBe(1) + expect(diffs[0].deletions).toBe(0) + }), + ), + { git: true }, +) - const newFileDiff = diffs[0] - expect(newFileDiff.file).toBe("new.txt") - expect(newFileDiff.patch).toContain("+new content") - expect(newFileDiff.additions).toBe(1) - expect(newFileDiff.deletions).toBe(0) - }, - }) -}) +it.instance( + "diffFull with a large interleaved mixed diff", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + const ids = Array.from({ length: 60 }, (_, i) => i.toString().padStart(3, "0")) + const mod = ids.map((id) => fwd(tmp.path, "mix", `${id}-mod.txt`)) + const del = ids.map((id) => fwd(tmp.path, "mix", `${id}-del.txt`)) + const add = ids.map((id) => fwd(tmp.path, "mix", `${id}-add.txt`)) + const bin = ids.map((id) => fwd(tmp.path, "mix", `${id}-bin.bin`)) + yield* mkdirp(`${tmp.path}/mix`) + yield* Effect.all( + [ + ...mod.map((file, i) => write(file, `before-${ids[i]}-é\n🙂\nline`)), + ...del.map((file, i) => write(file, `gone-${ids[i]}\n你好`)), + ...bin.map((file, i) => write(file, new Uint8Array([0, i, 255, i % 251]))), + ], + { concurrency: "unbounded" }, + ) + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* Effect.all( + [ + ...mod.map((file, i) => write(file, `after-${ids[i]}-é\n🚀\nline`)), + ...add.map((file, i) => write(file, `new-${ids[i]}\nこんにちは`)), + ...bin.map((file, i) => write(file, new Uint8Array([9, i, 8, i % 251]))), + ...del.map((file) => rm(file)), + ], + { concurrency: "unbounded" }, + ) + const after = yield* snapshot.track() + expect(after).toBeTruthy() + const diffs = yield* snapshot.diffFull(before!, after!) + expect(diffs).toHaveLength(ids.length * 4) + const map = new Map(diffs.map((item) => [item.file, item])) + for (let i = 0; i < ids.length; i++) { + const m = map.get(fwd("mix", `${ids[i]}-mod.txt`)) + expect(m).toBeDefined() + expect(m!.patch).toContain(`-before-${ids[i]}-é`) + expect(m!.patch).toContain(`+after-${ids[i]}-é`) + expect(m!.status).toBe("modified") + const d = map.get(fwd("mix", `${ids[i]}-del.txt`)) + expect(d).toBeDefined() + expect(d!.patch).toContain(`-gone-${ids[i]}`) + expect(d!.status).toBe("deleted") + const a = map.get(fwd("mix", `${ids[i]}-add.txt`)) + expect(a).toBeDefined() + expect(a!.patch).toContain(`+new-${ids[i]}`) + expect(a!.status).toBe("added") + const b = map.get(fwd("mix", `${ids[i]}-bin.bin`)) + expect(b).toBeDefined() + expect(b!.patch).toBe("") + expect(b!.additions).toBe(0) + expect(b!.deletions).toBe(0) + expect(b!.status).toBe("modified") + } + }), + { git: true }, +) -test("diffFull with a large interleaved mixed diff", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const ids = Array.from({ length: 60 }, (_, i) => i.toString().padStart(3, "0")) - const mod = ids.map((id) => fwd(tmp.path, "mix", `${id}-mod.txt`)) - const del = ids.map((id) => fwd(tmp.path, "mix", `${id}-del.txt`)) - const add = ids.map((id) => fwd(tmp.path, "mix", `${id}-add.txt`)) - const bin = ids.map((id) => fwd(tmp.path, "mix", `${id}-bin.bin`)) +it.instance( + "diffFull preserves git diff order across batch boundaries", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + const ids = Array.from({ length: 140 }, (_, i) => i.toString().padStart(3, "0")) + yield* mkdirp(`${tmp.path}/order`) + yield* Effect.all( + ids.map((id) => write(`${tmp.path}/order/${id}.txt`, `before-${id}`)), + { concurrency: "unbounded" }, + ) + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* Effect.all( + ids.map((id) => write(`${tmp.path}/order/${id}.txt`, `after-${id}`)), + { concurrency: "unbounded" }, + ) + const after = yield* snapshot.track() + expect(after).toBeTruthy() + expect((yield* snapshot.diffFull(before!, after!)).map((item) => item.file)).toEqual( + ids.map((id) => `order/${id}.txt`), + ) + }), + { git: true }, +) - await $`mkdir -p ${tmp.path}/mix`.quiet() - await Promise.all([ - ...mod.map((file, i) => Filesystem.write(file, `before-${ids[i]}-é\n🙂\nline`)), - ...del.map((file, i) => Filesystem.write(file, `gone-${ids[i]}\n你好`)), - ...bin.map((file, i) => Filesystem.write(file, new Uint8Array([0, i, 255, i % 251]))), - ]) - - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Promise.all([ - ...mod.map((file, i) => Filesystem.write(file, `after-${ids[i]}-é\n🚀\nline`)), - ...add.map((file, i) => Filesystem.write(file, `new-${ids[i]}\nこんにちは`)), - ...bin.map((file, i) => Filesystem.write(file, new Uint8Array([9, i, 8, i % 251]))), - ...del.map((file) => fs.rm(file)), - ]) - - const after = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "diffFull with file modifications", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/b.txt`, "modified content") + const after = yield* snapshot.track() expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) - expect(diffs).toHaveLength(ids.length * 4) - - const map = new Map(diffs.map((item) => [item.file, item])) - for (let i = 0; i < ids.length; i++) { - const m = map.get(fwd("mix", `${ids[i]}-mod.txt`)) - expect(m).toBeDefined() - expect(m!.patch).toContain(`-before-${ids[i]}-é`) - expect(m!.patch).toContain(`+after-${ids[i]}-é`) - expect(m!.status).toBe("modified") - - const d = map.get(fwd("mix", `${ids[i]}-del.txt`)) - expect(d).toBeDefined() - expect(d!.patch).toContain(`-gone-${ids[i]}`) - expect(d!.status).toBe("deleted") - - const a = map.get(fwd("mix", `${ids[i]}-add.txt`)) - expect(a).toBeDefined() - expect(a!.patch).toContain(`+new-${ids[i]}`) - expect(a!.status).toBe("added") - - const b = map.get(fwd("mix", `${ids[i]}-bin.bin`)) - expect(b).toBeDefined() - expect(b!.patch).toBe("") - expect(b!.additions).toBe(0) - expect(b!.deletions).toBe(0) - expect(b!.status).toBe("modified") - } - }, - }) -}) - -test("diffFull preserves git diff order across batch boundaries", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const ids = Array.from({ length: 140 }, (_, i) => i.toString().padStart(3, "0")) - - await $`mkdir -p ${tmp.path}/order`.quiet() - await Promise.all(ids.map((id) => Filesystem.write(`${tmp.path}/order/${id}.txt`, `before-${id}`))) - - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Promise.all(ids.map((id) => Filesystem.write(`${tmp.path}/order/${id}.txt`, `after-${id}`))) - - const after = await run(tmp.path, (snapshot) => snapshot.track()) - expect(after).toBeTruthy() - - const expected = ids.map((id) => `order/${id}.txt`) - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) - expect(diffs.map((item) => item.file)).toEqual(expected) - }, - }) -}) - -test("diffFull with file modifications", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/b.txt`, "modified content") - - const after = await run(tmp.path, (snapshot) => snapshot.track()) - expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) + const diffs = yield* snapshot.diffFull(before, after!) expect(diffs.length).toBe(1) + expect(diffs[0].file).toBe("b.txt") + expect(diffs[0].patch).toContain(`-${tmp.extra.bContent}`) + expect(diffs[0].patch).toContain("+modified content") + expect(diffs[0].additions).toBeGreaterThan(0) + expect(diffs[0].deletions).toBeGreaterThan(0) + }), + ), + { git: true }, +) - const modifiedFileDiff = diffs[0] - expect(modifiedFileDiff.file).toBe("b.txt") - expect(modifiedFileDiff.patch).toContain(`-${tmp.extra.bContent}`) - expect(modifiedFileDiff.patch).toContain("+modified content") - expect(modifiedFileDiff.additions).toBeGreaterThan(0) - expect(modifiedFileDiff.deletions).toBeGreaterThan(0) - }, - }) -}) - -test("diffFull with file deletions", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await $`rm ${tmp.path}/a.txt`.quiet() - - const after = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "diffFull with file deletions", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* rm(`${tmp.path}/a.txt`) + const after = yield* snapshot.track() expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) + const diffs = yield* snapshot.diffFull(before, after!) expect(diffs.length).toBe(1) + expect(diffs[0].file).toBe("a.txt") + expect(diffs[0].patch).toContain(`-${tmp.extra.aContent}`) + expect(diffs[0].additions).toBe(0) + expect(diffs[0].deletions).toBe(1) + }), + ), + { git: true }, +) - const removedFileDiff = diffs[0] - expect(removedFileDiff.file).toBe("a.txt") - expect(removedFileDiff.patch).toContain(`-${tmp.extra.aContent}`) - expect(removedFileDiff.additions).toBe(0) - expect(removedFileDiff.deletions).toBe(1) - }, - }) -}) - -test("diffFull with multiple line additions", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/multi.txt`, "line1\nline2\nline3") - - const after = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "diffFull with multiple line additions", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/multi.txt`, "line1\nline2\nline3") + const after = yield* snapshot.track() expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) + const diffs = yield* snapshot.diffFull(before, after!) expect(diffs.length).toBe(1) + expect(diffs[0].file).toBe("multi.txt") + expect(diffs[0].patch).toContain("+line1") + expect(diffs[0].patch).toContain("+line3") + expect(diffs[0].additions).toBe(3) + expect(diffs[0].deletions).toBe(0) + }), + ), + { git: true }, +) - const multiDiff = diffs[0] - expect(multiDiff.file).toBe("multi.txt") - expect(multiDiff.patch).toContain("+line1") - expect(multiDiff.patch).toContain("+line3") - expect(multiDiff.additions).toBe(3) - expect(multiDiff.deletions).toBe(0) - }, - }) -}) - -test("diffFull with addition and deletion", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/added.txt`, "added content") - await $`rm ${tmp.path}/a.txt`.quiet() - - const after = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "diffFull with addition and deletion", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/added.txt`, "added content") + yield* rm(`${tmp.path}/a.txt`) + const after = yield* snapshot.track() expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) + const diffs = yield* snapshot.diffFull(before, after!) expect(diffs.length).toBe(2) + const added = diffs.find((d) => d.file === "added.txt")! + expect(added.patch).toContain("+added content") + expect(added.additions).toBe(1) + expect(added.deletions).toBe(0) + const removed = diffs.find((d) => d.file === "a.txt")! + expect(removed.patch).toContain(`-${tmp.extra.aContent}`) + expect(removed.additions).toBe(0) + expect(removed.deletions).toBe(1) + }), + ), + { git: true }, +) - const addedFileDiff = diffs.find((d) => d.file === "added.txt") - expect(addedFileDiff).toBeDefined() - expect(addedFileDiff!.patch).toContain("+added content") - expect(addedFileDiff!.additions).toBe(1) - expect(addedFileDiff!.deletions).toBe(0) - - const removedFileDiff = diffs.find((d) => d.file === "a.txt") - expect(removedFileDiff).toBeDefined() - expect(removedFileDiff!.patch).toContain(`-${tmp.extra.aContent}`) - expect(removedFileDiff!.additions).toBe(0) - expect(removedFileDiff!.deletions).toBe(1) - }, - }) -}) - -test("diffFull with multiple additions and deletions", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/multi1.txt`, "line1\nline2\nline3") - await Filesystem.write(`${tmp.path}/multi2.txt`, "single line") - await $`rm ${tmp.path}/a.txt`.quiet() - await $`rm ${tmp.path}/b.txt`.quiet() - - const after = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "diffFull with multiple additions and deletions", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/multi1.txt`, "line1\nline2\nline3") + yield* write(`${tmp.path}/multi2.txt`, "single line") + yield* rm(`${tmp.path}/a.txt`) + yield* rm(`${tmp.path}/b.txt`) + const after = yield* snapshot.track() expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) + const diffs = yield* snapshot.diffFull(before, after!) expect(diffs.length).toBe(4) + expect(diffs.find((d) => d.file === "multi1.txt")!.additions).toBe(3) + expect(diffs.find((d) => d.file === "multi1.txt")!.deletions).toBe(0) + expect(diffs.find((d) => d.file === "multi2.txt")!.additions).toBe(1) + expect(diffs.find((d) => d.file === "multi2.txt")!.deletions).toBe(0) + expect(diffs.find((d) => d.file === "a.txt")!.additions).toBe(0) + expect(diffs.find((d) => d.file === "a.txt")!.deletions).toBe(1) + expect(diffs.find((d) => d.file === "b.txt")!.additions).toBe(0) + expect(diffs.find((d) => d.file === "b.txt")!.deletions).toBe(1) + }), + ), + { git: true }, +) - const multi1Diff = diffs.find((d) => d.file === "multi1.txt") - expect(multi1Diff).toBeDefined() - expect(multi1Diff!.additions).toBe(3) - expect(multi1Diff!.deletions).toBe(0) - - const multi2Diff = diffs.find((d) => d.file === "multi2.txt") - expect(multi2Diff).toBeDefined() - expect(multi2Diff!.additions).toBe(1) - expect(multi2Diff!.deletions).toBe(0) - - const removedADiff = diffs.find((d) => d.file === "a.txt") - expect(removedADiff).toBeDefined() - expect(removedADiff!.additions).toBe(0) - expect(removedADiff!.deletions).toBe(1) - - const removedBDiff = diffs.find((d) => d.file === "b.txt") - expect(removedBDiff).toBeDefined() - expect(removedBDiff!.additions).toBe(0) - expect(removedBDiff!.deletions).toBe(1) - }, - }) -}) - -test("diffFull with no changes", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - const after = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "diffFull with no changes", + withTrackedSnapshot(({ snapshot, before }) => + Effect.gen(function* () { + const after = yield* snapshot.track() expect(after).toBeTruthy() + expect((yield* snapshot.diffFull(before, after!)).length).toBe(0) + }), + ), + { git: true }, +) - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) - expect(diffs.length).toBe(0) - }, - }) -}) - -test("diffFull with binary file changes", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/binary.bin`, new Uint8Array([0x00, 0x01, 0x02, 0x03])) - - const after = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "diffFull with binary file changes", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/binary.bin`, new Uint8Array([0x00, 0x01, 0x02, 0x03])) + const after = yield* snapshot.track() expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) + const diffs = yield* snapshot.diffFull(before, after!) expect(diffs.length).toBe(1) + expect(diffs[0].file).toBe("binary.bin") + expect(diffs[0].patch).toBe("") + }), + ), + { git: true }, +) - const binaryDiff = diffs[0] - expect(binaryDiff.file).toBe("binary.bin") - expect(binaryDiff.patch).toBe("") - }, - }) -}) +it.instance( + "diffFull with whitespace changes", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + yield* write(`${tmp.path}/whitespace.txt`, "line1\nline2") + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${tmp.path}/whitespace.txt`, "line1\n\nline2\n") + const after = yield* snapshot.track() + expect(after).toBeTruthy() + const diffs = yield* snapshot.diffFull(before!, after!) + expect(diffs.length).toBe(1) + expect(diffs[0].file).toBe("whitespace.txt") + expect(diffs[0].additions).toBeGreaterThan(0) + }), + { git: true }, +) -test("diffFull with whitespace changes", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\nline2") - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() +it.instance( + "revert with overlapping files across patches uses first patch hash", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + yield* write(`${tmp.path}/shared.txt`, "v1") + const snap1 = yield* snapshot.track() + expect(snap1).toBeTruthy() + yield* write(`${tmp.path}/shared.txt`, "v2") + const snap2 = yield* snapshot.track() + expect(snap2).toBeTruthy() + yield* write(`${tmp.path}/shared.txt`, "v3") + const patch1 = yield* snapshot.patch(snap1!) + const patch2 = yield* snapshot.patch(snap2!) + expect(patch1.files).toContain(fwd(tmp.path, "shared.txt")) + expect(patch2.files).toContain(fwd(tmp.path, "shared.txt")) + yield* snapshot.revert([patch1, patch2]) + expect(yield* readText(`${tmp.path}/shared.txt`)).toBe("v1") + }), + { git: true }, +) - await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\n\nline2\n") +it.instance( + "revert preserves patch order when the same hash appears again", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + yield* mkdirp(`${tmp.path}/foo`) + yield* write(`${tmp.path}/foo/bar`, "v1") + yield* write(`${tmp.path}/a.txt`, "v1") + const snap1 = yield* snapshot.track() + expect(snap1).toBeTruthy() + yield* rm(`${tmp.path}/foo`) + yield* write(`${tmp.path}/foo`, "v2") + yield* write(`${tmp.path}/a.txt`, "v2") + const snap2 = yield* snapshot.track() + expect(snap2).toBeTruthy() + yield* rm(`${tmp.path}/foo`) + yield* write(`${tmp.path}/a.txt`, "v3") + yield* snapshot.revert([ + { hash: snap1!, files: [fwd(tmp.path, "a.txt")] }, + { hash: snap2!, files: [fwd(tmp.path, "foo")] }, + { hash: snap1!, files: [fwd(tmp.path, "foo", "bar")] }, + ]) + expect(yield* readText(`${tmp.path}/a.txt`)).toBe("v1") + expect((yield* Effect.promise(() => fs.stat(`${tmp.path}/foo`))).isDirectory()).toBe(true) + expect(yield* readText(`${tmp.path}/foo/bar`)).toBe("v1") + }), + { git: true }, +) - const after = await run(tmp.path, (snapshot) => snapshot.track()) - expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) - expect(diffs.length).toBe(1) - - const whitespaceDiff = diffs[0] - expect(whitespaceDiff.file).toBe("whitespace.txt") - expect(whitespaceDiff.additions).toBeGreaterThan(0) - }, - }) -}) - -test("revert with overlapping files across patches uses first patch hash", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - // Write initial content and snapshot - await Filesystem.write(`${tmp.path}/shared.txt`, "v1") - const snap1 = await run(tmp.path, (snapshot) => snapshot.track()) - expect(snap1).toBeTruthy() - - // Modify and snapshot again - await Filesystem.write(`${tmp.path}/shared.txt`, "v2") - const snap2 = await run(tmp.path, (snapshot) => snapshot.track()) - expect(snap2).toBeTruthy() - - // Modify once more so both patches include shared.txt - await Filesystem.write(`${tmp.path}/shared.txt`, "v3") - - const patch1 = await run(tmp.path, (snapshot) => snapshot.patch(snap1!)) - const patch2 = await run(tmp.path, (snapshot) => snapshot.patch(snap2!)) - - // Both patches should include shared.txt - expect(patch1.files).toContain(fwd(tmp.path, "shared.txt")) - expect(patch2.files).toContain(fwd(tmp.path, "shared.txt")) - - // Revert with patch1 first — should use snap1's hash (restoring "v1") - await run(tmp.path, (snapshot) => snapshot.revert([patch1, patch2])) - - const content = await fs.readFile(`${tmp.path}/shared.txt`, "utf-8") - expect(content).toBe("v1") - }, - }) -}) - -test("revert preserves patch order when the same hash appears again", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await $`mkdir -p ${tmp.path}/foo`.quiet() - await Filesystem.write(`${tmp.path}/foo/bar`, "v1") - await Filesystem.write(`${tmp.path}/a.txt`, "v1") - - const snap1 = await run(tmp.path, (snapshot) => snapshot.track()) - expect(snap1).toBeTruthy() - - await $`rm -rf ${tmp.path}/foo`.quiet() - await Filesystem.write(`${tmp.path}/foo`, "v2") - await Filesystem.write(`${tmp.path}/a.txt`, "v2") - - const snap2 = await run(tmp.path, (snapshot) => snapshot.track()) - expect(snap2).toBeTruthy() - - await $`rm -rf ${tmp.path}/foo`.quiet() - await Filesystem.write(`${tmp.path}/a.txt`, "v3") - - await run(tmp.path, (snapshot) => - snapshot.revert([ - { hash: snap1!, files: [fwd(tmp.path, "a.txt")] }, - { hash: snap2!, files: [fwd(tmp.path, "foo")] }, - { hash: snap1!, files: [fwd(tmp.path, "foo", "bar")] }, - ]), - ) - - expect(await fs.readFile(`${tmp.path}/a.txt`, "utf-8")).toBe("v1") - expect((await fs.stat(`${tmp.path}/foo`)).isDirectory()).toBe(true) - expect(await fs.readFile(`${tmp.path}/foo/bar`, "utf-8")).toBe("v1") - }, - }) -}) - -test("revert handles large mixed batches across chunk boundaries", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const base = Array.from({ length: 140 }, (_, i) => fwd(tmp.path, "batch", `${i}.txt`)) - const fresh = Array.from({ length: 140 }, (_, i) => fwd(tmp.path, "fresh", `${i}.txt`)) - - await $`mkdir -p ${tmp.path}/batch ${tmp.path}/fresh`.quiet() - await Promise.all(base.map((file, i) => Filesystem.write(file, `base-${i}`))) - - const snap = await run(tmp.path, (snapshot) => snapshot.track()) - expect(snap).toBeTruthy() - - await Promise.all(base.map((file, i) => Filesystem.write(file, `next-${i}`))) - await Promise.all(fresh.map((file, i) => Filesystem.write(file, `fresh-${i}`))) - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(snap!)) - expect(patch.files.length).toBe(base.length + fresh.length) - - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - - await Promise.all( - base.map(async (file, i) => { - expect(await fs.readFile(file, "utf-8")).toBe(`base-${i}`) - }), - ) - - await Promise.all( - fresh.map(async (file) => { - expect( - await fs - .access(file) - .then(() => true) - .catch(() => false), - ).toBe(false) - }), - ) - }, - }) -}) +it.instance( + "revert handles large mixed batches across chunk boundaries", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + const base = Array.from({ length: 140 }, (_, i) => fwd(tmp.path, "batch", `${i}.txt`)) + const fresh = Array.from({ length: 140 }, (_, i) => fwd(tmp.path, "fresh", `${i}.txt`)) + yield* mkdirp(`${tmp.path}/batch`) + yield* mkdirp(`${tmp.path}/fresh`) + yield* Effect.all( + base.map((file, i) => write(file, `base-${i}`)), + { concurrency: "unbounded" }, + ) + const snap = yield* snapshot.track() + expect(snap).toBeTruthy() + yield* Effect.all( + [...base.map((file, i) => write(file, `next-${i}`)), ...fresh.map((file, i) => write(file, `fresh-${i}`))], + { concurrency: "unbounded" }, + ) + const patch = yield* snapshot.patch(snap!) + expect(patch.files.length).toBe(base.length + fresh.length) + yield* snapshot.revert([patch]) + for (let i = 0; i < base.length; i++) expect(yield* readText(base[i])).toBe(`base-${i}`) + for (const file of fresh) expect(yield* exists(file)).toBe(false) + }), + { git: true }, +) From c4003579bbe6d943562814686a0cdd5a1357f784 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 20:59:34 -0400 Subject: [PATCH 054/188] test(project): migrate VCS tests to Effect runner (#26965) --- packages/opencode/test/project/vcs.test.ts | 527 ++++++++++----------- 1 file changed, 252 insertions(+), 275 deletions(-) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 82eacfb6d..75d1feadd 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -1,161 +1,139 @@ -import { $ } from "bun" -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect } from "bun:test" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { parsePatch } from "diff" -import { Effect } from "effect" +import { Deferred, Effect, Layer, Stream } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import fs from "fs/promises" import path from "path" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" -import { AppRuntime } from "../../src/effect/app-runtime" +import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { Bus } from "../../src/bus" import { FileWatcher } from "../../src/file/watcher" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" -import { GlobalBus } from "../../src/bus/global" +import { Git } from "../../src/git" import { Vcs } from "@/project/vcs" - -// Skip in CI — native @parcel/watcher binding needed -const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip +import { testEffect } from "../lib/effect" // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -async function withVcs(directory: string, body: () => Promise) { - return WithInstance.provide({ - directory, - fn: async () => { - await AppRuntime.runPromise( - Effect.gen(function* () { - const watcher = yield* FileWatcher.Service - const vcs = yield* Vcs.Service - yield* watcher.init() - yield* vcs.init() - }), - ) - await Bun.sleep(500) - await body() - }, - }) -} - -function withVcsOnly(directory: string, body: () => Promise) { - return WithInstance.provide({ - directory, - fn: async () => { - await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - yield* vcs.init() - }), - ) - await body() - }, - }) -} - -type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } } const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" -/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */ -function nextBranchUpdate(directory: string, timeout = 10_000) { - return new Promise((resolve, reject) => { - let settled = false +const layer = Layer.mergeAll( + Vcs.layer.pipe(Layer.provideMerge(Git.defaultLayer), Layer.provideMerge(Bus.layer)), + CrossSpawnSpawner.defaultLayer, + AppFileSystem.defaultLayer, +) +const it = testEffect(layer) - const timer = setTimeout(() => { - if (settled) return - settled = true - GlobalBus.off("event", on) - reject(new Error("timed out waiting for BranchUpdated event")) - }, timeout) +const git = Effect.fn("VcsTest.git")(function* (cwd: string, args: string[]) { + const result = yield* Git.Service.use((git) => git.run(args, { cwd })) + if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString("utf8")}`) +}) - function on(evt: BranchEvent) { - if (evt.directory !== directory) return - if (evt.payload.type !== Vcs.Event.BranchUpdated.type) return - if (settled) return - settled = true - clearTimeout(timer) - GlobalBus.off("event", on) - resolve(evt.payload.properties.branch) - } +const write = Effect.fn("VcsTest.write")(function* (file: string, content: string) { + yield* AppFileSystem.Service.use((fs) => fs.writeWithDirs(file, content)) +}) - GlobalBus.on("event", on) - }) -} +const remove = Effect.fn("VcsTest.remove")(function* (file: string) { + yield* AppFileSystem.Service.use((fs) => fs.remove(file)) +}) + +const symlink = (target: string, file: string) => Effect.promise(() => fs.symlink(target, file)) + +const init = Effect.fn("VcsTest.init")(function* () { + const vcs = yield* Vcs.Service + yield* vcs.init() + return vcs +}) + +const nextBranchUpdate = Effect.fn("VcsTest.nextBranchUpdate")(function* () { + const bus = yield* Bus.Service + const updated = yield* Deferred.make() + + yield* Stream.runForEach(bus.subscribe(Vcs.Event.BranchUpdated), (evt) => + Deferred.succeed(updated, evt.properties.branch), + ).pipe(Effect.forkScoped) + + return updated +}) // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -describeVcs("Vcs", () => { +describe("Vcs", () => { afterEach(async () => { await disposeAllInstances() }) - test("branch() returns current branch name", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance( + "branch() returns current branch name", + () => + Effect.gen(function* () { + const vcs = yield* init() + const branch = yield* vcs.branch() - await withVcs(tmp.path, async () => { - const branch = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.branch() - }), - ) - expect(branch).toBeDefined() - expect(typeof branch).toBe("string") - }) - }) + expect(branch).toBeDefined() + expect(typeof branch).toBe("string") + }), + { git: true }, + ) - test("branch() returns undefined for non-git directories", async () => { - await using tmp = await tmpdir() + it.instance("branch() returns undefined for non-git directories", () => + Effect.gen(function* () { + const vcs = yield* init() + const branch = yield* vcs.branch() - await withVcs(tmp.path, async () => { - const branch = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.branch() - }), - ) expect(branch).toBeUndefined() - }) - }) + }), + ) - test("publishes BranchUpdated when .git/HEAD changes", async () => { - await using tmp = await tmpdir({ git: true }) - const branch = `test-${Math.random().toString(36).slice(2)}` - await $`git branch ${branch}`.cwd(tmp.path).quiet() + it.instance( + "publishes BranchUpdated when .git/HEAD changes", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const branch = `test-${Math.random().toString(36).slice(2)}` + yield* git(test.directory, ["branch", branch]) - await withVcs(tmp.path, async () => { - const pending = nextBranchUpdate(tmp.path) + const vcs = yield* init() + yield* vcs.branch() + const pending = yield* nextBranchUpdate() + const bus = yield* Bus.Service - const head = path.join(tmp.path, ".git", "HEAD") - await fs.writeFile(head, `ref: refs/heads/${branch}\n`) + const head = path.join(test.directory, ".git", "HEAD") + yield* write(head, `ref: refs/heads/${branch}\n`) + yield* bus.publish(FileWatcher.Event.Updated, { file: head, event: "change" }) - const updated = await pending - expect(updated).toBe(branch) - }) - }) + const updated = yield* Deferred.await(pending).pipe(Effect.timeout("2 seconds")) + expect(updated).toBe(branch) + }), + { git: true }, + ) - test("branch() reflects the new branch after HEAD change", async () => { - await using tmp = await tmpdir({ git: true }) - const branch = `test-${Math.random().toString(36).slice(2)}` - await $`git branch ${branch}`.cwd(tmp.path).quiet() + it.instance( + "branch() reflects the new branch after HEAD change", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const branch = `test-${Math.random().toString(36).slice(2)}` + yield* git(test.directory, ["branch", branch]) - await withVcs(tmp.path, async () => { - const pending = nextBranchUpdate(tmp.path) + const vcs = yield* init() + yield* vcs.branch() + const pending = yield* nextBranchUpdate() + const bus = yield* Bus.Service - const head = path.join(tmp.path, ".git", "HEAD") - await fs.writeFile(head, `ref: refs/heads/${branch}\n`) + const head = path.join(test.directory, ".git", "HEAD") + yield* write(head, `ref: refs/heads/${branch}\n`) + yield* bus.publish(FileWatcher.Event.Updated, { file: head, event: "change" }) + yield* Deferred.await(pending).pipe(Effect.timeout("2 seconds")) - await pending - const current = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.branch() - }), - ) - expect(current).toBe(branch) - }) - }) + const current = yield* vcs.branch() + expect(current).toBe(branch) + }), + { git: true }, + ) }) describe("Vcs diff", () => { @@ -163,177 +141,176 @@ describe("Vcs diff", () => { await disposeAllInstances() }) - test("defaultBranch() falls back to main", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M main`.cwd(tmp.path).quiet() + it.instance( + "defaultBranch() falls back to main", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* git(test.directory, ["branch", "-M", "main"]) - await withVcsOnly(tmp.path, async () => { - const branch = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.defaultBranch() - }), - ) - expect(branch).toBe("main") - }) - }) + const vcs = yield* init() + const branch = yield* vcs.defaultBranch() - test("defaultBranch() uses init.defaultBranch when available", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M trunk`.cwd(tmp.path).quiet() - await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet() + expect(branch).toBe("main") + }), + { git: true }, + ) - await withVcsOnly(tmp.path, async () => { - const branch = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.defaultBranch() - }), - ) - expect(branch).toBe("trunk") - }) - }) + it.instance( + "defaultBranch() uses init.defaultBranch when available", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* git(test.directory, ["branch", "-M", "trunk"]) + yield* git(test.directory, ["config", "init.defaultBranch", "trunk"]) - test("detects current branch from the active worktree", async () => { - await using tmp = await tmpdir({ git: true }) - await using wt = await tmpdir() - await $`git branch -M main`.cwd(tmp.path).quiet() - const dir = path.join(wt.path, "feature") - await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet() + const vcs = yield* init() + const branch = yield* vcs.defaultBranch() - await withVcsOnly(dir, async () => { - const [branch, base] = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 }) - }), - ) + expect(branch).toBe("trunk") + }), + { git: true }, + ) + + it.live("detects current branch from the active worktree", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped({ git: true }) + const wt = yield* tmpdirScoped() + yield* git(tmp, ["branch", "-M", "main"]) + const dir = path.join(wt, "feature") + yield* git(tmp, ["worktree", "add", "-b", "feature/test", dir, "HEAD"]) + + const [branch, base] = yield* Effect.gen(function* () { + const vcs = yield* init() + return yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 }) + }).pipe(provideInstance(dir)) + + expect(branch).toBeDefined() expect(branch).toBe("feature/test") expect(base).toBe("main") - }) - }) + }), + ) - test("diff('git') returns uncommitted changes", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "file.txt"), "original\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8") + it.instance( + "diff('git') returns uncommitted changes", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* write(path.join(test.directory, "file.txt"), "original\n") + yield* git(test.directory, ["add", "."]) + yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"]) + yield* write(path.join(test.directory, "file.txt"), "changed\n") - await withVcsOnly(tmp.path, async () => { - const diff = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.diff("git") - }), - ) - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file: "file.txt", - status: "modified", - }), - ]), - ) - expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git") - }) - }) + const vcs = yield* init() + const diff = yield* vcs.diff("git") - test("diff('git') handles special filenames", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8") + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: "file.txt", + status: "modified", + }), + ]), + ) + expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git") + }), + { git: true }, + ) - await withVcsOnly(tmp.path, async () => { - const diff = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.diff("git") - }), - ) - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file: weird, - status: "added", - }), - ]), - ) - }) - }) + it.instance( + "diff('git') handles special filenames", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* write(path.join(test.directory, weird), "hello\n") - test("diff('git') keeps batched patches aligned for type changes", async () => { - if (process.platform === "win32") return + const vcs = yield* init() + const diff = yield* vcs.diff("git") - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "a.txt"), "old\n", "utf-8") - await fs.writeFile(path.join(tmp.path, "b.txt"), "old\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add files"`.cwd(tmp.path).quiet() - await fs.unlink(path.join(tmp.path, "a.txt")) - await fs.symlink("target", path.join(tmp.path, "a.txt")) - await fs.writeFile(path.join(tmp.path, "b.txt"), "new\n", "utf-8") + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: weird, + status: "added", + }), + ]), + ) + }), + { git: true }, + ) - await withVcsOnly(tmp.path, async () => { - const diff = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.diff("git") - }), - ) - const a = diff.find((item) => item.file === "a.txt") - const b = diff.find((item) => item.file === "b.txt") + it.instance( + "diff('git') keeps batched patches aligned for type changes", + () => + Effect.gen(function* () { + if (process.platform === "win32") return - expect(a?.patch).toContain("deleted file mode") - expect(a?.patch).toContain("new file mode") - expect(b?.patch).toContain("+new") - }) - }) + const test = yield* TestInstance + yield* write(path.join(test.directory, "a.txt"), "old\n") + yield* write(path.join(test.directory, "b.txt"), "old\n") + yield* git(test.directory, ["add", "."]) + yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add files"]) + yield* remove(path.join(test.directory, "a.txt")) + yield* symlink("target", path.join(test.directory, "a.txt")) + yield* write(path.join(test.directory, "b.txt"), "new\n") - test("diff('git') keeps carriage returns inside patch hunks", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n", "utf-8") + const vcs = yield* init() + const diff = yield* vcs.diff("git") + const a = diff.find((item) => item.file === "a.txt") + const b = diff.find((item) => item.file === "b.txt") - await withVcsOnly(tmp.path, async () => { - const diff = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.diff("git") - }), - ) - const file = diff.find((item) => item.file === "file.txt") + expect(a?.patch).toContain("deleted file mode") + expect(a?.patch).toContain("new file mode") + expect(b?.patch).toContain("+new") + }), + { git: true }, + ) - expect(file?.patch).toContain(" same\rdiff --git inside") - expect(file?.patch).toContain("-delete") - expect(() => parsePatch(file?.patch ?? "")).not.toThrow() - }) - }, 20_000) + it.instance( + "diff('git') keeps carriage returns inside patch hunks", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* write(path.join(test.directory, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n") + yield* git(test.directory, ["add", "."]) + yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"]) + yield* write(path.join(test.directory, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n") - test("diff('branch') returns changes against default branch", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M main`.cwd(tmp.path).quiet() - await $`git checkout -b feature/test`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet() + const vcs = yield* init() + const diff = yield* vcs.diff("git") + const file = diff.find((item) => item.file === "file.txt") - await withVcsOnly(tmp.path, async () => { - const diff = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.diff("branch") - }), - ) - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file: "branch.txt", - status: "added", - }), - ]), - ) - }) - }) + expect(file?.patch).toContain(" same\rdiff --git inside") + expect(file?.patch).toContain("-delete") + expect(() => parsePatch(file?.patch ?? "")).not.toThrow() + }), + { git: true }, + 20_000, + ) + + it.instance( + "diff('branch') returns changes against default branch", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* git(test.directory, ["branch", "-M", "main"]) + yield* git(test.directory, ["checkout", "-b", "feature/test"]) + yield* write(path.join(test.directory, "branch.txt"), "hello\n") + yield* git(test.directory, ["add", "."]) + yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "branch file"]) + + const vcs = yield* init() + const diff = yield* vcs.diff("branch") + + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: "branch.txt", + status: "added", + }), + ]), + ) + }), + { git: true }, + ) }) From abb1ee627858be10a88be1b63500e0b770fb9e55 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 20:59:51 -0400 Subject: [PATCH 055/188] docs(test): add Effect migration orchestration notes (#26963) --- packages/opencode/test/EFFECT_TEST_MIGRATION.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/EFFECT_TEST_MIGRATION.md b/packages/opencode/test/EFFECT_TEST_MIGRATION.md index 60cd33264..2c160b993 100644 --- a/packages/opencode/test/EFFECT_TEST_MIGRATION.md +++ b/packages/opencode/test/EFFECT_TEST_MIGRATION.md @@ -200,11 +200,20 @@ Use this as a migration queue. Each checkbox should be safe for one agent or one Parallelization notes: -- The first four items are mostly independent and good for parallel agents. +- The first four items are mostly independent and good for separate worktrees. - `provider.test.ts`, `tool/edit.test.ts`, and `config.test.ts` should be split by cluster so agents do not edit the same file concurrently. - Any new fake boundary layer under `test/fake/*` should be small and independently useful. Do not add a fake just for one assertion unless it removes a real external dependency. - Do not combine assertion-helper design with file migrations. First collect repeated shapes, then add helpers in a separate pass. +Orchestration rules: + +- Prefer supervised foreground agents for implementation. Background agents are acceptable for research-only surveys, but code migrations need a returned diff, focused test output, and local commit before moving on. +- Create one worktree per claim and verify the branch/worktree path before edits. A status check should include `git status --short --branch` from the claimed worktree. +- After an agent reports completion, the coordinator must independently inspect `git status`, run the focused test, run `bun typecheck`, and review the diff before pushing. +- If an agent edits the wrong worktree, move the patch deliberately with `git diff` / `git apply`, then clean the accidental worktree before opening a PR. +- Keep dependency setup boring. Prefer reusing existing installed dependencies via worktrees or symlinks over running a fresh `bun install` in a temporary path unless the native build path is known to work. +- Do not delete worktrees with unpushed commits or uncommitted changes. Once a migration PR branch is pushed and clean, the local worktree can be removed while leaving the branch on the fork. + ## Effectified Test Rough Edges Track patterns that are technically Effect-native but still too noisy. These should become a second cleanup pass after the Promise-land migration is underway. From e5aa5161f2317f466cdb5eb2fe97b16120ddded5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 21:14:55 -0400 Subject: [PATCH 056/188] Remove effect-zod bridge (#26956) --- packages/core/src/effect-zod.ts | 370 --------- packages/core/src/schema.ts | 2 - packages/opencode/specs/effect/migration.md | 12 +- packages/opencode/specs/effect/schema.md | 84 +- .../specs/openapi-translation-cleanup.md | 2 +- packages/opencode/src/command/index.ts | 5 +- packages/opencode/src/config/model-id.ts | 10 +- packages/opencode/src/lsp/lsp.ts | 6 +- .../instance/httpapi/handlers/experimental.ts | 4 +- packages/opencode/src/session/message-v2.ts | 3 +- packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/src/tool/json-schema.ts | 164 ++++ packages/opencode/src/tool/registry.ts | 72 +- packages/opencode/src/tool/tool.ts | 2 + packages/opencode/src/tool/webfetch.ts | 5 +- .../opencode/src/util/named-schema-error.ts | 16 +- packages/opencode/test/session/retry.test.ts | 24 +- .../__snapshots__/parameters.test.ts.snap | 24 +- .../opencode/test/tool/parameters.test.ts | 38 +- packages/opencode/test/tool/registry.test.ts | 90 ++- .../opencode/test/util/effect-zod.test.ts | 754 ------------------ 21 files changed, 425 insertions(+), 1266 deletions(-) delete mode 100644 packages/core/src/effect-zod.ts create mode 100644 packages/opencode/src/tool/json-schema.ts delete mode 100644 packages/opencode/test/util/effect-zod.test.ts diff --git a/packages/core/src/effect-zod.ts b/packages/core/src/effect-zod.ts deleted file mode 100644 index 42d89ec7d..000000000 --- a/packages/core/src/effect-zod.ts +++ /dev/null @@ -1,370 +0,0 @@ -import { Effect, Option, Schema, SchemaAST } from "effect" -import z from "zod" - -/** - * Annotation key for providing a hand-crafted Zod schema that the walker - * should use instead of re-deriving from the AST. Attach it via - * `Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") })`. - */ -export const ZodOverride: unique symbol = Symbol.for("effect-zod/override") - -// AST nodes are immutable and frequently shared across schemas (e.g. a single -// Schema.Class embedded in multiple parents). Memoizing by node identity -// avoids rebuilding equivalent Zod subtrees and keeps derived children stable -// by reference across callers. -const walkCache = new WeakMap() - -// Shared empty ParseOptions for the rare callers that need one — avoids -// allocating a fresh object per parse inside refinements and transforms. -const EMPTY_PARSE_OPTIONS = {} as SchemaAST.ParseOptions - -export function zod(schema: S): z.ZodType> { - return walk(schema.ast) as z.ZodType> -} - -/** - * Derive a Zod value from an Effect Schema (or a Schema-backed export with a - * `.zod` static) and narrow the result to `z.ZodObject` so `.shape`, - * `.omit`, `.extend`, and friends are accessible. - * - * The `zod()` walker returns `z.ZodType` because not every AST node decodes - * to an object; this helper keeps the "I started from a `Schema.Struct`" cast - * in one place instead of sprinkling `as unknown as z.ZodObject` across - * call sites. - * - * The return is intentionally loose — carrying Schema field types through the - * mapped `.omit()` / `.extend()` surface triggers brand-intersection - * explosions for branded primitives (`string & Brand<"SessionID">` extends - * `object` via the brand and gets walked into the prototype by `DeepPartial`, - * mapped-schema helpers, and zod's inference through `z.ZodType` - * wrappers also can't reconstruct `T` cleanly. Consumers that care about the - * post-`.omit()` shape should cast `c.req.valid(...)` to the expected type. - */ -export function zodObject(schema: S): z.ZodObject { - const derived: z.ZodTypeAny = "zod" in schema && isZodType(schema.zod) ? schema.zod : walk(schema.ast) - return derived as unknown as z.ZodObject -} - -function isZodType(value: unknown): value is z.ZodTypeAny { - return typeof value === "object" && value !== null && "_zod" in value -} - -/** - * Emit a JSON Schema for a tool/route parameter schema — derives the zod form - * via the walker so Effect Schema inputs flow through the same zod-openapi - * pipeline the LLM/SDK layer already depends on. `io: "input"` mirrors what - * `session/prompt.ts` has always passed to `ai`'s `jsonSchema()` helper. - */ -export function toJsonSchema(schema: S) { - return z.toJSONSchema(zod(schema), { io: "input" }) -} - -function walk(ast: SchemaAST.AST): z.ZodTypeAny { - const cached = walkCache.get(ast) - if (cached) return cached - const result = walkUncached(ast) - walkCache.set(ast, result) - return result -} - -function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny { - const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined - // `description` annotations layer on top of an override so callers can - // reuse a shared override schema (e.g. `SessionID`) and still add a - // per-field description on the outer wrapper. - const base = override ?? bodyWithChecks(ast) - const desc = SchemaAST.resolveDescription(ast) - const ref = SchemaAST.resolveIdentifier(ast) - const described = desc ? base.describe(desc) : base - return ref ? described.meta({ ref }) : described -} - -function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny { - // Schema.Class wraps its fields in a Declaration AST plus an encoding that - // constructs the class instance. For the Zod derivation we want the plain - // field shape (the decoded/consumer view), not the class instance — so - // Declarations fall through to body(), not encoded(). User-level - // Schema.decodeTo / Schema.transform attach encoding to non-Declaration - // nodes, where we do apply the transform. - // - // Schema.withDecodingDefault also attaches encoding, but we want `.default(v)` - // on the inner Zod rather than a transform wrapper — so optional ASTs whose - // encoding resolves a default from Option.none() route through body()/opt(). - const hasEncoding = ast.encoding?.length && (ast._tag !== "Declaration" || ast.typeParameters.length === 0) - const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) - const base = hasTransform ? encoded(ast) : body(ast) - return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base -} - -// Walk the encoded side and apply each link's decode to produce the decoded -// shape. A node `Target` produced by `from.decodeTo(Target)` carries -// `Target.encoding = [Link(from, transformation)]`. Chained decodeTo calls -// nest the encoding via `Link.to` so walking it recursively threads all -// prior transforms — typical encoding.length is 1. -function encoded(ast: SchemaAST.AST): z.ZodTypeAny { - const encoding = ast.encoding! - return encoding.reduce( - (acc, link) => acc.transform((v) => decode(link.transformation, v)), - walk(encoding[0].to), - ) -} - -// Transformations built via pure `SchemaGetter.transform(fn)` (the common -// decodeTo case) resolve synchronously, so running with no services is safe. -// Effectful / middleware-based transforms will surface as Effect defects. -function decode(transformation: SchemaAST.Link["transformation"], value: unknown): unknown { - const exit = Effect.runSyncExit( - (transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect< - Option.Option - >, - ) - if (exit._tag === "Failure") throw new Error(`effect-zod: transform failed: ${String(exit.cause)}`) - return Option.getOrElse(exit.value, () => value) -} - -// Flatten FilterGroups and any nested variants into a linear list of Filters. -// Well-known filters (Schema.isInt, isGreaterThan, isPattern, …) are -// translated into native Zod methods so their JSON Schema output includes -// the corresponding constraint (type: integer, exclusiveMinimum, pattern, …). -// Anything else falls back to a single .superRefine layer — runtime-only, -// emits no JSON Schema constraint. -function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny { - const filters: SchemaAST.Filter[] = [] - const collect = (c: SchemaAST.Check) => { - if (c._tag === "FilterGroup") c.checks.forEach(collect) - else filters.push(c) - } - checks.forEach(collect) - - const unhandled: SchemaAST.Filter[] = [] - const translated = filters.reduce((acc, filter) => { - const next = translateFilter(acc, filter) - if (next) return next - unhandled.push(filter) - return acc - }, out) - - if (unhandled.length === 0) return translated - - return translated.superRefine((value, ctx) => { - for (const filter of unhandled) { - const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS) - if (!issue) continue - const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed" - ctx.addIssue({ code: "custom", message }) - } - }) -} - -// Translate a well-known Effect Schema filter into a native Zod method call on -// `out`. Dispatch is keyed on `filter.annotations.meta._tag`, which every -// built-in check factory (isInt, isGreaterThan, isPattern, …) attaches at -// construction time. Returns `undefined` for unrecognised filters so the -// caller can fall back to the generic .superRefine path. -function translateFilter(out: z.ZodTypeAny, filter: SchemaAST.Filter): z.ZodTypeAny | undefined { - const meta = (filter.annotations as { meta?: Record } | undefined)?.meta - if (!meta || typeof meta._tag !== "string") return undefined - switch (meta._tag) { - case "isInt": - return call(out, "int") - case "isFinite": - return call(out, "finite") - case "isGreaterThan": - return call(out, "gt", meta.exclusiveMinimum) - case "isGreaterThanOrEqualTo": - return call(out, "gte", meta.minimum) - case "isLessThan": - return call(out, "lt", meta.exclusiveMaximum) - case "isLessThanOrEqualTo": - return call(out, "lte", meta.maximum) - case "isBetween": { - const lo = meta.exclusiveMinimum ? call(out, "gt", meta.minimum) : call(out, "gte", meta.minimum) - if (!lo) return undefined - return meta.exclusiveMaximum ? call(lo, "lt", meta.maximum) : call(lo, "lte", meta.maximum) - } - case "isMultipleOf": - return call(out, "multipleOf", meta.divisor) - case "isMinLength": - return call(out, "min", meta.minLength) - case "isMaxLength": - return call(out, "max", meta.maxLength) - case "isLengthBetween": { - const lo = call(out, "min", meta.minimum) - if (!lo) return undefined - return call(lo, "max", meta.maximum) - } - case "isPattern": - return call(out, "regex", meta.regExp) - case "isStartsWith": - return call(out, "startsWith", meta.startsWith) - case "isEndsWith": - return call(out, "endsWith", meta.endsWith) - case "isIncludes": - return call(out, "includes", meta.includes) - case "isUUID": - return call(out, "uuid") - case "isULID": - return call(out, "ulid") - case "isBase64": - return call(out, "base64") - case "isBase64Url": - return call(out, "base64url") - } - return undefined -} - -// Invoke a named Zod method on `target` if it exists, otherwise return -// undefined so the caller can fall back. Using this helper instead of a -// typed cast keeps `translateFilter` free of per-case narrowing noise. -function call(target: z.ZodTypeAny, method: string, ...args: unknown[]): z.ZodTypeAny | undefined { - const fn = (target as unknown as Record z.ZodTypeAny) | undefined>)[method] - return typeof fn === "function" ? fn.apply(target, args) : undefined -} - -function issueMessage(issue: any): string | undefined { - if (typeof issue?.annotations?.message === "string") return issue.annotations.message - if (typeof issue?.message === "string") return issue.message - return undefined -} - -function body(ast: SchemaAST.AST): z.ZodTypeAny { - if (SchemaAST.isOptional(ast)) return opt(ast) - - switch (ast._tag) { - case "String": - return z.string() - case "Number": - return z.number() - case "Boolean": - return z.boolean() - case "Null": - return z.null() - case "Undefined": - return z.undefined() - case "Any": - case "Unknown": - return z.unknown() - case "Never": - return z.never() - case "Literal": - return z.literal(ast.literal) - case "Union": - return union(ast) - case "Objects": - return object(ast) - case "Arrays": - return array(ast) - case "Declaration": - return decl(ast) - default: - return fail(ast) - } -} - -function opt(ast: SchemaAST.AST): z.ZodTypeAny { - if (ast._tag !== "Union") return fail(ast) - const items = ast.types.filter((item) => item._tag !== "Undefined") - const inner = - items.length === 1 - ? walk(items[0]) - : items.length > 1 - ? z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array]) - : z.undefined() - // Schema.withDecodingDefault attaches an encoding `Link` whose transformation - // decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke - // it to extract the default and emit `.default(...)` instead of `.optional()`. - const fallback = extractDefault(ast) - if (fallback !== undefined) return inner.default(fallback.value) - return inner.optional() -} - -type DecodeLink = { - readonly transformation: { - readonly decode: { - readonly run: ( - input: Option.Option, - options: SchemaAST.ParseOptions, - ) => Effect.Effect, unknown> - } - } -} - -function extractDefault(ast: SchemaAST.AST): { value: unknown } | undefined { - const encoding = (ast as { encoding?: ReadonlyArray }).encoding - if (!encoding?.length) return undefined - // Walk the chain of encoding Links in order; the first Getter that produces - // a value from Option.none wins. withDecodingDefault always puts its - // defaulting Link adjacent to the optional Union. - for (const link of encoding) { - const probe = Effect.runSyncExit(link.transformation.decode.run(Option.none(), {})) - if (probe._tag !== "Success") continue - if (Option.isSome(probe.value)) return { value: probe.value.value } - } - return undefined -} - -function union(ast: SchemaAST.Union): z.ZodTypeAny { - // When every member is a string literal, emit z.enum() so that - // JSON Schema produces { "enum": [...] } instead of { "anyOf": [{ "const": ... }] }. - if (ast.types.length >= 2 && ast.types.every((t) => t._tag === "Literal" && typeof t.literal === "string")) { - return z.enum(ast.types.map((t) => (t as SchemaAST.Literal).literal as string) as [string, ...string[]]) - } - - const items = ast.types.map(walk) - if (items.length === 1) return items[0] - if (items.length < 2) return fail(ast) - - const discriminator = ast.annotations?.discriminator - if (typeof discriminator === "string") { - return z.discriminatedUnion(discriminator, items as [z.ZodObject, z.ZodObject, ...z.ZodObject[]]) - } - - return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array]) -} - -function object(ast: SchemaAST.Objects): z.ZodTypeAny { - // Pure record: { [k: string]: V } - if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) { - const sig = ast.indexSignatures[0] - if (sig.parameter._tag !== "String") return fail(ast) - return z.record(z.string(), walk(sig.type)) - } - - // Pure object with known fields and no index signatures. - if (ast.indexSignatures.length === 0) { - return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)]))) - } - - // Struct with a catchall (StructWithRest): known fields + index signature. - // Only supports a single string-keyed index signature; multi-signature or - // symbol/number keys fall through to fail. - if (ast.indexSignatures.length !== 1) return fail(ast) - const sig = ast.indexSignatures[0] - if (sig.parameter._tag !== "String") return fail(ast) - return z - .object(Object.fromEntries(ast.propertySignatures.map((p) => [String(p.name), walk(p.type)]))) - .catchall(walk(sig.type)) -} - -function array(ast: SchemaAST.Arrays): z.ZodTypeAny { - // Pure variadic arrays: { elements: [], rest: [item] } - if (ast.elements.length === 0) { - if (ast.rest.length !== 1) return fail(ast) - return z.array(walk(ast.rest[0])) - } - // Fixed-length tuples: { elements: [a, b, ...], rest: [] } - // Tuples with a variadic tail (...rest) are not yet supported. - if (ast.rest.length > 0) return fail(ast) - const items = ast.elements.map(walk) - return z.tuple(items as [z.ZodTypeAny, ...Array]) -} - -function decl(ast: SchemaAST.Declaration): z.ZodTypeAny { - if (ast.typeParameters.length !== 1) return fail(ast) - return walk(ast.typeParameters[0]) -} - -function fail(ast: SchemaAST.AST): never { - const ref = SchemaAST.resolveIdentifier(ast) - throw new Error(`unsupported effect schema: ${ref ?? ast._tag}`) -} diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 2a6c02349..5b4042c73 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -1,5 +1,4 @@ import { Option, Schema, SchemaGetter } from "effect" -import { zod, ZodOverride } from "./effect-zod" /** * Integer greater than zero. @@ -21,7 +20,6 @@ export const optionalOmitUndefined = (schema: S) => decode: SchemaGetter.passthrough({ strict: false }), encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)), }), - Schema.annotate({ [ZodOverride]: zod(schema).optional() }), ) /** diff --git a/packages/opencode/specs/effect/migration.md b/packages/opencode/specs/effect/migration.md index 01af9da6c..13838e833 100644 --- a/packages/opencode/specs/effect/migration.md +++ b/packages/opencode/specs/effect/migration.md @@ -57,17 +57,9 @@ Rules: - Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase - No `Layer.fresh` for normal per-directory isolation; use `InstanceState` -## Schema → Zod interop +## Schema boundaries -When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@opencode-ai/core/effect-zod`: - -```ts -import { zod } from "@opencode-ai/core/effect-zod" - -export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union -``` - -See `Auth.ZodInfo` for the canonical example. +Use Effect Schema directly at HTTP, tool, and AI SDK boundaries. For provider-facing JSON Schema, use a boundary-specific helper such as `ToolJsonSchema.fromSchema(...)`; do not reintroduce generic Effect Schema → Zod conversion. ## InstanceState init patterns diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index 20b3e70e7..1fc6a4478 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -1,19 +1,16 @@ # Schema migration Practical reference for migrating data types in `packages/opencode` from -Zod-first definitions to Effect Schema with Zod compatibility shims. +Zod-first definitions to Effect Schema. ## Goal Use Effect Schema as the source of truth for domain models, IDs, inputs, -outputs, and typed errors. Keep Zod available at existing HTTP, tool, and -compatibility boundaries by exposing a `.zod` static derived from the Effect -schema via `@opencode-ai/core/effect-zod`. +outputs, and typed errors. Prefer native Effect Schema, Standard Schema, and +native JSON Schema generation at HTTP, tool, and AI SDK boundaries. -The long-term driver is `specs/effect/http-api.md` — once the HTTP server -moves to `@effect/platform`, every Schema-first DTO can flow through -`HttpApi` / `HttpRouter` without a zod translation layer, and the entire -`effect-zod` walker plus every `.zod` static can be deleted. +The long-term driver is `specs/effect/http-api.md`: Schema-first DTOs should +flow through `HttpApi` / `HttpRouter` without a Zod translation layer. ## Preferred shapes @@ -26,19 +23,16 @@ export class Info extends Schema.Class("Foo.Info")({ id: FooID, name: Schema.String, enabled: Schema.Boolean, -}) { - static readonly zod = zod(Info) -} +}) {} ``` -If the class cannot reference itself cleanly during initialization, use the -two-step `withStatics` pattern: +If a schema needs local static helpers, use the two-step `withStatics` pattern: ```ts export const Info = Schema.Struct({ id: FooID, name: Schema.String, -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}).pipe(withStatics((s) => ({ decode: Schema.decodeUnknownOption(s) }))) ``` ### Errors @@ -53,15 +47,13 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Foo ### IDs and branded leaf types -Keep branded/schema-backed IDs as Effect schemas and expose -`static readonly zod` for compatibility when callers still expect Zod. +Keep branded/schema-backed IDs as Effect schemas. ### Refinements -Reuse named refinements instead of re-spelling `z.number().int().positive()` -in every schema. The `effect-zod` walker translates the Effect versions into -the corresponding zod methods, so JSON Schema output (`type: integer`, -`exclusiveMinimum`, `pattern`, `format: uuid`, …) is preserved. +Reuse named refinements instead of re-spelling numeric or string constraints in +every schema. Boundary JSON Schema helpers should normalize native Effect JSON +Schema output only where a provider requires it. ```ts const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)) @@ -69,18 +61,15 @@ const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreate const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)) ``` -See `test/util/effect-zod.test.ts` for the full set of translated checks. - ## Compatibility rule -During migration, route validators, tool parameters, and any existing -Zod-based boundary should consume the derived `.zod` schema instead of +During migration, route validators, tool parameters, and AI SDK schemas should +consume Effect schemas directly or use a narrow boundary helper. Avoid maintaining a second hand-written Zod schema. The default should be: - Effect Schema owns the type -- `.zod` exists only as a compatibility surface - new domain models should not start Zod-first unless there is a concrete boundary-specific need @@ -89,27 +78,22 @@ The default should be: It is fine to keep a Zod-native schema temporarily when: - the type is only used at an HTTP or tool boundary and is not reused elsewhere -- the validator depends on Zod-only transforms or behavior not yet covered by `zod()` +- the validator is part of an existing public API that explicitly accepts Zod - the migration would force unrelated churn across a large call graph When this happens, prefer leaving a short note or TODO rather than silently creating a parallel schema source of truth. -## Escape hatches +## Boundary helpers -The walker in `@opencode-ai/core/effect-zod` exposes two explicit escape hatches for -cases the pure-Schema path cannot express. Each one stays in the codebase -only as long as its upstream or local dependency requires it — inline -comments document when each can be deleted. +Use narrow helpers at concrete boundaries instead of a generic Schema → Zod bridge. -### `ZodOverride` annotation +- Tool parameters: `ToolJsonSchema.fromSchema(...)` and `ToolJsonSchema.fromTool(...)` +- Public config/TUI schemas: `packages/opencode/script/schema.ts` +- AI SDK object generation: `Schema.toStandardSchemaV1(...)` plus `Schema.toStandardJSONSchemaV1(...)` -Replaces the entire derivation with a hand-crafted zod schema. Used when: - -- the target carries external `$ref` metadata (e.g. - `config/model-id.ts` points at `https://models.dev/...`) -- the target is a zod-only schema that cannot yet be expressed as Schema - (e.g. `ConfigAgent.Info`, `Log.Level`) +Plugin tools are the main remaining intentional Zod boundary because the public +plugin API exposes `tool.schema = z` and `args: z.ZodRawShape`. ### Local `DeepMutable` in `config/config.ts` @@ -133,7 +117,7 @@ Migrate in this order: 2. Exported `Info`, `Input`, `Output`, and DTO types 3. Tagged domain errors 4. Service-local internal models -5. Route and tool boundary validators that can switch to `.zod` +5. Route and tool boundary validators that can switch to native Effect Schema helpers This keeps shared types canonical first and makes boundary updates mostly mechanical. @@ -142,21 +126,18 @@ mechanical. ### `src/config/` ✅ complete -All of `packages/opencode/src/config/` has been migrated. Files that still -import `z` do so only for local `ZodOverride` bridges or for `z.ZodType` -type annotations — the `export const ` values are all Effect -Schema at source. +All of `packages/opencode/src/config/` has been migrated. The `export const +` values are all Effect Schema at source. A file is considered "done" when: - its exported schema values (`Info`, `Input`, `Event`, `Definition`, etc.) are authored as Effect Schema -- any remaining zod is either a derived compat bridge (via `zod()` / - `zodObject()`), a `z.ZodType` type annotation, or a documented - `ZodOverride` escape hatch — never a hand-written parallel source of truth +- any remaining Zod is an explicit boundary compatibility choice, not a + hand-written parallel source of truth -Files that meet this bar but still carry a compat bridge are checked off -with an inline note describing the bridge and what unblocks its removal. +Files that meet this bar but still carry a compatibility boundary are checked +off with an inline note describing the boundary and what unblocks its removal. - [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider - [x] server, layout @@ -361,15 +342,8 @@ piecewise. - [ ] `src/util/update-schema.ts` - [ ] `src/worktree/index.ts` -### Do-not-migrate - -- `src/util/effect-zod.ts` — the walker itself. Stays zod-importing forever - (it's what emits zod from Schema). Goes away only when the `.zod` - compatibility layer is no longer needed anywhere. - ## Notes -- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion. - Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type. - Keep the migration incremental. Converting the domain model first is more diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md index 255c09644..5be155d1b 100644 --- a/packages/opencode/specs/openapi-translation-cleanup.md +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -100,7 +100,7 @@ Verification: - Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`. - Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions. -- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides. +- Add or fix OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides. - Delete one path override only after generated OpenAPI is unchanged for that param. Concrete first targets: diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 54cfe4fcc..3da260ea6 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -4,8 +4,6 @@ import { EffectBridge } from "@/effect/bridge" import type { InstanceContext } from "@/project/instance" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context, Schema } from "effect" -import z from "zod" -import { ZodOverride } from "@opencode-ai/core/effect-zod" import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" @@ -35,12 +33,11 @@ export const Info = Schema.Struct({ model: Schema.optional(Schema.String), source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])), // Some command templates are lazy promises from MCP prompt resolution. - template: Schema.Unknown.annotate({ [ZodOverride]: z.promise(z.string()).or(z.string()) }), + template: Schema.Unknown, subtask: Schema.optional(Schema.Boolean), hints: Schema.Array(Schema.String), }).annotate({ identifier: "Command" }) -// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it export type Info = Omit, "template"> & { template: Promise | string } export function hints(template: string) { diff --git a/packages/opencode/src/config/model-id.ts b/packages/opencode/src/config/model-id.ts index 6cba3ecd2..ba763f999 100644 --- a/packages/opencode/src/config/model-id.ts +++ b/packages/opencode/src/config/model-id.ts @@ -1,13 +1,5 @@ import { Schema } from "effect" -import z from "zod" -import { ZodOverride } from "@opencode-ai/core/effect-zod" -// The original Zod schema carried an external $ref pointing at the models.dev -// JSON schema. That external reference is not a named SDK component — it is a -// literal pointer to an outside schema — so the walker cannot re-derive it -// from AST metadata. Preserve the exact original Zod via ZodOverride. -export const ConfigModelID = Schema.String.annotate({ - [ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }), -}) +export const ConfigModelID = Schema.String export type ConfigModelID = Schema.Schema.Type diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 12ce5f581..0249721c4 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -5,7 +5,6 @@ import * as LSPClient from "./client" import path from "path" import { pathToFileURL, fileURLToPath } from "url" import * as LSPServer from "./server" -import z from "zod" import { Config } from "@/config/config" import { Flag } from "@opencode-ai/core/flag/flag" import { Process } from "@/util/process" @@ -14,7 +13,6 @@ import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { containsPath } from "@/project/instance-context" import { NonNegativeInt } from "@opencode-ai/core/schema" -import { ZodOverride } from "@opencode-ai/core/effect-zod" const log = Log.create({ service: "lsp" }) @@ -56,9 +54,7 @@ export const Status = Schema.Struct({ id: Schema.String, name: Schema.String, root: Schema.String, - status: Schema.Literals(["connected", "error"]).annotate({ - [ZodOverride]: z.union([z.literal("connected"), z.literal("error")]), - }), + status: Schema.Literals(["connected", "error"]), }).annotate({ identifier: "LSPStatus" }) export type Status = typeof Status.Type diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index 55272fc2f..360daf54a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -5,8 +5,8 @@ import { InstanceState } from "@/effect/instance-state" import { MCP } from "@/mcp" import { Project } from "@/project/project" import { Session } from "@/session/session" +import { ToolJsonSchema } from "@/tool/json-schema" import { ToolRegistry } from "@/tool/registry" -import * as EffectZod from "@opencode-ai/core/effect-zod" import { Worktree } from "@/worktree" import { Effect, Option } from "effect" import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" @@ -84,7 +84,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper return list.map((item) => ({ id: item.id, description: item.description, - parameters: EffectZod.toJsonSchema(item.parameters), + parameters: ToolJsonSchema.fromTool(item), })) }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2d1d05e15..4dae82038 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1,6 +1,5 @@ import { BusEvent } from "@/bus/bus-event" import { SessionID, MessageID, PartID } from "./schema" -import z from "zod" import { NamedError } from "@opencode-ai/core/util/error" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import { LSP } from "@/lsp/lsp" @@ -55,7 +54,7 @@ export const APIError = namedSchemaError("APIError", { responseBody: Schema.optional(Schema.String), metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), }) -export type APIError = z.infer +export type APIError = Schema.Schema.Type export const ContextOverflowError = namedSchemaError("ContextOverflowError", { message: Schema.String, responseBody: Schema.optional(Schema.String), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2de4bbd30..15246dac3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,6 +1,5 @@ import path from "path" import os from "os" -import * as EffectZod from "@opencode-ai/core/effect-zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" import * as Log from "@opencode-ai/core/util/log" @@ -21,6 +20,7 @@ import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" import { ToolRegistry } from "@/tool/registry" +import { ToolJsonSchema } from "@/tool/json-schema" import { MCP } from "../mcp" import { LSP } from "@/lsp/lsp" import { Flag } from "@opencode-ai/core/flag/flag" @@ -565,7 +565,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: input.model.providerID, agent: input.agent, })) { - const schema = ProviderTransform.schema(input.model, EffectZod.toJsonSchema(item.parameters)) + const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item)) tools[item.id] = tool({ description: item.description, inputSchema: jsonSchema(schema), diff --git a/packages/opencode/src/tool/json-schema.ts b/packages/opencode/src/tool/json-schema.ts new file mode 100644 index 000000000..edb43e11c --- /dev/null +++ b/packages/opencode/src/tool/json-schema.ts @@ -0,0 +1,164 @@ +import type { JSONSchema7 } from "@ai-sdk/provider" +import { JsonSchema, Schema } from "effect" +import type * as Tool from "./tool" + +type JsonObject = Record +const cache = new WeakMap() + +export function fromSchema(schema: Schema.Top): JSONSchema7 { + const cached = cache.get(schema) + if (cached) return cached + + const document = Schema.toJsonSchemaDocument(schema, { additionalProperties: true }) + const result = normalize({ + $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12, + ...document.schema, + ...(Object.keys(document.definitions).length > 0 ? { $defs: document.definitions } : {}), + }) + const inlined = dropDefinitionsIfResolved(inlineLocalReferences(result)) + if (!isJsonSchema(inlined)) throw new Error("tool JSON Schema helper produced a non-schema value") + cache.set(schema, inlined) + return inlined +} + +export function fromTool(tool: Tool.Def): JSONSchema7 { + return tool.jsonSchema ?? fromSchema(tool.parameters as Schema.Top) +} + +function normalize(value: unknown, options: { stripNull?: boolean } = {}): unknown { + if (Array.isArray(value)) return value.map((item) => normalize(item)) + if (!isRecord(value)) return value + + const required = Array.isArray(value.required) + ? new Set(value.required.filter((item) => typeof item === "string")) + : undefined + const schema = Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + key === "properties" && isRecord(item) + ? Object.fromEntries( + Object.entries(item).map(([name, property]) => [ + name, + normalize(property, { stripNull: !required?.has(name) }), + ]), + ) + : normalize(item), + ]), + ) + + if (schema.additionalProperties === true) delete schema.additionalProperties + + if (options.stripNull && Array.isArray(schema.anyOf)) { + const withoutNull = schema.anyOf.filter((item) => !isRecord(item) || item.type !== "null") + if (withoutNull.length !== schema.anyOf.length) return normalize({ ...schema, anyOf: withoutNull }) + } + + if (Array.isArray(schema.anyOf)) { + const withoutNull = schema.anyOf + const number = withoutNull.find((item) => isRecord(item) && item.type === "number") + const nonFinite = withoutNull.filter( + (item) => isRecord(item) && Array.isArray(item.enum) && item.enum.every((entry) => isNonFiniteNumber(entry)), + ) + if (number && nonFinite.length === withoutNull.length - 1) { + const { anyOf: _, ...rest } = schema + return normalize({ ...number, ...rest }) + } + + if (isEmptyStructUnion(withoutNull)) { + const { anyOf: _, ...rest } = schema + return normalize({ type: "object", properties: {}, ...rest }) + } + + if (withoutNull.length === 1 && isRecord(withoutNull[0])) { + const { anyOf: _, ...rest } = schema + return normalize({ ...withoutNull[0], ...rest }) + } + } + + if (Array.isArray(schema.allOf) && schema.allOf.every(isRecord) && canFlattenAllOf(schema.allOf, schema)) { + const { allOf, ...rest } = schema + return normalize({ ...Object.assign({}, ...allOf), ...rest }) + } + + if (schema.type === "integer" && schema.maximum === undefined) { + return { minimum: Number.MIN_SAFE_INTEGER, ...schema, maximum: Number.MAX_SAFE_INTEGER } + } + + return schema +} + +function isRecord(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function isJsonSchema(value: unknown): value is JSONSchema7 { + return typeof value === "boolean" || isRecord(value) +} + +function isNonFiniteNumber(value: unknown) { + return value === "NaN" || value === "Infinity" || value === "-Infinity" +} + +function isEmptyStructUnion(items: unknown[]) { + return ( + items.length === 2 && + items.some((item) => isRecord(item) && item.type === "object" && item.properties === undefined) && + items.some((item) => isRecord(item) && item.type === "array" && item.items === undefined) + ) +} + +function canFlattenAllOf(allOf: JsonObject[], parent: JsonObject) { + const keys = new Set(Object.keys(parent).filter((key) => key !== "allOf")) + return allOf.every((item) => + Object.keys(item).every((key) => { + if (keys.has(key)) return false + keys.add(key) + return true + }), + ) +} + +function inlineLocalReferences(value: unknown, definitions?: JsonObject, seen = new Set()): unknown { + if (Array.isArray(value)) return value.map((item) => inlineLocalReferences(item, definitions, seen)) + if (!isRecord(value)) return value + + const localDefinitions = definitions ?? (isRecord(value.$defs) ? value.$defs : undefined) + if (typeof value.$ref === "string" && localDefinitions) { + const name = value.$ref.match(/^#\/\$defs\/(.+)$/)?.[1] ?? value.$ref.match(/^#\/definitions\/(.+)$/)?.[1] + if (name && !seen.has(name)) { + const target = localDefinitions[name] + if (target) { + const { $ref: _, ...rest } = value + return inlineLocalReferences( + { ...(isRecord(target) ? target : {}), ...rest }, + localDefinitions, + new Set(seen).add(name), + ) + } + } + } + + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, inlineLocalReferences(item, localDefinitions, seen)]), + ) +} + +function dropDefinitionsIfResolved(value: unknown): unknown { + if (!isRecord(value) || hasLocalReference(value)) return value + const { $defs: _, definitions: __, ...rest } = value + return rest +} + +function hasLocalReference(value: unknown): boolean { + if (Array.isArray(value)) return value.some(hasLocalReference) + if (!isRecord(value)) return false + if ( + typeof value.$ref === "string" && + (value.$ref.startsWith("#/$defs/") || value.$ref.startsWith("#/definitions/")) + ) { + return true + } + return Object.values(value).some(hasLocalReference) +} + +export * as ToolJsonSchema from "./json-schema" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 68251c342..a7411a077 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -15,9 +15,9 @@ import { SkillTool } from "./skill" import * as Tool from "./tool" import { Config } from "@/config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" +import type { JSONSchema7, JSONSchema7Definition } from "@ai-sdk/provider" import { Schema } from "effect" import z from "zod" -import { ZodOverride } from "@opencode-ai/core/effect-zod" import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" import { ProviderID, type ModelID } from "../provider/schema" @@ -137,17 +137,19 @@ export const layer: Layer.Layer< const custom: Tool.Def[] = [] function fromPlugin(id: string, def: ToolDefinition): Tool.Def { - // Plugin tools define their args as a raw Zod shape. Wrap the - // derived Zod object in a `Schema.declare` so it slots into the - // Schema-typed framework, and annotate with `ZodOverride` so the - // walker emits the original Zod object for LLM JSON Schema. - const zodParams = z.object(def.args) - const parameters = Schema.declare((u): u is unknown => zodParams.safeParse(u).success).annotate({ - [ZodOverride]: zodParams, - }) + // Plugin tools still expose Zod args publicly; keep that compatibility + // boxed at the registry boundary and give the LLM the original JSON Schema. + const entries = Object.entries(def.args) + const allZod = entries.every((entry) => isZodType(entry[1])) + const zodParams = allZod ? z.object(def.args) : undefined + const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries) + const parameters = zodParams + ? Schema.declare((u): u is unknown => zodParams.safeParse(u).success) + : Schema.Unknown return { id, parameters, + jsonSchema, description: def.description, execute: (args, toolCtx) => Effect.gen(function* () { @@ -323,8 +325,13 @@ export const layer: Layer.Layer< const output = { description: tool.description, parameters: tool.parameters, + jsonSchema: tool.jsonSchema, } yield* plugin.trigger("tool.definition", { toolID: tool.id }, output) + const jsonSchema = + output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema + ? output.jsonSchema + : undefined return { id: tool.id, description: [ @@ -335,6 +342,7 @@ export const layer: Layer.Layer< .filter(Boolean) .join("\n"), parameters: output.parameters, + jsonSchema, execute: tool.execute, formatValidationError: tool.formatValidationError, } @@ -376,4 +384,50 @@ export const defaultLayer = Layer.suspend(() => ), ) +function isZodType(value: unknown): value is z.ZodType { + return typeof value === "object" && value !== null && "_zod" in value +} + +function isJsonSchemaDefinition(value: unknown): value is JSONSchema7Definition { + return typeof value === "boolean" || (typeof value === "object" && value !== null && !Array.isArray(value)) +} + +function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 { + const properties = Object.fromEntries( + entries.filter((entry): entry is [string, JSONSchema7Definition] => isJsonSchemaDefinition(entry[1])), + ) + return { + type: "object", + properties, + required: Object.keys(properties), + } +} + +function zodJsonSchema(schema: z.ZodType): JSONSchema7 { + const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input" })) + if (!isJsonSchemaObject(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema") + const { $defs, ...rest } = result + return ( + $defs && isJsonSchemaObject($defs) ? { ...rest, definitions: $defs as JSONSchema7["definitions"] } : rest + ) as JSONSchema7 +} + +function normalizeZodJsonSchema(value: unknown): unknown { + if (Array.isArray(value)) return value.map((item) => normalizeZodJsonSchema(item)) + if (typeof value !== "object" || value === null) return value + return Object.fromEntries( + Object.entries(value) + .filter((entry) => + (entry[0] === "exclusiveMaximum" || entry[0] === "exclusiveMinimum") && typeof entry[1] === "boolean" + ? false + : true, + ) + .map(([key, item]) => [key, normalizeZodJsonSchema(item)]), + ) +} + +function isJsonSchemaObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + export * as ToolRegistry from "./registry" diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 4b9ea8774..a26422d04 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -1,4 +1,5 @@ import { Effect, Schema } from "effect" +import type { JSONSchema7 } from "@ai-sdk/provider" import type { MessageV2 } from "../session/message-v2" import type { Permission } from "../permission" import type { SessionID, MessageID } from "../session/schema" @@ -38,6 +39,7 @@ export interface Def< id: string description: string parameters: Parameters + jsonSchema?: JSONSchema7 execute(args: Schema.Schema.Type, ctx: Context): Effect.Effect> formatValidationError?(error: unknown): string } diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index d2561a130..8c2be44e9 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -12,10 +12,11 @@ const MAX_TIMEOUT = 120 * 1000 // 2 minutes export const Parameters = Schema.Struct({ url: Schema.String.annotate({ description: "The URL to fetch content from" }), format: Schema.Literals(["text", "markdown", "html"]) - .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const))) .annotate({ description: "The format to return the content in (text, markdown, or html). Defaults to markdown.", - }), + default: "markdown", + }) + .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const))), timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in seconds (max 120)" }), }) diff --git a/packages/opencode/src/util/named-schema-error.ts b/packages/opencode/src/util/named-schema-error.ts index d87e1dcdb..a5ff0828e 100644 --- a/packages/opencode/src/util/named-schema-error.ts +++ b/packages/opencode/src/util/named-schema-error.ts @@ -1,6 +1,4 @@ import { Schema } from "effect" -import z from "zod" -import { zod } from "@opencode-ai/core/effect-zod" /** * Create a Schema-backed NamedError-shaped class. @@ -11,22 +9,14 @@ import { zod } from "@opencode-ai/core/effect-zod" * OpenAPI/SDK output is byte-identical to the original NamedError schema. * * Preserves the existing surface: - * - static `Schema` (Zod schema of the wire shape) + * - static `Schema` (Effect schema of the wire shape) * - static `isInstance(x)` * - instance `toObject()` returning `{ name, data }` * - `new X({ ...data }, { cause })` */ export function namedSchemaError(tag: Tag, fields: Fields) { - // Wire shape matches the original NamedError output so the SDK stays stable. const dataSchema = Schema.Struct(fields) - const wire = z - .object({ - name: z.literal(tag), - data: zod(dataSchema), - }) - .meta({ ref: tag }) - - // Effect Schema for the wire shape — used by HttpApi OpenAPI generation. + // Wire shape matches the original NamedError output so the SDK stays stable. const effectSchema = Schema.Struct({ name: Schema.Literal(tag), data: dataSchema, @@ -35,7 +25,7 @@ export function namedSchemaError class NamedSchemaError extends Error { - static readonly Schema = wire + static readonly Schema = effectSchema static readonly EffectSchema = effectSchema static readonly tag = tag public static isInstance(input: unknown): input is NamedSchemaError { diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 9da45c911..22ff6cde8 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import type { NamedError } from "@opencode-ai/core/util/error" import { APICallError } from "ai" import { setTimeout as sleep } from "node:timers/promises" -import { Effect, Layer, Schedule } from "effect" +import { Effect, Layer, Schedule, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" @@ -17,7 +17,7 @@ const retryProvider = "test" const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer)) function apiError(headers?: Record): MessageV2.APIError { - return MessageV2.APIError.Schema.parse( + return Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "boom", isRetryable: true, @@ -94,7 +94,7 @@ describe("session.retry.delay", () => { const step = yield* Schedule.toStepWithMetadata( SessionRetry.policy({ provider: "test", - parse: (err) => MessageV2.APIError.Schema.parse(err), + parse: Schema.decodeUnknownSync(MessageV2.APIError.Schema), set: (info) => status.set(sessionID, { type: "retry", @@ -173,7 +173,7 @@ describe("session.retry.retryable", () => { }) test("retries 500 errors even when isRetryable is false", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Internal server error", isRetryable: false, @@ -186,7 +186,7 @@ describe("session.retry.retryable", () => { }) test("retries 502 bad gateway errors", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Bad gateway", isRetryable: false, @@ -198,7 +198,7 @@ describe("session.retry.retryable", () => { }) test("retries 503 service unavailable errors", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Service unavailable", isRetryable: false, @@ -210,7 +210,7 @@ describe("session.retry.retryable", () => { }) test("does not retry 4xx errors when isRetryable is false", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Bad request", isRetryable: false, @@ -222,7 +222,7 @@ describe("session.retry.retryable", () => { }) test("retries ZlibError decompression failures", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Response decompression failed", isRetryable: true, @@ -236,7 +236,7 @@ describe("session.retry.retryable", () => { }) test("maps free limits to Go upsell action", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Free usage exceeded", isRetryable: true, @@ -262,7 +262,7 @@ describe("session.retry.retryable", () => { }) test("maps Go subscription limits to workspace PAYG upsell", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Subscription quota exceeded. You can continue using free models.", isRetryable: true, @@ -300,7 +300,7 @@ describe("session.retry.retryable", () => { }) test("maps Go subscription limits without limit metadata", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Subscription quota exceeded. You can continue using free models.", isRetryable: true, @@ -366,7 +366,7 @@ describe("session.message-v2.fromError", () => { ) test("ECONNRESET socket error is retryable", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Connection reset by server", isRetryable: true, diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 601f07cb3..d6c1bc45d 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -45,6 +45,7 @@ Output: Creates directory 'foo'" "description": "Optional timeout in milliseconds", "exclusiveMinimum": 0, "maximum": 9007199254740991, + "minimum": -9007199254740991, "type": "integer", }, "workdir": { @@ -240,7 +241,6 @@ exports[`tool parameters JSON Schema (wire shape) question 1`] = ` "type": "string", }, }, - "ref": "QuestionOption", "required": [ "label", "description", @@ -254,7 +254,6 @@ exports[`tool parameters JSON Schema (wire shape) question 1`] = ` "type": "string", }, }, - "ref": "QuestionPrompt", "required": [ "question", "header", @@ -393,14 +392,21 @@ exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = ` "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "format": { - "default": "markdown", - "description": "The format to return the content in (text, markdown, or html). Defaults to markdown.", - "enum": [ - "text", - "markdown", - "html", + "anyOf": [ + { + "default": "markdown", + "description": "The format to return the content in (text, markdown, or html). Defaults to markdown.", + "enum": [ + "text", + "markdown", + "html", + ], + "type": "string", + }, + { + "type": "null", + }, ], - "type": "string", }, "timeout": { "description": "Optional timeout in seconds (max 120)", diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 17af7b983..8b2dc9a74 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -1,13 +1,13 @@ import { describe, expect, test } from "bun:test" import { Result, Schema } from "effect" -import { toJsonSchema } from "@opencode-ai/core/effect-zod" +import { ToolJsonSchema } from "../../src/tool/json-schema" // Each tool exports its parameters schema at module scope so this test can // import them without running the tool's Effect-based init. The JSON Schema // snapshot captures what the LLM sees; the parse assertions pin down the -// accepts/rejects contract. `toJsonSchema` is the same helper `session/ +// accepts/rejects contract. `ToolJsonSchema.fromSchema` is the same helper `session/ // prompt.ts` uses to emit tool schemas to the LLM, so the snapshots stay -// byte-identical regardless of whether a tool has migrated from zod to Schema. +// provider-compatible while tools use Effect Schema internally. import { Parameters as ApplyPatch } from "../../src/tool/apply_patch" import { Parameters as Edit } from "../../src/tool/edit" @@ -32,6 +32,8 @@ const parse = >(schema: S, input: unknown): S[ const accepts = (schema: Schema.Decoder, input: unknown): boolean => Result.isSuccess(Schema.decodeUnknownResult(schema)(input)) +const toJsonSchema = ToolJsonSchema.fromSchema + describe("tool parameters", () => { describe("JSON Schema (wire shape)", () => { test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot()) @@ -50,6 +52,36 @@ describe("tool parameters", () => { test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot()) test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot()) test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot()) + + test("inlines named child schemas for provider compatibility", () => { + const schema = toJsonSchema(Question) + expect(schema).not.toHaveProperty("$defs") + expect(schema).toMatchObject({ + properties: { + questions: { items: { properties: { options: { items: { properties: { label: { type: "string" } } } } } } }, + }, + }) + }) + + test("preserves required nullable fields", () => { + expect(toJsonSchema(Schema.Struct({ value: Schema.NullOr(Schema.String) }))).toMatchObject({ + properties: { value: { anyOf: expect.arrayContaining([{ type: "null" }]) } }, + }) + }) + + test("keeps repeated allOf constraints instead of dropping duplicates", () => { + expect( + toJsonSchema( + Schema.Struct({ value: Schema.String.check(Schema.isPattern(/^a/)).check(Schema.isPattern(/z$/)) }), + ), + ).toMatchObject({ properties: { value: { allOf: [{ pattern: "^a" }, { pattern: "z$" }] } } }) + }) + + test("bounds bare integer fields to safe integer range", () => { + expect(toJsonSchema(Schema.Struct({ value: Schema.Int }))).toMatchObject({ + properties: { value: { minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER } }, + }) + }) }) describe("apply_patch", () => { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index dc66c308a..37cb7a43d 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" -import { Effect, Layer } from "effect" +import { pathToFileURL } from "url" +import { Effect, Layer, Result, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" import { Flag } from "@opencode-ai/core/flag/flag" @@ -26,6 +27,8 @@ import { Ripgrep } from "@/file/ripgrep" import * as Truncate from "@/tool/truncate" import { InstanceState } from "@/effect/instance-state" import { Reference } from "@/reference/reference" +import { ProviderID, ModelID } from "@/provider/schema" +import { ToolJsonSchema } from "@/tool/json-schema" const node = CrossSpawnSpawner.defaultLayer const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT @@ -55,7 +58,7 @@ const registryLayer = ToolRegistry.layer.pipe( Layer.provide(Truncate.defaultLayer), ) -const it = testEffect(Layer.mergeAll(registryLayer, node)) +const it = testEffect(Layer.mergeAll(registryLayer, node, Agent.defaultLayer)) afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_SCOUT = originalExperimentalScout @@ -141,6 +144,89 @@ describe("tool.registry", () => { }), ) + it.instance("loads Zod-schema custom tools with JSON Schema and validation", () => + Effect.gen(function* () { + const test = yield* TestInstance + const customTools = path.join(test.directory, ".opencode", "tools") + const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href + yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(customTools, "sql.ts"), + [ + `import { tool } from ${JSON.stringify(pluginTool)}`, + "export default tool({", + " description: 'query database',", + " args: { query: tool.schema.string().describe('SQL query to execute') },", + " execute: async ({ query }) => query,", + "})", + "", + ].join("\n"), + ), + ) + + const registry = yield* ToolRegistry.Service + const loaded = (yield* registry.all()).find((tool) => tool.id === "sql") + if (!loaded) throw new Error("custom sql tool was not loaded") + expect(loaded?.jsonSchema).toMatchObject({ + type: "object", + properties: { + query: { type: "string", description: "SQL query to execute" }, + }, + required: ["query"], + }) + expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({ query: "select 1" }))).toBe(true) + expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false) + + const agents = yield* Agent.Service + const promptTools = yield* registry.tools({ + providerID: ProviderID.opencode, + modelID: ModelID.make("test"), + agent: yield* agents.get(yield* agents.defaultAgent()), + }) + const promptTool = promptTools.find((tool) => tool.id === "sql") + if (!promptTool) throw new Error("custom sql tool was not returned for prompts") + expect(ToolJsonSchema.fromTool(promptTool)).toMatchObject({ + properties: { + query: { type: "string", description: "SQL query to execute" }, + }, + required: ["query"], + }) + }), + ) + + it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () => + Effect.gen(function* () { + const test = yield* TestInstance + const tools = path.join(test.directory, ".opencode", "tools") + yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tools, "legacy.ts"), + [ + "export default {", + " description: 'legacy schema tool',", + " args: { text: { type: 'string', description: 'Text to render' } },", + " execute: async ({ text }) => text,", + "}", + "", + ].join("\n"), + ), + ) + + const registry = yield* ToolRegistry.Service + const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy") + if (!loaded) throw new Error("legacy custom tool was not loaded") + expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({ + type: "object", + properties: { + text: { type: "string", description: "Text to render" }, + }, + required: ["text"], + }) + }), + ) + it.instance("loads tools with external dependencies without crashing", () => Effect.gen(function* () { const test = yield* TestInstance diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts deleted file mode 100644 index ab3923d8e..000000000 --- a/packages/opencode/test/util/effect-zod.test.ts +++ /dev/null @@ -1,754 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Effect, Schema, SchemaGetter } from "effect" -import z from "zod" - -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" - -function json(schema: z.ZodTypeAny) { - const { $schema: _, ...rest } = z.toJSONSchema(schema) - return rest -} - -describe("util.effect-zod", () => { - test("converts class schemas for route dto shapes", () => { - class Method extends Schema.Class("ProviderAuthMethod")({ - type: Schema.Union([Schema.Literal("oauth"), Schema.Literal("api")]), - label: Schema.String, - }) {} - - const out = zod(Method) - - expect(out.meta()?.ref).toBe("ProviderAuthMethod") - expect( - out.parse({ - type: "oauth", - label: "OAuth", - }), - ).toEqual({ - type: "oauth", - label: "OAuth", - }) - }) - - test("converts structs with optional fields, arrays, and records", () => { - const out = zod( - Schema.Struct({ - foo: Schema.optional(Schema.String), - bar: Schema.Array(Schema.Number), - baz: Schema.Record(Schema.String, Schema.Boolean), - }), - ) - - expect( - out.parse({ - bar: [1, 2], - baz: { ok: true }, - }), - ).toEqual({ - bar: [1, 2], - baz: { ok: true }, - }) - expect( - out.parse({ - foo: "hi", - bar: [1], - baz: { ok: false }, - }), - ).toEqual({ - foo: "hi", - bar: [1], - baz: { ok: false }, - }) - }) - - describe("Tuples", () => { - test("fixed-length tuple parses matching array", () => { - const out = zod(Schema.Tuple([Schema.String, Schema.Number])) - expect(out.parse(["a", 1])).toEqual(["a", 1]) - expect(out.safeParse(["a"]).success).toBe(false) - expect(out.safeParse(["a", "b"]).success).toBe(false) - }) - - test("single-element tuple parses a one-element array", () => { - const out = zod(Schema.Tuple([Schema.Boolean])) - expect(out.parse([true])).toEqual([true]) - expect(out.safeParse([true, false]).success).toBe(false) - }) - - test("tuple inside a union picks the right branch", () => { - const out = zod(Schema.Union([Schema.String, Schema.Tuple([Schema.String, Schema.Number])])) - expect(out.parse("hello")).toBe("hello") - expect(out.parse(["foo", 42])).toEqual(["foo", 42]) - expect(out.safeParse(["foo"]).success).toBe(false) - }) - - test("plain arrays still work (no element positions)", () => { - const out = zod(Schema.Array(Schema.String)) - expect(out.parse(["a", "b", "c"])).toEqual(["a", "b", "c"]) - expect(out.parse([])).toEqual([]) - }) - }) - - test("string literal unions produce z.enum with enum in JSON Schema", () => { - const Action = Schema.Literals(["allow", "deny", "ask"]) - const out = zod(Action) - - expect(out.parse("allow")).toBe("allow") - expect(out.parse("deny")).toBe("deny") - expect(() => out.parse("nope")).toThrow() - - // Matches native z.enum JSON Schema output - const bridged = json(out) - const native = json(z.enum(["allow", "deny", "ask"])) - expect(bridged).toEqual(native) - expect(bridged.enum).toEqual(["allow", "deny", "ask"]) - }) - - test("ZodOverride annotation provides the Zod schema for branded IDs", () => { - const override = z.string().startsWith("per") - const ID = Schema.String.annotate({ [ZodOverride]: override }).pipe(Schema.brand("TestID")) - - const Parent = Schema.Struct({ id: ID, name: Schema.String }) - const out = zod(Parent) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((out as any).parse({ id: "per_abc", name: "test" })).toEqual({ id: "per_abc", name: "test" }) - - const schema = json(out) as any - expect(schema.properties.id).toEqual({ type: "string", pattern: "^per.*" }) - }) - - test("Schema.Class nested in a parent preserves ref via identifier", () => { - class Inner extends Schema.Class("MyInner")({ - value: Schema.String, - }) {} - - class Outer extends Schema.Class("MyOuter")({ - inner: Inner, - }) {} - - const out = zod(Outer) - expect(out.meta()?.ref).toBe("MyOuter") - - const shape = (out as any).shape ?? (out as any)._def?.shape?.() - expect(shape.inner.meta()?.ref).toBe("MyInner") - }) - - test("Schema.Class preserves identifier and uses enum format", () => { - class Rule extends Schema.Class("PermissionRule")({ - permission: Schema.String, - pattern: Schema.String, - action: Schema.Literals(["allow", "deny", "ask"]), - }) {} - - const out = zod(Rule) - expect(out.meta()?.ref).toBe("PermissionRule") - - const schema = json(out) as any - expect(schema.properties.action).toEqual({ - type: "string", - enum: ["allow", "deny", "ask"], - }) - }) - - test("ZodOverride on ID carries pattern through Schema.Class", () => { - const ID = Schema.String.annotate({ - [ZodOverride]: z.string().startsWith("per"), - }) - - class Request extends Schema.Class("TestRequest")({ - id: ID, - name: Schema.String, - }) {} - - const schema = json(zod(Request)) as any - expect(schema.properties.id).toEqual({ type: "string", pattern: "^per.*" }) - expect(schema.properties.name).toEqual({ type: "string" }) - }) - - test("Permission schemas match original Zod equivalents", () => { - const MsgID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("msg") }) - const PerID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") }) - const SesID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("ses") }) - - class Tool extends Schema.Class("PermissionTool")({ - messageID: MsgID, - callID: Schema.String, - }) {} - - class Request extends Schema.Class("PermissionRequest")({ - id: PerID, - sessionID: SesID, - permission: Schema.String, - patterns: Schema.Array(Schema.String), - metadata: Schema.Record(Schema.String, Schema.Unknown), - always: Schema.Array(Schema.String), - tool: Schema.optional(Tool), - }) {} - - const bridged = json(zod(Request)) as any - expect(bridged.properties.id).toEqual({ type: "string", pattern: "^per.*" }) - expect(bridged.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" }) - expect(bridged.properties.permission).toEqual({ type: "string" }) - expect(bridged.required?.sort()).toEqual(["id", "sessionID", "permission", "patterns", "metadata", "always"].sort()) - - // Tool field is present with the ref from Schema.Class identifier - const toolSchema = json(zod(Tool)) as any - expect(toolSchema.properties.messageID).toEqual({ type: "string", pattern: "^msg.*" }) - expect(toolSchema.properties.callID).toEqual({ type: "string" }) - }) - - test("ZodOverride survives Schema.brand", () => { - const override = z.string().startsWith("ses") - const ID = Schema.String.annotate({ [ZodOverride]: override }).pipe(Schema.brand("SessionID")) - - // The branded schema's AST still has the override - class Parent extends Schema.Class("Parent")({ - sessionID: ID, - }) {} - - const schema = json(zod(Parent)) as any - expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" }) - }) - - describe("Schema.check translation", () => { - test("filter returning string triggers refinement with that message", () => { - const isEven = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "expected an even number")) - const schema = zod(Schema.Number.check(isEven)) - - expect(schema.parse(4)).toBe(4) - const result = schema.safeParse(3) - expect(result.success).toBe(false) - expect(result.error!.issues[0].message).toBe("expected an even number") - }) - - test("filter returning false triggers refinement with fallback message", () => { - const nonEmpty = Schema.makeFilter((s: string) => s.length > 0) - const schema = zod(Schema.String.check(nonEmpty)) - - expect(schema.parse("hi")).toBe("hi") - const result = schema.safeParse("") - expect(result.success).toBe(false) - expect(result.error!.issues[0].message).toMatch(/./) - }) - - test("filter returning undefined passes validation", () => { - const alwaysOk = Schema.makeFilter(() => undefined) - const schema = zod(Schema.Number.check(alwaysOk)) - - expect(schema.parse(42)).toBe(42) - }) - - test("annotations.message on the filter is used when filter returns false", () => { - const positive = Schema.makeFilter((n: number) => n > 0, { message: "must be positive" }) - const schema = zod(Schema.Number.check(positive)) - - const result = schema.safeParse(-1) - expect(result.success).toBe(false) - expect(result.error!.issues[0].message).toBe("must be positive") - }) - - test("cross-field check on a record flags missing key", () => { - const hasKey = Schema.makeFilter((data: Record) => - "required" in data ? undefined : "missing 'required' key", - ) - const schema = zod(Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })).check(hasKey)) - - expect(schema.parse({ required: { enabled: true } })).toEqual({ - required: { enabled: true }, - }) - - const result = schema.safeParse({ other: { enabled: true } }) - expect(result.success).toBe(false) - expect(result.error!.issues[0].message).toBe("missing 'required' key") - }) - }) - - describe("StructWithRest / catchall", () => { - test("struct with a string-keyed record rest parses known AND extra keys", () => { - const schema = zod( - Schema.StructWithRest( - Schema.Struct({ - apiKey: Schema.optional(Schema.String), - baseURL: Schema.optional(Schema.String), - }), - [Schema.Record(Schema.String, Schema.Unknown)], - ), - ) - - // Known fields come through as declared - expect(schema.parse({ apiKey: "sk-x" })).toEqual({ apiKey: "sk-x" }) - - // Extra keys are preserved (catchall) - expect( - schema.parse({ - apiKey: "sk-x", - baseURL: "https://api.example.com", - customField: "anything", - nested: { foo: 1 }, - }), - ).toEqual({ - apiKey: "sk-x", - baseURL: "https://api.example.com", - customField: "anything", - nested: { foo: 1 }, - }) - }) - - test("catchall value type constrains the extras", () => { - const schema = zod( - Schema.StructWithRest( - Schema.Struct({ - count: Schema.Number, - }), - [Schema.Record(Schema.String, Schema.Number)], - ), - ) - - // Known field + numeric extras - expect(schema.parse({ count: 10, a: 1, b: 2 })).toEqual({ count: 10, a: 1, b: 2 }) - - // Non-numeric extra is rejected - expect(schema.safeParse({ count: 10, bad: "not a number" }).success).toBe(false) - }) - - test("JSON schema output marks additionalProperties appropriately", () => { - const schema = zod( - Schema.StructWithRest( - Schema.Struct({ - id: Schema.String, - }), - [Schema.Record(Schema.String, Schema.Unknown)], - ), - ) - const shape = json(schema) as { additionalProperties?: unknown } - // Presence of `additionalProperties` (truthy or a schema) signals catchall. - expect(shape.additionalProperties).not.toBe(false) - expect(shape.additionalProperties).toBeDefined() - }) - - test("plain struct without rest still emits additionalProperties unchanged (regression)", () => { - const schema = zod(Schema.Struct({ id: Schema.String })) - expect(schema.parse({ id: "x" })).toEqual({ id: "x" }) - }) - }) - - describe("transforms (Schema.decodeTo)", () => { - test("Number -> pseudo-Duration (seconds) applies the decode function", () => { - // Models the account/account.ts DurationFromSeconds pattern. - const SecondsToMs = Schema.Number.pipe( - Schema.decodeTo(Schema.Number, { - decode: SchemaGetter.transform((n: number) => n * 1000), - encode: SchemaGetter.transform((ms: number) => ms / 1000), - }), - ) - - const schema = zod(SecondsToMs) - expect(schema.parse(3)).toBe(3000) - expect(schema.parse(0)).toBe(0) - }) - - test("String -> Number via parseInt decode", () => { - const ParsedInt = Schema.String.pipe( - Schema.decodeTo(Schema.Number, { - decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)), - encode: SchemaGetter.transform((n: number) => String(n)), - }), - ) - - const schema = zod(ParsedInt) - expect(schema.parse("42")).toBe(42) - expect(schema.parse("0")).toBe(0) - }) - - test("transform inside a struct field applies per-field", () => { - const Field = Schema.Number.pipe( - Schema.decodeTo(Schema.Number, { - decode: SchemaGetter.transform((n: number) => n + 1), - encode: SchemaGetter.transform((n: number) => n - 1), - }), - ) - - const schema = zod( - Schema.Struct({ - plain: Schema.Number, - bumped: Field, - }), - ) - - expect(schema.parse({ plain: 5, bumped: 10 })).toEqual({ plain: 5, bumped: 11 }) - }) - - test("chained decodeTo composes transforms in order", () => { - // String -> Number (parseInt) -> Number (doubled). - // Exercises the encoded() reduce, not just a single link. - const Chained = Schema.String.pipe( - Schema.decodeTo(Schema.Number, { - decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)), - encode: SchemaGetter.transform((n: number) => String(n)), - }), - Schema.decodeTo(Schema.Number, { - decode: SchemaGetter.transform((n: number) => n * 2), - encode: SchemaGetter.transform((n: number) => n / 2), - }), - ) - - const schema = zod(Chained) - expect(schema.parse("21")).toBe(42) - expect(schema.parse("0")).toBe(0) - }) - - test("Schema.Class is unaffected by transform walker (returns plain object, not instance)", () => { - // Schema.Class uses Declaration + encoding under the hood to construct - // class instances. The walker must NOT apply that transform, or zod - // parsing would return class instances instead of plain objects. - class Method extends Schema.Class("TxTestMethod")({ - type: Schema.String, - value: Schema.Number, - }) {} - - const schema = zod(Method) - const parsed = schema.parse({ type: "oauth", value: 1 }) - expect(parsed).toEqual({ type: "oauth", value: 1 }) - // Guardrail: ensure we didn't get back a Method instance. - expect(parsed).not.toBeInstanceOf(Method) - }) - }) - - describe("optimizations", () => { - test("walk() memoizes by AST identity — same AST node returns same Zod", () => { - const shared = Schema.Struct({ id: Schema.String, name: Schema.String }) - const left = zod(shared) - const right = zod(shared) - expect(left).toBe(right) - }) - - test("nested reuse of the same AST reuses the cached Zod child", () => { - // Two different parents embed the same inner schema. The inner zod - // child should be identical by reference inside both parents. - class Inner extends Schema.Class("MemoTestInner")({ - value: Schema.String, - }) {} - - class OuterA extends Schema.Class("MemoTestOuterA")({ - inner: Inner, - }) {} - - class OuterB extends Schema.Class("MemoTestOuterB")({ - inner: Inner, - }) {} - - const shapeA = (zod(OuterA) as any).shape ?? (zod(OuterA) as any)._def?.shape?.() - const shapeB = (zod(OuterB) as any).shape ?? (zod(OuterB) as any)._def?.shape?.() - expect(shapeA.inner).toBe(shapeB.inner) - }) - - test("multiple checks run in a single refinement layer (all fire on one value)", () => { - // Three checks attached to the same schema. All three must run and - // report — asserting that no check silently got dropped when we - // flattened into one superRefine. - const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive")) - const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even")) - const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big")) - - const schema = zod(Schema.Number.check(positive).check(even).check(under100)) - - const neg = schema.safeParse(-3) - expect(neg.success).toBe(false) - expect(neg.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"])) - - const big = schema.safeParse(101) - expect(big.success).toBe(false) - expect(big.error!.issues.map((i) => i.message)).toContain("too big") - - // Passing value satisfies all three - expect(schema.parse(42)).toBe(42) - }) - - test("FilterGroup flattens into the single refinement layer alongside its siblings", () => { - const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive")) - const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even")) - const group = Schema.makeFilterGroup([positive, even]) - const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big")) - - const schema = zod(Schema.Number.check(group).check(under100)) - - const bad = schema.safeParse(-3) - expect(bad.success).toBe(false) - expect(bad.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"])) - }) - }) - - describe("well-known refinement translation", () => { - test("Schema.isInt emits type: integer in JSON Schema", () => { - const schema = zod(Schema.Number.check(Schema.isInt())) - const native = json(z.number().int()) - expect(json(schema)).toEqual(native) - expect(schema.parse(3)).toBe(3) - expect(schema.safeParse(1.5).success).toBe(false) - }) - - test("Schema.isGreaterThan(0) emits exclusiveMinimum: 0", () => { - const schema = zod(Schema.Number.check(Schema.isGreaterThan(0))) - expect((json(schema) as any).exclusiveMinimum).toBe(0) - expect(schema.parse(1)).toBe(1) - expect(schema.safeParse(0).success).toBe(false) - expect(schema.safeParse(-1).success).toBe(false) - }) - - test("Schema.isGreaterThanOrEqualTo(0) emits minimum: 0", () => { - const schema = zod(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))) - expect((json(schema) as any).minimum).toBe(0) - expect(schema.parse(0)).toBe(0) - expect(schema.safeParse(-1).success).toBe(false) - }) - - test("Schema.isLessThan(10) emits exclusiveMaximum: 10", () => { - const schema = zod(Schema.Number.check(Schema.isLessThan(10))) - expect((json(schema) as any).exclusiveMaximum).toBe(10) - expect(schema.parse(9)).toBe(9) - expect(schema.safeParse(10).success).toBe(false) - }) - - test("Schema.isLessThanOrEqualTo(10) emits maximum: 10", () => { - const schema = zod(Schema.Number.check(Schema.isLessThanOrEqualTo(10))) - expect((json(schema) as any).maximum).toBe(10) - expect(schema.parse(10)).toBe(10) - expect(schema.safeParse(11).success).toBe(false) - }) - - test("Schema.isMultipleOf(5) emits multipleOf: 5", () => { - const schema = zod(Schema.Number.check(Schema.isMultipleOf(5))) - expect((json(schema) as any).multipleOf).toBe(5) - expect(schema.parse(10)).toBe(10) - expect(schema.safeParse(7).success).toBe(false) - }) - - test("Schema.isFinite validates at runtime", () => { - const schema = zod(Schema.Number.check(Schema.isFinite())) - expect(schema.parse(1)).toBe(1) - expect(schema.safeParse(Infinity).success).toBe(false) - expect(schema.safeParse(NaN).success).toBe(false) - }) - - test("chained isInt + isGreaterThan(0) matches z.number().int().positive()", () => { - const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))) - const native = json(z.number().int().positive()) - expect(json(schema)).toEqual(native) - expect(schema.parse(3)).toBe(3) - expect(schema.safeParse(0).success).toBe(false) - expect(schema.safeParse(1.5).success).toBe(false) - }) - - test("chained isInt + isGreaterThanOrEqualTo(0) matches z.number().int().min(0)", () => { - const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))) - const native = json(z.number().int().min(0)) - expect(json(schema)).toEqual(native) - expect(schema.parse(0)).toBe(0) - expect(schema.safeParse(-1).success).toBe(false) - }) - - test("Schema.isBetween emits both bounds", () => { - const schema = zod(Schema.Number.check(Schema.isBetween({ minimum: 1, maximum: 10 }))) - const shape = json(schema) as any - expect(shape.minimum).toBe(1) - expect(shape.maximum).toBe(10) - expect(schema.parse(5)).toBe(5) - expect(schema.safeParse(11).success).toBe(false) - expect(schema.safeParse(0).success).toBe(false) - }) - - test("Schema.isBetween with exclusive bounds emits exclusiveMinimum/Maximum", () => { - const schema = zod( - Schema.Number.check( - Schema.isBetween({ minimum: 1, maximum: 10, exclusiveMinimum: true, exclusiveMaximum: true }), - ), - ) - const shape = json(schema) as any - expect(shape.exclusiveMinimum).toBe(1) - expect(shape.exclusiveMaximum).toBe(10) - expect(schema.parse(5)).toBe(5) - expect(schema.safeParse(1).success).toBe(false) - expect(schema.safeParse(10).success).toBe(false) - }) - - test("Schema.isInt32 (FilterGroup) produces integer bounds", () => { - const schema = zod(Schema.Number.check(Schema.isInt32())) - const shape = json(schema) as any - expect(shape.type).toBe("integer") - expect(shape.minimum).toBe(-2147483648) - expect(shape.maximum).toBe(2147483647) - expect(schema.parse(42)).toBe(42) - expect(schema.safeParse(1.5).success).toBe(false) - expect(schema.safeParse(2147483648).success).toBe(false) - }) - - test("Schema.isMinLength on string emits minLength", () => { - const schema = zod(Schema.String.check(Schema.isMinLength(3))) - expect((json(schema) as any).minLength).toBe(3) - expect(schema.parse("abc")).toBe("abc") - expect(schema.safeParse("ab").success).toBe(false) - }) - - test("Schema.isMaxLength on string emits maxLength", () => { - const schema = zod(Schema.String.check(Schema.isMaxLength(5))) - expect((json(schema) as any).maxLength).toBe(5) - expect(schema.parse("abcde")).toBe("abcde") - expect(schema.safeParse("abcdef").success).toBe(false) - }) - - test("Schema.isLengthBetween on string emits both bounds", () => { - const schema = zod(Schema.String.check(Schema.isLengthBetween(2, 4))) - const shape = json(schema) as any - expect(shape.minLength).toBe(2) - expect(shape.maxLength).toBe(4) - expect(schema.parse("abc")).toBe("abc") - expect(schema.safeParse("a").success).toBe(false) - expect(schema.safeParse("abcde").success).toBe(false) - }) - - test("Schema.isMinLength on array emits minItems", () => { - const schema = zod(Schema.Array(Schema.String).check(Schema.isMinLength(1))) - expect((json(schema) as any).minItems).toBe(1) - expect(schema.parse(["x"])).toEqual(["x"]) - expect(schema.safeParse([]).success).toBe(false) - }) - - test("Schema.isPattern emits pattern", () => { - const schema = zod(Schema.String.check(Schema.isPattern(/^per/))) - expect((json(schema) as any).pattern).toBe("^per") - expect(schema.parse("per_abc")).toBe("per_abc") - expect(schema.safeParse("abc").success).toBe(false) - }) - - test("Schema.isStartsWith matches native zod .startsWith() JSON Schema", () => { - const schema = zod(Schema.String.check(Schema.isStartsWith("per"))) - const native = json(z.string().startsWith("per")) - expect(json(schema)).toEqual(native) - expect(schema.parse("per_abc")).toBe("per_abc") - expect(schema.safeParse("abc").success).toBe(false) - }) - - test("Schema.isEndsWith matches native zod .endsWith() JSON Schema", () => { - const schema = zod(Schema.String.check(Schema.isEndsWith(".json"))) - const native = json(z.string().endsWith(".json")) - expect(json(schema)).toEqual(native) - expect(schema.parse("a.json")).toBe("a.json") - expect(schema.safeParse("a.txt").success).toBe(false) - }) - - test("Schema.isUUID emits format: uuid", () => { - const schema = zod(Schema.String.check(Schema.isUUID())) - expect((json(schema) as any).format).toBe("uuid") - }) - - test("mix of well-known and anonymous filters translates known and reroutes unknown to superRefine", () => { - // isInt is well-known (translates to .int()); the anonymous filter falls - // back to superRefine. - const notSeven = Schema.makeFilter((n: number) => (n !== 7 ? undefined : "no sevens allowed")) - const schema = zod(Schema.Number.check(Schema.isInt()).check(notSeven)) - - const shape = json(schema) as any - // Well-known translation is preserved — type is integer, not plain number - expect(shape.type).toBe("integer") - - // Runtime: both constraints fire - expect(schema.parse(3)).toBe(3) - expect(schema.safeParse(1.5).success).toBe(false) - const seven = schema.safeParse(7) - expect(seven.success).toBe(false) - expect(seven.error!.issues[0].message).toBe("no sevens allowed") - }) - - test("inside a struct field, well-known refinements propagate through", () => { - // Mirrors config.ts port: z.number().int().positive().optional() - const Port = Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))) - const schema = zod(Schema.Struct({ port: Port })) - const shape = json(schema) as any - expect(shape.properties.port.type).toBe("integer") - expect(shape.properties.port.exclusiveMinimum).toBe(0) - }) - }) - - describe("Schema.optionalWith defaults", () => { - test("parsing undefined returns the default value", () => { - const schema = zod( - Schema.Struct({ - mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))), - }), - ) - expect(schema.parse({})).toEqual({ mode: "ctrl-x" }) - expect(schema.parse({ mode: undefined })).toEqual({ mode: "ctrl-x" }) - }) - - test("parsing a real value returns that value (default does not fire)", () => { - const schema = zod( - Schema.Struct({ - mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))), - }), - ) - expect(schema.parse({ mode: "ctrl-y" })).toEqual({ mode: "ctrl-y" }) - }) - - test("default on a number field", () => { - const schema = zod( - Schema.Struct({ - count: Schema.Number.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(42))), - }), - ) - expect(schema.parse({})).toEqual({ count: 42 }) - expect(schema.parse({ count: 7 })).toEqual({ count: 7 }) - }) - - test("multiple defaulted fields inside a struct", () => { - const schema = zod( - Schema.Struct({ - leader: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))), - quit: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-c"))), - inner: Schema.String, - }), - ) - expect(schema.parse({ inner: "hi" })).toEqual({ - leader: "ctrl-x", - quit: "ctrl-c", - inner: "hi", - }) - expect(schema.parse({ leader: "a", quit: "b", inner: "c" })).toEqual({ - leader: "a", - quit: "b", - inner: "c", - }) - }) - - test("JSON Schema output includes the default key", () => { - const schema = zod( - Schema.Struct({ - mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))), - }), - ) - const shape = json(schema) as any - expect(shape.properties.mode.default).toBe("ctrl-x") - }) - - test("default referencing a computed value resolves when evaluated", () => { - // Simulates `keybinds.ts` style of per-platform defaults: the default is - // produced by an Effect that computes a value at decode time. - const platform = "darwin" - const fallback = platform === "darwin" ? "cmd-k" : "ctrl-k" - const schema = zod( - Schema.Struct({ - command_palette: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.sync(() => fallback))), - }), - ) - expect(schema.parse({})).toEqual({ command_palette: "cmd-k" }) - const shape = json(schema) as any - expect(shape.properties.command_palette.default).toBe("cmd-k") - }) - - test("plain Schema.optional (no default) still emits .optional() (regression)", () => { - const schema = zod(Schema.Struct({ foo: Schema.optional(Schema.String) })) - expect(schema.parse({})).toEqual({}) - expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" }) - }) - }) -}) From 8030a6c1873d73491601c08616fdf87bc3159cc8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 21:31:48 -0400 Subject: [PATCH 057/188] Emit LLM stream lifecycle events (#26971) --- .../llm/src/protocols/anthropic-messages.ts | 102 ++++++++++++------ .../llm/src/protocols/bedrock-converse.ts | 92 ++++++++++++---- packages/llm/src/protocols/gemini.ts | 26 +++-- packages/llm/src/protocols/openai-chat.ts | 18 +++- .../llm/src/protocols/openai-responses.ts | 78 +++++++++----- packages/llm/src/protocols/utils/lifecycle.ts | 88 +++++++++++++++ .../llm/src/protocols/utils/tool-stream.ts | 62 ++++++++--- packages/llm/src/tool-runtime.ts | 4 +- .../test/provider/anthropic-messages.test.ts | 40 +++++-- packages/llm/test/provider/gemini.test.ts | 84 ++++++++++----- .../llm/test/provider/openai-chat.test.ts | 56 ++++++---- .../test/provider/openai-responses.test.ts | 78 +++++++++----- packages/llm/test/tool-runtime.test.ts | 9 +- packages/llm/test/tool-stream.test.ts | 19 +++- 14 files changed, 560 insertions(+), 196 deletions(-) create mode 100644 packages/llm/src/protocols/utils/lifecycle.ts diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index d893888fd..e27af1842 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -17,6 +17,7 @@ import { } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import * as Cache from "./utils/cache" +import { Lifecycle } from "./utils/lifecycle" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "anthropic-messages" @@ -190,6 +191,7 @@ type AnthropicEvent = Schema.Schema.Type interface ParserState { readonly tools: ToolStream.State readonly usage?: Usage + readonly lifecycle: Lifecycle.State } const invalid = ProviderShared.invalidRequest @@ -500,37 +502,45 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes if (!block) return [state, NO_EVENTS] if ((block.type === "tool_use" || block.type === "server_tool_use") && event.index !== undefined) { + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) return [ { ...state, + lifecycle, tools: ToolStream.start(state.tools, event.index, { id: block.id ?? String(event.index), name: block.name ?? "", providerExecuted: block.type === "server_tool_use", }), }, - NO_EVENTS, + [...events, LLMEvent.toolInputStart({ id: block.id ?? String(event.index), name: block.name ?? "" })], ] } if (block.type === "text" && block.text) { - return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: block.text })]] + const events: LLMEvent[] = [] + return [ + { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, block.text) }, + events, + ] } if (block.type === "thinking" && block.thinking) { + const events: LLMEvent[] = [] return [ - state, - [ - LLMEvent.reasoningDelta({ - id: `reasoning-${event.index ?? 0}`, - text: block.thinking, - }), - ], + { + ...state, + lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, block.thinking), + }, + events, ] } const result = serverToolResultEvent(block) - return [state, result ? [result] : NO_EVENTS] + if (!result) return [state, NO_EVENTS] + const events: LLMEvent[] = [] + return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, [...events, result]] } const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(function* ( @@ -540,25 +550,37 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f const delta = event.delta if (delta?.type === "text_delta" && delta.text) { - return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: delta.text })]] satisfies StepResult + const events: LLMEvent[] = [] + return [ + { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, delta.text) }, + events, + ] satisfies StepResult } if (delta?.type === "thinking_delta" && delta.thinking) { + const events: LLMEvent[] = [] return [ - state, - [LLMEvent.reasoningDelta({ id: `reasoning-${event.index ?? 0}`, text: delta.thinking })], + { + ...state, + lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, delta.thinking), + }, + events, ] satisfies StepResult } if (delta?.type === "signature_delta" && delta.signature) { + const events: LLMEvent[] = [] return [ - state, - [ - LLMEvent.reasoningEnd({ - id: `reasoning-${event.index ?? 0}`, - providerMetadata: anthropicMetadata({ signature: delta.signature }), - }), - ], + { + ...state, + lifecycle: Lifecycle.reasoningEnd( + state.lifecycle, + events, + `reasoning-${event.index ?? 0}`, + anthropicMetadata({ signature: delta.signature }), + ), + }, + events, ] satisfies StepResult } @@ -572,7 +594,10 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f "Anthropic Messages tool argument delta is missing its tool call", ) if (ToolStream.isError(result)) return yield* result - return [{ ...state, tools: result.tools }, result.event ? [result.event] : NO_EVENTS] satisfies StepResult + const events: LLMEvent[] = [] + const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...result.events) + return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult } return [state, NO_EVENTS] satisfies StepResult @@ -584,23 +609,30 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun ) { if (event.index === undefined) return [state, NO_EVENTS] satisfies StepResult const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index) - return [{ ...state, tools: result.tools }, result.event ? [result.event] : NO_EVENTS] satisfies StepResult + const events: LLMEvent[] = [] + const resultEvents = result.events ?? [] + const lifecycle = resultEvents.length + ? Lifecycle.stepStart(state.lifecycle, events) + : Lifecycle.reasoningEnd( + Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`), + events, + `reasoning-${event.index}`, + ) + events.push(...resultEvents) + return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult }) const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => { const usage = mergeUsage(state.usage, mapUsage(event.usage)) - return [ - { ...state, usage }, - [ - LLMEvent.requestFinish({ - reason: mapFinishReason(event.delta?.stop_reason), - usage, - providerMetadata: event.delta?.stop_sequence - ? anthropicMetadata({ stopSequence: event.delta.stop_sequence }) - : undefined, - }), - ], - ] + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.finish(state.lifecycle, events, { + reason: mapFinishReason(event.delta?.stop_reason), + usage, + providerMetadata: event.delta?.stop_sequence + ? anthropicMetadata({ stopSequence: event.delta.stop_sequence }) + : undefined, + }) + return [{ ...state, lifecycle, usage }, events] } const onError = (state: ParserState, event: AnthropicEvent): StepResult => [ @@ -634,7 +666,7 @@ export const protocol = Protocol.make({ }, stream: { event: Protocol.jsonEvent(AnthropicEvent), - initial: () => ({ tools: ToolStream.empty() }), + initial: () => ({ tools: ToolStream.empty(), lifecycle: Lifecycle.initial() }), step, }, }) diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index f561a6d7c..7f5647c4a 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -17,6 +17,7 @@ import { JsonObject, optionalArray, ProviderShared } from "./shared" import { BedrockAuth, type Credentials as BedrockCredentials } from "./utils/bedrock-auth" import { BedrockCache } from "./utils/bedrock-cache" import { BedrockMedia } from "./utils/bedrock-media" +import { Lifecycle } from "./utils/lifecycle" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "bedrock-converse" @@ -420,45 +421,64 @@ interface ParserState { // `metadata` (carries usage). Hold the terminal event in state so `onHalt` // can emit exactly one finish after both chunks have had a chance to arrive. readonly pendingFinish: { readonly reason: FinishReason; readonly usage?: Usage } | undefined + readonly hasToolCalls: boolean + readonly lifecycle: Lifecycle.State } const step = (state: ParserState, event: BedrockEvent) => Effect.gen(function* () { if (event.contentBlockStart?.start?.toolUse) { const index = event.contentBlockStart.contentBlockIndex + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) return [ { ...state, + lifecycle, tools: ToolStream.start(state.tools, index, { id: event.contentBlockStart.start.toolUse.toolUseId, name: event.contentBlockStart.start.toolUse.name, }), }, - [], + [ + ...events, + LLMEvent.toolInputStart({ + id: event.contentBlockStart.start.toolUse.toolUseId, + name: event.contentBlockStart.start.toolUse.name, + }), + ], ] as const } if (event.contentBlockDelta?.delta?.text) { + const events: LLMEvent[] = [] return [ - state, - [ - LLMEvent.textDelta({ - id: `text-${event.contentBlockDelta.contentBlockIndex}`, - text: event.contentBlockDelta.delta.text, - }), - ], + { + ...state, + lifecycle: Lifecycle.textDelta( + state.lifecycle, + events, + `text-${event.contentBlockDelta.contentBlockIndex}`, + event.contentBlockDelta.delta.text, + ), + }, + events, ] as const } if (event.contentBlockDelta?.delta?.reasoningContent?.text) { + const events: LLMEvent[] = [] return [ - state, - [ - LLMEvent.reasoningDelta({ - id: `reasoning-${event.contentBlockDelta.contentBlockIndex}`, - text: event.contentBlockDelta.delta.reasoningContent.text, - }), - ], + { + ...state, + lifecycle: Lifecycle.reasoningDelta( + state.lifecycle, + events, + `reasoning-${event.contentBlockDelta.contentBlockIndex}`, + event.contentBlockDelta.delta.reasoningContent.text, + ), + }, + events, ] as const } @@ -472,12 +492,33 @@ const step = (state: ParserState, event: BedrockEvent) => "Bedrock Converse tool delta is missing its tool call", ) if (ToolStream.isError(result)) return yield* result - return [{ ...state, tools: result.tools }, result.event ? [result.event] : []] as const + const events: LLMEvent[] = [] + const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...result.events) + return [{ ...state, lifecycle, tools: result.tools }, events] as const } if (event.contentBlockStop) { const result = yield* ToolStream.finish(ADAPTER, state.tools, event.contentBlockStop.contentBlockIndex) - return [{ ...state, tools: result.tools }, result.event ? [result.event] : []] as const + const events: LLMEvent[] = [] + const resultEvents = result.events ?? [] + const lifecycle = resultEvents.length + ? Lifecycle.stepStart(state.lifecycle, events) + : Lifecycle.reasoningEnd( + Lifecycle.textEnd(state.lifecycle, events, `text-${event.contentBlockStop.contentBlockIndex}`), + events, + `reasoning-${event.contentBlockStop.contentBlockIndex}`, + ) + events.push(...resultEvents) + return [ + { + ...state, + hasToolCalls: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasToolCalls, + lifecycle, + tools: result.tools, + }, + events, + ] as const } if (event.messageStop) { @@ -517,7 +558,15 @@ const framing = BedrockEventStream.framing(ADAPTER) const onHalt = (state: ParserState): ReadonlyArray => state.pendingFinish - ? [LLMEvent.requestFinish({ reason: state.pendingFinish.reason, usage: state.pendingFinish.usage })] + ? (() => { + const events: LLMEvent[] = [] + Lifecycle.finish(state.lifecycle, events, { + reason: + state.pendingFinish.reason === "stop" && state.hasToolCalls ? "tool-calls" : state.pendingFinish.reason, + usage: state.pendingFinish.usage, + }) + return events + })() : [] // ============================================================================= @@ -535,7 +584,12 @@ export const protocol = Protocol.make({ }, stream: { event: BedrockEvent, - initial: () => ({ tools: ToolStream.empty(), pendingFinish: undefined }), + initial: () => ({ + tools: ToolStream.empty(), + pendingFinish: undefined, + hasToolCalls: false, + lifecycle: Lifecycle.initial(), + }), step, onHalt, }, diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index 0ee88f3be..6e0b82abb 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -16,6 +16,7 @@ import { } from "../schema" import { JsonObject, optionalArray, ProviderShared } from "./shared" import { GeminiToolSchema } from "./utils/gemini-tool-schema" +import { Lifecycle } from "./utils/lifecycle" const ADAPTER = "gemini" export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" @@ -134,10 +135,9 @@ interface ParserState { readonly hasToolCalls: boolean readonly nextToolCallId: number readonly usage?: Usage + readonly lifecycle: Lifecycle.State } -const invalid = ProviderShared.invalidRequest - const mediaData = ProviderShared.mediaBytes // ============================================================================= @@ -324,7 +324,14 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean const finish = (state: ParserState): ReadonlyArray => state.finishReason || state.usage - ? [LLMEvent.requestFinish({ reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage })] + ? (() => { + const events: LLMEvent[] = [] + Lifecycle.finish(state.lifecycle, events, { + reason: mapFinishReason(state.finishReason, state.hasToolCalls), + usage: state.usage, + }) + return events + })() : [] const step = (state: ParserState, event: GeminiEvent) => { @@ -341,21 +348,21 @@ const step = (state: ParserState, event: GeminiEvent) => { const events: LLMEvent[] = [] let hasToolCalls = nextState.hasToolCalls + let lifecycle = nextState.lifecycle let nextToolCallId = nextState.nextToolCallId for (const part of candidate.content.parts) { if ("text" in part && part.text.length > 0) { - events.push( - part.thought - ? LLMEvent.reasoningDelta({ id: "reasoning-0", text: part.text }) - : LLMEvent.textDelta({ id: "text-0", text: part.text }), - ) + lifecycle = part.thought + ? Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", part.text) + : Lifecycle.textDelta(lifecycle, events, "text-0", part.text) continue } if ("functionCall" in part) { const input = part.functionCall.args const id = `tool_${nextToolCallId++}` + lifecycle = Lifecycle.stepStart(lifecycle, events) events.push(LLMEvent.toolCall({ id, name: part.functionCall.name, input })) hasToolCalls = true } @@ -365,6 +372,7 @@ const step = (state: ParserState, event: GeminiEvent) => { { ...nextState, hasToolCalls, + lifecycle, nextToolCallId, finishReason: candidate.finishReason ?? nextState.finishReason, }, @@ -388,7 +396,7 @@ export const protocol = Protocol.make({ }, stream: { event: Protocol.jsonEvent(GeminiEvent), - initial: () => ({ hasToolCalls: false, nextToolCallId: 0 }), + initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }), step, onHalt: finish, }, diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 133adb503..470a1473c 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -16,6 +16,7 @@ import { } from "../schema" import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { OpenAIOptions } from "./utils/openai-options" +import { Lifecycle } from "./utils/lifecycle" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "openai-chat" @@ -147,6 +148,7 @@ interface ParserState { readonly toolCallEvents: ReadonlyArray readonly usage?: Usage readonly finishReason?: FinishReason + readonly lifecycle: Lifecycle.State } const invalid = ProviderShared.invalidRequest @@ -321,7 +323,9 @@ const step = (state: ParserState, event: OpenAIChatEvent) => const toolDeltas = delta?.tool_calls ?? [] let tools = state.tools - if (delta?.content) events.push(LLMEvent.textDelta({ id: "text-0", text: delta.content })) + let lifecycle = state.lifecycle + + if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content) for (const tool of toolDeltas) { const result = ToolStream.appendOrStart( @@ -333,7 +337,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) => ) if (ToolStream.isError(result)) return yield* result tools = result.tools - if (result.event) events.push(result.event) + if (result.events.length) lifecycle = Lifecycle.stepStart(lifecycle, events) + events.push(...result.events) } // Finalize accumulated tool inputs eagerly when finish_reason arrives so @@ -349,15 +354,20 @@ const step = (state: ParserState, event: OpenAIChatEvent) => toolCallEvents: finished?.events ?? state.toolCallEvents, usage, finishReason, + lifecycle, }, events, ] as const }) const finishEvents = (state: ParserState): ReadonlyArray => { + const events: LLMEvent[] = [] const hasToolCalls = state.toolCallEvents.length > 0 const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason - return [...state.toolCallEvents, ...(reason ? [LLMEvent.requestFinish({ reason, usage: state.usage })] : [])] + const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...state.toolCallEvents) + if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage }) + return events } // ============================================================================= @@ -377,7 +387,7 @@ export const protocol = Protocol.make({ }, stream: { event: Protocol.jsonEvent(OpenAIChatEvent), - initial: () => ({ tools: ToolStream.empty(), toolCallEvents: [] }), + initial: () => ({ tools: ToolStream.empty(), toolCallEvents: [], lifecycle: Lifecycle.initial() }), step, onHalt: finishEvents, }, diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 035cc0771..e31a42cd5 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -17,6 +17,7 @@ import { } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { OpenAIOptions } from "./utils/openai-options" +import { Lifecycle } from "./utils/lifecycle" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "openai-responses" @@ -165,6 +166,7 @@ type OpenAIResponsesEvent = Schema.Schema.Type interface ParserState { readonly tools: ToolStream.State readonly hasFunctionCall: boolean + readonly lifecycle: Lifecycle.State } const invalid = ProviderShared.invalidRequest @@ -385,23 +387,32 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { if (!event.delta) return [state, NO_EVENTS] - return [state, [LLMEvent.textDelta({ id: event.item_id ?? "text-0", text: event.delta })]] + const events: LLMEvent[] = [] + return [ + { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) }, + events, + ] } const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { const item = event.item if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS] + const providerMetadata = openaiMetadata({ itemId: item.id }) + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) return [ { + ...state, + lifecycle, hasFunctionCall: state.hasFunctionCall, tools: ToolStream.start(state.tools, item.id, { id: item.call_id ?? item.id, name: item.name ?? "", input: item.arguments ?? "", - providerMetadata: openaiMetadata({ itemId: item.id }), + providerMetadata, }), }, - NO_EVENTS, + [...events, LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata })], ] } @@ -418,10 +429,10 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenAIResponses.onFunctionCallAr "OpenAI Responses tool argument delta is missing its tool call", ) if (ToolStream.isError(result)) return yield* result - return [ - { hasFunctionCall: state.hasFunctionCall, tools: result.tools }, - result.event ? [result.event] : NO_EVENTS, - ] satisfies StepResult + const events: LLMEvent[] = [] + const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...result.events) + return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult }) const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* ( @@ -440,33 +451,46 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* item.arguments === undefined ? yield* ToolStream.finish(ADAPTER, tools, item.id) : yield* ToolStream.finishWithInput(ADAPTER, tools, item.id, item.arguments) + const events: LLMEvent[] = [] + const resultEvents = result.events ?? [] + const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...resultEvents) return [ - { hasFunctionCall: result.event ? true : state.hasFunctionCall, tools: result.tools }, - result.event ? [result.event] : NO_EVENTS, + { + ...state, + lifecycle, + hasFunctionCall: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasFunctionCall, + tools: result.tools, + }, + events, ] satisfies StepResult } - if (isHostedToolItem(item)) return [state, hostedToolEvents(item)] satisfies StepResult + if (isHostedToolItem(item)) { + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) + events.push(...hostedToolEvents(item)) + return [{ ...state, lifecycle }, events] satisfies StepResult + } return [state, NO_EVENTS] satisfies StepResult }) -const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ - state, - [ - LLMEvent.requestFinish({ - reason: mapFinishReason(event, state.hasFunctionCall), - usage: mapUsage(event.response?.usage), - providerMetadata: - event.response?.id || event.response?.service_tier - ? openaiMetadata({ - responseId: event.response.id, - serviceTier: event.response.service_tier, - }) - : undefined, - }), - ], -] +const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.finish(state.lifecycle, events, { + reason: mapFinishReason(event, state.hasFunctionCall), + usage: mapUsage(event.response?.usage), + providerMetadata: + event.response?.id || event.response?.service_tier + ? openaiMetadata({ + responseId: event.response.id, + serviceTier: event.response.service_tier, + }) + : undefined, + }) + return [{ ...state, lifecycle }, events] +} const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ state, @@ -506,7 +530,7 @@ export const protocol = Protocol.make({ }, stream: { event: Protocol.jsonEvent(OpenAIResponsesEvent), - initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty() }), + initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty(), lifecycle: Lifecycle.initial() }), step, terminal: (event) => TERMINAL_TYPES.has(event.type), }, diff --git a/packages/llm/src/protocols/utils/lifecycle.ts b/packages/llm/src/protocols/utils/lifecycle.ts new file mode 100644 index 000000000..67039b137 --- /dev/null +++ b/packages/llm/src/protocols/utils/lifecycle.ts @@ -0,0 +1,88 @@ +import { LLMEvent, type FinishReason, type ProviderMetadata, type Usage } from "../../schema" + +export interface State { + readonly stepStarted: boolean + readonly text: ReadonlySet + readonly reasoning: ReadonlySet +} + +export const initial = (): State => ({ stepStarted: false, text: new Set(), reasoning: new Set() }) + +export const stepStart = (state: State, events: LLMEvent[]): State => { + if (state.stepStarted) return state + events.push(LLMEvent.stepStart({ index: 0 })) + return { ...state, stepStarted: true } +} + +export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { + const stepped = stepStart(state, events) + if (stepped.text.has(id)) { + events.push(LLMEvent.textDelta({ id, text })) + return stepped + } + events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text })) + return { ...stepped, text: new Set([...stepped.text, id]) } +} + +export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { + const stepped = stepStart(state, events) + if (stepped.reasoning.has(id)) { + events.push(LLMEvent.reasoningDelta({ id, text })) + return stepped + } + events.push(LLMEvent.reasoningStart({ id }), LLMEvent.reasoningDelta({ id, text })) + return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) } +} + +export const reasoningEnd = ( + state: State, + events: LLMEvent[], + id: string, + providerMetadata?: ProviderMetadata, +): State => { + if (!state.reasoning.has(id)) return state + const stepped = stepStart(state, events) + events.push(LLMEvent.reasoningEnd({ id, providerMetadata })) + const reasoning = new Set(stepped.reasoning) + reasoning.delete(id) + return { ...stepped, reasoning } +} + +export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => { + if (!state.text.has(id)) return state + const stepped = stepStart(state, events) + events.push(LLMEvent.textEnd({ id, providerMetadata })) + const text = new Set(stepped.text) + text.delete(id) + return { ...stepped, text } +} + +const closeOpenBlocks = (state: State, events: LLMEvent[]): State => { + for (const id of state.reasoning) events.push(LLMEvent.reasoningEnd({ id })) + for (const id of state.text) events.push(LLMEvent.textEnd({ id })) + return { ...state, text: new Set(), reasoning: new Set() } +} + +export const finish = ( + state: State, + events: LLMEvent[], + input: { + readonly reason: FinishReason + readonly usage?: Usage + readonly providerMetadata?: ProviderMetadata + }, +): State => { + const stepped = closeOpenBlocks(stepStart(state, events), events) + events.push( + LLMEvent.stepFinish({ + index: 0, + reason: input.reason, + usage: input.usage, + providerMetadata: input.providerMetadata, + }), + LLMEvent.requestFinish(input), + ) + return { ...stepped, stepStarted: false } +} + +export * as Lifecycle from "./lifecycle" diff --git a/packages/llm/src/protocols/utils/tool-stream.ts b/packages/llm/src/protocols/utils/tool-stream.ts index aa9c70f01..8e07a64bf 100644 --- a/packages/llm/src/protocols/utils/tool-stream.ts +++ b/packages/llm/src/protocols/utils/tool-stream.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema" +import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema" import { eventError, parseToolInput, type ToolAccumulator } from "../shared" type StreamKey = string | number @@ -27,13 +27,13 @@ export type State = Partial> /** * Result of adding argument text to one pending tool call. It returns both the * next `tools` state and the updated `tool` because parsers often need the - * current id/name immediately. `event` is present only when new text arrived; - * metadata-only deltas update identity without emitting `tool-input-delta`. + * current id/name immediately. `events` contains lifecycle and delta events + * produced by the append; metadata-only deltas update identity without output. */ export interface AppendOutcome { readonly tools: State readonly tool: PendingTool - readonly event?: ToolInputDelta + readonly events: ReadonlyArray } /** Create empty accumulator state for one provider stream. */ @@ -49,7 +49,14 @@ const withoutTool = (tools: State, key: K): State => return next } -const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => +const inputStart = (tool: PendingTool) => + LLMEvent.toolInputStart({ + id: tool.id, + name: tool.name, + providerMetadata: tool.providerMetadata, + }) + +const inputDelta = (tool: PendingTool, text: string) => LLMEvent.toolInputDelta({ id: tool.id, name: tool.name, @@ -76,11 +83,16 @@ const appendTool = ( key: K, tool: PendingTool, text: string, -): AppendOutcome => ({ - tools: withTool(tools, key, tool), - tool, - event: text.length === 0 ? undefined : inputDelta(tool, text), -}) +): AppendOutcome => { + const events: LLMEvent[] = [] + if (!tools[key]) events.push(inputStart(tool)) + if (text.length > 0) events.push(inputDelta(tool, text)) + return { + tools: withTool(tools, key, tool), + tool, + events, + } +} export const isError = (result: AppendOutcome | LLMError): result is LLMError => result instanceof LLMError @@ -121,7 +133,8 @@ export const appendOrStart = ( providerExecuted: current?.providerExecuted, providerMetadata: current?.providerMetadata, } - if (current && delta.text.length === 0 && current.id === id && current.name === name) return { tools, tool: current } + if (current && delta.text.length === 0 && current.id === id && current.name === name) + return { tools, tool: current, events: [] } return appendTool(tools, key, tool, delta.text) } @@ -139,7 +152,7 @@ export const appendExisting = ( ): AppendOutcome | LLMError => { const current = tools[key] if (!current) return eventError(route, missingToolMessage) - if (text.length === 0) return { tools, tool: current } + if (text.length === 0) return { tools, tool: current, events: [] } return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text) } @@ -152,7 +165,13 @@ export const finish = (route: string, tools: State, key: Effect.gen(function* () { const tool = tools[key] if (!tool) return { tools } - return { tools: withoutTool(tools, key), event: yield* toolCall(route, tool) } + return { + tools: withoutTool(tools, key), + events: [ + LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), + yield* toolCall(route, tool), + ], + } }) /** @@ -164,7 +183,13 @@ export const finishWithInput = (route: string, tools: State Effect.gen(function* () { const tool = tools[key] if (!tool) return { tools } - return { tools: withoutTool(tools, key), event: yield* toolCall(route, tool, input) } + return { + tools: withoutTool(tools, key), + events: [ + LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), + yield* toolCall(route, tool, input), + ], + } }) /** @@ -179,7 +204,14 @@ export const finishAll = (route: string, tools: State) = ) return { tools: empty(), - events: yield* Effect.forEach(pending, (tool) => toolCall(route, tool)), + events: yield* Effect.forEach(pending, (tool) => + toolCall(route, tool).pipe( + Effect.map((call) => [ + LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), + call, + ]), + ), + ).pipe(Effect.map((events) => events.flat())), } }) diff --git a/packages/llm/src/tool-runtime.ts b/packages/llm/src/tool-runtime.ts index c6e716d45..f46452582 100644 --- a/packages/llm/src/tool-runtime.ts +++ b/packages/llm/src/tool-runtime.ts @@ -154,8 +154,8 @@ const accumulate = (state: StepState, event: LLMEvent) => { ) return } - if (event.type === "request-finish") { - state.finishReason = event.reason + if (event.type === "step-finish" || event.type === "request-finish") { + state.finishReason = event.reason === "stop" && state.toolCalls.length > 0 ? "tool-calls" : event.reason } } diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 0df3541d5..6417f73c2 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -146,24 +146,46 @@ describe("Anthropic Messages route", () => { tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], }), ).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + cacheReadInputTokens: undefined, + cacheWriteInputTokens: undefined, + totalTokens: 6, + providerMetadata: { anthropic: { input_tokens: 5, output_tokens: 1 } }, + }) expect(response.toolCalls).toEqual([ - { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + { + type: "tool-call", + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, ]) expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "tool-input-start", id: "call_1", name: "lookup" }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, - { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined }, + { + type: "tool-call", + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, + { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, { type: "request-finish", reason: "tool-calls", - usage: new Usage({ - inputTokens: 5, - outputTokens: 1, - nonCachedInputTokens: 5, - totalTokens: 6, - providerMetadata: { anthropic: { input_tokens: 5, output_tokens: 1 } }, - }), + providerMetadata: undefined, + usage, }, ]) }), diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index ea4eadc49..80c32c58b 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -204,30 +204,37 @@ describe("Gemini route", () => { reasoningTokens: 1, totalTokens: 7, }) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 3, + nonCachedInputTokens: 4, + cacheReadInputTokens: 1, + reasoningTokens: 1, + totalTokens: 7, + providerMetadata: { + google: { + promptTokenCount: 5, + candidatesTokenCount: 2, + totalTokenCount: 7, + thoughtsTokenCount: 1, + cachedContentTokenCount: 1, + }, + }, + }) expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "reasoning-start", id: "reasoning-0" }, { type: "reasoning-delta", id: "reasoning-0", text: "thinking" }, + { type: "text-start", id: "text-0" }, { type: "text-delta", id: "text-0", text: "Hello" }, { type: "text-delta", id: "text-0", text: "!" }, + { type: "reasoning-end", id: "reasoning-0" }, + { type: "text-end", id: "text-0" }, + { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined }, { type: "request-finish", reason: "stop", - usage: new Usage({ - inputTokens: 5, - outputTokens: 3, - nonCachedInputTokens: 4, - cacheReadInputTokens: 1, - reasoningTokens: 1, - totalTokens: 7, - providerMetadata: { - google: { - promptTokenCount: 5, - candidatesTokenCount: 2, - totalTokenCount: 7, - thoughtsTokenCount: 1, - cachedContentTokenCount: 1, - }, - }, - }), + usage, }, ]) }), @@ -252,22 +259,41 @@ describe("Gemini route", () => { tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], }), ).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + cacheReadInputTokens: undefined, + reasoningTokens: undefined, + totalTokens: 6, + providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } }, + }) expect(response.toolCalls).toEqual([ - { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } }, + { + type: "tool-call", + id: "tool_0", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, ]) expect(response.events).toEqual([ - { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } }, + { type: "step-start", index: 0 }, + { + type: "tool-call", + id: "tool_0", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, + { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, { type: "request-finish", reason: "tool-calls", - usage: new Usage({ - inputTokens: 5, - outputTokens: 1, - nonCachedInputTokens: 5, - totalTokens: 6, - providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } }, - }), + usage, }, ]) }), @@ -318,8 +344,10 @@ describe("Gemini route", () => { ), ) - expect(length.events).toEqual([{ type: "request-finish", reason: "length" }]) - expect(filtered.events).toEqual([{ type: "request-finish", reason: "content-filter" }]) + expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "request-finish"]) + expect(length.events.at(-1)).toMatchObject({ type: "request-finish", reason: "length" }) + expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "request-finish"]) + expect(filtered.events.at(-1)).toMatchObject({ type: "request-finish", reason: "content-filter" }) }), ) diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 9c8142263..115c58849 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -222,31 +222,36 @@ describe("OpenAI Chat route", () => { }), ) const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 2, + nonCachedInputTokens: 4, + cacheReadInputTokens: 1, + reasoningTokens: 0, + totalTokens: 7, + providerMetadata: { + openai: { + prompt_tokens: 5, + completion_tokens: 2, + total_tokens: 7, + prompt_tokens_details: { cached_tokens: 1 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }) expect(response.text).toBe("Hello!") expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "text-start", id: "text-0" }, { type: "text-delta", id: "text-0", text: "Hello" }, { type: "text-delta", id: "text-0", text: "!" }, + { type: "text-end", id: "text-0" }, + { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined }, { type: "request-finish", reason: "stop", - usage: new Usage({ - inputTokens: 5, - outputTokens: 2, - nonCachedInputTokens: 4, - cacheReadInputTokens: 1, - reasoningTokens: 0, - totalTokens: 7, - providerMetadata: { - openai: { - prompt_tokens: 5, - completion_tokens: 2, - total_tokens: 7, - prompt_tokens_details: { cached_tokens: 1 }, - completion_tokens_details: { reasoning_tokens: 0 }, - }, - }, - }), + usage, }, ]) }), @@ -269,9 +274,20 @@ describe("OpenAI Chat route", () => { ).pipe(Effect.provide(fixedResponse(body))) expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, - { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined }, + { + type: "tool-call", + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, + { type: "step-finish", index: 0, reason: "tool-calls", usage: undefined, providerMetadata: undefined }, { type: "request-finish", reason: "tool-calls", usage: undefined }, ]) }), @@ -293,6 +309,8 @@ describe("OpenAI Chat route", () => { ).pipe(Effect.provide(fixedResponse(body))) expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, ]) @@ -352,7 +370,7 @@ describe("OpenAI Chat route", () => { const events = Array.from( yield* LLMClient.stream(request).pipe(Stream.take(1), Stream.runCollect, Effect.provide(fixedResponse(body))), ) - expect(events.map((event) => event.type)).toEqual(["text-delta"]) + expect(events.map((event) => event.type)).toEqual(["step-start"]) }), ) }) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index da9dbd82c..8b4469f4e 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -333,32 +333,43 @@ describe("OpenAI Responses route", () => { }, ) const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 2, + nonCachedInputTokens: 4, + cacheReadInputTokens: 1, + reasoningTokens: 0, + totalTokens: 7, + providerMetadata: { + openai: { + input_tokens: 5, + output_tokens: 2, + total_tokens: 7, + input_tokens_details: { cached_tokens: 1 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }) expect(response.text).toBe("Hello!") expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "text-start", id: "msg_1" }, { type: "text-delta", id: "msg_1", text: "Hello" }, { type: "text-delta", id: "msg_1", text: "!" }, + { type: "text-end", id: "msg_1" }, + { + type: "step-finish", + index: 0, + reason: "stop", + providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, + usage, + }, { type: "request-finish", reason: "stop", providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, - usage: new Usage({ - inputTokens: 5, - outputTokens: 2, - nonCachedInputTokens: 4, - cacheReadInputTokens: 1, - reasoningTokens: 0, - totalTokens: 7, - providerMetadata: { - openai: { - input_tokens: 5, - output_tokens: 2, - total_tokens: 7, - input_tokens_details: { cached_tokens: 1 }, - output_tokens_details: { reasoning_tokens: 0 }, - }, - }, - }), + usage, }, ]) }), @@ -390,8 +401,24 @@ describe("OpenAI Responses route", () => { tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], }), ).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + cacheReadInputTokens: undefined, + reasoningTokens: undefined, + totalTokens: 6, + providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } }, + }) expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { + type: "tool-input-start", + id: "call_1", + name: "lookup", + providerMetadata: { openai: { itemId: "item_1" } }, + }, { type: "tool-input-delta", id: "call_1", @@ -404,23 +431,26 @@ describe("OpenAI Responses route", () => { name: "lookup", text: ':"weather"}', }, + { + type: "tool-input-end", + id: "call_1", + name: "lookup", + providerMetadata: { openai: { itemId: "item_1" } }, + }, { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, + providerExecuted: undefined, providerMetadata: { openai: { itemId: "item_1" } }, }, + { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, { type: "request-finish", reason: "tool-calls", - usage: new Usage({ - inputTokens: 5, - outputTokens: 1, - nonCachedInputTokens: 5, - totalTokens: 6, - providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } }, - }), + providerMetadata: undefined, + usage, }, ]) }), diff --git a/packages/llm/test/tool-runtime.test.ts b/packages/llm/test/tool-runtime.test.ts index 8f4221784..040a11fb6 100644 --- a/packages/llm/test/tool-runtime.test.ts +++ b/packages/llm/test/tool-runtime.test.ts @@ -313,7 +313,14 @@ describe("LLMClient tools", () => { ), ) - expect(events.map((event) => event.type)).toEqual(["text-delta", "request-finish"]) + expect(events.map((event) => event.type)).toEqual([ + "step-start", + "text-start", + "text-delta", + "text-end", + "step-finish", + "request-finish", + ]) expect(LLMResponse.text({ events })).toBe("Done.") }), ) diff --git a/packages/llm/test/tool-stream.test.ts b/packages/llm/test/tool-stream.test.ts index 04a0035c9..b005d2666 100644 --- a/packages/llm/test/tool-stream.test.ts +++ b/packages/llm/test/tool-stream.test.ts @@ -21,11 +21,17 @@ describe("ToolStream", () => { if (ToolStream.isError(second)) return yield* second const finished = yield* ToolStream.finish(ADAPTER, second.tools, 0) - expect(first.event).toEqual({ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }) - expect(second.event).toEqual({ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }) + expect(first.events).toEqual([ + { type: "tool-input-start", id: "call_1", name: "lookup" }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, + ]) + expect(second.events).toEqual([{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }]) expect(finished).toEqual({ tools: {}, - event: { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + events: [ + { type: "tool-input-end", id: "call_1", name: "lookup" }, + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + ], }) }), ) @@ -50,7 +56,10 @@ describe("ToolStream", () => { expect(finished).toEqual({ tools: {}, - event: { type: "tool-call", id: "call_1", name: "lookup", input: { query: "final" } }, + events: [ + { type: "tool-input-end", id: "call_1", name: "lookup" }, + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "final" } }, + ], }) }), ) @@ -73,7 +82,9 @@ describe("ToolStream", () => { expect(finished).toEqual({ tools: {}, events: [ + { type: "tool-input-end", id: "call_1", name: "lookup" }, { type: "tool-call", id: "call_1", name: "lookup", input: {} }, + { type: "tool-input-end", id: "call_2", name: "web_search" }, { type: "tool-call", id: "call_2", From 74aa735e6ac84078b4dd04bb1b93a611b8946885 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 21:35:28 -0400 Subject: [PATCH 058/188] fix(tui): guard prompt submit against concurrent invocation (#26972) --- .../cli/cmd/tui/component/prompt/index.tsx | 17 ++++ .../test/cli/tui/prompt-submit-race.test.ts | 98 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 packages/opencode/test/cli/tui/prompt-submit-race.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index f3217fcba..3bbfc261b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -989,7 +989,24 @@ export function Prompt(props: PromptProps) { } }) + let submitting = false async function submit() { + // Prevent overlapping invocations (e.g. a double-pressed Enter, or the + // input's native onSubmit racing another dispatch). Without this guard, + // a second call slips past the empty-input check before the first call + // clears `store.prompt.input`, then awaits its own `session.create` and + // ultimately reads the now-empty store — sending a phantom empty prompt + // to a freshly created session. + if (submitting) return false + submitting = true + try { + return await submitInner() + } finally { + submitting = false + } + } + + async function submitInner() { setWarpNotice(undefined) // IME: double-defer may fire before onContentChange flushes the last diff --git a/packages/opencode/test/cli/tui/prompt-submit-race.test.ts b/packages/opencode/test/cli/tui/prompt-submit-race.test.ts new file mode 100644 index 000000000..df659a01d --- /dev/null +++ b/packages/opencode/test/cli/tui/prompt-submit-race.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test" + +// Regression test for the prompt submit race in +// packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx (`submit`). +// +// Before the fix, two concurrent `submit()` calls (e.g. a double-pressed +// Enter, or the input's native onSubmit racing another dispatch) each +// passed the `if (!store.prompt.input) return false` guard, each +// `await sdk.client.session.create(...)`, and each only captured +// `inputText = store.prompt.input` AFTER that await. The first invocation +// finished, sent the prompt, and cleared the store; the second invocation, +// now past its await, read the cleared store and sent an empty prompt to a +// second freshly-created session - leaving an orphaned session with the +// user's actual text and a phantom session visible to the user containing +// only an assistant reply. +// +// `submitMirror` below has the exact shape of the production `submit()` +// after the fix: an in-flight `submitting` guard wraps the original body. +// Two concurrent invocations must result in exactly one submission carrying +// the user's text, with no empty-text submission. + +type Store = { input: string } + +type SubmitResult = { sessionID: string; text: string } + +type Harness = { + store: Store + submissions: SubmitResult[] + createSession(): Promise + sendPrompt(sessionID: string, text: string): Promise +} + +function createHarness(opts: { sessionCreateDelayMs: number }): Harness { + let sessionCounter = 0 + const submissions: SubmitResult[] = [] + + return { + store: { input: "" }, + submissions, + async createSession() { + sessionCounter += 1 + const id = `ses_${sessionCounter}` + await Bun.sleep(opts.sessionCreateDelayMs) + return id + }, + async sendPrompt(sessionID, text) { + submissions.push({ sessionID, text }) + }, + } +} + +function createSubmit() { + let submitting = false + return async function submit(h: Harness) { + if (submitting) return false + submitting = true + try { + if (!h.store.input) return false + const sessionID = await h.createSession() + const inputText = h.store.input + await h.sendPrompt(sessionID, inputText) + h.store.input = "" + return true + } finally { + submitting = false + } + } +} + +describe("Prompt.submit race", () => { + test("concurrent submits must not lose the user's text", async () => { + const submit = createSubmit() + const h = createHarness({ sessionCreateDelayMs: 5 }) + h.store.input = "Hello there." + + // Two invocations back-to-back, mimicking a double-Enter. + await Promise.all([submit(h), submit(h)]) + + // Every submission that did make it through must carry the actual user + // text, and no submission may have an empty text payload. + expect(h.submissions.every((s) => s.text === "Hello there.")).toBe(true) + expect(h.submissions.some((s) => s.text === "")).toBe(false) + }) + + test("a sequential second submit after clear is a no-op, not a phantom session", async () => { + const submit = createSubmit() + const h = createHarness({ sessionCreateDelayMs: 1 }) + h.store.input = "Hello there." + + await submit(h) + // After the first submission completes, the store is cleared; a second + // Enter on an empty input must not create a phantom session. + await submit(h) + + expect(h.submissions).toHaveLength(1) + expect(h.submissions[0].text).toBe("Hello there.") + }) +}) From 9e8274d2dacf0632d91e4c41f465e125e0d74da9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 21:40:44 -0400 Subject: [PATCH 059/188] Remove internal Zod schemas (#26974) --- packages/opencode/src/file/watcher.ts | 1 - packages/opencode/src/installation/index.ts | 15 +++++---------- packages/opencode/src/patch/index.ts | 9 ++++----- packages/opencode/src/provider/transform.ts | 10 ++++++---- 4 files changed, 15 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 146d7b4d0..ecbf76424 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -4,7 +4,6 @@ import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" import { readdir } from "fs/promises" import path from "path" -import z from "zod" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index be3bc4769..e8c434276 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -4,7 +4,6 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { withTransientReadRetry } from "@/util/effect-http-client" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import path from "path" -import z from "zod" import { BusEvent } from "@/bus/bus-event" import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" @@ -45,15 +44,11 @@ export function getReleaseType(current: string, latest: string): ReleaseType { return "patch" } -export const Info = z - .object({ - version: z.string(), - latest: z.string(), - }) - .meta({ - ref: "InstallationInfo", - }) -export type Info = z.infer +export const Info = Schema.Struct({ + version: Schema.String, + latest: Schema.String, +}).annotate({ identifier: "InstallationInfo" }) +export type Info = Schema.Schema.Type export const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}` diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index fd5fff562..3dfa6f2d0 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -1,4 +1,4 @@ -import z from "zod" +import { Schema } from "effect" import * as path from "path" import * as fs from "fs/promises" import { readFileSync } from "fs" @@ -7,12 +7,11 @@ import * as Bom from "../util/bom" const log = Log.create({ service: "patch" }) -// Schema definitions -export const PatchSchema = z.object({ - patchText: z.string().describe("The full patch text that describes all changes to be made"), +export const PatchSchema = Schema.Struct({ + patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }), }) -export type PatchParams = z.infer +export type PatchParams = Schema.Schema.Type // Core types matching the Rust implementation export interface ApplyPatchArgs { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index bd778dacc..72ec881e7 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1,7 +1,6 @@ import type { ModelMessage, ToolResultPart } from "ai" import { mergeDeep, unique } from "remeda" import type { JSONSchema7 } from "@ai-sdk/provider" -import type { JSONSchema } from "zod/v4/core" import type * as Provider from "./provider" import type * as ModelsDev from "./models" import { iife } from "@/util/iife" @@ -1281,7 +1280,7 @@ export function maxOutputTokens(model: Provider.Model): number { return Math.min(model.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX } -export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JSONSchema7): JSONSchema7 { +export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 { /* if (["openai", "azure"].includes(providerID)) { if (schema.type === "object" && schema.properties) { @@ -1312,7 +1311,10 @@ export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JS return result } - schema = sanitizeMoonshot(schema) as JSONSchema.BaseSchema | JSONSchema7 + const sanitized = sanitizeMoonshot(schema) + if (typeof sanitized === "object" && sanitized !== null && !Array.isArray(sanitized)) { + schema = sanitized + } } // Convert integer enums to string enums for Google/Gemini @@ -1394,7 +1396,7 @@ export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JS schema = sanitizeGemini(schema) } - return schema as JSONSchema7 + return schema } export * as ProviderTransform from "./transform" From 100763034792ccf0386adfa356e097d5f825f5c0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 21:41:56 -0400 Subject: [PATCH 060/188] Migrate runtime validators to Effect Schema (#26975) --- .../src/cli/cmd/tui/context/editor-zed.ts | 47 ++--- .../src/cli/cmd/tui/context/editor.ts | 163 +++++++++--------- packages/opencode/src/mcp/auth.ts | 50 +++--- .../src/plugin/github-copilot/models.ts | 77 +++++---- 4 files changed, 176 insertions(+), 161 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts index 6805f0b66..611db406b 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts @@ -1,33 +1,36 @@ import { Database } from "bun:sqlite" import os from "node:os" import path from "node:path" -import z from "zod" +import { Option, Schema } from "effect" import { Filesystem } from "@/util/filesystem" import type { EditorSelection } from "./editor" -const ZedEditorRowSchema = z.object({ - item_kind: z.string(), - editor_id: z.number().nullable(), - workspace_id: z.number(), - workspace_paths: z.string().nullable(), - timestamp: z.string(), - buffer_path: z.string().nullable(), +const ZedEditorRowSchema = Schema.Struct({ + item_kind: Schema.String, + editor_id: Schema.NullOr(Schema.Number), + workspace_id: Schema.Number, + workspace_paths: Schema.NullOr(Schema.String), + timestamp: Schema.String, + buffer_path: Schema.NullOr(Schema.String), }) -const ZedSelectionRowSchema = z.object({ - selection_start: z.number().nullable(), - selection_end: z.number().nullable(), +const ZedSelectionRowSchema = Schema.Struct({ + selection_start: Schema.NullOr(Schema.Number), + selection_end: Schema.NullOr(Schema.Number), }) -const ZedEditorContentsSchema = z.object({ - contents: z.string().nullable(), +const ZedEditorContentsSchema = Schema.Struct({ + contents: Schema.NullOr(Schema.String), }) +const decodeZedEditorRow = Schema.decodeUnknownOption(ZedEditorRowSchema) +const decodeZedSelectionRow = Schema.decodeUnknownOption(ZedSelectionRowSchema) +const decodeZedEditorContents = Schema.decodeUnknownOption(ZedEditorContentsSchema) + const utf8 = new TextEncoder() -type ZedEditorRow = z.infer +type ZedEditorRow = Schema.Schema.Type type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number } -type ZedSelectionRow = z.infer export type ZedSelectionResult = | { type: "selection"; selection: EditorSelection } @@ -107,8 +110,8 @@ function queryZedActiveEditor(dbPath: string, cwd: string) { .all() const rows = raw.flatMap((row) => { - const parsed = ZedEditorRowSchema.safeParse(row) - return parsed.success ? [parsed.data] : [] + const parsed = decodeZedEditorRow(row) + return Option.isSome(parsed) ? [parsed.value] : [] }) if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const } @@ -143,8 +146,8 @@ function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) { .all({ $editorID: row.editor_id, $workspaceID: row.workspace_id }) const selections = raw.flatMap((selection) => { - const parsed = ZedSelectionRowSchema.safeParse(selection) - return parsed.success ? [parsed.data] : [] + const parsed = decodeZedSelectionRow(selection) + return Option.isSome(parsed) ? [parsed.value] : [] }) if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const } @@ -160,7 +163,7 @@ function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) { let db: Database | undefined try { db = new Database(dbPath, { readonly: true }) - const parsed = ZedEditorContentsSchema.safeParse( + const parsed = decodeZedEditorContents( db .query( `select contents @@ -169,8 +172,8 @@ function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) { ) .get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }), ) - if (!parsed.success) return { type: "unavailable" as const } - return { type: "contents" as const, contents: parsed.data.contents } + if (Option.isNone(parsed)) return { type: "unavailable" as const } + return { type: "contents" as const, contents: parsed.value.contents } } catch { return { type: "unavailable" as const } } finally { diff --git a/packages/opencode/src/cli/cmd/tui/context/editor.ts b/packages/opencode/src/cli/cmd/tui/context/editor.ts index 6d9e04cf8..ea7fd5810 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor.ts @@ -3,92 +3,102 @@ import os from "node:os" import path from "node:path" import { onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" -import z from "zod" +import { Option, Schema, SchemaGetter } from "effect" import { isRecord } from "@/util/record" import { createSimpleContext } from "./helper" import { resolveZedDbPath, resolveZedSelection } from "./editor-zed" const MCP_PROTOCOL_VERSION = "2025-11-25" -const JsonRpcMessageSchema = z.object({ - id: z.union([z.number(), z.string(), z.null()]).optional(), - method: z.string().optional(), - params: z.unknown().optional(), - result: z.unknown().optional(), - error: z - .object({ - code: z.number().optional(), - message: z.string().optional(), - }) - .optional(), +const JsonRpcMessageSchema = Schema.Struct({ + id: Schema.optional(Schema.Union([Schema.Number, Schema.String, Schema.Null])), + method: Schema.optional(Schema.String), + params: Schema.optional(Schema.Unknown), + result: Schema.optional(Schema.Unknown), + error: Schema.optional( + Schema.Struct({ + code: Schema.optional(Schema.Number), + message: Schema.optional(Schema.String), + }), + ), }) -const PositionSchema = z.object({ - line: z.number(), - character: z.number(), +const PositionSchema = Schema.Struct({ + line: Schema.Number, + character: Schema.Number, }) -const EditorSelectionRangeSchema = z.object({ - text: z.string(), - selection: z.object({ +const EditorSelectionRangeSchema = Schema.Struct({ + text: Schema.String, + selection: Schema.Struct({ start: PositionSchema, end: PositionSchema, }), }) -const EditorSelectionSchema = z - .union([ - z.object({ - filePath: z.string(), - source: z.enum(["websocket", "zed"]).optional(), - ranges: z.array(EditorSelectionRangeSchema).min(1), - }), - z.object({ - text: z.string(), - filePath: z.string(), - source: z.enum(["websocket", "zed"]).optional(), - selection: z.object({ - start: PositionSchema, - end: PositionSchema, - }), - }), - ]) - .transform((value) => - "ranges" in value - ? value - : { - filePath: value.filePath, - source: value.source, - ranges: [ - { - text: value.text, - selection: value.selection, - }, - ], - }, - ) - -const EditorMentionSchema = z.object({ - filePath: z.string(), - lineStart: z.number(), - lineEnd: z.number(), +const EditorSelectionRangesSchema = Schema.Struct({ + filePath: Schema.String, + source: Schema.optional(Schema.Literals(["websocket", "zed"])), + ranges: Schema.mutable(Schema.Array(EditorSelectionRangeSchema).check(Schema.isMinLength(1))), }) -const EditorServerInfoSchema = z.object({ - protocolVersion: z.string().optional(), - serverInfo: z - .object({ - name: z.string().optional(), - version: z.string().optional(), - }) - .optional(), +const EditorSelectionSchema = Schema.Union([ + EditorSelectionRangesSchema, + Schema.Struct({ + text: Schema.String, + filePath: Schema.String, + source: Schema.optional(Schema.Literals(["websocket", "zed"])), + selection: Schema.Struct({ + start: PositionSchema, + end: PositionSchema, + }), + }), +]).pipe( + Schema.decodeTo(EditorSelectionRangesSchema, { + decode: SchemaGetter.transform((value) => + "ranges" in value + ? value + : { + filePath: value.filePath, + source: value.source, + ranges: [ + { + text: value.text, + selection: value.selection, + }, + ], + }, + ), + encode: SchemaGetter.passthrough({ strict: false }), + }), +) + +const EditorMentionSchema = Schema.Struct({ + filePath: Schema.String, + lineStart: Schema.Number, + lineEnd: Schema.Number, }) -type JsonRpcMessage = z.infer -export type EditorSelection = z.infer -export type EditorMention = z.infer +const EditorServerInfoSchema = Schema.Struct({ + protocolVersion: Schema.optional(Schema.String), + serverInfo: Schema.optional( + Schema.Struct({ + name: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), + }), + ), +}) + +const decodeJsonRpcMessage = Schema.decodeUnknownOption(JsonRpcMessageSchema) +const decodeEditorSelection = Schema.decodeUnknownOption(EditorSelectionSchema) +const decodeEditorMention = Schema.decodeUnknownOption(EditorMentionSchema) +const decodeEditorServerInfo = Schema.decodeUnknownOption(EditorServerInfoSchema) + +type JsonRpcMessage = Schema.Schema.Type +export type EditorSelection = Schema.Schema.Type +export type EditorMention = Schema.Schema.Type export type EditorLabelState = "pending" | "sent" | "none" -type EditorServerInfo = z.infer +type EditorServerInfo = Schema.Schema.Type type EditorConnection = { url: string @@ -214,16 +224,15 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create const message = parseMessage(event.data) if (!message) return - const selection = - message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined - if (selection?.success) { - setSelection({ ...selection.data, source: "websocket" }) + const selection = message.method === "selection_changed" ? decodeEditorSelection(message.params) : Option.none() + if (Option.isSome(selection)) { + setSelection({ ...selection.value, source: "websocket" }) return } - const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined - if (mention?.success) { - mentionListeners.forEach((listener) => listener(mention.data)) + const mention = message.method === "at_mentioned" ? decodeEditorMention(message.params) : Option.none() + if (Option.isSome(mention)) { + mentionListeners.forEach((listener) => listener(mention.value)) return } @@ -235,9 +244,9 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create pending.delete(message.id) if (message.error) return - const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined - if (initialize?.success) { - setStore("server", initialize.data) + const initialize = method === "initialize" ? decodeEditorServerInfo(message.result) : Option.none() + if (Option.isSome(initialize)) { + setStore("server", initialize.value) send({ method: "notifications/initialized" }) return } @@ -447,7 +456,7 @@ function parseMessage(value: unknown) { if (typeof value !== "string") return try { - return JsonRpcMessageSchema.parse(JSON.parse(value)) + return Option.getOrUndefined(decodeJsonRpcMessage(JSON.parse(value))) } catch { return } diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index b07d59870..be19be0af 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -1,33 +1,35 @@ import path from "path" -import z from "zod" import { Global } from "@opencode-ai/core/global" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, Context, Option, Schema } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" -export const Tokens = z.object({ - accessToken: z.string(), - refreshToken: z.string().optional(), - expiresAt: z.number().optional(), - scope: z.string().optional(), +export const Tokens = Schema.Struct({ + accessToken: Schema.mutableKey(Schema.String), + refreshToken: Schema.mutableKey(Schema.optional(Schema.String)), + expiresAt: Schema.mutableKey(Schema.optional(Schema.Number)), + scope: Schema.mutableKey(Schema.optional(Schema.String)), }) -export type Tokens = z.infer +export type Tokens = Schema.Schema.Type -export const ClientInfo = z.object({ - clientId: z.string(), - clientSecret: z.string().optional(), - clientIdIssuedAt: z.number().optional(), - clientSecretExpiresAt: z.number().optional(), +export const ClientInfo = Schema.Struct({ + clientId: Schema.mutableKey(Schema.String), + clientSecret: Schema.mutableKey(Schema.optional(Schema.String)), + clientIdIssuedAt: Schema.mutableKey(Schema.optional(Schema.Number)), + clientSecretExpiresAt: Schema.mutableKey(Schema.optional(Schema.Number)), }) -export type ClientInfo = z.infer +export type ClientInfo = Schema.Schema.Type -export const Entry = z.object({ - tokens: Tokens.optional(), - clientInfo: ClientInfo.optional(), - codeVerifier: z.string().optional(), - oauthState: z.string().optional(), - serverUrl: z.string().optional(), +export const Entry = Schema.Struct({ + tokens: Schema.mutableKey(Schema.optional(Tokens)), + clientInfo: Schema.mutableKey(Schema.optional(ClientInfo)), + codeVerifier: Schema.mutableKey(Schema.optional(Schema.String)), + oauthState: Schema.mutableKey(Schema.optional(Schema.String)), + serverUrl: Schema.mutableKey(Schema.optional(Schema.String)), }) -export type Entry = z.infer +export type Entry = Schema.Schema.Type + +const decodeAuthData = Schema.decodeUnknownOption(Schema.Record(Schema.String, Entry)) +type AuthData = Record const filepath = path.join(Global.Path.data, "mcp-auth.json") @@ -56,8 +58,8 @@ export const layer = Layer.effect( const all = Effect.fn("McpAuth.all")(function* () { return yield* fs.readJson(filepath).pipe( - Effect.map((data) => data as Record), - Effect.catch(() => Effect.succeed({} as Record)), + Effect.map((data): AuthData => Option.getOrElse(decodeAuthData(data), () => ({}) as AuthData) as AuthData), + Effect.catch(() => Effect.succeed({} as AuthData)), ) }) @@ -93,7 +95,7 @@ export const layer = Layer.effect( yield* set(mcpName, entry, serverUrl) }) - const clearField = (field: K, spanName: string) => + const clearField = (field: keyof Entry, spanName: string) => Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) { const entry = yield* get(mcpName) if (entry) { diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index 8fa8dee76..a488be4a4 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -1,50 +1,51 @@ -import { z } from "zod" import type { Model } from "@opencode-ai/sdk/v2" +import { Schema } from "effect" -export const schema = z.object({ - data: z.array( - z.object({ - model_picker_enabled: z.boolean(), - id: z.string(), - name: z.string(), +export const schema = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + model_picker_enabled: Schema.Boolean, + id: Schema.String, + name: Schema.String, // every version looks like: `{model.id}-YYYY-MM-DD` - version: z.string(), - supported_endpoints: z.array(z.string()).optional(), - policy: z - .object({ - state: z.string().optional(), - }) - .optional(), - capabilities: z.object({ - family: z.string(), - limits: z.object({ - max_context_window_tokens: z.number(), - max_output_tokens: z.number(), - max_prompt_tokens: z.number(), - vision: z - .object({ - max_prompt_image_size: z.number(), - max_prompt_images: z.number(), - supported_media_types: z.array(z.string()), - }) - .optional(), + version: Schema.String, + supported_endpoints: Schema.optional(Schema.Array(Schema.String)), + policy: Schema.optional( + Schema.Struct({ + state: Schema.optional(Schema.String), }), - supports: z.object({ - adaptive_thinking: z.boolean().optional(), - max_thinking_budget: z.number().optional(), - min_thinking_budget: z.number().optional(), - reasoning_effort: z.array(z.string()).optional(), - streaming: z.boolean(), - structured_outputs: z.boolean().optional(), - tool_calls: z.boolean(), - vision: z.boolean().optional(), + ), + capabilities: Schema.Struct({ + family: Schema.String, + limits: Schema.Struct({ + max_context_window_tokens: Schema.Number, + max_output_tokens: Schema.Number, + max_prompt_tokens: Schema.Number, + vision: Schema.optional( + Schema.Struct({ + max_prompt_image_size: Schema.Number, + max_prompt_images: Schema.Number, + supported_media_types: Schema.Array(Schema.String), + }), + ), + }), + supports: Schema.Struct({ + adaptive_thinking: Schema.optional(Schema.Boolean), + max_thinking_budget: Schema.optional(Schema.Number), + min_thinking_budget: Schema.optional(Schema.Number), + reasoning_effort: Schema.optional(Schema.Array(Schema.String)), + streaming: Schema.Boolean, + structured_outputs: Schema.optional(Schema.Boolean), + tool_calls: Schema.Boolean, + vision: Schema.optional(Schema.Boolean), }), }), }), ), }) -type Item = z.infer["data"][number] +type Item = Schema.Schema.Type["data"][number] +const decodeModels = Schema.decodeUnknownSync(schema) function build(key: string, remote: Item, url: string, prev?: Model): Model { const reasoning = @@ -165,7 +166,7 @@ export async function get( if (!res.ok) { throw new Error(`Failed to fetch models: ${res.status}`) } - return schema.parse(await res.json()) + return decodeModels(await res.json()) }) const result = { ...existing } From c43d606f8e7019131add5140076ddf5ba7a271d7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 21:42:04 -0400 Subject: [PATCH 061/188] agent: use Effect schema for generated agent object (#26973) --- packages/opencode/src/agent/agent.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index d96e508c9..b9b56396f 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,5 +1,4 @@ import { Config } from "@/config/config" -import z from "zod" import { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "../provider/schema" import { generateObject, streamObject, type ModelMessage } from "ai" @@ -49,6 +48,12 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Agent" }) export type Info = DeepMutable> +const GeneratedAgent = Schema.Struct({ + identifier: Schema.String, + whenToUse: Schema.String, + systemPrompt: Schema.String, +}) + export interface Interface { readonly get: (agent: string) => Effect.Effect readonly list: () => Effect.Effect @@ -405,11 +410,10 @@ export const layer = Layer.effect( }, ], model: language, - schema: z.object({ - identifier: z.string(), - whenToUse: z.string(), - systemPrompt: z.string(), - }), + schema: Object.assign( + Schema.toStandardSchemaV1(GeneratedAgent), + Schema.toStandardJSONSchemaV1(GeneratedAgent), + ), } satisfies Parameters[0] if (isOpenaiOauth) { From ce720207500f66127629d1e9f7209324d8b6370f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 21:42:17 -0400 Subject: [PATCH 062/188] test(tool): migrate edit tests to Effect runner (#26977) --- packages/opencode/test/tool/edit.test.ts | 910 +++++++++-------------- 1 file changed, 356 insertions(+), 554 deletions(-) diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index a629ff07d..572fcd9aa 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -1,19 +1,20 @@ import { afterAll, afterEach, describe, test, expect } from "bun:test" import path from "path" import fs from "fs/promises" -import { Effect, Layer, ManagedRuntime } from "effect" +import { Cause, Deferred, Effect, Exit, Layer, ManagedRuntime } from "effect" import { EditTool } from "../../src/tool/edit" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, TestInstance, tmpdir } from "../fixture/fixture" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" -import { BusEvent } from "../../src/bus/bus-event" import { Truncate } from "@/tool/truncate" import { SessionID, MessageID } from "../../src/session/schema" +import * as Tool from "../../src/tool/tool" +import { testEffect } from "../lib/effect" +import { FileWatcher } from "../../src/file/watcher" const ctx = { sessionID: SessionID.make("ses_test-edit-session"), @@ -30,17 +31,19 @@ afterEach(async () => { await disposeAllInstances() }) -const runtime = ManagedRuntime.make( - Layer.mergeAll( - LSP.defaultLayer, - AppFileSystem.defaultLayer, - Format.defaultLayer, - Bus.layer, - Truncate.defaultLayer, - Agent.defaultLayer, - ), +const layer = Layer.mergeAll( + LSP.defaultLayer, + AppFileSystem.defaultLayer, + Format.defaultLayer, + Bus.layer, + Truncate.defaultLayer, + Agent.defaultLayer, ) +const it = testEffect(layer) + +const runtime = ManagedRuntime.make(layer) + afterAll(async () => { await runtime.dispose() }) @@ -53,464 +56,258 @@ const resolve = () => }), ) -const subscribeBus = (def: D, callback: () => unknown) => - runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback))) +const init = Effect.fn("EditToolTest.init")(function* () { + const info = yield* EditTool + return yield* info.init() +}) -async function onceBus(def: D) { - const result = Promise.withResolvers() - const unsub = await subscribeBus(def, () => { - unsub() - result.resolve() - }) - return { - wait: result.promise, - unsub, +const run = Effect.fn("EditToolTest.run")(function* ( + args: Tool.InferParameters, + next: Tool.Context = ctx, +) { + const tool = yield* init() + return yield* tool.execute(args, next) +}) + +const fail = Effect.fn("EditToolTest.fail")(function* (args: Tool.InferParameters) { + const exit = yield* run(args).pipe(Effect.exit) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + return err instanceof Error ? err : new Error(String(err)) } -} + throw new Error("expected edit to fail") +}) + +const put = Effect.fn("EditToolTest.put")(function* (p: string, content: string) { + const fs = yield* AppFileSystem.Service + yield* fs.writeWithDirs(p, content) +}) + +const load = Effect.fn("EditToolTest.load")(function* (p: string) { + const fs = yield* AppFileSystem.Service + return yield* fs.readFileString(p) +}) + +const loadRaw = Effect.fn("EditToolTest.loadRaw")(function* (p: string) { + return yield* Effect.promise(() => fs.readFile(p, "utf-8")) +}) + +const makeDirectory = Effect.fn("EditToolTest.makeDirectory")(function* (p: string) { + const fs = yield* AppFileSystem.Service + yield* fs.makeDirectory(p) +}) + +const onceBus = Effect.fn("EditToolTest.onceBus")(function* (def: typeof FileWatcher.Event.Updated) { + const bus = yield* Bus.Service + const deferred = yield* Deferred.make() + const unsub = yield* bus.subscribeCallback(def, () => Effect.runSync(Deferred.succeed(deferred, undefined))) + yield* Effect.addFinalizer(() => Effect.sync(unsub)) + return deferred +}) describe("tool.edit", () => { describe("creating new files", () => { - test("creates new file when oldString is empty", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "newfile.txt") + it.instance("creates new file when oldString is empty", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "newfile.txt") + const result = yield* run({ filePath: filepath, oldString: "", newString: "new content" }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - const result = await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "", - newString: "new content", - }, - ctx, - ), - ) + expect(result.metadata.diff).toContain("new content") + expect(yield* load(filepath)).toBe("new content") + }), + ) - expect(result.metadata.diff).toContain("new content") + it.instance("preserves BOM when oldString is empty on existing files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "existing.cs") + const bom = String.fromCharCode(0xfeff) + yield* put(filepath, `${bom}using System;\n`) - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("new content") - }, - }) - }) + const result = yield* run({ filePath: filepath, oldString: "", newString: "using Up;\n" }) - test("preserves BOM when oldString is empty on existing files", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "existing.cs") - const bom = String.fromCharCode(0xfeff) - await fs.writeFile(filepath, `${bom}using System;\n`, "utf-8") + expect(result.metadata.diff).toContain("-using System;") + expect(result.metadata.diff).toContain("+using Up;") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - const result = await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "", - newString: "using Up;\n", - }, - ctx, - ), - ) + const content = yield* loadRaw(filepath) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }), + ) - expect(result.metadata.diff).toContain("-using System;") - expect(result.metadata.diff).toContain("+using Up;") + it.instance("creates new file with nested directories", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "nested", "dir", "file.txt") - const content = await fs.readFile(filepath, "utf-8") - expect(content.charCodeAt(0)).toBe(0xfeff) - expect(content.slice(1)).toBe("using Up;\n") - }, - }) - }) + yield* run({ filePath: filepath, oldString: "", newString: "nested file" }) - test("creates new file with nested directories", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "nested", "dir", "file.txt") + expect(yield* load(filepath)).toBe("nested file") + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "", - newString: "nested file", - }, - ctx, - ), - ) + it.instance("emits add event for new files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const updated = yield* onceBus(FileWatcher.Event.Updated) - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("nested file") - }, - }) - }) - - test("emits add event for new files", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "new.txt") - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const { FileWatcher } = await import("../../src/file/watcher") - - const updated = await onceBus(FileWatcher.Event.Updated) - - try { - const edit = await resolve() - await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "", - newString: "content", - }, - ctx, - ), - ) - - await updated.wait - } finally { - updated.unsub() - } - }, - }) - }) + yield* run({ filePath: path.join(test.directory, "new.txt"), oldString: "", newString: "content" }) + yield* Deferred.await(updated) + }), + ) }) describe("editing existing files", () => { - test("replaces text in existing file", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "existing.txt") - await fs.writeFile(filepath, "old content here", "utf-8") + it.instance("replaces text in existing file", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "existing.txt") + yield* put(filepath, "old content here") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - const result = await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "old content", - newString: "new content", - }, - ctx, - ), - ) + const result = yield* run({ filePath: filepath, oldString: "old content", newString: "new content" }) - expect(result.output).toContain("Edit applied successfully") + expect(result.output).toContain("Edit applied successfully") + expect(yield* load(filepath)).toBe("new content here") + }), + ) - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("new content here") - }, - }) - }) + it.instance("replaces the first visible line in BOM files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "existing.cs") + const bom = String.fromCharCode(0xfeff) + yield* put(filepath, `${bom}using System;\nclass Test {}\n`) - test("replaces the first visible line in BOM files", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "existing.cs") - const bom = String.fromCharCode(0xfeff) - await fs.writeFile(filepath, `${bom}using System;\nclass Test {}\n`, "utf-8") + const result = yield* run({ filePath: filepath, oldString: "using System;", newString: "using Up;" }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - const result = await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "using System;", - newString: "using Up;", - }, - ctx, - ), - ) + expect(result.metadata.diff).toContain("-using System;") + expect(result.metadata.diff).toContain("+using Up;") + expect(result.metadata.diff).not.toContain(bom) - expect(result.metadata.diff).toContain("-using System;") - expect(result.metadata.diff).toContain("+using Up;") - expect(result.metadata.diff).not.toContain(bom) + const content = yield* loadRaw(filepath) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\nclass Test {}\n") + }), + ) - const content = await fs.readFile(filepath, "utf-8") - expect(content.charCodeAt(0)).toBe(0xfeff) - expect(content.slice(1)).toBe("using Up;\nclass Test {}\n") - }, - }) - }) + it.instance("throws error when file does not exist", () => + Effect.gen(function* () { + const test = yield* TestInstance + expect( + (yield* fail({ filePath: path.join(test.directory, "nonexistent.txt"), oldString: "old", newString: "new" })) + .message, + ).toContain("not found") + }), + ) - test("throws error when file does not exist", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "nonexistent.txt") + it.instance("throws error when oldString equals newString", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* put(filepath, "content") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await expect( - Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "old", - newString: "new", - }, - ctx, - ), - ), - ).rejects.toThrow("not found") - }, - }) - }) + expect((yield* fail({ filePath: filepath, oldString: "same", newString: "same" })).message).toContain( + "identical", + ) + }), + ) - test("throws error when oldString equals newString", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") + it.instance("throws error when oldString not found in file", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* put(filepath, "actual content") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await expect( - Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "same", - newString: "same", - }, - ctx, - ), - ), - ).rejects.toThrow("identical") - }, - }) - }) + expect(yield* fail({ filePath: filepath, oldString: "not in file", newString: "replacement" })).toBeInstanceOf( + Error, + ) + }), + ) - test("throws error when oldString not found in file", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "actual content", "utf-8") + it.instance("replaces all occurrences with replaceAll option", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* put(filepath, "foo bar foo baz foo") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await expect( - Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "not in file", - newString: "replacement", - }, - ctx, - ), - ), - ).rejects.toThrow() - }, - }) - }) + yield* run({ filePath: filepath, oldString: "foo", newString: "qux", replaceAll: true }) - test("replaces all occurrences with replaceAll option", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8") + expect(yield* load(filepath)).toBe("qux bar qux baz qux") + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "foo", - newString: "qux", - replaceAll: true, - }, - ctx, - ), - ) + it.instance("emits change event for existing files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* put(filepath, "original") + const updated = yield* onceBus(FileWatcher.Event.Updated) - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("qux bar qux baz qux") - }, - }) - }) - - test("emits change event for existing files", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "original", "utf-8") - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const { FileWatcher } = await import("../../src/file/watcher") - - const updated = await onceBus(FileWatcher.Event.Updated) - - try { - const edit = await resolve() - await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "original", - newString: "modified", - }, - ctx, - ), - ) - - await updated.wait - } finally { - updated.unsub() - } - }, - }) - }) + yield* run({ filePath: filepath, oldString: "original", newString: "modified" }) + yield* Deferred.await(updated) + }), + ) }) describe("edge cases", () => { - test("handles multiline replacements", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") + it.instance("handles multiline replacements", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* put(filepath, "line1\nline2\nline3") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "line2", - newString: "new line 2\nextra line", - }, - ctx, - ), - ) + yield* run({ filePath: filepath, oldString: "line2", newString: "new line 2\nextra line" }) - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("line1\nnew line 2\nextra line\nline3") - }, - }) - }) + expect(yield* load(filepath)).toBe("line1\nnew line 2\nextra line\nline3") + }), + ) - test("handles CRLF line endings", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8") + it.instance("handles CRLF line endings", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* put(filepath, "line1\r\nold\r\nline3") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "old", - newString: "new", - }, - ctx, - ), - ) + yield* run({ filePath: filepath, oldString: "old", newString: "new" }) - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("line1\r\nnew\r\nline3") - }, - }) - }) + expect(yield* load(filepath)).toBe("line1\r\nnew\r\nline3") + }), + ) - test("throws error when oldString equals newString", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") + it.instance("throws error when oldString equals newString", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* put(filepath, "content") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await expect( - Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "", - newString: "", - }, - ctx, - ), - ), - ).rejects.toThrow("identical") - }, - }) - }) + expect((yield* fail({ filePath: filepath, oldString: "", newString: "" })).message).toContain("identical") + }), + ) - test("throws error when path is directory", async () => { - await using tmp = await tmpdir() - const dirpath = path.join(tmp.path, "adir") - await fs.mkdir(dirpath) + it.instance("throws error when path is directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + const dirpath = path.join(test.directory, "adir") + yield* makeDirectory(dirpath) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await expect( - Effect.runPromise( - edit.execute( - { - filePath: dirpath, - oldString: "old", - newString: "new", - }, - ctx, - ), - ), - ).rejects.toThrow("directory") - }, - }) - }) + expect((yield* fail({ filePath: dirpath, oldString: "old", newString: "new" })).message).toContain("directory") + }), + ) - test("tracks file diff statistics", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") + it.instance("tracks file diff statistics", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* put(filepath, "line1\nline2\nline3") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - const result = await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "line2", - newString: "new line a\nnew line b", - }, - ctx, - ), - ) + const result = yield* run({ filePath: filepath, oldString: "line2", newString: "new line a\nnew line b" }) - expect(result.metadata.filediff).toBeDefined() - expect(result.metadata.filediff.file).toBe(filepath) - expect(result.metadata.filediff.additions).toBeGreaterThan(0) - }, - }) - }) + expect(result.metadata.filediff).toBeDefined() + expect(result.metadata.filediff.file).toBe(filepath) + expect(result.metadata.filediff.additions).toBeGreaterThan(0) + }), + ) }) describe("line endings", () => { @@ -552,149 +349,154 @@ describe("tool.edit", () => { replaceAll?: boolean } - const apply = async (input: Input) => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "test.txt"), input.content) - }, + const apply = Effect.fn("EditToolTest.lineEndings.apply")(function* (input: Input) { + const test = yield* TestInstance + const filePath = path.join(test.directory, "test.txt") + yield* put(filePath, input.content) + yield* run({ + filePath, + oldString: input.oldString, + newString: input.newString, + replaceAll: input.replaceAll, }) - - return await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - const filePath = path.join(tmp.path, "test.txt") - await Effect.runPromise( - edit.execute( - { - filePath, - oldString: input.oldString, - newString: input.newString, - replaceAll: input.replaceAll, - }, - ctx, - ), - ) - return await Bun.file(filePath).text() - }, - }) - } - - test("preserves LF with LF multi-line strings", async () => { - const content = normalize(old + "\n", "\n") - const output = await apply({ - content, - oldString: normalize(old, "\n"), - newString: normalize(next, "\n"), - }) - expect(output).toBe(normalize(next + "\n", "\n")) - expectLf(output) + return yield* load(filePath) }) - test("preserves CRLF with CRLF multi-line strings", async () => { - const content = normalize(old + "\n", "\r\n") - const output = await apply({ - content, - oldString: normalize(old, "\r\n"), - newString: normalize(next, "\r\n"), - }) - expect(output).toBe(normalize(next + "\n", "\r\n")) - expectCrlf(output) - }) + it.instance("preserves LF with LF multi-line strings", () => + Effect.gen(function* () { + const content = normalize(old + "\n", "\n") + const output = yield* apply({ + content, + oldString: normalize(old, "\n"), + newString: normalize(next, "\n"), + }) + expect(output).toBe(normalize(next + "\n", "\n")) + expectLf(output) + }), + ) - test("preserves LF when old/new use CRLF", async () => { - const content = normalize(old + "\n", "\n") - const output = await apply({ - content, - oldString: normalize(old, "\r\n"), - newString: normalize(next, "\r\n"), - }) - expect(output).toBe(normalize(next + "\n", "\n")) - expectLf(output) - }) + it.instance("preserves CRLF with CRLF multi-line strings", () => + Effect.gen(function* () { + const content = normalize(old + "\n", "\r\n") + const output = yield* apply({ + content, + oldString: normalize(old, "\r\n"), + newString: normalize(next, "\r\n"), + }) + expect(output).toBe(normalize(next + "\n", "\r\n")) + expectCrlf(output) + }), + ) - test("preserves CRLF when old/new use LF", async () => { - const content = normalize(old + "\n", "\r\n") - const output = await apply({ - content, - oldString: normalize(old, "\n"), - newString: normalize(next, "\n"), - }) - expect(output).toBe(normalize(next + "\n", "\r\n")) - expectCrlf(output) - }) + it.instance("preserves LF when old/new use CRLF", () => + Effect.gen(function* () { + const content = normalize(old + "\n", "\n") + const output = yield* apply({ + content, + oldString: normalize(old, "\r\n"), + newString: normalize(next, "\r\n"), + }) + expect(output).toBe(normalize(next + "\n", "\n")) + expectLf(output) + }), + ) - test("preserves LF when newString uses CRLF", async () => { - const content = normalize(old + "\n", "\n") - const output = await apply({ - content, - oldString: normalize(old, "\n"), - newString: normalize(next, "\r\n"), - }) - expect(output).toBe(normalize(next + "\n", "\n")) - expectLf(output) - }) + it.instance("preserves CRLF when old/new use LF", () => + Effect.gen(function* () { + const content = normalize(old + "\n", "\r\n") + const output = yield* apply({ + content, + oldString: normalize(old, "\n"), + newString: normalize(next, "\n"), + }) + expect(output).toBe(normalize(next + "\n", "\r\n")) + expectCrlf(output) + }), + ) - test("preserves CRLF when newString uses LF", async () => { - const content = normalize(old + "\n", "\r\n") - const output = await apply({ - content, - oldString: normalize(old, "\r\n"), - newString: normalize(next, "\n"), - }) - expect(output).toBe(normalize(next + "\n", "\r\n")) - expectCrlf(output) - }) + it.instance("preserves LF when newString uses CRLF", () => + Effect.gen(function* () { + const content = normalize(old + "\n", "\n") + const output = yield* apply({ + content, + oldString: normalize(old, "\n"), + newString: normalize(next, "\r\n"), + }) + expect(output).toBe(normalize(next + "\n", "\n")) + expectLf(output) + }), + ) - test("preserves LF with mixed old/new line endings", async () => { - const content = normalize(old + "\n", "\n") - const output = await apply({ - content, - oldString: "alpha\nbeta\r\ngamma", - newString: "alpha\r\nbeta\nomega", - }) - expect(output).toBe(normalize(alt + "\n", "\n")) - expectLf(output) - }) + it.instance("preserves CRLF when newString uses LF", () => + Effect.gen(function* () { + const content = normalize(old + "\n", "\r\n") + const output = yield* apply({ + content, + oldString: normalize(old, "\r\n"), + newString: normalize(next, "\n"), + }) + expect(output).toBe(normalize(next + "\n", "\r\n")) + expectCrlf(output) + }), + ) - test("preserves CRLF with mixed old/new line endings", async () => { - const content = normalize(old + "\n", "\r\n") - const output = await apply({ - content, - oldString: "alpha\r\nbeta\ngamma", - newString: "alpha\nbeta\r\nomega", - }) - expect(output).toBe(normalize(alt + "\n", "\r\n")) - expectCrlf(output) - }) + it.instance("preserves LF with mixed old/new line endings", () => + Effect.gen(function* () { + const content = normalize(old + "\n", "\n") + const output = yield* apply({ + content, + oldString: "alpha\nbeta\r\ngamma", + newString: "alpha\r\nbeta\nomega", + }) + expect(output).toBe(normalize(alt + "\n", "\n")) + expectLf(output) + }), + ) - test("replaceAll preserves LF for multi-line blocks", async () => { - const blockOld = "alpha\nbeta" - const blockNew = "alpha\nbeta-updated" - const content = normalize(blockOld + "\n" + blockOld + "\n", "\n") - const output = await apply({ - content, - oldString: normalize(blockOld, "\n"), - newString: normalize(blockNew, "\n"), - replaceAll: true, - }) - expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n")) - expectLf(output) - }) + it.instance("preserves CRLF with mixed old/new line endings", () => + Effect.gen(function* () { + const content = normalize(old + "\n", "\r\n") + const output = yield* apply({ + content, + oldString: "alpha\r\nbeta\ngamma", + newString: "alpha\nbeta\r\nomega", + }) + expect(output).toBe(normalize(alt + "\n", "\r\n")) + expectCrlf(output) + }), + ) - test("replaceAll preserves CRLF for multi-line blocks", async () => { - const blockOld = "alpha\nbeta" - const blockNew = "alpha\nbeta-updated" - const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n") - const output = await apply({ - content, - oldString: normalize(blockOld, "\r\n"), - newString: normalize(blockNew, "\r\n"), - replaceAll: true, - }) - expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n")) - expectCrlf(output) - }) + it.instance("replaceAll preserves LF for multi-line blocks", () => + Effect.gen(function* () { + const blockOld = "alpha\nbeta" + const blockNew = "alpha\nbeta-updated" + const content = normalize(blockOld + "\n" + blockOld + "\n", "\n") + const output = yield* apply({ + content, + oldString: normalize(blockOld, "\n"), + newString: normalize(blockNew, "\n"), + replaceAll: true, + }) + expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n")) + expectLf(output) + }), + ) + + it.instance("replaceAll preserves CRLF for multi-line blocks", () => + Effect.gen(function* () { + const blockOld = "alpha\nbeta" + const blockNew = "alpha\nbeta-updated" + const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n") + const output = yield* apply({ + content, + oldString: normalize(blockOld, "\r\n"), + newString: normalize(blockNew, "\r\n"), + replaceAll: true, + }) + expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n")) + expectCrlf(output) + }), + ) }) describe("concurrent editing", () => { From 0f5d4ae648241e459e24bc825190fac575dcffa6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 22:12:07 -0400 Subject: [PATCH 063/188] test(project): stabilize VCS branch update test (#26979) --- packages/opencode/test/project/vcs.test.ts | 27 +++++++++++++++------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 75d1feadd..b1d637302 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { parsePatch } from "diff" -import { Deferred, Effect, Layer, Stream } from "effect" +import { Deferred, Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import fs from "fs/promises" import path from "path" @@ -50,13 +50,26 @@ const nextBranchUpdate = Effect.fn("VcsTest.nextBranchUpdate")(function* () { const bus = yield* Bus.Service const updated = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribe(Vcs.Event.BranchUpdated), (evt) => - Deferred.succeed(updated, evt.properties.branch), - ).pipe(Effect.forkScoped) + const off = yield* bus.subscribeCallback(Vcs.Event.BranchUpdated, (evt) => { + Effect.runSync(Deferred.succeed(updated, evt.properties.branch)) + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) return updated }) +const publishHeadChangeUntil = Effect.fn("VcsTest.publishHeadChangeUntil")(function* ( + pending: Deferred.Deferred, + head: string, +) { + const bus = yield* Bus.Service + for (let i = 0; i < 50; i++) { + yield* bus.publish(FileWatcher.Event.Updated, { file: head, event: "change" }) + if (yield* Deferred.isDone(pending)) return + yield* Effect.sleep("10 millis") + } +}) + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -99,11 +112,10 @@ describe("Vcs", () => { const vcs = yield* init() yield* vcs.branch() const pending = yield* nextBranchUpdate() - const bus = yield* Bus.Service const head = path.join(test.directory, ".git", "HEAD") yield* write(head, `ref: refs/heads/${branch}\n`) - yield* bus.publish(FileWatcher.Event.Updated, { file: head, event: "change" }) + yield* publishHeadChangeUntil(pending, head) const updated = yield* Deferred.await(pending).pipe(Effect.timeout("2 seconds")) expect(updated).toBe(branch) @@ -122,11 +134,10 @@ describe("Vcs", () => { const vcs = yield* init() yield* vcs.branch() const pending = yield* nextBranchUpdate() - const bus = yield* Bus.Service const head = path.join(test.directory, ".git", "HEAD") yield* write(head, `ref: refs/heads/${branch}\n`) - yield* bus.publish(FileWatcher.Event.Updated, { file: head, event: "change" }) + yield* publishHeadChangeUntil(pending, head) yield* Deferred.await(pending).pipe(Effect.timeout("2 seconds")) const current = yield* vcs.branch() From cc1835e0dbc9ce9f4dbbd92dbc01a30518d0cc40 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 22:23:52 -0400 Subject: [PATCH 064/188] test(provider): migrate config-backed cases to Effect runner (#26969) --- .../opencode/test/provider/provider.test.ts | 131 ++++++++---------- 1 file changed, 59 insertions(+), 72 deletions(-) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index cdb9d2057..ea65c90c4 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -15,6 +15,7 @@ import { Env } from "../../src/env" import { Effect } from "effect" import { AppRuntime } from "../../src/effect/app-runtime" import { makeRuntime } from "../../src/effect/run-service" +import { testEffect } from "../lib/effect" const env = makeRuntime(Env.Service, Env.defaultLayer) const set = (k: string, v: string) => env.runSync((svc) => svc.set(k, v)) @@ -70,6 +71,8 @@ function paid(providers: Awaited>) { return Object.values(item.models).filter((model) => model.cost.input > 0).length } +const it = testEffect(Provider.defaultLayer) + test("provider loaded from env variable", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -515,85 +518,69 @@ test("defaultModel respects config model setting", async () => { }) }) -test("provider with baseURL from config", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - provider: { - "custom-openai": { - name: "Custom OpenAI", - npm: "@ai-sdk/openai-compatible", - env: [], - models: { - "gpt-4": { - name: "GPT-4", - tool_call: true, - limit: { context: 128000, output: 4096 }, - }, - }, - options: { - apiKey: "test-key", - baseURL: "https://custom.openai.com/v1", - }, +it.instance( + "provider with baseURL from config", + Effect.gen(function* () { + const providers = yield* Provider.Service.use((provider) => provider.list()) + expect(providers[ProviderID.make("custom-openai")]).toBeDefined() + expect(providers[ProviderID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1") + }), + { + config: { + provider: { + "custom-openai": { + name: "Custom OpenAI", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { + "gpt-4": { + name: "GPT-4", + tool_call: true, + limit: { context: 128000, output: 4096 }, }, }, - }), - ) + options: { + apiKey: "test-key", + baseURL: "https://custom.openai.com/v1", + }, + }, + }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const providers = await list() - expect(providers[ProviderID.make("custom-openai")]).toBeDefined() - expect(providers[ProviderID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1") - }, - }) -}) + }, +) -test("model cost defaults to zero when not specified", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - provider: { - "test-provider": { - name: "Test Provider", - npm: "@ai-sdk/openai-compatible", - env: [], - models: { - "test-model": { - name: "Test Model", - tool_call: true, - limit: { context: 128000, output: 4096 }, - }, - }, - options: { - apiKey: "test-key", - }, +it.instance( + "model cost defaults to zero when not specified", + Effect.gen(function* () { + const providers = yield* Provider.Service.use((provider) => provider.list()) + const model = providers[ProviderID.make("test-provider")].models["test-model"] + expect(model.cost.input).toBe(0) + expect(model.cost.output).toBe(0) + expect(model.cost.cache.read).toBe(0) + expect(model.cost.cache.write).toBe(0) + }), + { + config: { + provider: { + "test-provider": { + name: "Test Provider", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { + "test-model": { + name: "Test Model", + tool_call: true, + limit: { context: 128000, output: 4096 }, }, }, - }), - ) + options: { + apiKey: "test-key", + }, + }, + }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const providers = await list() - const model = providers[ProviderID.make("test-provider")].models["test-model"] - expect(model.cost.input).toBe(0) - expect(model.cost.output).toBe(0) - expect(model.cost.cache.read).toBe(0) - expect(model.cost.cache.write).toBe(0) - }, - }) -}) + }, +) test("model options are merged from existing model", async () => { await using tmp = await tmpdir({ From 78a2639e5ed83571282268e449b812ba5f1a2f05 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 12 May 2026 10:38:21 +0800 Subject: [PATCH 065/188] fix(app): open next project when closing current one (#26987) --- packages/app/src/pages/layout.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 45fcc6ee2..68a17f73c 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1409,19 +1409,20 @@ export default function Layout(props: ParentProps) { const index = list.findIndex((x) => pathKey(x.worktree) === key) const active = pathKey(currentProject()?.worktree ?? "") === key if (index === -1) return - const next = list[index + 1] if (!active) { layout.projects.close(directory) return } - if (!next) { + if (list.length === 1) { layout.projects.close(directory) navigate("/") return } + const next = list[index + 1] ?? list[index - 1] + navigateWithSidebarReset(`/${base64Encode(next.worktree)}/session`) layout.projects.close(directory) queueMicrotask(() => { From 871374804f1ed989dc49b0a296ce39f0df4ca588 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 12 May 2026 10:39:36 +0800 Subject: [PATCH 066/188] fix(app): use keyed Show for project in layout (#26985) --- packages/app/src/pages/layout.tsx | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 68a17f73c..11bc4fdb5 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2097,6 +2097,7 @@ export default function Layout(props: ParentProps) {
} + keyed > {(project) => ( <> @@ -2107,9 +2108,7 @@ export default function Layout(props: ParentProps) { id={`project:${projectId()}`} value={projectName} onSave={(next) => { - const item = project() - if (!item) return - void renameProject(item, next) + void renameProject(project, next) }} class="text-14-medium text-text-strong truncate" displayClass="text-14-medium text-text-strong truncate" @@ -2151,9 +2150,7 @@ export default function Layout(props: ParentProps) { { - const item = project() - if (!item) return - showEditProjectDialog(item) + showEditProjectDialog(project) }} > {language.t("common.edit")} @@ -2163,9 +2160,7 @@ export default function Layout(props: ParentProps) { data-project={slug()} disabled={!canToggle()} onSelect={() => { - const item = project() - if (!item) return - toggleProjectWorkspaces(item) + toggleProjectWorkspaces(project) }} > @@ -2224,7 +2219,7 @@ export default function Layout(props: ParentProps) { +
@@ -2239,9 +2234,7 @@ export default function Layout(props: ParentProps) { icon="plus-small" class="w-full" onClick={() => { - const item = project() - if (!item) return - void createWorkspace(item) + void createWorkspace(project) }} > {language.t("workspace.new")} @@ -2268,7 +2261,7 @@ export default function Layout(props: ParentProps) { From 1a28924ed89b98ee96e644d0e6e8e4a6a8bcfccc Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 11 May 2026 21:47:35 -0500 Subject: [PATCH 067/188] fix: grep external directory permission evaluation (#26958) --- packages/opencode/src/tool/grep.ts | 21 +++++----- packages/opencode/test/tool/grep.test.ts | 53 ++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 4e89198df..01aa6a0b7 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -54,19 +54,20 @@ export const GrepTool = Tool.define( }) const ins = yield* InstanceState.context - const search = AppFileSystem.resolve( - path.isAbsolute(params.path ?? ins.directory) - ? (params.path ?? ins.directory) - : path.join(ins.directory, params.path ?? "."), - ) - yield* reference.ensure(search) + const requested = path.isAbsolute(params.path ?? ins.directory) + ? (params.path ?? ins.directory) + : path.join(ins.directory, params.path ?? ".") + yield* reference.ensure(requested) + const requestedInfo = yield* fs.stat(requested).pipe(Effect.catch(() => Effect.succeed(undefined))) + yield* assertExternalDirectoryEffect(ctx, requested, { + bypass: yield* reference.contains(requested), + kind: requestedInfo?.type === "Directory" ? "directory" : "file", + }) + + const search = AppFileSystem.resolve(requested) const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) const cwd = info?.type === "Directory" ? search : path.dirname(search) const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)] - yield* assertExternalDirectoryEffect(ctx, search, { - bypass: yield* reference.contains(search), - kind: info?.type === "Directory" ? "directory" : "file", - }) const result = yield* rg.search({ cwd, diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 53f5d9a19..29b5a60db 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -1,4 +1,6 @@ import { describe, expect } from "bun:test" +import fs from "fs/promises" +import os from "os" import path from "path" import { Effect, Layer } from "effect" import { GrepTool } from "../../src/tool/grep" @@ -11,6 +13,8 @@ import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { testEffect } from "../lib/effect" import { Reference } from "@/reference/reference" +import { Permission } from "../../src/permission" +import type * as Tool from "../../src/tool/tool" const it = testEffect( Layer.mergeAll( @@ -110,4 +114,53 @@ describe("tool.grep", () => { expect(result.output).toContain("Line 2: line2") }), ) + + it.instance("does not ask for external_directory when alias path is allowed", () => + Effect.gen(function* () { + if (process.platform === "win32") return + + yield* TestInstance + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "opencode-grep-alias-"))), + (dir) => Effect.promise(() => fs.rm(dir, { recursive: true, force: true })), + ) + const real = path.join(tmp, "real") + const alias = path.join(tmp, "alias") + yield* Effect.promise(() => fs.mkdir(real)) + yield* Effect.promise(() => fs.symlink(real, alias, "dir")) + yield* Effect.promise(() => Bun.write(path.join(real, "test.txt"), "needle")) + + const ruleset = Permission.fromConfig({ + grep: "allow", + external_directory: { + [path.join(alias, "*")]: "allow", + }, + }) + const requests: Array> = [] + const next: Tool.Context = { + ...ctx, + ask: (req) => + Effect.sync(() => { + const needsAsk = req.patterns.some( + (pattern) => Permission.evaluate(req.permission, pattern, ruleset).action !== "allow", + ) + if (needsAsk) requests.push(req) + }), + } + + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "needle", + path: alias, + include: "*.txt", + }, + next, + ) + + expect(result.metadata.matches).toBe(1) + expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined() + }), + ) }) From ddce77622576f2800d7c79b25ad9c15de8b3a289 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 11 May 2026 22:25:41 -0500 Subject: [PATCH 068/188] ignore: add codebase skill to repo (#26990) --- .../DEEPENING.md | 37 ++++++++++ .../INTERFACE-DESIGN.md | 44 ++++++++++++ .../improve-codebase-architecture/LANGUAGE.md | 53 ++++++++++++++ .../improve-codebase-architecture/SKILL.md | 71 +++++++++++++++++++ 4 files changed, 205 insertions(+) create mode 100644 .opencode/skills/improve-codebase-architecture/DEEPENING.md create mode 100644 .opencode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md create mode 100644 .opencode/skills/improve-codebase-architecture/LANGUAGE.md create mode 100644 .opencode/skills/improve-codebase-architecture/SKILL.md diff --git a/.opencode/skills/improve-codebase-architecture/DEEPENING.md b/.opencode/skills/improve-codebase-architecture/DEEPENING.md new file mode 100644 index 000000000..ecaf5d7dc --- /dev/null +++ b/.opencode/skills/improve-codebase-architecture/DEEPENING.md @@ -0,0 +1,37 @@ +# Deepening + +How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**. + +## Dependency categories + +When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam. + +### 1. In-process + +Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed. + +### 2. Local-substitutable + +Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface. + +### 3. Remote but owned (Ports & Adapters) + +Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter. + +Recommendation shape: *"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."* + +### 4. True external (Mock) + +Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter. + +## Seam discipline + +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection. +- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them. + +## Testing strategy: replace, don't layer + +- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them. +- Write new tests at the deepened module's interface. The **interface is the test surface**. +- Tests assert on observable outcomes through the interface, not internal state. +- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface. diff --git a/.opencode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md b/.opencode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md new file mode 100644 index 000000000..3197723a0 --- /dev/null +++ b/.opencode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md @@ -0,0 +1,44 @@ +# Interface Design + +When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best. + +Uses the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**, **leverage**. + +## Process + +### 1. Frame the problem space + +Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate: + +- The constraints any new interface would need to satisfy +- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md)) +- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete + +Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel. + +### 2. Spawn sub-agents + +Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module. + +Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint: + +- Agent 1: "Minimize the interface — aim for 1–3 entry points max. Maximise leverage per entry point." +- Agent 2: "Maximise flexibility — support many use cases and extension." +- Agent 3: "Optimise for the most common caller — make the default case trivial." +- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies." + +Include both [LANGUAGE.md](LANGUAGE.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language. + +Each sub-agent outputs: + +1. Interface (types, methods, params — plus invariants, ordering, error modes) +2. Usage example showing how callers use it +3. What the implementation hides behind the seam +4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md)) +5. Trade-offs — where leverage is high, where it's thin + +### 3. Present and compare + +Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**. + +After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu. diff --git a/.opencode/skills/improve-codebase-architecture/LANGUAGE.md b/.opencode/skills/improve-codebase-architecture/LANGUAGE.md new file mode 100644 index 000000000..530c27630 --- /dev/null +++ b/.opencode/skills/improve-codebase-architecture/LANGUAGE.md @@ -0,0 +1,53 @@ +# Language + +Shared vocabulary for every suggestion this skill makes. Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point. + +## Terms + +**Module** +Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, class, package, or tier-spanning slice. +_Avoid_: unit, component, service. + +**Interface** +Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics. +_Avoid_: API, signature (too narrow — those refer only to the type-level surface). + +**Implementation** +What's inside a module — its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise. + +**Depth** +Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation. + +**Seam** _(from Michael Feathers)_ +A place where you can alter behaviour without editing in that place. The *location* at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it. +_Avoid_: boundary (overloaded with DDD's bounded context). + +**Adapter** +A concrete thing that satisfies an interface at a seam. Describes *role* (what slot it fills), not substance (what's inside). + +**Leverage** +What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests. + +**Locality** +What maintainers get from depth. Change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere. + +## Principles + +- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface. +- **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep. +- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test *past* the interface, the module is probably the wrong shape. +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it. + +## Relationships + +- A **Module** has exactly one **Interface** (the surface it presents to callers and tests). +- **Depth** is a property of a **Module**, measured against its **Interface**. +- A **Seam** is where a **Module**'s **Interface** lives. +- An **Adapter** sits at a **Seam** and satisfies the **Interface**. +- **Depth** produces **Leverage** for callers and **Locality** for maintainers. + +## Rejected framings + +- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead. +- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know. +- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**. diff --git a/.opencode/skills/improve-codebase-architecture/SKILL.md b/.opencode/skills/improve-codebase-architecture/SKILL.md new file mode 100644 index 000000000..05984a609 --- /dev/null +++ b/.opencode/skills/improve-codebase-architecture/SKILL.md @@ -0,0 +1,71 @@ +--- +name: improve-codebase-architecture +description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable. +--- + +# Improve Codebase Architecture + +Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability. + +## Glossary + +Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md). + +- **Module** — anything with an interface and an implementation (function, class, package, slice). +- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature. +- **Implementation** — the code inside. +- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. **Deep** = high leverage. **Shallow** = interface nearly as complex as the implementation. +- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. (Use this, not "boundary.") +- **Adapter** — a concrete thing satisfying an interface at a seam. +- **Leverage** — what callers get from depth. +- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place. + +Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list): + +- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep. +- **The interface is the test surface.** +- **One adapter = hypothetical seam. Two adapters = real seam.** + +This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate. + +## Process + +### 1. Explore + +Read the project's domain glossary and any ADRs in the area you're touching first. + +Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction: + +- Where does understanding one concept require bouncing between many small modules? +- Where are modules **shallow** — interface nearly as complex as the implementation? +- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)? +- Where do tightly-coupled modules leak across their seams? +- Which parts of the codebase are untested, or hard to test through their current interface? + +Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want. + +### 2. Present candidates + +Present a numbered list of deepening opportunities. For each candidate: + +- **Files** — which files/modules are involved +- **Problem** — why the current architecture is causing friction +- **Solution** — plain English description of what would change +- **Benefits** — explained in terms of locality and leverage, and also in how tests would improve + +**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." + +**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly (e.g. _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids. + +Do NOT propose interfaces yet. Ask the user: "Which of these would you like to explore?" + +### 3. Grilling loop + +Once the user picks a candidate, drop into a grilling conversation. Walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive. + +Side effects happen inline as decisions crystallize: + +- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist. +- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. +- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md). +- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md). From 591eb667d5c3c28034bea0b2d3870625feb809eb Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 03:26:47 +0000 Subject: [PATCH 069/188] chore: generate --- .opencode/skills/improve-codebase-architecture/DEEPENING.md | 2 +- .opencode/skills/improve-codebase-architecture/LANGUAGE.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.opencode/skills/improve-codebase-architecture/DEEPENING.md b/.opencode/skills/improve-codebase-architecture/DEEPENING.md index ecaf5d7dc..c52fdfd99 100644 --- a/.opencode/skills/improve-codebase-architecture/DEEPENING.md +++ b/.opencode/skills/improve-codebase-architecture/DEEPENING.md @@ -18,7 +18,7 @@ Dependencies that have local test stand-ins (PGLite for Postgres, in-memory file Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter. -Recommendation shape: *"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."* +Recommendation shape: _"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."_ ### 4. True external (Mock) diff --git a/.opencode/skills/improve-codebase-architecture/LANGUAGE.md b/.opencode/skills/improve-codebase-architecture/LANGUAGE.md index 530c27630..dd9b60fea 100644 --- a/.opencode/skills/improve-codebase-architecture/LANGUAGE.md +++ b/.opencode/skills/improve-codebase-architecture/LANGUAGE.md @@ -19,11 +19,11 @@ What's inside a module — its body of code. Distinct from **Adapter**: a thing Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation. **Seam** _(from Michael Feathers)_ -A place where you can alter behaviour without editing in that place. The *location* at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it. +A place where you can alter behaviour without editing in that place. The _location_ at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it. _Avoid_: boundary (overloaded with DDD's bounded context). **Adapter** -A concrete thing that satisfies an interface at a seam. Describes *role* (what slot it fills), not substance (what's inside). +A concrete thing that satisfies an interface at a seam. Describes _role_ (what slot it fills), not substance (what's inside). **Leverage** What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests. @@ -35,7 +35,7 @@ What maintainers get from depth. Change, bugs, knowledge, and verification conce - **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface. - **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep. -- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test *past* the interface, the module is probably the wrong shape. +- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test _past_ the interface, the module is probably the wrong shape. - **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it. ## Relationships From 2b432d9e036ef5871e169b00816cb5e5ddf3a77c Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 11 May 2026 23:42:04 -0400 Subject: [PATCH 070/188] fix(tui): scope events by project (#26936) --- .../opencode/src/cli/cmd/tui/context/event.ts | 32 ++++------ .../opencode/src/cli/cmd/tui/context/sync.tsx | 6 +- .../test/cli/cmd/tui/sync-fixture.tsx | 33 ++++++++-- .../opencode/test/cli/cmd/tui/sync.test.tsx | 42 ++++++++++++- .../opencode/test/cli/tui/use-event.test.tsx | 63 +++++++++++-------- 5 files changed, 122 insertions(+), 54 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/event.ts b/packages/opencode/src/cli/cmd/tui/context/event.ts index 156f9c947..5d814ecdc 100644 --- a/packages/opencode/src/cli/cmd/tui/context/event.ts +++ b/packages/opencode/src/cli/cmd/tui/context/event.ts @@ -2,39 +2,33 @@ import type { Event } from "@opencode-ai/sdk/v2" import { useProject } from "./project" import { useSDK } from "./sdk" +type EventMetadata = { + workspace: string | undefined +} + export function useEvent() { const project = useProject() const sdk = useSDK() - function subscribe(handler: (event: Event) => void) { + function subscribe(handler: (event: Event, metadata: EventMetadata) => void) { return sdk.event.on("event", (event) => { if (event.payload.type === "sync") { return } - // Special hack for truly global events - if (event.directory === "global") { - handler(event.payload) - } - - if (project.workspace.current()) { - if (event.workspace === project.workspace.current()) { - handler(event.payload) - } - - return - } - - if (event.directory === project.instance.directory()) { - handler(event.payload) + if (event.directory === "global" || event.project === project.project()) { + handler(event.payload, { workspace: event.workspace }) } }) } - function on(type: T, handler: (event: Extract) => void) { - return subscribe((event) => { + function on( + type: T, + handler: (event: Extract, metadata: EventMetadata) => void, + ) { + return subscribe((event: Event, metadata: EventMetadata) => { if (event.type !== type) return - handler(event as Extract) + handler(event as Extract, metadata) }) } diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 0d4cb2e6e..76b1807ab 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -131,7 +131,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) } - event.subscribe((event) => { + event.subscribe((event, { workspace }) => { switch (event.type) { case "server.instance.disposed": void bootstrap() @@ -364,7 +364,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "vcs.branch.updated": { - setStore("vcs", { branch: event.properties.branch }) + if (workspace === project.workspace.current()) { + setStore("vcs", { branch: event.properties.branch }) + } break } } diff --git a/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx index d9ecdbe9d..5f51374c1 100644 --- a/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx +++ b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx @@ -4,9 +4,10 @@ import { onMount } from "solid-js" import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" -import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" +import { ProjectProvider, useProject } from "../../../../src/cli/cmd/tui/context/project" import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" +import type { GlobalEvent } from "@opencode-ai/sdk/v2" export const worktree = "/tmp/opencode" export const directory = `${worktree}/packages/opencode` @@ -30,6 +31,25 @@ export function eventSource(): EventSource { return { subscribe: async () => () => {} } } +export function createEventSource() { + let fn: ((event: GlobalEvent) => void) | undefined + + return { + source: { + subscribe: async (handler: (event: GlobalEvent) => void) => { + fn = handler + return () => { + if (fn === handler) fn = undefined + } + }, + } satisfies EventSource, + emit(event: GlobalEvent) { + if (!fn) throw new Error("event source not ready") + fn(event) + }, + } +} + type FetchHandler = (url: URL) => Response | Promise | undefined export function createFetch(override?: FetchHandler) { @@ -77,11 +97,13 @@ export function createFetch(override?: FetchHandler) { return { fetch, session } } -type Ctx = { kv: ReturnType; sync: ReturnType } +type Ctx = { kv: ReturnType; project: ReturnType; sync: ReturnType } export async function mount(override?: FetchHandler) { const calls = createFetch(override) + const events = createEventSource() let sync!: ReturnType + let project!: ReturnType let kv!: ReturnType let done!: () => void const ready = new Promise((resolve) => { @@ -89,9 +111,10 @@ export async function mount(override?: FetchHandler) { }) function Probe() { - const ctx: Ctx = { kv: useKV(), sync: useSync() } + const ctx: Ctx = { kv: useKV(), project: useProject(), sync: useSync() } onMount(() => { sync = ctx.sync + project = ctx.project kv = ctx.kv done() }) @@ -102,7 +125,7 @@ export async function mount(override?: FetchHandler) { - + @@ -116,5 +139,5 @@ export async function mount(override?: FetchHandler) { await ready await wait(() => sync.status === "complete") - return { app, kv, sync, session: calls.session } + return { app, emit: events.emit, kv, project, sync, session: calls.session } } diff --git a/packages/opencode/test/cli/cmd/tui/sync.test.tsx b/packages/opencode/test/cli/cmd/tui/sync.test.tsx index f67257f6c..714c39a78 100644 --- a/packages/opencode/test/cli/cmd/tui/sync.test.tsx +++ b/packages/opencode/test/cli/cmd/tui/sync.test.tsx @@ -2,7 +2,21 @@ import { describe, expect, test } from "bun:test" import { Global } from "@opencode-ai/core/global" import { tmpdir } from "../../../fixture/fixture" -import { mount } from "./sync-fixture" +import { mount, wait } from "./sync-fixture" +import type { GlobalEvent } from "@opencode-ai/sdk/v2" + +function branchEvent(branch: string, workspace?: string): GlobalEvent { + return { + directory: "/tmp/other", + project: "proj_test", + workspace, + payload: { + id: `evt_vcs_${branch}`, + type: "vcs.branch.updated", + properties: { branch }, + }, + } +} describe("tui sync", () => { test("refresh scopes sessions by default and lists project sessions when disabled", async () => { @@ -27,4 +41,30 @@ describe("tui sync", () => { Global.Path.state = previous } }) + + test("vcs branch updates only apply for the active workspace", async () => { + const previous = Global.Path.state + await using tmp = await tmpdir() + Global.Path.state = tmp.path + await Bun.write(`${tmp.path}/kv.json`, "{}") + const { app, emit, project, sync } = await mount() + + try { + expect(sync.data.vcs?.branch).toBe("main") + + project.workspace.set("ws_a") + emit(branchEvent("other", "ws_b")) + await Bun.sleep(30) + + expect(sync.data.vcs?.branch).toBe("main") + + emit(branchEvent("feature", "ws_a")) + await wait(() => sync.data.vcs?.branch === "feature") + + expect(sync.data.vcs?.branch).toBe("feature") + } finally { + app.renderer.destroy() + Global.Path.state = previous + } + }) }) diff --git a/packages/opencode/test/cli/tui/use-event.test.tsx b/packages/opencode/test/cli/tui/use-event.test.tsx index 78253361b..ac2d942db 100644 --- a/packages/opencode/test/cli/tui/use-event.test.tsx +++ b/packages/opencode/test/cli/tui/use-event.test.tsx @@ -7,6 +7,8 @@ import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/pr import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" import { useEvent } from "../../../src/cli/cmd/tui/context/event" +const projectID = "proj_test" + async function wait(fn: () => boolean, timeout = 2000) { const start = Date.now() while (!fn()) { @@ -15,9 +17,10 @@ async function wait(fn: () => boolean, timeout = 2000) { } } -function event(payload: Event, input: { directory: string; workspace?: string }): GlobalEvent { +function event(payload: Event, input: { directory: string; project?: string; workspace?: string }): GlobalEvent { return { directory: input.directory, + project: input.project, workspace: input.workspace, payload, } @@ -65,6 +68,13 @@ function createSource() { async function mount() { const source = createSource() const seen: Event[] = [] + const workspaces: Array = [] + const fetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + if (url.pathname === "/path") return Response.json({ home: "", state: "", config: "", directory: "/tmp/root" }) + if (url.pathname === "/project/current") return Response.json({ id: projectID }) + throw new Error(`unexpected request: ${url.pathname}`) + }) as typeof globalThis.fetch let project!: ReturnType let done!: () => void const ready = new Promise((resolve) => { @@ -72,30 +82,42 @@ async function mount() { }) const app = await testRender(() => ( - + { + onReady={async (ctx) => { project = ctx.project + await project.sync() done() }} seen={seen} + workspaces={workspaces} /> )) await ready - return { app, emit: source.emit, project, seen } + return { app, emit: source.emit, project, seen, workspaces } } -function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType }) => void }) { +function Probe(props: { + seen: Event[] + workspaces: Array + onReady: (ctx: { project: ReturnType }) => void +}) { const project = useProject() const event = useEvent() onMount(() => { - event.subscribe((evt) => { + event.subscribe((evt, { workspace }) => { props.seen.push(evt) + props.workspaces.push(workspace) }) props.onReady({ project }) }) @@ -104,25 +126,26 @@ function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType { - test("delivers matching directory events without an active workspace", async () => { - const { app, emit, seen } = await mount() + test("delivers events for the current project", async () => { + const { app, emit, seen, workspaces } = await mount() try { - emit(event(vcs("main"), { directory: "/tmp/root" })) + emit(event(vcs("main"), { directory: "/tmp/other", project: projectID, workspace: "ws_a" })) await wait(() => seen.length === 1) expect(seen).toEqual([vcs("main")]) + expect(workspaces).toEqual(["ws_a"]) } finally { app.renderer.destroy() } }) - test("ignores non-matching directory events without an active workspace", async () => { + test("ignores events for other projects", async () => { const { app, emit, seen } = await mount() try { - emit(event(vcs("other"), { directory: "/tmp/other" })) + emit(event(vcs("other"), { directory: "/tmp/root", project: "proj_other" })) await Bun.sleep(30) expect(seen).toHaveLength(0) @@ -131,12 +154,12 @@ describe("useEvent", () => { } }) - test("delivers matching workspace events when a workspace is active", async () => { + test("delivers current project events regardless of active workspace", async () => { const { app, emit, project, seen } = await mount() try { project.workspace.set("ws_a") - emit(event(vcs("ws"), { directory: "/tmp/other", workspace: "ws_a" })) + emit(event(vcs("ws"), { directory: "/tmp/other", project: projectID, workspace: "ws_b" })) await wait(() => seen.length === 1) @@ -146,20 +169,6 @@ describe("useEvent", () => { } }) - test("ignores non-matching workspace events when a workspace is active", async () => { - const { app, emit, project, seen } = await mount() - - try { - project.workspace.set("ws_a") - emit(event(vcs("ws"), { directory: "/tmp/root", workspace: "ws_b" })) - await Bun.sleep(30) - - expect(seen).toHaveLength(0) - } finally { - app.renderer.destroy() - } - }) - test("delivers truly global events even when a workspace is active", async () => { const { app, emit, project, seen } = await mount() From 5cc84800dc236353c839d845ae2403482d3bac11 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 03:43:40 +0000 Subject: [PATCH 071/188] chore: generate --- packages/opencode/test/cli/tui/use-event.test.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/opencode/test/cli/tui/use-event.test.tsx b/packages/opencode/test/cli/tui/use-event.test.tsx index ac2d942db..d690cfd6c 100644 --- a/packages/opencode/test/cli/tui/use-event.test.tsx +++ b/packages/opencode/test/cli/tui/use-event.test.tsx @@ -82,12 +82,7 @@ async function mount() { }) const app = await testRender(() => ( - + { From 487575773d2d51fb541eea1fb40f647a3bbf62cf Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 11 May 2026 23:13:22 -0500 Subject: [PATCH 072/188] feat: create global opencode.jsonc if no configs exist (#26992) --- packages/opencode/src/config/config.ts | 10 ++++ packages/opencode/test/config/config.test.ts | 48 ++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 4b10665ac..e44405f42 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -409,6 +409,16 @@ export const layer = Layer.effect( const loadGlobal = Effect.fnUntraced(function* () { let result: Info = {} + // Seed the default global config with the schema for editor completion, but avoid writing when the user + // explicitly routes config through env-provided paths or content. + if (!Flag.OPENCODE_CONFIG && !Flag.OPENCODE_CONFIG_DIR && !Flag.OPENCODE_CONFIG_CONTENT) { + const file = globalConfigFile() + if (!existsSync(file)) { + yield* fs + .writeWithDirs(file, JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2)) + .pipe(Effect.catch(() => Effect.void)) + } + } result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"))) result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"))) result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index fa9fd332e..90e78efcd 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -141,6 +141,54 @@ test("loads config with defaults when no files exist", async () => { }) }) +test("creates global jsonc config with schema when no global configs exist", async () => { + await using tmp = await tmpdir() + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = tmp.path + await clear(true) + + try { + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + await load() + }, + }) + + const content = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc")) + expect(content).toContain('"$schema": "https://opencode.ai/config.json"') + } finally { + ;(Global.Path as { config: string }).config = prev + await clear(true) + } +}) + +test("does not create global config when OPENCODE_CONFIG_DIR is set", async () => { + await using tmp = await tmpdir() + await using custom = await tmpdir() + const prevConfig = Global.Path.config + const prevEnv = process.env.OPENCODE_CONFIG_DIR + ;(Global.Path as { config: string }).config = tmp.path + process.env.OPENCODE_CONFIG_DIR = custom.path + await clear(true) + + try { + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + await load() + }, + }) + + expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc"))).toBe(false) + } finally { + ;(Global.Path as { config: string }).config = prevConfig + if (prevEnv === undefined) delete process.env.OPENCODE_CONFIG_DIR + else process.env.OPENCODE_CONFIG_DIR = prevEnv + await clear(true) + } +}) + test("loads JSON config file", async () => { await using tmp = await tmpdir({ init: async (dir) => { From e36bc20f844fe3aa1ee581502cc921ebba072d3d Mon Sep 17 00:00:00 2001 From: James Long Date: Tue, 12 May 2026 00:30:03 -0400 Subject: [PATCH 073/188] fix(tui): fix flicker by avoiding redundant workspace session sync (#26997) --- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 5 ----- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 3 ++- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 76b1807ab..31104ddd9 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -113,7 +113,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const kv = useKV() const fullSyncedSessions = new Set() - let syncedWorkspace = project.workspace.current() function sessionListQuery(): { scope?: "project"; path?: string } { if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" } @@ -378,10 +377,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ async function bootstrap(input: { fatal?: boolean } = {}) { const fatal = input.fatal ?? true const workspace = project.workspace.current() - if (workspace !== syncedWorkspace) { - fullSyncedSessions.clear() - syncedWorkspace = workspace - } const projectPromise = project.sync() const sessionListPromise = projectPromise.then(() => listSessions()) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index b2ee3af62..3e966d9a5 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -10,6 +10,7 @@ import { onMount, Show, Switch, + untrack, useContext, } from "solid-js" import { Dynamic } from "solid-js/web" @@ -242,7 +243,7 @@ export function Session() { createEffect(() => { const sessionID = route.sessionID void (async () => { - const previousWorkspace = project.workspace.current() + const previousWorkspace = untrack(() => project.workspace.current()) const result = await sdk.client.session.get({ sessionID }, { throwOnError: true }) if (!result.data) { toast.show({ From 36d40fee4dec052e5c81664390e61a74705dfa13 Mon Sep 17 00:00:00 2001 From: Dax Date: Tue, 12 May 2026 01:18:57 -0400 Subject: [PATCH 074/188] Track session usage totals (#26644) --- .opencode/opencode.jsonc | 6 +- .../migration.sql | 6 + .../snapshot.json | 1591 +++++++++++++++++ packages/opencode/src/cli/cmd/stats.ts | 12 +- .../cli/cmd/tui/component/prompt/index.tsx | 3 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 3 +- .../tui/feature-plugins/sidebar/context.tsx | 3 +- .../opencode/src/cli/cmd/tui/plugin/api.tsx | 3 + .../tui/routes/session/subagent-footer.tsx | 2 +- packages/opencode/src/data-migration.ts | 100 +- packages/opencode/src/session/projectors.ts | 51 + packages/opencode/src/session/session.sql.ts | 10 +- packages/opencode/src/session/session.ts | 34 + .../opencode/src/storage/json-migration.ts | 6 + packages/opencode/src/v2/session.ts | 20 + packages/opencode/test/effect/runner.test.ts | 13 +- packages/opencode/test/fixture/tui-plugin.ts | 1 + .../test/session/session-schema.test.ts | 2 + packages/plugin/src/tui.ts | 2 + packages/sdk/js/src/v2/gen/types.gen.ts | 40 + 20 files changed, 1882 insertions(+), 26 deletions(-) create mode 100644 packages/opencode/migration/20260510033149_session_usage/migration.sql create mode 100644 packages/opencode/migration/20260510033149_session_usage/snapshot.json diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index dab531d33..0ae2fbe26 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -1,11 +1,7 @@ { "$schema": "https://opencode.ai/config.json", "provider": {}, - "permission": { - "edit": { - "packages/opencode/migration/*": "ask", - }, - }, + "permission": {}, "mcp": {}, "tools": { "github-triage": false, diff --git a/packages/opencode/migration/20260510033149_session_usage/migration.sql b/packages/opencode/migration/20260510033149_session_usage/migration.sql new file mode 100644 index 000000000..68e12aad0 --- /dev/null +++ b/packages/opencode/migration/20260510033149_session_usage/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE `session` ADD `cost` real DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `session` ADD `tokens_input` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `session` ADD `tokens_output` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `session` ADD `tokens_reasoning` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `session` ADD `tokens_cache_read` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `session` ADD `tokens_cache_write` integer DEFAULT 0 NOT NULL; diff --git a/packages/opencode/migration/20260510033149_session_usage/snapshot.json b/packages/opencode/migration/20260510033149_session_usage/snapshot.json new file mode 100644 index 000000000..4ec5dbc52 --- /dev/null +++ b/packages/opencode/migration/20260510033149_session_usage/snapshot.json @@ -0,0 +1,1591 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "be5eae31-b7f8-4292-8827-c36a524abd1b", + "prevIds": [ + "630a93f2-c6c6-4191-a351-868d8f3a05d4" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 0124a2693..3dadea9dd 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -164,8 +164,8 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( Effect.gen(function* () { const messages = yield* svc.messages({ sessionID: session.id }) - let sessionCost = 0 - let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + const sessionCost = session.cost ?? 0 + const sessionTokens = session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } let sessionToolUsage: Record = {} let sessionModelUsage: Record< string, @@ -178,8 +178,6 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( for (const message of messages) { if (message.info.role === "assistant") { - sessionCost += message.info.cost || 0 - const modelKey = `${message.info.providerID}/${message.info.modelID}` if (!sessionModelUsage[modelKey]) { sessionModelUsage[modelKey] = { @@ -192,12 +190,6 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( sessionModelUsage[modelKey].cost += message.info.cost || 0 if (message.info.tokens) { - sessionTokens.input += message.info.tokens.input || 0 - sessionTokens.output += message.info.tokens.output || 0 - sessionTokens.reasoning += message.info.tokens.reasoning || 0 - sessionTokens.cache.read += message.info.tokens.cache?.read || 0 - sessionTokens.cache.write += message.info.tokens.cache?.write || 0 - sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0 sessionModelUsage[modelKey].tokens.output += (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 3bbfc261b..c80daf9cf 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -337,6 +337,7 @@ export function Prompt(props: PromptProps) { const usage = createMemo(() => { if (!props.sessionID) return + const session = sync.session.get(props.sessionID) const msg = sync.data.message[props.sessionID] ?? [] const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) if (!last) return @@ -347,7 +348,7 @@ export function Prompt(props: PromptProps) { const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined - const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0) + const cost = session?.cost ?? 0 return { context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), cost: cost > 0 ? money.format(cost) : undefined, diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 31104ddd9..9f8a384f7 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -345,7 +345,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ case "message.part.removed": { const parts = store.part[event.properties.messageID] const result = Binary.search(parts, event.properties.partID, (p) => p.id) - if (result.found) + if (result.found) { setStore( "part", event.properties.messageID, @@ -353,6 +353,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ draft.splice(result.index, 1) }), ) + } break } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx index b3cf2beb4..405e8c145 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx @@ -13,7 +13,8 @@ const money = new Intl.NumberFormat("en-US", { function View(props: { api: TuiPluginApi; session_id: string }) { const theme = () => props.api.theme.current const msg = createMemo(() => props.api.state.session.messages(props.session_id)) - const cost = createMemo(() => msg().reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)) + const session = createMemo(() => props.api.state.session.get(props.session_id)) + const cost = createMemo(() => session()?.cost ?? 0) const state = createMemo(() => { const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 54059f4a2..8958a9285 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -147,6 +147,9 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { count() { return sync.data.session.length }, + get(sessionID) { + return sync.session.get(sessionID) + }, diff(sessionID) { return (sync.data.session_diff[sessionID] ?? []).flatMap((item) => item.file === undefined ? [] : [{ ...item, file: item.file }], diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx index 2a6813ffb..f4a458b63 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx @@ -42,7 +42,7 @@ export function SubagentFooter() { const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined - const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0) + const cost = session()?.cost ?? 0 const money = new Intl.NumberFormat("en-US", { style: "currency", diff --git a/packages/opencode/src/data-migration.ts b/packages/opencode/src/data-migration.ts index 0a2973de5..f2268b4c8 100644 --- a/packages/opencode/src/data-migration.ts +++ b/packages/opencode/src/data-migration.ts @@ -2,7 +2,9 @@ import { Context, Effect, Layer } from "effect" import { Database } from "./storage/db" import { DataMigrationTable } from "./data-migration.sql" import * as Log from "@opencode-ai/core/util/log" -import { eq } from "drizzle-orm" +import { and, asc, eq, gt, inArray, sql } from "drizzle-orm" +import { MessageTable, SessionTable } from "./session/session.sql" +import type { SessionID } from "./session/schema" export type Migration = { name: string @@ -18,7 +20,101 @@ export class Service extends Context.Service()("@opencode/Da export const layer = Layer.effect( Service, Effect.gen(function* () { - const migrations: Migration[] = [] + const migrations: Migration[] = [ + { + name: "session_usage_from_messages", + run: Effect.gen(function* () { + type Usage = { + cost: number + tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } + } + + for (let cursor: SessionID | undefined, page = 1; ; page++) { + const next = yield* Effect.gen(function* () { + const sessions = yield* Effect.sync(() => + Database.use((db) => + db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(cursor ? gt(SessionTable.id, cursor) : undefined) + .orderBy(asc(SessionTable.id)) + .limit(100) + .all(), + ), + ) + if (sessions.length === 0) return + + yield* Effect.sync(() => + Database.transaction((db) => { + const usageBySession = new Map( + sessions.map((session) => [ + session.id, + { cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }, + ]), + ) + + for (const row of db + .select({ + session_id: MessageTable.session_id, + cost: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.cost'), 0)), 0)`, + tokens_input: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.input'), 0)), 0)`, + tokens_output: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.output'), 0)), 0)`, + tokens_reasoning: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.reasoning'), 0)), 0)`, + tokens_cache_read: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.cache.read'), 0)), 0)`, + tokens_cache_write: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.cache.write'), 0)), 0)`, + }) + .from(MessageTable) + .where( + and( + inArray(MessageTable.session_id, sessions.map((session) => session.id)), + sql`json_extract(${MessageTable.data}, '$.role') = 'assistant'`, + ), + ) + .groupBy(MessageTable.session_id) + .all()) { + const current = usageBySession.get(row.session_id) + if (!current) continue + current.cost = row.cost + current.tokens.input = row.tokens_input + current.tokens.output = row.tokens_output + current.tokens.reasoning = row.tokens_reasoning + current.tokens.cache.read = row.tokens_cache_read + current.tokens.cache.write = row.tokens_cache_write + } + + for (const [sessionID, value] of usageBySession) { + db.update(SessionTable) + .set({ + cost: value.cost, + tokens_input: value.tokens.input, + tokens_output: value.tokens.output, + tokens_reasoning: value.tokens.reasoning, + tokens_cache_read: value.tokens.cache.read, + tokens_cache_write: value.tokens.cache.write, + }) + .where(eq(SessionTable.id, sessionID)) + .run() + } + }), + ) + + return sessions.at(-1)?.id + }).pipe( + Effect.withSpan("DataMigration.sessionUsage.page", { + attributes: { + "data_migration.name": "session_usage_from_messages", + "data_migration.page": page, + "data_migration.cursor": cursor ?? "", + }, + }), + ) + if (!next) return + cursor = next + yield* Effect.sleep("10 millis") + } + }), + }, + ] yield* Effect.gen(function* () { if (migrations.length === 0) return diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index 93acd4546..8b5cc2bdc 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -1,6 +1,8 @@ import { NotFoundError } from "@/storage/storage" import { eq } from "drizzle-orm" import { and } from "drizzle-orm" +import { sql } from "drizzle-orm" +import type { TxOrDb } from "@/storage/db" import { SyncEvent } from "@/sync" import * as Session from "./session" import { MessageV2 } from "./message-v2" @@ -19,6 +21,28 @@ function foreign(err: unknown) { export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial | null } : T +type Usage = Pick + +function usage(part: MessageV2.Part | (typeof PartTable.$inferSelect)["data"]): Usage | undefined { + if (part.type !== "step-finish") return undefined + if (!("cost" in part) || !("tokens" in part)) return undefined + return { cost: part.cost, tokens: part.tokens } +} + +function applyUsage(db: TxOrDb, sessionID: Session.Info["id"], value: Usage, sign = 1) { + db.update(SessionTable) + .set({ + cost: sql`${SessionTable.cost} + ${value.cost * sign}`, + tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input * sign}`, + tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`, + tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`, + tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`, + tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`, + }) + .where(eq(SessionTable.id, sessionID)) + .run() +} + function grab( obj: T, field1: K1, @@ -54,6 +78,12 @@ export function toPartialRow(info: DeepPartial) { summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")), summary_files: grab(info, "summary", (v) => grab(v, "files")), summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")), + cost: grab(info, "cost"), + tokens_input: grab(info, "tokens", (v) => grab(v, "input")), + tokens_output: grab(info, "tokens", (v) => grab(v, "output")), + tokens_reasoning: grab(info, "tokens", (v) => grab(v, "reasoning")), + tokens_cache_read: grab(info, "tokens", (v) => grab(v, "cache", (cache) => grab(cache, "read"))), + tokens_cache_write: grab(info, "tokens", (v) => grab(v, "cache", (cache) => grab(cache, "write"))), revert: grab(info, "revert"), permission: grab(info, "permission"), time_created: grab(info, "time", (v) => grab(v, "created")), @@ -112,12 +142,28 @@ export default [ }), SyncEvent.project(MessageV2.Event.Removed, (db, data) => { + for (const row of db + .select() + .from(PartTable) + .where(and(eq(PartTable.message_id, data.messageID), eq(PartTable.session_id, data.sessionID))) + .all()) { + const previous = usage(row.data) + if (previous) applyUsage(db, data.sessionID, previous, -1) + } db.delete(MessageTable) .where(and(eq(MessageTable.id, data.messageID), eq(MessageTable.session_id, data.sessionID))) .run() }), SyncEvent.project(MessageV2.Event.PartRemoved, (db, data) => { + const row = db + .select() + .from(PartTable) + .where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID))) + .get() + const previous = row && usage(row.data) + if (previous) applyUsage(db, data.sessionID, previous, -1) + db.delete(PartTable) .where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID))) .run() @@ -125,6 +171,7 @@ export default [ SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => { const { id, messageID, sessionID, ...rest } = data.part + const row = db.select().from(PartTable).where(eq(PartTable.id, id)).get() try { db.insert(PartTable) @@ -137,6 +184,10 @@ export default [ }) .onConflictDoUpdate({ target: PartTable.id, set: { data: rest } }) .run() + const previous = row && usage(row.data) + const next = usage(data.part) + if (previous) applyUsage(db, row.session_id, previous, -1) + if (next) applyUsage(db, sessionID, next) } catch (err) { if (!foreign(err)) throw err log.warn("ignored late part update", { partID: id, messageID, sessionID }) diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 421fa6869..18d041f45 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core" +import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" import type { SessionMessage } from "../v2/session-message" @@ -10,7 +10,7 @@ import type { WorkspaceID } from "../control-plane/schema" import { Timestamps } from "../storage/schema.sql" type PartData = Omit -type InfoData = Omit +type InfoData = T extends unknown ? Omit : never type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> export const SessionTable = sqliteTable( @@ -33,6 +33,12 @@ export const SessionTable = sqliteTable( summary_deletions: integer(), summary_files: integer(), summary_diffs: text({ mode: "json" }).$type(), + cost: real().notNull().default(0), + tokens_input: integer().notNull().default(0), + tokens_output: integer().notNull().default(0), + tokens_reasoning: integer().notNull().default(0), + tokens_cache_read: integer().notNull().default(0), + tokens_cache_write: integer().notNull().default(0), revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), permission: text({ mode: "json" }).$type(), agent: text(), diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 92b4329e6..eff027579 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -87,6 +87,16 @@ export function fromRow(row: SessionRow): Info { : undefined, version: row.version, summary, + cost: row.cost, + tokens: { + input: row.tokens_input, + output: row.tokens_output, + reasoning: row.tokens_reasoning, + cache: { + read: row.tokens_cache_read, + write: row.tokens_cache_write, + }, + }, share, revert, permission: row.permission ?? undefined, @@ -117,6 +127,12 @@ export function toRow(info: Info) { summary_deletions: info.summary?.deletions, summary_files: info.summary?.files, summary_diffs: info.summary?.diffs, + cost: info.cost ?? 0, + tokens_input: (info.tokens ?? EmptyTokens).input, + tokens_output: (info.tokens ?? EmptyTokens).output, + tokens_reasoning: (info.tokens ?? EmptyTokens).reasoning, + tokens_cache_read: (info.tokens ?? EmptyTokens).cache.read, + tokens_cache_write: (info.tokens ?? EmptyTokens).cache.write, revert: info.revert ?? null, permission: info.permission, time_created: info.time.created, @@ -147,6 +163,18 @@ const Summary = Schema.Struct({ diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)), }) +const Tokens = Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}) + +const EmptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + const Share = Schema.Struct({ url: Schema.String, }) @@ -184,6 +212,8 @@ export const Info = Schema.Struct({ path: optionalOmitUndefined(Schema.String), parentID: optionalOmitUndefined(SessionID), summary: optionalOmitUndefined(Summary), + cost: optionalOmitUndefined(Schema.Finite), + tokens: optionalOmitUndefined(Tokens), share: optionalOmitUndefined(Share), title: Schema.String, agent: optionalOmitUndefined(Schema.String), @@ -281,6 +311,8 @@ const UpdatedInfo = Schema.Struct({ path: Schema.optional(Schema.NullOr(Schema.String)), parentID: Schema.optional(Schema.NullOr(SessionID)), summary: Schema.optional(Schema.NullOr(Summary)), + cost: Schema.optional(Schema.Finite), + tokens: Schema.optional(Tokens), share: Schema.optional(UpdatedShare), title: Schema.optional(Schema.NullOr(Schema.String)), agent: Schema.optional(Schema.NullOr(Schema.String)), @@ -503,6 +535,8 @@ export const layer: Layer.Layer | NodeSQLiteDatabase("Session.Info")({ path: optionalOmitUndefined(Schema.String), agent: optionalOmitUndefined(Schema.String), model: Modelv2.Ref.pipe(optionalOmitUndefined), + cost: Schema.Finite, + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, updated: V2Schema.DateTimeUtcFromMillis, @@ -136,6 +146,16 @@ export const layer = Layer.effect( variant: Modelv2.VariantID.make(row.model.variant ?? "default"), } : undefined, + cost: row.cost, + tokens: { + input: row.tokens_input, + output: row.tokens_output, + reasoning: row.tokens_reasoning, + cache: { + read: row.tokens_cache_read, + write: row.tokens_cache_write, + }, + }, time: { created: DateTime.makeUnsafe(row.time_created), updated: DateTime.makeUnsafe(row.time_updated), diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index 0f5783bfc..c37cb276b 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect" +import { Deferred, Effect, Exit, Fiber, Latch, Ref, Scope } from "effect" import { Runner } from "@/effect/runner" import { it } from "../lib/effect" @@ -352,11 +352,18 @@ describe("Runner", () => { Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s, { onInterrupt: Effect.succeed("interrupted") }) + const ready = yield* Latch.make() const sh = yield* runner - .startShell(Effect.never.pipe(Effect.ensuring(Effect.die("boom")), Effect.as("ignored"))) + .startShell( + Effect.gen(function* () { + yield* ready.open + return yield* Effect.never.pipe(Effect.as("ignored")) + }).pipe(Effect.ensuring(Effect.die("boom"))), + ready, + ) .pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* ready.await.pipe(Effect.timeout("250 millis")) yield* runner.cancel expect(Exit.isFailure(yield* Fiber.await(sh))).toBe(true) diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 62a3ae6e6..3d894bd0a 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -292,6 +292,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { }, session: { count: opts.state?.session?.count ?? (() => 0), + get: opts.state?.session?.get ?? (() => undefined), diff: opts.state?.session?.diff ?? (() => []), todo: opts.state?.session?.todo ?? (() => []), messages: opts.state?.session?.messages ?? (() => []), diff --git a/packages/opencode/test/session/session-schema.test.ts b/packages/opencode/test/session/session-schema.test.ts index 38531d15b..906414fdb 100644 --- a/packages/opencode/test/session/session-schema.test.ts +++ b/packages/opencode/test/session/session-schema.test.ts @@ -12,6 +12,8 @@ const info = { directory: "/tmp/opencode", parentID: undefined, summary: undefined, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, share: undefined, title: "Test session", version: "1.0.0", diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 851b0476e..d4c2261b2 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -11,6 +11,7 @@ import type { Provider, PermissionRequest, QuestionRequest, + Session, SessionStatus, TextPart, Config as SdkConfig, @@ -310,6 +311,7 @@ export type TuiState = { readonly vcs: { branch?: string } | undefined session: { count: () => number + get: (sessionID: string) => Session | undefined diff: (sessionID: string) => ReadonlyArray todo: (sessionID: string) => ReadonlyArray messages: (sessionID: string) => ReadonlyArray diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c7a479f5a..fd5783660 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -741,6 +741,16 @@ export type Session = { files: number diffs?: Array } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } share?: { url: string } @@ -1430,6 +1440,16 @@ export type GlobalSession = { files: number diffs?: Array } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } share?: { url: string } @@ -1893,6 +1913,16 @@ export type SyncEventSessionUpdated = { files: number diffs?: Array } | null + cost?: number | null + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } | null share?: { url?: string | null } @@ -3085,6 +3115,16 @@ export type SessionInfo = { providerID: string variant: string } + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } time: { created: number updated: number From ea6eabe1d93f6acbf8ebbcbee95eba61de1bb2cf Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 05:20:22 +0000 Subject: [PATCH 075/188] chore: generate --- .../snapshot.json | 144 +++++------------- packages/opencode/src/data-migration.ts | 5 +- packages/sdk/openapi.json | 144 +++++++++++++++++- 3 files changed, 183 insertions(+), 110 deletions(-) diff --git a/packages/opencode/migration/20260510033149_session_usage/snapshot.json b/packages/opencode/migration/20260510033149_session_usage/snapshot.json index 4ec5dbc52..ce5e56f48 100644 --- a/packages/opencode/migration/20260510033149_session_usage/snapshot.json +++ b/packages/opencode/migration/20260510033149_session_usage/snapshot.json @@ -2,9 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "be5eae31-b7f8-4292-8827-c36a524abd1b", - "prevIds": [ - "630a93f2-c6c6-4191-a351-868d8f3a05d4" - ], + "prevIds": ["630a93f2-c6c6-4191-a351-868d8f3a05d4"], "ddl": [ { "name": "account_state", @@ -1153,13 +1151,9 @@ "table": "event" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1168,13 +1162,9 @@ "table": "account_state" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1183,13 +1173,9 @@ "table": "workspace" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1198,13 +1184,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1213,13 +1195,9 @@ "table": "part" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1228,13 +1206,9 @@ "table": "permission" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1243,13 +1217,9 @@ "table": "session_message" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1258,13 +1228,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1273,13 +1239,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1288,13 +1250,9 @@ "table": "session_share" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1303,128 +1261,98 @@ "table": "event" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", @@ -1588,4 +1516,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/opencode/src/data-migration.ts b/packages/opencode/src/data-migration.ts index f2268b4c8..53e3196b7 100644 --- a/packages/opencode/src/data-migration.ts +++ b/packages/opencode/src/data-migration.ts @@ -66,7 +66,10 @@ export const layer = Layer.effect( .from(MessageTable) .where( and( - inArray(MessageTable.session_id, sessions.map((session) => session.id)), + inArray( + MessageTable.session_id, + sessions.map((session) => session.id), + ), sql`json_extract(${MessageTable.data}, '$.role') = 'assistant'`, ), ) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 3d452cc9c..9850c8341 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11018,6 +11018,38 @@ "required": ["additions", "deletions", "files"], "additionalProperties": false }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, "share": { "type": "object", "properties": { @@ -12891,6 +12923,38 @@ "required": ["additions", "deletions", "files"], "additionalProperties": false }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, "share": { "type": "object", "properties": { @@ -14389,6 +14453,52 @@ } ] }, + "cost": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "tokens": { + "anyOf": [ + { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, "share": { "type": "object", "properties": { @@ -18138,6 +18248,38 @@ "required": ["id", "providerID", "variant"], "additionalProperties": false }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, "time": { "type": "object", "properties": { @@ -18158,7 +18300,7 @@ "type": "string" } }, - "required": ["id", "projectID", "time", "title"], + "required": ["id", "projectID", "cost", "tokens", "time", "title"], "additionalProperties": false }, "SessionDelivery": { From 3992e2a76b3e0d064cc8250d5b6008d7dd5dbb4e Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 12 May 2026 14:43:07 +0800 Subject: [PATCH 076/188] feat(app): add ctrl/cmd+number keybinds to switch projects (#26280) --- .../app/src/components/dialog-select-file.tsx | 2 +- .../app/src/components/settings-keybinds.tsx | 2 ++ packages/app/src/context/command.tsx | 17 ++++++++------ packages/app/src/i18n/en.ts | 1 + packages/app/src/pages/layout.tsx | 22 +++++++++++++++++++ 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 63a321e46..c5ac52919 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -107,7 +107,7 @@ function createCommandEntries(props: { const allowed = createMemo(() => { if (props.filesOnly()) return [] return props.command.options.filter( - (option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open", + (option) => !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open", ) }) diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 7d2dfaa63..149a0309b 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -123,11 +123,13 @@ function listFor(command: CommandContext, map: KeybindMap, palette: string) { for (const opt of command.catalog) { if (opt.id.startsWith("suggested.")) continue + if (opt.hidden) continue out.set(opt.id, { title: opt.title, group: groupFor(opt.id) }) } for (const opt of command.options) { if (opt.id.startsWith("suggested.")) continue + if (opt.hidden) continue out.set(opt.id, { title: opt.title, group: groupFor(opt.id) }) } diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index d2238828c..e979ad6a0 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -81,6 +81,7 @@ export interface CommandOption { slash?: string suggested?: boolean disabled?: boolean + hidden?: boolean onSelect?: (source?: "palette" | "keybind" | "slash") => void onHighlight?: () => (() => void) | void } @@ -93,6 +94,7 @@ export type CommandCatalogItem = { category?: string keybind?: KeybindConfig slash?: string + hidden?: boolean } export type CommandRegistration = { @@ -279,13 +281,14 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex setCatalog( registered().reduce((acc, opt) => { const id = actionId(opt.id) - acc[id] = { - title: opt.title, - description: opt.description, - category: opt.category, - keybind: opt.keybind, - slash: opt.slash, - } + if (opt.title) + acc[id] = { + title: opt.title, + description: opt.description, + category: opt.category, + keybind: opt.keybind, + slash: opt.slash, + } return acc }, {} as CommandCatalog), ) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 250d26edb..a42bb6261 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -25,6 +25,7 @@ export const dict = { "command.project.open": "Open project", "command.project.previous": "Previous project", "command.project.next": "Next project", + "command.project.index": "Switch to project {{index}}", "command.provider.connect": "Connect provider", "command.server.switch": "Switch server", "command.settings.open": "Open settings", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 11bc4fdb5..31d3e5dcc 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -960,6 +960,15 @@ export default function Layout(props: ParentProps) { void openProject(target.worktree) } + function navigateToProjectIndex(index: number) { + const projects = layout.projects.list() + const target = projects[index] + if (!target) return + + globalSync.child(target.worktree) + void openProject(target.worktree) + } + function navigateSessionByUnseen(offset: number) { const sessions = currentSessions() if (sessions.length === 0) return @@ -1040,6 +1049,19 @@ export default function Layout(props: ParentProps) { keybind: "mod+alt+arrowdown", onSelect: () => navigateProjectByOffset(1), }, + ...Array.from({ length: 9 }, (_, i) => { + const index = i + const number = index + 1 + return { + id: `project.${number}`, + category: language.t("command.category.project"), + title: `Open Project {number}`, + keybind: `mod+${number}`, + disabled: layout.projects.list().length <= index, + hidden: true, + onSelect: () => navigateToProjectIndex(index), + } + }), { id: "provider.connect", title: language.t("command.provider.connect"), From 907281a80a61d57f06354a74ff1c8195e0778c76 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 06:44:09 +0000 Subject: [PATCH 077/188] chore: generate --- packages/app/src/components/dialog-select-file.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index c5ac52919..ac3bc03e4 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -107,7 +107,8 @@ function createCommandEntries(props: { const allowed = createMemo(() => { if (props.filesOnly()) return [] return props.command.options.filter( - (option) => !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open", + (option) => + !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open", ) }) From 61174b787855a582f5adcce484b0d2cde4e2682b Mon Sep 17 00:00:00 2001 From: Matt H Date: Tue, 12 May 2026 04:01:16 -0400 Subject: [PATCH 078/188] fix(tui): make websearch provider label reactive (#26943) --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 3e966d9a5..95d1b072f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1985,11 +1985,11 @@ function WebFetch(props: ToolProps) { } function WebSearch(props: ToolProps) { - const metadata = props.metadata as { numResults?: number; provider?: unknown } + const metadata = () => props.metadata as { numResults?: number; provider?: unknown } return ( - {webSearchProviderLabel(metadata.provider)} "{props.input.query}"{" "} - ({metadata.numResults} results) + {webSearchProviderLabel(metadata().provider)} "{props.input.query}"{" "} + ({metadata().numResults} results) ) } From 2481dde36d9aeb48de9fb21ffd2cfdc1d42804f1 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 12 May 2026 13:44:02 +0530 Subject: [PATCH 079/188] chore: remove codesearch tool (#27019) --- packages/opencode/src/agent/agent.ts | 1 - .../tui/feature-plugins/system/session-v2.tsx | 12 ---- packages/opencode/src/config/permission.ts | 1 - .../src/skill/prompt/customize-opencode.md | 6 +- packages/opencode/src/tool/codesearch.ts | 63 ------------------- packages/opencode/src/tool/codesearch.txt | 12 ---- packages/opencode/src/tool/mcp-websearch.ts | 5 -- packages/opencode/src/tool/registry.ts | 5 +- packages/opencode/test/tool/registry.test.ts | 2 - packages/sdk/js/src/v2/gen/types.gen.ts | 1 - packages/sdk/openapi.json | 3 - 11 files changed, 4 insertions(+), 107 deletions(-) delete mode 100644 packages/opencode/src/tool/codesearch.ts delete mode 100644 packages/opencode/src/tool/codesearch.txt diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b9b56396f..c1a644282 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -206,7 +206,6 @@ export const layer = Layer.effect( glob: "allow", webfetch: "allow", websearch: "allow", - codesearch: "allow", read: "allow", repo_clone: "allow", repo_overview: "allow", diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 8b741ccb4..bcf3032ea 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -438,9 +438,6 @@ function AssistantTool(props: { part: SessionMessageAssistantTool; sessionID: st - - - @@ -773,15 +770,6 @@ function WebFetch(props: ToolProps) { ) } -function CodeSearch(props: ToolProps) { - return ( - - Exa Code Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} - {(results) => <>({results()} results)} - - ) -} - function WebSearch(props: ToolProps) { const label = createMemo(() => webSearchProviderLabel(props.metadata.provider)) return ( diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index a04b404e8..1092ae2b7 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -27,7 +27,6 @@ const InputObject = Schema.StructWithRest( question: Schema.optional(Action), webfetch: Schema.optional(Action), websearch: Schema.optional(Action), - codesearch: Schema.optional(Action), repo_clone: Schema.optional(Rule), repo_overview: Schema.optional(Rule), lsp: Schema.optional(Rule), diff --git a/packages/opencode/src/skill/prompt/customize-opencode.md b/packages/opencode/src/skill/prompt/customize-opencode.md index 744690b15..4ba118b09 100644 --- a/packages/opencode/src/skill/prompt/customize-opencode.md +++ b/packages/opencode/src/skill/prompt/customize-opencode.md @@ -335,9 +335,9 @@ rules last. everything" and is rarely what the user wants. Known permission keys: `read, edit, glob, grep, list, bash, task, -external_directory, todowrite, question, webfetch, websearch, codesearch, -repo_clone, repo_overview, lsp, doom_loop, skill`. Some of these (`todowrite, -question, webfetch, websearch, codesearch, doom_loop`) only accept a flat +external_directory, todowrite, question, webfetch, websearch, repo_clone, +repo_overview, lsp, doom_loop, skill`. Some of these (`todowrite, +question, webfetch, websearch, doom_loop`) only accept a flat action, not a per-pattern object. `external_directory` patterns are filesystem paths (use `~/`, absolute paths, diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts deleted file mode 100644 index 4616d5900..000000000 --- a/packages/opencode/src/tool/codesearch.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Effect, Schema } from "effect" -import { HttpClient } from "effect/unstable/http" -import * as Tool from "./tool" -import * as McpWebSearch from "./mcp-websearch" -import DESCRIPTION from "./codesearch.txt" - -export const Parameters = Schema.Struct({ - query: Schema.String.annotate({ - description: - "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", - }), - tokensNum: Schema.Number.check(Schema.isGreaterThanOrEqualTo(1000)) - .check(Schema.isLessThanOrEqualTo(50000)) - .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000))) - .annotate({ - description: - "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", - }), -}) - -export const CodeSearchTool = Tool.define( - "codesearch", - Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - - return { - description: DESCRIPTION, - parameters: Parameters, - execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) => - Effect.gen(function* () { - yield* ctx.ask({ - permission: "codesearch", - patterns: [params.query], - always: ["*"], - metadata: { - query: params.query, - tokensNum: params.tokensNum, - }, - }) - - const result = yield* McpWebSearch.call( - http, - McpWebSearch.EXA_URL, - "get_code_context_exa", - McpWebSearch.CodeArgs, - { - query: params.query, - tokensNum: params.tokensNum, - }, - "30 seconds", - ) - - return { - output: - result ?? - "No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.", - title: `Code search: ${params.query}`, - metadata: {}, - } - }).pipe(Effect.orDie), - } - }), -) diff --git a/packages/opencode/src/tool/codesearch.txt b/packages/opencode/src/tool/codesearch.txt deleted file mode 100644 index 4187f08d1..000000000 --- a/packages/opencode/src/tool/codesearch.txt +++ /dev/null @@ -1,12 +0,0 @@ -- Search and get relevant context for any programming task using Exa Code API -- Provides the highest quality and freshest context for libraries, SDKs, and APIs -- Use this tool for ANY question or task related to programming -- Returns comprehensive code examples, documentation, and API references -- Optimized for finding specific programming patterns and solutions - -Usage notes: - - Adjustable token count (1000-50000) for focused or comprehensive results - - Default 5000 tokens provides balanced context for most queries - - Use lower values for specific questions, higher values for comprehensive documentation - - Supports queries about frameworks, libraries, APIs, and programming concepts - - Examples: 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware' diff --git a/packages/opencode/src/tool/mcp-websearch.ts b/packages/opencode/src/tool/mcp-websearch.ts index 42b864c6f..208924cba 100644 --- a/packages/opencode/src/tool/mcp-websearch.ts +++ b/packages/opencode/src/tool/mcp-websearch.ts @@ -48,11 +48,6 @@ export const SearchArgs = Schema.Struct({ contextMaxCharacters: Schema.optional(Schema.Number), }) -export const CodeArgs = Schema.Struct({ - query: Schema.String, - tokensNum: Schema.Number, -}) - export const ParallelSearchArgs = Schema.Struct({ objective: Schema.String, search_queries: Schema.Array(Schema.String), diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a7411a077..f72f10dd1 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -22,7 +22,6 @@ import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" import { ProviderID, type ModelID } from "../provider/schema" import { WebSearchTool } from "./websearch" -import { CodeSearchTool } from "./codesearch" import { RepoCloneTool } from "./repo_clone" import { RepoOverviewTool } from "./repo_overview" import { Flag } from "@opencode-ai/core/flag/flag" @@ -120,7 +119,6 @@ export const layer: Layer.Layer< const plan = yield* PlanExitTool const webfetch = yield* WebFetchTool const websearch = yield* WebSearchTool - const codesearch = yield* CodeSearchTool const repoClone = yield* RepoCloneTool const repoOverview = yield* RepoOverviewTool const shell = yield* ShellTool @@ -224,7 +222,6 @@ export const layer: Layer.Layer< fetch: Tool.init(webfetch), todo: Tool.init(todo), search: Tool.init(websearch), - code: Tool.init(codesearch), repo_clone: Tool.init(repoClone), repo_overview: Tool.init(repoOverview), skill: Tool.init(skilltool), @@ -249,7 +246,7 @@ export const layer: Layer.Layer< tool.fetch, tool.todo, tool.search, - ...(Flag.OPENCODE_EXPERIMENTAL_SCOUT ? [tool.code, tool.repo_clone, tool.repo_overview] : []), + ...(Flag.OPENCODE_EXPERIMENTAL_SCOUT ? [tool.repo_clone, tool.repo_overview] : []), tool.skill, tool.patch, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 37cb7a43d..5ee56300c 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -72,7 +72,6 @@ describe("tool.registry", () => { const registry = yield* ToolRegistry.Service const ids = yield* registry.ids() - expect(ids).not.toContain("codesearch") expect(ids).not.toContain("repo_clone") expect(ids).not.toContain("repo_overview") }), @@ -84,7 +83,6 @@ describe("tool.registry", () => { const registry = yield* ToolRegistry.Service const ids = yield* registry.ids() - expect(ids).toContain("codesearch") expect(ids).toContain("repo_clone") expect(ids).toContain("repo_overview") }), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fd5783660..f062700b7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -955,7 +955,6 @@ export type PermissionConfig = question?: PermissionActionConfig webfetch?: PermissionActionConfig websearch?: PermissionActionConfig - codesearch?: PermissionActionConfig repo_clone?: PermissionRuleConfig repo_overview?: PermissionRuleConfig lsp?: PermissionRuleConfig diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 9850c8341..7005382f6 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11631,9 +11631,6 @@ "websearch": { "$ref": "#/components/schemas/PermissionActionConfig" }, - "codesearch": { - "$ref": "#/components/schemas/PermissionActionConfig" - }, "repo_clone": { "$ref": "#/components/schemas/PermissionRuleConfig" }, From ff38bbeeeb64c7f2faaae54430b5bfa3a2f5435f Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 12 May 2026 16:39:56 +0800 Subject: [PATCH 080/188] refactor(desktop): remove configureEnv callback from spawnLocalServer (#27022) --- packages/desktop/src/main/index.ts | 28 +++++++++++----------------- packages/desktop/src/main/server.ts | 2 -- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 1b624800e..23f2d7027 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -291,25 +291,19 @@ const main = Effect.gen(function* () { if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) }) + ensureLoopbackNoProxy() + useEnvProxy() + logger.log("spawning sidecar", { url }) const { listener, health } = yield* Effect.promise(() => - spawnLocalServer( - hostname, - port, - password, - () => { - ensureLoopbackNoProxy() - useEnvProxy() - }, - { - needsMigration, - userDataPath: app.getPath("userData"), - onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), - onStdout: (message) => logger.log("sidecar stdout", { message }), - onStderr: (message) => logger.warn("sidecar stderr", { message }), - onExit: (code) => logger.warn("sidecar exited", { code }), - }, - ), + spawnLocalServer(hostname, port, password, { + needsMigration, + userDataPath: app.getPath("userData"), + onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), + onStdout: (message) => logger.log("sidecar stdout", { message }), + onStderr: (message) => logger.warn("sidecar stderr", { message }), + onExit: (code) => logger.warn("sidecar exited", { code }), + }), ) server = listener yield* Deferred.succeed(serverReady, { diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index 909138b89..cfdafdc67 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -70,10 +70,8 @@ export async function spawnLocalServer( hostname: string, port: number, password: string, - configureEnv: () => void, options: SpawnLocalServerOptions, ) { - configureEnv?.() const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js") const child = utilityProcess.fork(sidecar, [], { cwd: process.cwd(), From caf1151cb5d574d2aac2ed6ccb20a9121880c18a Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 12 May 2026 18:40:21 +1000 Subject: [PATCH 081/188] refactor(app): centralize sync query options (#25941) Co-authored-by: Brendan Allan Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> --- .../app/src/components/dialog-select-mcp.tsx | 6 +- packages/app/src/components/prompt-input.tsx | 12 ++-- .../src/components/status-popover-body.tsx | 6 +- packages/app/src/context/global-sync.tsx | 71 +++++++++++-------- .../context/global-sync/child-store.test.ts | 2 +- .../src/context/global-sync/child-store.ts | 17 ++--- .../src/pages/layout/sidebar-workspace.tsx | 8 ++- 7 files changed, 70 insertions(+), 52 deletions(-) diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 576ec8fec..cc841e278 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -6,7 +6,8 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" import { useLanguage } from "@/context/language" -import { mcpQueryKey } from "@/context/global-sync" +import { useQueryOptions } from "@/context/global-sync" +import { pathKey } from "@/utils/path-key" const statusLabels = { connected: "mcp.status.connected", @@ -20,6 +21,7 @@ export const DialogSelectMcp: Component = () => { const sdk = useSDK() const language = useLanguage() const queryClient = useQueryClient() + const queryOptions = useQueryOptions() const items = createMemo(() => Object.entries(sync.data.mcp ?? {}) @@ -32,7 +34,7 @@ export const DialogSelectMcp: Component = () => { if (sync.data.mcp[name]?.status === "connected") await sdk.client.mcp.disconnect({ name }) else await sdk.client.mcp.connect({ name }) }, - onSuccess: () => queryClient.refetchQueries({ queryKey: mcpQueryKey(sync.directory) }), + onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))), })) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2417fa98e..eaeedf087 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -16,7 +16,6 @@ import { } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" -import { useGlobalSDK } from "@/context/global-sdk" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" import { Button } from "@opencode-ai/ui/button" @@ -56,7 +55,8 @@ import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" import { ImagePreview } from "@opencode-ai/ui/image-preview" import { useQueries } from "@tanstack/solid-query" -import { loadAgentsQuery, loadProvidersQuery } from "@/context/global-sync/bootstrap" +import { useQueryOptions } from "@/context/global-sync" +import { pathKey } from "@/utils/path-key" interface PromptInputProps { class?: string @@ -103,7 +103,7 @@ const NON_EMPTY_TEXT = /[^\s\u200B]/ export const PromptInput: Component = (props) => { const sdk = useSDK() - const globalSDK = useGlobalSDK() + const queryOptions = useQueryOptions() const sync = useSync() const local = useLocal() @@ -1256,9 +1256,9 @@ export const PromptInput: Component = (props) => { const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({ queries: [ - loadAgentsQuery(sdk.directory, sdk.client), - loadProvidersQuery(null, globalSDK.client), - loadProvidersQuery(sdk.directory, sdk.client), + queryOptions.agents(pathKey(sdk.directory)), + queryOptions.providers(null), + queryOptions.providers(pathKey(sdk.directory)), ], })) diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index bbac56278..405c7538c 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -15,7 +15,8 @@ import { useSDK } from "@/context/sdk" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" -import { mcpQueryKey } from "@/context/global-sync" +import { useQueryOptions } from "@/context/global-sync" +import { pathKey } from "@/utils/path-key" const pollMs = 10_000 @@ -139,13 +140,14 @@ const useMcpToggleMutation = () => { const sdk = useSDK() const language = useLanguage() const queryClient = useQueryClient() + const queryOptions = useQueryOptions() return useMutation(() => ({ mutationFn: async (name: string) => { const status = sync.data.mcp[name] await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name })) }, - onSuccess: () => queryClient.refetchQueries({ queryKey: mcpQueryKey(sync.directory) }), + onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))), onError: (err) => { showToast({ variant: "error", diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 31c90463d..594f94fb6 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -18,8 +18,10 @@ import { bootstrapDirectory, bootstrapGlobal, clearProviderRev, + loadAgentsQuery, loadGlobalConfigQuery, loadPathQuery, + loadProjectsQuery, loadProvidersQuery, } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" @@ -33,6 +35,7 @@ import { formatServerError } from "@/utils/server-errors" import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" import { createRefreshQueue } from "./global-sync/queue" import { directoryKey } from "./global-sync/utils" +import { PathKey } from "@/utils/path-key" type GlobalStore = { ready: boolean @@ -48,24 +51,33 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } -export const loadSessionsQueryKey = (directory: string) => [directory, "loadSessions"] as const - -export const mcpQueryKey = (directory: string) => [directory, "mcp"] as const - export const loadMcpQuery = (directory: string, sdk: OpencodeClient) => queryOptions({ - queryKey: mcpQueryKey(directory), + queryKey: [directory, "mcp"] as const, queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), }) -export const lspQueryKey = (directory: string) => [directory, "lsp"] as const - export const loadLspQuery = (directory: string, sdk: OpencodeClient) => queryOptions({ - queryKey: lspQueryKey(directory), + queryKey: [directory, "lsp"] as const, queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), }) +function makeQueryOptionsApi(globalSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) { + return { + globalConfig: () => loadGlobalConfigQuery(globalSDK()), + projects: () => loadProjectsQuery(globalSDK()), + providers: (directory: PathKey | null) => + loadProvidersQuery(directory, directory === null ? globalSDK() : sdkFor(directory)), + path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? globalSDK() : sdkFor(directory)), + agents: (directory: PathKey) => loadAgentsQuery(directory, sdkFor(directory)), + mcp: (directory: PathKey) => loadMcpQuery(directory, sdkFor(directory)), + lsp: (directory: PathKey) => loadLspQuery(directory, sdkFor(directory)), + sessions: (directory: PathKey) => ({ queryKey: [directory, "loadSessions"] as const }), + } +} +export type QueryOptionsApi = ReturnType + function createGlobalSync() { const globalSDK = useGlobalSDK() const language = useLanguage() @@ -77,12 +89,22 @@ function createGlobalSync() { const sessionLoads = new Map>() const sessionMeta = new Map() + const sdkFor = (directory: string) => { + const key = directoryKey(directory) + const cached = sdkCache.get(key) + if (cached) return cached + const sdk = globalSDK.createClient({ + directory, + throwOnError: true, + }) + sdkCache.set(key, sdk) + return sdk + } + + const queryOptionsApi = makeQueryOptionsApi(() => globalSDK.client, sdkFor) + const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ - queries: [ - loadGlobalConfigQuery(globalSDK.client), - loadProvidersQuery(null, globalSDK.client), - loadPathQuery(null, globalSDK.client), - ], + queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)], })) const [globalStore, setGlobalStore] = createStore({ @@ -181,18 +203,6 @@ function createGlobalSync() { bootstrapInstance, }) - const sdkFor = (directory: string) => { - const key = directoryKey(directory) - const cached = sdkCache.get(key) - if (cached) return cached - const sdk = globalSDK.createClient({ - directory, - throwOnError: true, - }) - sdkCache.set(key, sdk) - return sdk - } - const children = createChildStoreManager({ owner, isBooting: (directory) => booting.has(directory), @@ -209,7 +219,7 @@ function createGlobalSync() { clearSessionPrefetchDirectory(key) }, translate: language.t, - getSdk: sdkFor, + queryOptions: queryOptionsApi, global: { provider: globalStore.provider, }, @@ -239,7 +249,7 @@ function createGlobalSync() { const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) const promise = queryClient .fetchQuery({ - queryKey: loadSessionsQueryKey(key), + ...queryOptionsApi.sessions(key), queryFn: () => loadRootSessionsWithFallback({ directory, @@ -368,7 +378,7 @@ function createGlobalSync() { setSessionTodo, vcsCache: children.vcsCache.get(key), loadLsp: () => { - void queryClient.fetchQuery(loadLspQuery(key, sdkFor(directory))) + void queryClient.fetchQuery(queryOptionsApi.lsp(key)) }, }) }) @@ -426,6 +436,7 @@ function createGlobalSync() { }, child: children.child, peek: children.peek, + queryOptions: queryOptionsApi, // bootstrap, updateConfig: updateConfigMutation.mutateAsync, project: projectApi, @@ -447,3 +458,7 @@ export function useGlobalSync() { if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider") return context } + +export function useQueryOptions() { + return useGlobalSync().queryOptions +} diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index 30dda8691..bb8eb7ce7 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -22,7 +22,7 @@ describe("createChildStoreManager", () => { onBootstrap() {}, onDispose() {}, translate: (key) => key, - getSdk: () => null!, + queryOptions: {} as any, global: { provider: null! }, }) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 737c6bedc..e8ca597d1 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -1,7 +1,7 @@ import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" -import type { OpencodeClient, ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client" +import type { ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client" import { DIR_IDLE_TTL_MS, MAX_DIR_STORES, @@ -15,8 +15,7 @@ import { } from "./types" import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" import { useQueries } from "@tanstack/solid-query" -import { loadPathQuery, loadProvidersQuery } from "./bootstrap" -import { loadLspQuery, loadMcpQuery } from "../global-sync" +import { QueryOptionsApi } from "../global-sync" import { directoryKey, type DirectoryKey } from "./utils" export function createChildStoreManager(input: { @@ -26,7 +25,7 @@ export function createChildStoreManager(input: { onBootstrap: (directory: string) => void onDispose: (directory: string) => void translate: (key: string, vars?: Record) => string - getSdk: (directory: string) => OpencodeClient + queryOptions: QueryOptionsApi global: { provider: ProviderListResponse } @@ -171,17 +170,15 @@ export function createChildStoreManager(input: { const init = () => createRoot((dispose) => { - const sdk = input.getSdk(directory) - const initialMeta = meta[0].value const initialIcon = icon[0].value const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({ queries: [ - loadPathQuery(key, sdk), - loadMcpQuery(key, sdk), - loadLspQuery(key, sdk), - loadProvidersQuery(key, sdk), + input.queryOptions.path(key), + input.queryOptions.mcp(key), + input.queryOptions.lsp(key), + input.queryOptions.providers(key), ], })) diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 9b80adac2..f423c13d1 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -14,7 +14,7 @@ import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip } from "@opencode-ai/ui/tooltip" import { type Session } from "@opencode-ai/sdk/v2/client" import { type LocalProject } from "@/context/layout" -import { loadSessionsQueryKey, useGlobalSync } from "@/context/global-sync" +import { useGlobalSync, useQueryOptions } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { pathKey } from "@/utils/path-key" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" @@ -300,6 +300,7 @@ export const SortableWorkspace = (props: { const navigate = useNavigate() const params = useParams() const globalSync = useGlobalSync() + const queryOptions = useQueryOptions() const language = useLanguage() const sortable = createSortable(props.directory) const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false }) @@ -320,7 +321,7 @@ export const SortableWorkspace = (props: { const boot = createMemo(() => open() || active()) const count = createMemo(() => sessions()?.length ?? 0) const hasMore = createMemo(() => workspaceStore.sessionTotal > count()) - const fetching = useIsFetching(() => ({ queryKey: loadSessionsQueryKey(props.directory) })) + const fetching = useIsFetching(() => queryOptions.sessions(pathKey(props.directory))) const busy = createMemo(() => props.ctx.isBusy(props.directory)) const loading = () => fetching() > 0 && count() === 0 const touch = createMediaQuery("(hover: none)") @@ -446,6 +447,7 @@ export const LocalWorkspace = (props: { mobile?: boolean }): JSX.Element => { const globalSync = useGlobalSync() + const queryOptions = useQueryOptions() const language = useLanguage() const workspace = createMemo(() => { const [store, setStore] = globalSync.child(props.project.worktree) @@ -454,7 +456,7 @@ export const LocalWorkspace = (props: { const slug = createMemo(() => base64Encode(props.project.worktree)) const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) const count = createMemo(() => sessions()?.length ?? 0) - const fetching = useIsFetching(() => ({ queryKey: loadSessionsQueryKey(props.project.worktree) })) + const fetching = useIsFetching(() => queryOptions.sessions(pathKey(props.project.worktree))) const hasMore = createMemo(() => workspace().store.sessionTotal > count()) const loading = () => fetching() > 0 && count() === 0 const loadMore = async () => { From d276d96cdfc03dc64cef820c5751d723915d9476 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 12 May 2026 17:44:50 +0800 Subject: [PATCH 082/188] fix(app): remember selected model variant when switching sessions/projects (#27029) --- packages/app/src/context/local.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index f467e9034..4465a0261 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -44,7 +44,7 @@ const migrate = (value: unknown) => { } const clone = (value: State | undefined) => { - if (!value) return undefined + if (!value) return return { ...value, model: value.model ? { ...value.model } : undefined, @@ -104,7 +104,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const pickAgent = (name: string | undefined) => { const items = list() - if (items.length === 0) return undefined + if (items.length === 0) return return items.find((item) => item.name === name) ?? items[0] } @@ -227,14 +227,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ () => agent.current()?.model, fallback, ) - if (!item) return undefined + if (!item) return return models.find(item) } const configured = () => { const item = agent.current() const model = current() - if (!item || !model) return undefined + if (!item || !model) return return getConfiguredAgentVariant({ agent: { model: item.model, variant: item.variant }, model: { providerID: model.provider.id, modelID: model.id, variants: model.variants }, @@ -314,11 +314,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ configured, selected, current() { - return resolveModelVariant({ + const resolved = resolveModelVariant({ variants: this.list(), selected: this.selected(), configured: this.configured(), }) + if (resolved) return resolved + const model = current() + if (!model) return + const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id }) + if (saved && this.list().includes(saved)) return saved }, list() { const item = current() @@ -335,6 +340,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ variant: value ?? null, }) write({ variant: value ?? null }) + if (model) { + models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined) + } }) }, cycle() { From 8f05bbfaa62192b357d460e1d2d2b34f13f8dec7 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Tue, 12 May 2026 11:45:28 +0200 Subject: [PATCH 083/188] prompt: fix cursor math for wide characters (#27017) String.length counts code points, not display columns, so CJK characters and emoji that occupy two terminal cells caused misaligned cursors, broken mention triggers, and incorrect history restoration offsets. Use Bun.stringWidth for now, we need an alternative for this. Fix #26716 Close #26922 --- .../opencode/src/cli/cmd/prompt-display.ts | 39 +++++++++++++++ .../src/cli/cmd/run/footer.prompt.tsx | 34 ++++++------- .../opencode/src/cli/cmd/run/prompt.shared.ts | 7 +-- .../cmd/tui/component/prompt/autocomplete.tsx | 12 ++--- .../test/cli/run/prompt.shared.test.ts | 50 +++++++++++++++++++ 5 files changed, 113 insertions(+), 29 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/prompt-display.ts diff --git a/packages/opencode/src/cli/cmd/prompt-display.ts b/packages/opencode/src/cli/cmd/prompt-display.ts new file mode 100644 index 000000000..7ec4bc0af --- /dev/null +++ b/packages/opencode/src/cli/cmd/prompt-display.ts @@ -0,0 +1,39 @@ +const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }) + +function displayOffsetIndex(value: string, offset: number) { + if (offset <= 0) return 0 + + let width = 0 + for (const part of graphemes.segment(value)) { + const next = width + Bun.stringWidth(part.segment) + if (next > offset) return part.index + width = next + } + + return value.length +} + +export function displaySlice(value: string, start = 0, end = Bun.stringWidth(value)) { + return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end)) +} + +export function displayCharAt(value: string, offset: number) { + let width = 0 + for (const part of graphemes.segment(value)) { + const next = width + Bun.stringWidth(part.segment) + if (offset === width || offset < next) return part.segment + width = next + } +} + +export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(value)) { + const text = displaySlice(value, 0, offset) + const index = text.lastIndexOf("@") + if (index === -1) return + + const before = index === 0 ? undefined : text[index - 1] + const query = text.slice(index) + if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) { + return Bun.stringWidth(text.slice(0, index)) + } +} diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index 8cd4fbfcf..54f20dbc0 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -14,7 +14,10 @@ import { createEffect, createMemo, createResource, createSignal, onCleanup, onMo import * as Locale from "@/util/locale" import { createPromptHistory, + displayCharAt, + displaySlice, isExitCommand, + mentionTriggerIndex, isNewCommand, movePromptHistory, promptCycle, @@ -537,7 +540,7 @@ export function createPromptState(input: PromptInput): PromptState { }) } - const restore = (value: RunPrompt, cursor = value.text.length) => { + const restore = (value: RunPrompt, cursor = Bun.stringWidth(value.text)) => { draft = clonePrompt(value) if (!area || area.isDestroyed) { return @@ -546,7 +549,7 @@ export function createPromptState(input: PromptInput): PromptState { hide() area.setText(value.text) restoreParts(value.parts) - area.cursorOffset = Math.min(cursor, area.plainText.length) + area.cursorOffset = Math.min(cursor, Bun.stringWidth(area.plainText)) scheduleRows() area.focus() } @@ -577,7 +580,7 @@ export function createPromptState(input: PromptInput): PromptState { area.setText(text) clearParts() draft = { text: area.plainText, parts: [] } - area.cursorOffset = Math.min(text.length, area.plainText.length) + area.cursorOffset = Math.min(Bun.stringWidth(text), Bun.stringWidth(area.plainText)) scheduleRows() area.focus() } @@ -610,12 +613,13 @@ export function createPromptState(input: PromptInput): PromptState { } if (visible() && mode() === "mention") { - if (cursor <= at() || /\s/.test(text.slice(at(), cursor))) { + const query = displaySlice(text, at(), cursor) + if (cursor <= at() || /\s/.test(query)) { hide() return } - setQuery(text.slice(at() + 1, cursor)) + setQuery(displaySlice(text, at() + 1, cursor)) return } @@ -623,19 +627,12 @@ export function createPromptState(input: PromptInput): PromptState { return } - const head = text.slice(0, cursor) - const idx = head.lastIndexOf("@") - if (idx === -1) { - return - } - - const before = idx === 0 ? undefined : head[idx - 1] - const tail = head.slice(idx) - if ((before === undefined || /\s/.test(before)) && !/\s/.test(tail)) { + const idx = mentionTriggerIndex(text, cursor) + if (idx !== undefined) { setAt(idx) menu.reset() setMode("mention") - setQuery(head.slice(idx + 1)) + setQuery(displaySlice(text, idx + 1, cursor)) } } @@ -782,7 +779,7 @@ export function createPromptState(input: PromptInput): PromptState { } const cursor = area.cursorOffset - const tail = area.plainText.at(cursor) + const tail = displayCharAt(area.plainText, cursor) const append = "@" + next.value + (tail === " " ? "" : " ") area.cursorOffset = at() const start = area.logicalCursor @@ -941,7 +938,8 @@ export function createPromptState(input: PromptInput): PromptState { } const dir = up ? -1 : 1 - if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === area.plainText.length)) { + const endOffset = Bun.stringWidth(area.plainText) + if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === endOffset)) { move(dir, event) return } @@ -955,7 +953,7 @@ export function createPromptState(input: PromptInput): PromptState { ? area.height - 1 : Math.max(0, (area.virtualLineCount ?? 1) - 1) if (dir === 1 && area.visualCursor.visualRow === end) { - area.cursorOffset = area.plainText.length + area.cursorOffset = endOffset } } diff --git a/packages/opencode/src/cli/cmd/run/prompt.shared.ts b/packages/opencode/src/cli/cmd/run/prompt.shared.ts index 1b639e6e7..0da787cb3 100644 --- a/packages/opencode/src/cli/cmd/run/prompt.shared.ts +++ b/packages/opencode/src/cli/cmd/run/prompt.shared.ts @@ -12,6 +12,7 @@ // The leader-key cycle (promptCycle) uses a two-step pattern: first press // arms the leader, second press within the timeout fires the action. import type { KeyBinding } from "@opentui/core" +export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display" import { formatBinding, parseBindings } from "./keymap.shared" import type { FooterKeybinds, RunPrompt } from "./types" @@ -275,7 +276,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: return { state, apply: false } } - if (dir === 1 && cursor !== text.length) { + if (dir === 1 && cursor !== Bun.stringWidth(text)) { return { state, apply: false } } @@ -309,7 +310,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: index: null, }, text: state.draft, - cursor: state.draft.length, + cursor: Bun.stringWidth(state.draft), apply: true, } } @@ -320,7 +321,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: index: idx, }, text: state.items[idx].text, - cursor: dir === -1 ? 0 : state.items[idx].text.length, + cursor: dir === -1 ? 0 : Bun.stringWidth(state.items[idx].text), apply: true, } } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 3242de94d..3f7604653 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -20,6 +20,7 @@ import { useFrecency } from "./frecency" import { useBindings } from "../../keymap" import { Reference } from "@/reference/reference" import type { Config } from "@/config/config" +import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -159,7 +160,7 @@ export function Autocomplete(props: { const input = props.input() const currentCursorOffset = input.cursorOffset - const charAfterCursor = props.value.at(currentCursorOffset) + const charAfterCursor = displayCharAt(props.value, currentCursorOffset) const needsSpace = charAfterCursor !== " " const append = "@" + text + (needsSpace ? " " : "") @@ -787,13 +788,8 @@ export function Autocomplete(props: { } // Check for "@" trigger - find the nearest "@" before cursor with no whitespace between - const text = value.slice(0, offset) - const idx = text.lastIndexOf("@") - if (idx === -1) return - - const between = text.slice(idx) - const before = idx === 0 ? undefined : value[idx - 1] - if ((before === undefined || /\s/.test(before)) && !between.match(/\s/)) { + const idx = mentionTriggerIndex(value, offset) + if (idx !== undefined) { show("@") setStore("index", idx) } diff --git a/packages/opencode/test/cli/run/prompt.shared.test.ts b/packages/opencode/test/cli/run/prompt.shared.test.ts index 85a9dfa40..299751eaa 100644 --- a/packages/opencode/test/cli/run/prompt.shared.test.ts +++ b/packages/opencode/test/cli/run/prompt.shared.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from "bun:test" import { createPromptHistory, + displayCharAt, + displaySlice, isExitCommand, isNewCommand, + mentionTriggerIndex, movePromptHistory, printableBinding, promptCycle, @@ -85,6 +88,53 @@ describe("run prompt shared", () => { expect(draft.state.index).toBeNull() }) + test("uses display-width cursors for history restoration", () => { + const base = createPromptHistory([prompt("one"), prompt("中文")]) + + const latest = movePromptHistory(base, -1, "草稿", 0) + expect(latest.apply).toBe(true) + expect(latest.text).toBe("中文") + expect(latest.cursor).toBe(0) + + const older = movePromptHistory(latest.state, -1, "中文", 0) + expect(older.apply).toBe(true) + expect(older.text).toBe("one") + expect(older.cursor).toBe(0) + + const newer = movePromptHistory(older.state, 1, "one", Bun.stringWidth("one")) + expect(newer.apply).toBe(true) + expect(newer.text).toBe("中文") + expect(newer.cursor).toBe(Bun.stringWidth("中文")) + + const draft = movePromptHistory(newer.state, 1, "中文", Bun.stringWidth("中文")) + expect(draft.apply).toBe(true) + expect(draft.text).toBe("草稿") + expect(draft.cursor).toBe(Bun.stringWidth("草稿")) + }) + + test("uses display-width offsets for mention helpers", () => { + expect(mentionTriggerIndex("@")).toBe(0) + expect(mentionTriggerIndex("test @")).toBe(5) + expect(mentionTriggerIndex("中文 @")).toBe(5) + expect(mentionTriggerIndex("こんにちは @")).toBe(11) + expect(mentionTriggerIndex("한국어 @")).toBe(7) + expect(mentionTriggerIndex("🙂 @")).toBe(3) + expect(mentionTriggerIndex("中文 @src file", Bun.stringWidth("中文 @src"))).toBe(5) + expect(displayCharAt("中文 @src", Bun.stringWidth("中文 @"))).toBe("s") + expect(displaySlice("中文 @src", 5, Bun.stringWidth("中文 @src"))).toBe("@src") + expect(displaySlice("中文 @src", 6, Bun.stringWidth("中文 @src"))).toBe("src") + expect(mentionTriggerIndex("👨‍👩‍👧‍👦 @src", Bun.stringWidth("👨‍👩‍👧‍👦 @src"))).toBe(3) + expect(displayCharAt("👨‍👩‍👧‍👦 @src", Bun.stringWidth("👨‍👩‍👧‍👦 @"))).toBe("s") + expect(displaySlice("👨‍👩‍👧‍👦 @src", 3, Bun.stringWidth("👨‍👩‍👧‍👦 @src"))).toBe("@src") + expect(mentionTriggerIndex("中文@")).toBeUndefined() + expect(mentionTriggerIndex("こんにちは@")).toBeUndefined() + expect(mentionTriggerIndex("한국어@")).toBeUndefined() + expect(mentionTriggerIndex("🙂@")).toBeUndefined() + expect(mentionTriggerIndex("hello@")).toBeUndefined() + expect(mentionTriggerIndex("foo@bar.com")).toBeUndefined() + expect(mentionTriggerIndex("中文 @src file")).toBeUndefined() + }) + test("handles direct and leader-based variant cycling", () => { const keys = promptKeys(keybinds) From 8feb4a31c75e8bd3bd8f84ec860cfd4d326479b4 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 12 May 2026 15:22:38 +0530 Subject: [PATCH 084/188] feat(core): add background job service (#27033) --- packages/opencode/src/background/job.ts | 200 ++++++++++++++++++ packages/opencode/src/effect/app-runtime.ts | 2 + packages/opencode/src/id/id.ts | 1 + packages/opencode/test/background/job.test.ts | 127 +++++++++++ 4 files changed, 330 insertions(+) create mode 100644 packages/opencode/src/background/job.ts create mode 100644 packages/opencode/test/background/job.test.ts diff --git a/packages/opencode/src/background/job.ts b/packages/opencode/src/background/job.ts new file mode 100644 index 000000000..3ea228f04 --- /dev/null +++ b/packages/opencode/src/background/job.ts @@ -0,0 +1,200 @@ +import { InstanceState } from "@/effect/instance-state" +import { Identifier } from "@/id/id" +import { Cause, Clock, Context, Deferred, Effect, Fiber, Layer, Scope, SynchronizedRef } from "effect" + +export type Status = "running" | "completed" | "error" | "cancelled" + +export type Info = { + id: string + type: string + title?: string + status: Status + started_at: number + completed_at?: number + output?: string + error?: string + metadata?: Record +} + +type Active = { + info: Info + done: Deferred.Deferred + fiber?: Fiber.Fiber +} + +type State = { + jobs: SynchronizedRef.SynchronizedRef> + scope: Scope.Scope +} + +type FinishResult = { + info?: Info + done?: Deferred.Deferred +} + +export type StartInput = { + id?: string + type: string + title?: string + metadata?: Record + run: Effect.Effect +} + +export type WaitInput = { + id: string + timeout?: number +} + +export type WaitResult = { + info?: Info + timedOut: boolean +} + +export interface Interface { + readonly list: () => Effect.Effect + readonly get: (id: string) => Effect.Effect + readonly start: (input: StartInput) => Effect.Effect + readonly wait: (input: WaitInput) => Effect.Effect + readonly cancel: (id: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/BackgroundJob") {} + +function snapshot(job: Active): Info { + return { + ...job.info, + ...(job.info.metadata ? { metadata: { ...job.info.metadata } } : {}), + } +} + +function errorText(error: unknown) { + if (error instanceof Error) return error.message + return String(error) +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make( + Effect.fn("BackgroundJob.state")(function* () { + return { + jobs: yield* SynchronizedRef.make(new Map()), + scope: yield* Scope.Scope, + } + }), + ) + + const finish = Effect.fn("BackgroundJob.finish")(function* ( + id: string, + status: Exclude, + data?: { output?: string; error?: string }, + ) { + const completed_at = yield* Clock.currentTimeMillis + const result = yield* SynchronizedRef.modify( + (yield* InstanceState.get(state)).jobs, + (jobs): readonly [FinishResult, Map] => { + const job = jobs.get(id) + if (!job) return [{}, jobs] + if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs] + const next = { + ...job, + fiber: undefined, + info: { + ...job.info, + status, + completed_at, + ...(data?.output !== undefined ? { output: data.output } : {}), + ...(data?.error !== undefined ? { error: data.error } : {}), + }, + } + return [{ info: snapshot(next), done: job.done }, new Map(jobs).set(id, next)] + }, + ) + if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore) + return result.info + }) + + const list: Interface["list"] = Effect.fn("BackgroundJob.list")(function* () { + return Array.from((yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).values()) + .map(snapshot) + .toSorted((a, b) => a.started_at - b.started_at) + }) + + const get: Interface["get"] = Effect.fn("BackgroundJob.get")(function* (id) { + const job = (yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).get(id) + if (!job) return + return snapshot(job) + }) + + const start: Interface["start"] = Effect.fn("BackgroundJob.start")(function* (input) { + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const s = yield* InstanceState.get(state) + const id = input.id ?? Identifier.ascending("job") + const started_at = yield* Clock.currentTimeMillis + const done = yield* Deferred.make() + return yield* SynchronizedRef.modifyEffect( + s.jobs, + Effect.fnUntraced(function* (jobs) { + const existing = jobs.get(id) + if (existing?.info.status === "running") return [snapshot(existing), jobs] as const + const fiber = yield* restore(input.run).pipe( + Effect.matchCauseEffect({ + onSuccess: (output) => finish(id, "completed", { output }), + onFailure: (cause) => + finish(id, Cause.hasInterruptsOnly(cause) ? "cancelled" : "error", { + error: errorText(Cause.squash(cause)), + }), + }), + Effect.asVoid, + Effect.forkIn(s.scope, { startImmediately: true }), + ) + const job = { + info: { + id, + type: input.type, + title: input.title, + status: "running" as const, + started_at, + metadata: input.metadata, + }, + done, + fiber, + } + return [snapshot(job), new Map(jobs).set(id, job)] as const + }), + ) + }), + ) + }) + + const wait: Interface["wait"] = Effect.fn("BackgroundJob.wait")(function* (input) { + const job = (yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).get(input.id) + if (!job) return { timedOut: false } + if (job.info.status !== "running") return { info: snapshot(job), timedOut: false } + if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false } + if (input.timeout <= 0) return { info: snapshot(job), timedOut: true } + const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout)) + if (info._tag === "Some") return { info: info.value, timedOut: false } + return { info: snapshot(job), timedOut: true } + }) + + const cancel: Interface["cancel"] = Effect.fn("BackgroundJob.cancel")(function* (id) { + const job = (yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).get(id) + if (!job) return + if (job.info.status !== "running") return snapshot(job) + if (job.fiber) { + yield* Fiber.interrupt(job.fiber).pipe(Effect.ignore) + yield* Fiber.await(job.fiber).pipe(Effect.ignore) + } + const info = yield* finish(id, "cancelled") + return info + }) + + return Service.of({ list, get, start, wait, cancel }) + }), +) + +export const defaultLayer = layer + +export * as BackgroundJob from "./job" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 4c1637006..b0efab1ae 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -55,6 +55,7 @@ import { SyncEvent } from "@/sync" import { Npm } from "@opencode-ai/core/npm" import { memoMap } from "@opencode-ai/core/effect/memo-map" import { DataMigration } from "@/data-migration" +import { BackgroundJob } from "@/background/job" export const AppLayer = Layer.mergeAll( Npm.defaultLayer, @@ -81,6 +82,7 @@ export const AppLayer = Layer.mergeAll( Todo.defaultLayer, Session.defaultLayer, SessionStatus.defaultLayer, + BackgroundJob.defaultLayer, SessionRunState.defaultLayer, SessionProcessor.defaultLayer, SessionCompaction.defaultLayer, diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 9e163cd6b..847a5c032 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -1,6 +1,7 @@ import { randomBytes } from "crypto" const prefixes = { + job: "job", event: "evt", session: "ses", message: "msg", diff --git a/packages/opencode/test/background/job.test.ts b/packages/opencode/test/background/job.test.ts new file mode 100644 index 000000000..afc7260bb --- /dev/null +++ b/packages/opencode/test/background/job.test.ts @@ -0,0 +1,127 @@ +import { describe, expect } from "bun:test" +import { Deferred, Effect } from "effect" +import { BackgroundJob } from "@/background/job" +import { testEffect } from "../lib/effect" + +const it = testEffect(BackgroundJob.defaultLayer) + +describe("background.job", () => { + it.instance("tracks started jobs through completion", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const latch = yield* Deferred.make() + const job = yield* jobs.start({ + type: "test", + title: "test job", + run: Deferred.await(latch).pipe(Effect.as("done")), + }) + + expect(job.id.startsWith("job_")).toBe(true) + expect(job.status).toBe("running") + expect(job.title).toBe("test job") + + yield* Deferred.succeed(latch, undefined) + const done = yield* jobs.wait({ id: job.id }) + + expect(done.timedOut).toBe(false) + expect(done.info?.status).toBe("completed") + expect(done.info?.output).toBe("done") + expect((yield* jobs.list()).map((item) => item.id)).toEqual([job.id]) + }), + ) + + it.instance("returns a running snapshot when wait times out", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const job = yield* jobs.start({ + type: "test", + run: Effect.never, + }) + + const result = yield* jobs.wait({ id: job.id, timeout: 1 }) + + expect(result.timedOut).toBe(true) + expect(result.info?.status).toBe("running") + }), + ) + + it.instance("deduplicates concurrent starts for a running id", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const started = yield* Deferred.make() + const id = "job_test" + const [first, second] = yield* Effect.all( + [ + jobs.start({ + id, + type: "test", + run: Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), + }), + jobs.start({ + id, + type: "test", + run: Effect.fail(new Error("duplicate started")), + }), + ], + { concurrency: "unbounded" }, + ) + + yield* Deferred.await(started) + + expect(first.id).toBe(id) + expect(second.id).toBe(id) + expect(first.status).toBe("running") + expect(second.status).toBe("running") + expect((yield* jobs.list()).map((item) => item.id)).toEqual([id]) + + yield* jobs.cancel(id) + }), + ) + + it.instance("records failed jobs", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const job = yield* jobs.start({ + type: "test", + run: Effect.fail(new Error("boom")), + }) + + const result = yield* jobs.wait({ id: job.id }) + + expect(result.info?.status).toBe("error") + expect(result.info?.error).toBe("boom") + }), + ) + + it.instance("can cancel running jobs", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const interrupted = yield* Deferred.make() + const job = yield* jobs.start({ + type: "test", + run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))), + }) + + const cancelled = yield* jobs.cancel(job.id) + + expect(cancelled?.status).toBe("cancelled") + yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second")) + expect((yield* jobs.get(job.id))?.status).toBe("cancelled") + }), + ) + + it.instance("returns immutable snapshots", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const job = yield* jobs.start({ + type: "test", + metadata: { value: "initial" }, + run: Effect.succeed("done"), + }) + + if (job.metadata) job.metadata.value = "changed" + + expect((yield* jobs.get(job.id))?.metadata?.value).toBe("initial") + }), + ) +}) From 28f38fc871091d3f0a9b360d3e256564603f6517 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 09:20:15 -0400 Subject: [PATCH 085/188] Remove Zod from named errors (#26982) --- packages/core/src/util/error.ts | 53 ++++++++------- .../enterprise/src/routes/share/[shareID].tsx | 30 ++++++--- packages/opencode/src/cli/ui.ts | 4 +- packages/opencode/src/config/config.ts | 14 ++-- packages/opencode/src/config/error.ts | 30 +++++---- packages/opencode/src/config/markdown.ts | 13 ++-- packages/opencode/src/config/parse.ts | 11 ++-- packages/opencode/src/ide/index.ts | 12 ++-- packages/opencode/src/index.ts | 18 ++++-- packages/opencode/src/lsp/client.ts | 10 +-- packages/opencode/src/mcp/index.ts | 9 +-- packages/opencode/src/session/message-v2.ts | 4 +- packages/opencode/src/session/message.ts | 25 ++------ packages/opencode/src/session/retry.ts | 5 +- packages/opencode/src/skill/index.ts | 55 +++++++++------- packages/opencode/src/storage/db.ts | 11 ++-- packages/opencode/src/storage/storage.ts | 10 +-- .../opencode/src/util/named-schema-error.ts | 46 +------------ packages/opencode/src/worktree/index.ts | 64 ++++++------------- packages/opencode/test/util/error.test.ts | 18 ++++++ 20 files changed, 197 insertions(+), 245 deletions(-) diff --git a/packages/core/src/util/error.ts b/packages/core/src/util/error.ts index 9d3b7c661..7338571f2 100644 --- a/packages/core/src/util/error.ts +++ b/packages/core/src/util/error.ts @@ -1,8 +1,8 @@ -import z from "zod" +import { Schema } from "effect" export abstract class NamedError extends Error { - abstract schema(): z.core.$ZodType - abstract toObject(): { name: string; data: any } + abstract schema(): Schema.Top + abstract toObject(): { name: string; data: unknown } static hasName(error: unknown, name: string): boolean { return ( @@ -10,30 +10,42 @@ export abstract class NamedError extends Error { ) } - static create(name: Name, data: Data) { - const schema = z - .object({ - name: z.literal(name), - data, - }) - .meta({ - ref: name, - }) + static create( + name: Name, + fields: Fields, + ): ReturnType>> + static create( + name: Name, + data: DataSchema, + ): ReturnType> + static create(name: Name, data: Schema.Top | Schema.Struct.Fields) { + return NamedError.createSchemaClass(name, Schema.isSchema(data) ? data : Schema.Struct(data)) + } + + private static createSchemaClass(name: Name, data: DataSchema) { + const schema = Schema.Struct({ + name: Schema.Literal(name), + data, + }).annotate({ identifier: name }) + type Data = Schema.Schema.Type + const result = class extends NamedError { public static readonly Schema = schema + public static readonly EffectSchema = schema + public static readonly tag = name - public override readonly name = name as Name + public override readonly name = name constructor( - public readonly data: z.input, + public readonly data: Data, options?: ErrorOptions, ) { super(name, options) this.name = name } - static isInstance(input: any): input is InstanceType { - return typeof input === "object" && "name" in input && input.name === name + static isInstance(input: unknown): input is InstanceType { + return NamedError.hasName(input, name) } schema() { @@ -51,10 +63,7 @@ export abstract class NamedError extends Error { return result } - public static readonly Unknown = NamedError.create( - "UnknownError", - z.object({ - message: z.string(), - }), - ) + public static readonly Unknown = NamedError.create("UnknownError", { + message: Schema.String, + }) } diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index b12afce27..7cfb2bb4a 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -15,7 +15,6 @@ import { Binary } from "@opencode-ai/core/util/binary" import { NamedError } from "@opencode-ai/core/util/error" import { DateTime } from "luxon" import { createStore } from "solid-js/store" -import z from "zod" import NotFound from "../[...404]" import { Tabs } from "@opencode-ai/ui/tabs" import { MessageNav } from "@opencode-ai/ui/message-nav" @@ -33,13 +32,28 @@ const ClientOnlyWorkerPoolProvider = clientOnly(() => })), ) -const SessionDataMissingError = NamedError.create( - "SessionDataMissingError", - z.object({ - sessionID: z.string(), - message: z.string().optional(), - }), -) +class SessionDataMissingError extends NamedError { + public override readonly name = "SessionDataMissingError" + + constructor( + public readonly data: { sessionID: string; message?: string }, + options?: ErrorOptions, + ) { + super("SessionDataMissingError", options) + } + + static isInstance(input: unknown): input is SessionDataMissingError { + return NamedError.hasName(input, "SessionDataMissingError") + } + + schema(): never { + throw new Error("SessionDataMissingError does not expose a schema") + } + + toObject() { + return { name: this.name, data: this.data } + } +} const getData = query(async (shareID) => { "use server" diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index 7b4cf7f34..69e04b925 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/packages/opencode/src/cli/ui.ts @@ -1,6 +1,6 @@ -import z from "zod" import { EOL } from "os" import { NamedError } from "@opencode-ai/core/util/error" +import { Schema } from "effect" import { logo as glyphs } from "./logo" const wordmark = [ @@ -10,7 +10,7 @@ const wordmark = [ `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`, ] -export const CancelledError = NamedError.create("UICancelledError", z.void()) +export const CancelledError = NamedError.create("UICancelledError", Schema.optional(Schema.Void)) export const Style = { TEXT_HIGHLIGHT: "\x1b[96m", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index e44405f42..d00c97f46 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -2,7 +2,6 @@ import * as Log from "@opencode-ai/core/util/log" import path from "path" import { pathToFileURL } from "url" import os from "os" -import z from "zod" import { mergeDeep } from "remeda" import { Global } from "@opencode-ai/core/global" import fsNode from "fs/promises" @@ -357,14 +356,11 @@ function writableGlobal(info: Info) { return next } -export const ConfigDirectoryTypoError = NamedError.create( - "ConfigDirectoryTypoError", - z.object({ - path: z.string(), - dir: z.string(), - suggestion: z.string(), - }), -) +export const ConfigDirectoryTypoError = NamedError.create("ConfigDirectoryTypoError", { + path: Schema.String, + dir: Schema.String, + suggestion: Schema.String, +}) export const layer = Layer.effect( Service, diff --git a/packages/opencode/src/config/error.ts b/packages/opencode/src/config/error.ts index c43598048..17d74fc1c 100644 --- a/packages/opencode/src/config/error.ts +++ b/packages/opencode/src/config/error.ts @@ -1,21 +1,23 @@ export * as ConfigError from "./error" -import z from "zod" import { NamedError } from "@opencode-ai/core/util/error" +import { Schema } from "effect" -export const JsonError = NamedError.create( - "ConfigJsonError", - z.object({ - path: z.string(), - message: z.string().optional(), +const Issue = Schema.StructWithRest( + Schema.Struct({ + message: Schema.String, + path: Schema.Array(Schema.String), }), + [Schema.Record(Schema.String, Schema.Unknown)], ) -export const InvalidError = NamedError.create( - "ConfigInvalidError", - z.object({ - path: z.string(), - issues: z.custom().optional(), - message: z.string().optional(), - }), -) +export const JsonError = NamedError.create("ConfigJsonError", { + path: Schema.String, + message: Schema.optional(Schema.String), +}) + +export const InvalidError = NamedError.create("ConfigInvalidError", { + path: Schema.String, + issues: Schema.optional(Schema.Array(Issue)), + message: Schema.optional(Schema.String), +}) diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index 390f7f8b0..820f4bf64 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -1,6 +1,6 @@ import { NamedError } from "@opencode-ai/core/util/error" import matter from "gray-matter" -import { z } from "zod" +import { Schema } from "effect" import { Filesystem } from "@/util/filesystem" export const FILE_REGEX = /(?>( keys: extra, path: [], message: `Unrecognized key${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}`, - } as z.core.$ZodIssue, + }, ], }) } @@ -61,8 +60,12 @@ export function schema>( { path: source, issues: EffectSchema.isSchemaError(error) - ? (SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues as z.core.$ZodIssue[]) - : ([{ code: "custom", message: String(error), path: [] }] as z.core.$ZodIssue[]), + ? SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues.map((issue) => ({ + ...issue, + message: issue.message, + path: issue.path?.map(String) ?? [], + })) + : [{ message: String(error), path: [] }], }, { cause: error }, ) diff --git a/packages/opencode/src/ide/index.ts b/packages/opencode/src/ide/index.ts index 2df293f16..a31c5bd05 100644 --- a/packages/opencode/src/ide/index.ts +++ b/packages/opencode/src/ide/index.ts @@ -1,5 +1,4 @@ import { BusEvent } from "@/bus/bus-event" -import z from "zod" import { Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import * as Log from "@opencode-ai/core/util/log" @@ -24,14 +23,11 @@ export const Event = { ), } -export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", z.object({})) +export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", {}) -export const InstallFailedError = NamedError.create( - "InstallFailedError", - z.object({ - stderr: z.string(), - }), -) +export const InstallFailedError = NamedError.create("InstallFailedError", { + stderr: Schema.String, +}) export function ide() { if (process.env["TERM_PROGRAM"] === "vscode") { diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 4c8e44704..d20f29dd4 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -39,6 +39,7 @@ import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" import { drizzle } from "drizzle-orm/bun-sqlite" import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" +import { isRecord } from "@/util/record" const processMetadata = ensureProcessMetadata("main") @@ -203,13 +204,6 @@ try { } } catch (e) { let data: Record = {} - if (e instanceof NamedError) { - const obj = e.toObject() - Object.assign(data, { - ...obj.data, - }) - } - if (e instanceof Error) { Object.assign(data, { name: e.name, @@ -219,6 +213,16 @@ try { }) } + if (e instanceof NamedError) { + const obj = e.toObject() + if (isRecord(obj.data)) { + for (const [key, value] of Object.entries(obj.data)) { + if (key === "name" || key === "stack" || key === "cause") continue + data[key] = value + } + } + } + if (e instanceof ResolveMessage) { Object.assign(data, { name: e.name, diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 809ea9509..ac9706fc3 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -7,7 +7,6 @@ import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types import * as Log from "@opencode-ai/core/util/log" import { Process } from "@/util/process" import { LANGUAGE_EXTENSIONS } from "./language" -import z from "zod" import { Schema } from "effect" import type * as LSPServer from "./server" import { NamedError } from "@opencode-ai/core/util/error" @@ -32,12 +31,9 @@ export type Info = NonNullable>> export type Diagnostic = VSCodeDiagnostic -export const InitializeError = NamedError.create( - "LSPInitializeError", - z.object({ - serverID: z.string(), - }), -) +export const InitializeError = NamedError.create("LSPInitializeError", { + serverID: Schema.String, +}) export const Event = { Diagnostics: BusEvent.define( diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index db43412f7..992825dd6 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -68,12 +68,9 @@ export const BrowserOpenFailed = BusEvent.define( }), ) -export const Failed = NamedError.create( - "MCPFailed", - z.object({ - name: z.string(), - }), -) +export const Failed = NamedError.create("MCPFailed", { + name: Schema.String, +}) type MCPClient = Client diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 4dae82038..f797e2dc3 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -382,9 +382,7 @@ export type Part = const AssistantErrorSchema = Schema.Union([ AuthError.EffectSchema, - Schema.Struct({ name: Schema.Literal("UnknownError"), data: Schema.Struct({ message: Schema.String }) }).annotate({ - identifier: "UnknownError", - }), + NamedError.Unknown.EffectSchema, OutputLengthError.EffectSchema, AbortedError.EffectSchema, StructuredOutputError.EffectSchema, diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index 16c010003..6a859ffaa 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -3,6 +3,7 @@ import { SessionID } from "./schema" import { ModelID, ProviderID } from "../provider/schema" import { NonNegativeInt } from "@opencode-ai/core/schema" import { namedSchemaError } from "@/util/named-schema-error" +import { NamedError } from "@opencode-ai/core/util/error" export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) export const AuthError = namedSchemaError("ProviderAuthError", { @@ -10,26 +11,6 @@ export const AuthError = namedSchemaError("ProviderAuthError", { message: Schema.String, }) -const AuthErrorEffect = Schema.Struct({ - name: Schema.Literal("ProviderAuthError"), - data: Schema.Struct({ - providerID: Schema.String, - message: Schema.String, - }), -}) - -const OutputLengthErrorEffect = Schema.Struct({ - name: Schema.Literal("MessageOutputLengthError"), - data: Schema.Struct({}), -}) - -const UnknownErrorEffect = Schema.Struct({ - name: Schema.Literal("UnknownError"), - data: Schema.Struct({ - message: Schema.String, - }), -}) - export const ToolCall = Schema.Struct({ state: Schema.Literal("call"), step: Schema.optional(NonNegativeInt), @@ -124,7 +105,9 @@ export const Info = Schema.Struct({ created: NonNegativeInt, completed: Schema.optional(NonNegativeInt), }), - error: Schema.optional(Schema.Union([AuthErrorEffect, UnknownErrorEffect, OutputLengthErrorEffect])), + error: Schema.optional( + Schema.Union([AuthError.EffectSchema, NamedError.Unknown.EffectSchema, OutputLengthError.EffectSchema]), + ), sessionID: SessionID, tool: Schema.Record( Schema.String, diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 1f73dee31..463bc27a9 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -2,6 +2,7 @@ import type { NamedError } from "@opencode-ai/core/util/error" import { Cause, Clock, Duration, Effect, Schedule } from "effect" import { MessageV2 } from "./message-v2" import { iife } from "@/util/iife" +import { isRecord } from "@/util/record" export type Err = ReturnType @@ -121,7 +122,7 @@ export function retryable(error: Err, provider: string) { } // Check for rate limit patterns in plain text error messages - const msg = error.data?.message + const msg = isRecord(error.data) ? error.data.message : undefined if (typeof msg === "string") { const lower = msg.toLowerCase() if ( @@ -133,7 +134,7 @@ export function retryable(error: Err, provider: string) { } } - const json = parseJSON(error.data?.message) + const json = parseJSON(msg) if (!json || typeof json !== "object") return undefined const code = typeof json.code === "string" ? json.code : "" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index a0cc383d0..59dfeb080 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -1,6 +1,5 @@ import path from "path" import { pathToFileURL } from "url" -import z from "zod" import { Effect, Layer, Context, Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import type { Agent } from "@/agent/agent" @@ -16,6 +15,7 @@ import { Glob } from "@opencode-ai/core/util/glob" import * as Log from "@opencode-ai/core/util/log" import { Discovery } from "./discovery" import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" } +import { isRecord } from "@/util/record" const log = Log.create({ service: "skill" }) const CLAUDE_EXTERNAL_DIR = ".claude" @@ -41,23 +41,33 @@ export const Info = Schema.Struct({ }) export type Info = Schema.Schema.Type -export const InvalidError = NamedError.create( - "SkillInvalidError", - z.object({ - path: z.string(), - message: z.string().optional(), - issues: z.custom().optional(), +const Issue = Schema.StructWithRest( + Schema.Struct({ + message: Schema.String, + path: Schema.Array(Schema.String), }), + [Schema.Record(Schema.String, Schema.Unknown)], ) -export const NameMismatchError = NamedError.create( - "SkillNameMismatchError", - z.object({ - path: z.string(), - expected: z.string(), - actual: z.string(), - }), -) +function isSkillFrontmatter(data: unknown): data is { name: string; description?: string } { + return ( + isRecord(data) && + typeof data.name === "string" && + (data.description === undefined || typeof data.description === "string") + ) +} + +export const InvalidError = NamedError.create("SkillInvalidError", { + path: Schema.String, + message: Schema.optional(Schema.String), + issues: Schema.optional(Schema.Array(Issue)), +}) + +export const NameMismatchError = NamedError.create("SkillNameMismatchError", { + path: Schema.String, + expected: Schema.String, + actual: Schema.String, +}) type State = { skills: Record @@ -101,21 +111,20 @@ const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.I if (!md) return - const parsed = z.object({ name: z.string(), description: z.string().optional() }).safeParse(md.data) - if (!parsed.success) return + if (!isSkillFrontmatter(md.data)) return - if (state.skills[parsed.data.name]) { + if (state.skills[md.data.name]) { log.warn("duplicate skill name", { - name: parsed.data.name, - existing: state.skills[parsed.data.name].location, + name: md.data.name, + existing: state.skills[md.data.name].location, duplicate: match, }) } state.dirs.add(path.dirname(match)) - state.skills[parsed.data.name] = { - name: parsed.data.name, - description: parsed.data.description, + state.skills[md.data.name] = { + name: md.data.name, + description: md.data.description, location: match, content: md.content, } diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 06cb99f97..86e14da56 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -7,7 +7,6 @@ import { lazy } from "../util/lazy" import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" -import z from "zod" import path from "path" import { readFileSync, readdirSync, existsSync } from "fs" import { Flag } from "@opencode-ai/core/flag/flag" @@ -15,15 +14,13 @@ import { InstallationChannel } from "@opencode-ai/core/installation/version" import { InstanceState } from "@/effect/instance-state" import { iife } from "@/util/iife" import { init } from "#db" +import { Schema } from "effect" declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined -export const NotFoundError = NamedError.create( - "NotFoundError", - z.object({ - message: z.string(), - }), -) +export const NotFoundError = NamedError.create("NotFoundError", { + message: Schema.String, +}) const log = Log.create({ service: "db" }) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index bc4d8b8f1..e1f5f681b 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -2,7 +2,6 @@ import * as Log from "@opencode-ai/core/util/log" import path from "path" import { Global } from "@opencode-ai/core/global" import { NamedError } from "@opencode-ai/core/util/error" -import z from "zod" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect" import { NonNegativeInt } from "@opencode-ai/core/schema" @@ -16,12 +15,9 @@ type Migration = ( git: Git.Interface, ) => Effect.Effect -export const NotFoundError = NamedError.create( - "NotFoundError", - z.object({ - message: z.string(), - }), -) +export const NotFoundError = NamedError.create("NotFoundError", { + message: Schema.String, +}) export type Error = AppFileSystem.Error | InstanceType diff --git a/packages/opencode/src/util/named-schema-error.ts b/packages/opencode/src/util/named-schema-error.ts index a5ff0828e..cc02c3731 100644 --- a/packages/opencode/src/util/named-schema-error.ts +++ b/packages/opencode/src/util/named-schema-error.ts @@ -1,51 +1,9 @@ import { Schema } from "effect" +import { NamedError } from "@opencode-ai/core/util/error" /** * Create a Schema-backed NamedError-shaped class. - * - * Drop-in replacement for `NamedError.create(tag, zodShape)` but backed by - * `Schema.Struct` under the hood. The wire shape emitted by the derived - * `.Schema` is still `{ name: tag, data: {...fields} }` so the generated - * OpenAPI/SDK output is byte-identical to the original NamedError schema. - * - * Preserves the existing surface: - * - static `Schema` (Effect schema of the wire shape) - * - static `isInstance(x)` - * - instance `toObject()` returning `{ name, data }` - * - `new X({ ...data }, { cause })` */ export function namedSchemaError(tag: Tag, fields: Fields) { - const dataSchema = Schema.Struct(fields) - // Wire shape matches the original NamedError output so the SDK stays stable. - const effectSchema = Schema.Struct({ - name: Schema.Literal(tag), - data: dataSchema, - }).annotate({ identifier: tag }) - - type Data = Schema.Schema.Type - - class NamedSchemaError extends Error { - static readonly Schema = effectSchema - static readonly EffectSchema = effectSchema - static readonly tag = tag - public static isInstance(input: unknown): input is NamedSchemaError { - return typeof input === "object" && input !== null && "name" in input && (input as { name: unknown }).name === tag - } - - public override readonly name: Tag = tag - public readonly data: Data - - constructor(data: Data, options?: ErrorOptions) { - super(tag, options) - this.data = data - } - - toObject(): { name: Tag; data: Data } { - return { name: tag, data: this.data } - } - } - - Object.defineProperty(NamedSchemaError, "name", { value: tag }) - - return NamedSchemaError + return NamedError.create(tag, fields) } diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 439f36e0a..7d0218926 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,4 +1,3 @@ -import z from "zod" import { NamedError } from "@opencode-ai/core/util/error" import { Global } from "@opencode-ai/core/global" import { InstanceLayer } from "@/project/instance-layer" @@ -65,54 +64,33 @@ export const ResetInput = Schema.Struct({ }).annotate({ identifier: "WorktreeResetInput" }) export type ResetInput = Schema.Schema.Type -export const NotGitError = NamedError.create( - "WorktreeNotGitError", - z.object({ - message: z.string(), - }), -) +export const NotGitError = NamedError.create("WorktreeNotGitError", { + message: Schema.String, +}) -export const NameGenerationFailedError = NamedError.create( - "WorktreeNameGenerationFailedError", - z.object({ - message: z.string(), - }), -) +export const NameGenerationFailedError = NamedError.create("WorktreeNameGenerationFailedError", { + message: Schema.String, +}) -export const CreateFailedError = NamedError.create( - "WorktreeCreateFailedError", - z.object({ - message: z.string(), - }), -) +export const CreateFailedError = NamedError.create("WorktreeCreateFailedError", { + message: Schema.String, +}) -export const StartCommandFailedError = NamedError.create( - "WorktreeStartCommandFailedError", - z.object({ - message: z.string(), - }), -) +export const StartCommandFailedError = NamedError.create("WorktreeStartCommandFailedError", { + message: Schema.String, +}) -export const RemoveFailedError = NamedError.create( - "WorktreeRemoveFailedError", - z.object({ - message: z.string(), - }), -) +export const RemoveFailedError = NamedError.create("WorktreeRemoveFailedError", { + message: Schema.String, +}) -export const ResetFailedError = NamedError.create( - "WorktreeResetFailedError", - z.object({ - message: z.string(), - }), -) +export const ResetFailedError = NamedError.create("WorktreeResetFailedError", { + message: Schema.String, +}) -export const ListFailedError = NamedError.create( - "WorktreeListFailedError", - z.object({ - message: z.string(), - }), -) +export const ListFailedError = NamedError.create("WorktreeListFailedError", { + message: Schema.String, +}) function slugify(input: string) { return input diff --git a/packages/opencode/test/util/error.test.ts b/packages/opencode/test/util/error.test.ts index e7a02d615..bc966133a 100644 --- a/packages/opencode/test/util/error.test.ts +++ b/packages/opencode/test/util/error.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { NamedError } from "@opencode-ai/core/util/error" import { errorData, errorFormat, errorMessage } from "../../src/util/error" +import { namedSchemaError } from "../../src/util/named-schema-error" +import { UI } from "../../src/cli/ui" describe("util.error", () => { test("formats native Error instances", () => { @@ -48,4 +52,18 @@ describe("util.error", () => { expect(data.message).toBe("ResolveMessage: Cannot resolve module") expect(String(data.formatted)).toContain("ResolveMessage") }) + + test("named schema errors are real NamedError instances", () => { + const ExampleError = namedSchemaError("ExampleError", { message: Schema.String }) + const error = new ExampleError({ message: "boom" }) + + expect(error).toBeInstanceOf(NamedError) + expect(error.toObject()).toEqual({ name: "ExampleError", data: { message: "boom" } }) + }) + + test("void named errors accept JSON without data", () => { + const serialized = JSON.parse(JSON.stringify(new UI.CancelledError(undefined).toObject())) + + expect(Schema.decodeUnknownOption(UI.CancelledError.Schema)(serialized)._tag).toBe("Some") + }) }) From 0de3b67cc04102c895a9138049083cc81b81be8b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 09:28:46 -0400 Subject: [PATCH 086/188] test(tool): migrate shell tests to Effect runner (#26968) --- packages/opencode/test/tool/shell.test.ts | 1643 ++++++++++----------- 1 file changed, 780 insertions(+), 863 deletions(-) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 287844141..4b4cc8c88 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -1,14 +1,13 @@ -import { describe, expect, test } from "bun:test" -import { Effect, Layer, ManagedRuntime } from "effect" +import { describe, expect } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import type * as Scope from "effect/Scope" import os from "os" import path from "path" import { Config } from "@/config/config" import { Shell } from "../../src/shell/shell" import { ShellTool } from "../../src/tool/shell" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdirScoped } from "../fixture/fixture" import type { Permission } from "../../src/permission" import { Agent } from "../../src/agent/agent" import { Truncate } from "@/tool/truncate" @@ -16,23 +15,48 @@ import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Plugin } from "../../src/plugin" +import { testEffect } from "../lib/effect" +import { Tool } from "@/tool/tool" -const runtime = ManagedRuntime.make( - Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - AppFileSystem.defaultLayer, - Plugin.defaultLayer, - Truncate.defaultLayer, - Config.defaultLayer, - Agent.defaultLayer, - ), +const shellLayer = Layer.mergeAll( + CrossSpawnSpawner.defaultLayer, + AppFileSystem.defaultLayer, + Plugin.defaultLayer, + Truncate.defaultLayer, + Config.defaultLayer, + Agent.defaultLayer, ) +const it = testEffect(shellLayer) +type ShellTestServices = (typeof shellLayer extends Layer.Layer ? ROut : never) | Scope.Scope -function initBash() { - return runtime.runPromise(ShellTool.pipe(Effect.flatMap((info) => info.init()))) -} +const initShell = Effect.fn("ShellToolTest.init")(function* () { + const info = yield* ShellTool + return yield* info.init() +}) -const initShell = initBash +const initBash = initShell + +const run = Effect.fn("ShellToolTest.run")(function* ( + args: Tool.InferParameters, + next: Tool.Context = ctx, +) { + const bash = yield* initShell() + return yield* bash.execute(args, next) +}) + +const runIn = (directory: string, self: Effect.Effect) => self.pipe(provideInstance(directory)) + +const fail = Effect.fn("ShellToolTest.fail")(function* ( + args: Tool.InferParameters, + next: Tool.Context = ctx, +) { + const exit = yield* run(args, next).pipe(Effect.exit) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + return err instanceof Error ? err : new Error(String(err)) + } + throw new Error("expected command to fail") +}) const ctx = { sessionID: SessionID.make("ses_test"), @@ -96,27 +120,31 @@ const forms = (dir: string) => { return Array.from(new Set([full, slash, root, root.toLowerCase()])) } -const withShell = (item: { label: string; shell: string }, fn: () => Promise) => async () => { - const prev = process.env.SHELL - process.env.SHELL = item.shell - Shell.acceptable.reset() - Shell.preferred.reset() - try { - await fn() - } finally { - if (prev === undefined) delete process.env.SHELL - else process.env.SHELL = prev - Shell.acceptable.reset() - Shell.preferred.reset() - } -} +const withShell = (item: { label: string; shell: string }, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const prev = process.env.SHELL + process.env.SHELL = item.shell + Shell.acceptable.reset() + Shell.preferred.reset() + return prev + }), + () => self, + (prev) => + Effect.sync(() => { + if (prev === undefined) delete process.env.SHELL + else process.env.SHELL = prev + Shell.acceptable.reset() + Shell.preferred.reset() + }), + ) -const each = (name: string, fn: (item: { label: string; shell: string }) => Promise) => { +const each = ( + name: string, + fn: (item: { label: string; shell: string }) => Effect.Effect, +) => { for (const item of shells) { - test( - `${name} [${item.label}]`, - withShell(item, () => fn(item)), - ) + it.live(`${name} [${item.label}]`, () => withShell(item, fn(item))) } } @@ -140,277 +168,248 @@ const mustTruncate = (result: { } describe("tool.shell", () => { - each("basic", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() - const result = await Effect.runPromise( - bash.execute( - { - command: "echo test", - description: "Echo test message", - }, - ctx, - ), - ) + each("basic", () => + runIn( + projectRoot, + Effect.gen(function* () { + const result = yield* run({ + command: "echo test", + description: "Echo test message", + }) expect(result.metadata.exit).toBe(0) expect(result.metadata.output).toContain("test") - }, - }) - }) + }), + ), + ) - test("falls back from terminal-only configured shell", async () => { - await using tmp = await tmpdir({ - config: { shell: "fish" }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const fallback = Shell.name(Shell.acceptable("fish")) - expect(fallback).not.toBe("fish") - expect(bash.description).toContain(fallback) + it.live("falls back from terminal-only configured shell", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped({ config: { shell: "fish" } }) + yield* runIn( + tmp, + Effect.gen(function* () { + const bash = yield* initBash() + const fallback = Shell.name(Shell.acceptable("fish")) + expect(fallback).not.toBe("fish") + expect(bash.description).toContain(fallback) - const result = await Effect.runPromise( - bash.execute( + const result = yield* bash.execute( { command: "echo fallback", description: "Echo fallback text", }, ctx, - ), - ) - expect(result.metadata.exit).toBe(0) - expect(result.output).toContain("fallback") - }, - }) - }) + ) + expect(result.metadata.exit).toBe(0) + expect(result.output).toContain("fallback") + }), + ) + }), + ) }) describe("tool.shell permissions", () => { - each("asks for bash permission with correct pattern", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initShell() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( + each("asks for bash permission with correct pattern", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const requests: Array> = [] + yield* run( { command: "echo hello", description: "Echo hello", }, capture(requests), - ), - ) - expect(requests.length).toBe(1) - expect(requests[0].permission).toBe("bash") - expect(requests[0].patterns).toContain("echo hello") - }, - }) - }) + ) + expect(requests.length).toBe(1) + expect(requests[0].permission).toBe("bash") + expect(requests[0].patterns).toContain("echo hello") + }), + ) + }), + ) - each("asks for bash permission with multiple commands", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initShell() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( + each("asks for bash permission with multiple commands", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const requests: Array> = [] + yield* run( { command: "echo foo && echo bar", description: "Echo twice", }, capture(requests), - ), - ) - expect(requests.length).toBe(1) - expect(requests[0].permission).toBe("bash") - expect(requests[0].patterns).toContain("echo foo") - expect(requests[0].patterns).toContain("echo bar") - }, - }) - }) + ) + expect(requests.length).toBe(1) + expect(requests[0].permission).toBe("bash") + expect(requests[0].patterns).toContain("echo foo") + expect(requests[0].patterns).toContain("echo bar") + }), + ) + }), + ) for (const item of ps) { - test( - `parses PowerShell conditionals for permission prompts [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + it.live(`parses PowerShell conditionals for permission prompts [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: "Write-Host foo; if ($?) { Write-Host bar }", - description: "Check PowerShell conditional", - }, - capture(requests), - ), + yield* run( + { + command: "Write-Host foo; if ($?) { Write-Host bar }", + description: "Check PowerShell conditional", + }, + capture(requests), ) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() expect(bashReq!.patterns).toContain("Write-Host foo") expect(bashReq!.patterns).toContain("Write-Host bar") expect(bashReq!.always).toContain("Write-Host *") - }, - }) - }), + }), + ), + ), ) } for (const item of ps) { - test( - `uses PowerShell cmdlet prefixes for always-allow prompts [${item.label}]`, - withShell(item, async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initShell() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( + it.live(`uses PowerShell cmdlet prefixes for always-allow prompts [${item.label}]`, () => + withShell( + item, + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + expect( + yield* fail( { command: "Remove-Item -Recurse tmp", description: "Remove a temp directory", }, capture(requests, err), ), - ), - ).rejects.toThrow(err.message) - const bashReq = requests.find((r) => r.permission === "bash") - expect(bashReq).toBeDefined() - expect(bashReq!.always).toContain("Remove-Item *") - expect(bashReq!.always).not.toContain("Remove-Item -Recurse *") - }, - }) - }), + ).toMatchObject({ message: err.message }) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.always).toContain("Remove-Item *") + expect(bashReq!.always).not.toContain("Remove-Item -Recurse *") + }), + ) + }), + ), ) } - each("asks for external_directory permission for wildcard external paths", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + each("asks for external_directory permission for wildcard external paths", () => + runIn( + projectRoot, + Effect.gen(function* () { const err = new Error("stop after permission") const requests: Array> = [] const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*" - await expect( - Effect.runPromise( - bash.execute( - { - command: `cat ${file}`, - description: "Read wildcard path", - }, - capture(requests, err), - ), + expect( + yield* fail( + { + command: `cat ${file}`, + description: "Read wildcard path", + }, + capture(requests, err), ), - ).rejects.toThrow(err.message) + ).toMatchObject({ message: err.message }) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain(want) - }, - }) - }) + }), + ), + ) if (process.platform === "win32") { if (bash) { - test( - "asks for nested bash command permissions [bash]", - withShell({ label: "bash", shell: bash }, async () => { - await using outerTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "outside.txt"), "x") - }, - }) - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() - const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") - const requests: Array> = [] - await Effect.runPromise( - bash.execute( + it.live("asks for nested bash command permissions [bash]", () => + withShell( + { label: "bash", shell: bash }, + Effect.gen(function* () { + const outerTmp = yield* tmpdirScoped() + yield* Effect.promise(() => Bun.write(path.join(outerTmp, "outside.txt"), "x")) + yield* runIn( + projectRoot, + Effect.gen(function* () { + const file = path.join(outerTmp, "outside.txt").replaceAll("\\", "/") + const requests: Array> = [] + yield* run( { command: `echo $(cat "${file}")`, description: "Read nested bash file", }, capture(requests), - ), - ) - const extDirReq = requests.find((r) => r.permission === "external_directory") - const bashReq = requests.find((r) => r.permission === "bash") - expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*"))) - expect(bashReq).toBeDefined() - expect(bashReq!.patterns).toContain(`cat "${file}"`) - }, - }) - }), + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + const bashReq = requests.find((r) => r.permission === "bash") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp, "*"))) + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).toContain(`cat "${file}"`) + }), + ) + }), + ), ) } - } - if (process.platform === "win32") { for (const item of ps) { - test( - `asks for external_directory permission for PowerShell paths after switches [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + it.live(`asks for external_directory permission for PowerShell paths after switches [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { const err = new Error("stop after permission") const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`, - description: "Copy Windows ini", - }, - capture(requests, err), - ), + expect( + yield* fail( + { + command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`, + description: "Copy Windows ini", + }, + capture(requests, err), ), - ).rejects.toThrow(err.message) + ).toMatchObject({ message: err.message }) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) - }, - }) - }), + }), + ), + ), ) } for (const item of ps) { - test( - `asks for nested PowerShell command permissions [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + it.live(`asks for nested PowerShell command permissions [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { const requests: Array> = [] const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` - await Effect.runPromise( - bash.execute( - { - command: `Write-Output $(Get-Content ${file})`, - description: "Read nested PowerShell file", - }, - capture(requests), - ), + yield* run( + { + command: `Write-Output $(Get-Content ${file})`, + description: "Read nested PowerShell file", + }, + capture(requests), ) const extDirReq = requests.find((r) => r.permission === "external_directory") const bashReq = requests.find((r) => r.permission === "bash") @@ -418,283 +417,266 @@ describe("tool.shell permissions", () => { expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) expect(bashReq).toBeDefined() expect(bashReq!.patterns).toContain(`Get-Content ${file}`) - }, - }) - }), + }), + ), + ), ) } for (const item of ps) { - test( - `asks for external_directory permission for drive-relative PowerShell paths [${item.label}]`, - withShell(item, async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initShell() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( + it.live(`asks for external_directory permission for drive-relative PowerShell paths [${item.label}]`, () => + withShell( + item, + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + expect( + yield* fail( { command: 'Get-Content "C:../outside.txt"', description: "Read drive-relative file", }, capture(requests, err), ), - ), - ).rejects.toThrow(err.message) - expect(requests[0]?.permission).toBe("external_directory") - if (requests[0]?.permission !== "external_directory") return - expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp.path), "*"))) - }, - }) - }), + ).toMatchObject({ message: err.message }) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp), "*"))) + }), + ) + }), + ), ) } for (const item of ps) { - test( - `asks for external_directory permission for $HOME PowerShell paths [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + it.live(`asks for external_directory permission for $HOME PowerShell paths [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { const err = new Error("stop after permission") const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: 'Get-Content "$HOME/.ssh/config"', - description: "Read home config", - }, - capture(requests, err), - ), + expect( + yield* fail( + { + command: 'Get-Content "$HOME/.ssh/config"', + description: "Read home config", + }, + capture(requests, err), ), - ).rejects.toThrow(err.message) + ).toMatchObject({ message: err.message }) expect(requests[0]?.permission).toBe("external_directory") if (requests[0]?.permission !== "external_directory") return expect(requests[0].patterns).toContain(glob(path.join(os.homedir(), ".ssh", "*"))) - }, - }) - }), + }), + ), + ), ) } for (const item of ps) { - test( - `asks for external_directory permission for $PWD PowerShell paths [${item.label}]`, - withShell(item, async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( + it.live(`asks for external_directory permission for $PWD PowerShell paths [${item.label}]`, () => + withShell( + item, + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + expect( + yield* fail( { command: 'Get-Content "$PWD/../outside.txt"', description: "Read pwd-relative file", }, capture(requests, err), ), - ), - ).rejects.toThrow(err.message) - expect(requests[0]?.permission).toBe("external_directory") - if (requests[0]?.permission !== "external_directory") return - expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp.path), "*"))) - }, - }) - }), + ).toMatchObject({ message: err.message }) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp), "*"))) + }), + ) + }), + ), ) } for (const item of ps) { - test( - `asks for external_directory permission for $PSHOME PowerShell paths [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() + it.live(`asks for external_directory permission for $PSHOME PowerShell paths [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { const err = new Error("stop after permission") const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: 'Get-Content "$PSHOME/outside.txt"', - description: "Read pshome file", - }, - capture(requests, err), - ), + expect( + yield* fail( + { + command: 'Get-Content "$PSHOME/outside.txt"', + description: "Read pshome file", + }, + capture(requests, err), ), - ).rejects.toThrow(err.message) + ).toMatchObject({ message: err.message }) expect(requests[0]?.permission).toBe("external_directory") if (requests[0]?.permission !== "external_directory") return expect(requests[0].patterns).toContain(glob(path.join(path.dirname(item.shell), "*"))) - }, - }) - }), + }), + ), + ), ) } for (const item of ps) { - test( - `asks for external_directory permission for missing PowerShell env paths [${item.label}]`, - withShell(item, async () => { - const key = "OPENCODE_TEST_MISSING" - const prev = process.env[key] - delete process.env[key] - try { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() - const err = new Error("stop after permission") - const requests: Array> = [] - const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") - await expect( - Effect.runPromise( - bash.execute( + it.live(`asks for external_directory permission for missing PowerShell env paths [${item.label}]`, () => + withShell( + item, + Effect.acquireUseRelease( + Effect.sync(() => { + const key = "OPENCODE_TEST_MISSING" + const prev = process.env[key] + delete process.env[key] + return { key, prev } + }), + ({ key }) => + runIn( + projectRoot, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") + expect( + yield* fail( { command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`, description: "Read Windows ini with missing env", }, capture(requests, err), ), - ), - ).rejects.toThrow(err.message) - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) - }, - }) - } finally { - if (prev === undefined) delete process.env[key] - else process.env[key] = prev - } - }), + ).toMatchObject({ message: err.message }) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) + }), + ), + ({ key, prev }) => + Effect.sync(() => { + if (prev === undefined) delete process.env[key] + else process.env[key] = prev + }), + ), + ), ) } for (const item of ps) { - test( - `asks for external_directory permission for PowerShell env paths [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() + it.live(`asks for external_directory permission for PowerShell env paths [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: "Get-Content $env:WINDIR/win.ini", - description: "Read Windows ini from env", - }, - capture(requests), - ), + yield* run( + { + command: "Get-Content $env:WINDIR/win.ini", + description: "Read Windows ini from env", + }, + capture(requests), ) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain( Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), ) - }, - }) - }), + }), + ), + ), ) } for (const item of ps) { - test( - `asks for external_directory permission for PowerShell FileSystem paths [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() + it.live(`asks for external_directory permission for PowerShell FileSystem paths [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { const err = new Error("stop after permission") const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`, - description: "Read Windows ini from FileSystem provider", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - expect(requests[0]?.permission).toBe("external_directory") - if (requests[0]?.permission !== "external_directory") return - expect(requests[0].patterns).toContain( - Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), - ) - }, - }) - }), - ) - } - - for (const item of ps) { - test( - `asks for external_directory permission for braced PowerShell env paths [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: "Get-Content ${env:WINDIR}/win.ini", - description: "Read Windows ini from braced env", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - expect(requests[0]?.permission).toBe("external_directory") - if (requests[0]?.permission !== "external_directory") return - expect(requests[0].patterns).toContain( - Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), - ) - }, - }) - }), - ) - } - - for (const item of ps) { - test( - `treats Set-Location like cd for permissions [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( + expect( + yield* fail( { - command: "Set-Location C:/Windows", - description: "Change location", + command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`, + description: "Read Windows ini from FileSystem provider", }, - capture(requests), + capture(requests, err), ), + ).toMatchObject({ message: err.message }) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain( + Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), + ) + }), + ), + ), + ) + } + + for (const item of ps) { + it.live(`asks for external_directory permission for braced PowerShell env paths [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + expect( + yield* fail( + { + command: "Get-Content ${env:WINDIR}/win.ini", + description: "Read Windows ini from braced env", + }, + capture(requests, err), + ), + ).toMatchObject({ message: err.message }) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain( + Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), + ) + }), + ), + ), + ) + } + + for (const item of ps) { + it.live(`treats Set-Location like cd for permissions [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { + const requests: Array> = [] + yield* run( + { + command: "Set-Location C:/Windows", + description: "Change location", + }, + capture(requests), ) const extDirReq = requests.find((r) => r.permission === "external_directory") const bashReq = requests.find((r) => r.permission === "bash") @@ -703,104 +685,96 @@ describe("tool.shell permissions", () => { Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), ) expect(bashReq).toBeUndefined() - }, - }) - }), + }), + ), + ), ) } for (const item of ps) { - test( - `does not add nested PowerShell expressions to permission prompts [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + it.live(`does not add nested PowerShell expressions to permission prompts [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: "Write-Output ('a' * 3)", - description: "Write repeated text", - }, - capture(requests), - ), + yield* run( + { + command: "Write-Output ('a' * 3)", + description: "Write repeated text", + }, + capture(requests), ) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() expect(bashReq!.patterns).not.toContain("a * 3") expect(bashReq!.always).not.toContain("a *") - }, - }) - }), + }), + ), + ), ) } } if (process.platform === "win32" && cmdShell) { - test( - "asks for external_directory permission for cmd file commands [cmd]", - withShell(cmdShell, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + it.live("asks for external_directory permission for cmd file commands [cmd]", () => + withShell( + cmdShell, + runIn( + projectRoot, + Effect.gen(function* () { const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`, - description: "Read Windows ini with cmd", - }, - capture(requests), - ), + yield* run( + { + command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`, + description: "Read Windows ini with cmd", + }, + capture(requests), ) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain(Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*"))) - }, - }) - }), + }), + ), + ), ) } - each("asks for external_directory permission when cd to parent", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( + each("asks for external_directory permission when cd to parent", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + expect( + yield* fail( { command: "cd ../", description: "Change to parent directory", }, capture(requests, err), ), - ), - ).rejects.toThrow(err.message) - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect(extDirReq).toBeDefined() - }, - }) - }) + ).toMatchObject({ message: err.message }) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + }), + ) + }), + ) - each("asks for external_directory permission when workdir is outside project", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( + each("asks for external_directory permission when workdir is outside project", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + expect( + yield* fail( { command: "echo ok", workdir: os.tmpdir(), @@ -808,31 +782,30 @@ describe("tool.shell permissions", () => { }, capture(requests, err), ), - ), - ).rejects.toThrow(err.message) - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain(glob(path.join(os.tmpdir(), "*"))) - }, - }) - }) + ).toMatchObject({ message: err.message }) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(os.tmpdir(), "*"))) + }), + ) + }), + ) if (process.platform === "win32") { - test("normalizes external_directory workdir variants on Windows", async () => { - const err = new Error("stop after permission") - await using outerTmp = await tmpdir() - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*")) + it.live("normalizes external_directory workdir variants on Windows", () => + Effect.gen(function* () { + const err = new Error("stop after permission") + const outerTmp = yield* tmpdirScoped() + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const want = Filesystem.normalizePathPattern(path.join(outerTmp, "*")) - for (const dir of forms(outerTmp.path)) { - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( + for (const dir of forms(outerTmp)) { + const requests: Array> = [] + expect( + yield* fail( { command: "echo ok", workdir: dir, @@ -840,240 +813,224 @@ describe("tool.shell permissions", () => { }, capture(requests, err), ), - ), - ).rejects.toThrow(err.message) + ).toMatchObject({ message: err.message }) - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect({ dir, patterns: extDirReq?.patterns, always: extDirReq?.always }).toEqual({ - dir, - patterns: [want], - always: [want], - }) - } - }, - }) - }) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect({ dir, patterns: extDirReq?.patterns, always: extDirReq?.always }).toEqual({ + dir, + patterns: [want], + always: [want], + }) + } + }), + ) + }), + ) if (bash) { - test( - "uses Git Bash /tmp semantics for external workdir", - withShell({ label: "bash", shell: bash }, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() + it.live("uses Git Bash /tmp semantics for external workdir", () => + withShell( + { label: "bash", shell: bash }, + runIn( + projectRoot, + Effect.gen(function* () { const err = new Error("stop after permission") const requests: Array> = [] const want = glob(path.join(os.tmpdir(), "*")) - await expect( - Effect.runPromise( - bash.execute( - { - command: "echo ok", - workdir: "/tmp", - description: "Echo from Git Bash tmp", - }, - capture(requests, err), - ), + expect( + yield* fail( + { + command: "echo ok", + workdir: "/tmp", + description: "Echo from Git Bash tmp", + }, + capture(requests, err), ), - ).rejects.toThrow(err.message) + ).toMatchObject({ message: err.message }) expect(requests[0]).toMatchObject({ permission: "external_directory", patterns: [want], always: [want], }) - }, - }) - }), + }), + ), + ), ) - test( - "uses Git Bash /tmp semantics for external file paths", - withShell({ label: "bash", shell: bash }, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() + it.live("uses Git Bash /tmp semantics for external file paths", () => + withShell( + { label: "bash", shell: bash }, + runIn( + projectRoot, + Effect.gen(function* () { const err = new Error("stop after permission") const requests: Array> = [] const want = glob(path.join(os.tmpdir(), "*")) - await expect( - Effect.runPromise( - bash.execute( - { - command: "cat /tmp/opencode-does-not-exist", - description: "Read Git Bash tmp file", - }, - capture(requests, err), - ), + expect( + yield* fail( + { + command: "cat /tmp/opencode-does-not-exist", + description: "Read Git Bash tmp file", + }, + capture(requests, err), ), - ).rejects.toThrow(err.message) + ).toMatchObject({ message: err.message }) expect(requests[0]).toMatchObject({ permission: "external_directory", patterns: [want], always: [want], }) - }, - }) - }), + }), + ), + ), ) } } - each("asks for external_directory permission when file arg is outside project", async () => { - await using outerTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "outside.txt"), "x") - }, - }) - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - const filepath = path.join(outerTmp.path, "outside.txt") - await expect( - Effect.runPromise( - bash.execute( + each("asks for external_directory permission when file arg is outside project", () => + Effect.gen(function* () { + const outerTmp = yield* tmpdirScoped() + yield* Effect.promise(() => Bun.write(path.join(outerTmp, "outside.txt"), "x")) + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + const filepath = path.join(outerTmp, "outside.txt") + expect( + yield* fail( { command: `cat ${filepath}`, description: "Read external file", }, capture(requests, err), ), - ), - ).rejects.toThrow(err.message) - const extDirReq = requests.find((r) => r.permission === "external_directory") - const expected = glob(path.join(outerTmp.path, "*")) - expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain(expected) - expect(extDirReq!.always).toContain(expected) - }, - }) - }) + ).toMatchObject({ message: err.message }) + const extDirReq = requests.find((r) => r.permission === "external_directory") + const expected = glob(path.join(outerTmp, "*")) + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(expected) + expect(extDirReq!.always).toContain(expected) + }), + ) + }), + ) - each("does not ask for external_directory permission when rm inside project", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "tmpfile"), "x") - }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( + each("does not ask for external_directory permission when rm inside project", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* Effect.promise(() => Bun.write(path.join(tmp, "tmpfile"), "x")) + yield* runIn( + tmp, + Effect.gen(function* () { + const requests: Array> = [] + yield* run( { - command: `rm -rf ${path.join(tmp.path, "nested")}`, + command: `rm -rf ${path.join(tmp, "nested")}`, description: "Remove nested dir", }, capture(requests), - ), - ) - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect(extDirReq).toBeUndefined() - }, - }) - }) + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeUndefined() + }), + ) + }), + ) - each("includes always patterns for auto-approval", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( + each("includes always patterns for auto-approval", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const requests: Array> = [] + yield* run( { command: "git log --oneline -5", description: "Git log", }, capture(requests), - ), - ) - expect(requests.length).toBe(1) - expect(requests[0].always.length).toBeGreaterThan(0) - expect(requests[0].always.some((item) => item.endsWith("*"))).toBe(true) - }, - }) - }) + ) + expect(requests.length).toBe(1) + expect(requests[0].always.length).toBeGreaterThan(0) + expect(requests[0].always.some((item) => item.endsWith("*"))).toBe(true) + }), + ) + }), + ) - each("does not ask for bash permission when command is cd only", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initShell() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( + each("does not ask for bash permission when command is cd only", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const requests: Array> = [] + yield* run( { command: "cd .", description: "Stay in current directory", }, capture(requests), - ), - ) - const bashReq = requests.find((r) => r.permission === "bash") - expect(bashReq).toBeUndefined() - }, - }) - }) + ) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeUndefined() + }), + ) + }), + ) - each("matches redirects in permission pattern", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initShell() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( + each("matches redirects in permission pattern", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + expect( + yield* fail( { command: "echo test > output.txt", description: "Redirect test output" }, capture(requests, err), ), - ), - ).rejects.toThrow(err.message) - const bashReq = requests.find((r) => r.permission === "bash") - expect(bashReq).toBeDefined() - expect(bashReq!.patterns).toContain("echo test > output.txt") - }, - }) - }) + ).toMatchObject({ message: err.message }) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).toContain("echo test > output.txt") + }), + ) + }), + ) - each("always pattern has space before wildcard to not include different commands", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const requests: Array> = [] - await Effect.runPromise(bash.execute({ command: "ls -la", description: "List" }, capture(requests))) - const bashReq = requests.find((r) => r.permission === "bash") - expect(bashReq).toBeDefined() - expect(bashReq!.always[0]).toBe("ls *") - }, - }) - }) + each("always pattern has space before wildcard to not include different commands", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const requests: Array> = [] + yield* run({ command: "ls -la", description: "List" }, capture(requests)) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.always[0]).toBe("ls *") + }), + ) + }), + ) }) describe("tool.shell abort", () => { - test("preserves output when aborted", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() - const controller = new AbortController() - const collected: string[] = [] - const res = await Effect.runPromise( - bash.execute( + it.live( + "preserves output when aborted", + () => + runIn( + projectRoot, + Effect.gen(function* () { + const controller = new AbortController() + const collected: string[] = [] + const res = yield* run( { command: `echo before && sleep 30`, description: "Long running command", @@ -1090,198 +1047,158 @@ describe("tool.shell abort", () => { } }), }, - ), - ) - expect(res.output).toContain("before") - expect(res.output).toContain("User aborted the command") - expect(collected.length).toBeGreaterThan(0) - }, - }) - }, 15_000) + ) + expect(res.output).toContain("before") + expect(res.output).toContain("User aborted the command") + expect(collected.length).toBeGreaterThan(0) + }), + ), + 15_000, + ) - test("terminates command on timeout", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() - const result = await Effect.runPromise( - bash.execute( - { - command: `echo started && sleep 60`, - description: "Timeout test", - timeout: 500, - }, - ctx, - ), - ) - expect(result.output).toContain("started") - expect(result.output).toContain("shell tool terminated command after exceeding timeout") - expect(result.output).toContain("retry with a larger timeout value in milliseconds") - }, - }) - }, 15_000) + it.live( + "terminates command on timeout", + () => + runIn( + projectRoot, + Effect.gen(function* () { + const result = yield* run({ + command: `echo started && sleep 60`, + description: "Timeout test", + timeout: 500, + }) + expect(result.output).toContain("started") + expect(result.output).toContain("shell tool terminated command after exceeding timeout") + expect(result.output).toContain("retry with a larger timeout value in milliseconds") + }), + ), + 15_000, + ) - test.skipIf(process.platform === "win32")("captures stderr in output", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() - const result = await Effect.runPromise( - bash.execute( - { - command: `echo stdout_msg && echo stderr_msg >&2`, - description: "Stderr test", - }, - ctx, - ), - ) - expect(result.output).toContain("stdout_msg") - expect(result.output).toContain("stderr_msg") - expect(result.metadata.exit).toBe(0) - }, - }) - }) + if (process.platform !== "win32") { + it.live("captures stderr in output", () => + runIn( + projectRoot, + Effect.gen(function* () { + const result = yield* run({ + command: `echo stdout_msg && echo stderr_msg >&2`, + description: "Stderr test", + }) + expect(result.output).toContain("stdout_msg") + expect(result.output).toContain("stderr_msg") + expect(result.metadata.exit).toBe(0) + }), + ), + ) + } - test("returns non-zero exit code", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() - const result = await Effect.runPromise( - bash.execute( - { - command: `exit 42`, - description: "Non-zero exit", - }, - ctx, - ), - ) + it.live("returns non-zero exit code", () => + runIn( + projectRoot, + Effect.gen(function* () { + const result = yield* run({ + command: `exit 42`, + description: "Non-zero exit", + }) expect(result.metadata.exit).toBe(42) - }, - }) - }) + }), + ), + ) - test("streams metadata updates progressively", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() + it.live("streams metadata updates progressively", () => + runIn( + projectRoot, + Effect.gen(function* () { const updates: string[] = [] - const result = await Effect.runPromise( - bash.execute( - { - command: `echo first && sleep 0.1 && echo second`, - description: "Streaming test", - }, - { - ...ctx, - metadata: (input) => - Effect.sync(() => { - const output = (input.metadata as { output?: string })?.output - if (output) updates.push(output) - }), - }, - ), + const result = yield* run( + { + command: `echo first && sleep 0.1 && echo second`, + description: "Streaming test", + }, + { + ...ctx, + metadata: (input) => + Effect.sync(() => { + const output = (input.metadata as { output?: string })?.output + if (output) updates.push(output) + }), + }, ) expect(result.output).toContain("first") expect(result.output).toContain("second") expect(updates.length).toBeGreaterThan(1) - }, - }) - }) + }), + ), + ) }) describe("tool.shell truncation", () => { - test("truncates output exceeding line limit", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + it.live("truncates output exceeding line limit", () => + runIn( + projectRoot, + Effect.gen(function* () { const lineCount = Truncate.MAX_LINES + 500 - const result = await Effect.runPromise( - bash.execute( - { - command: fill("lines", lineCount), - description: "Generate lines exceeding limit", - }, - ctx, - ), - ) + const result = yield* run({ + command: fill("lines", lineCount), + description: "Generate lines exceeding limit", + }) mustTruncate(result) expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) expect(result.output).toMatch(/Full output saved to:\s+\S+/) - }, - }) - }) + }), + ), + ) - test("truncates output exceeding byte limit", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + it.live("truncates output exceeding byte limit", () => + runIn( + projectRoot, + Effect.gen(function* () { const byteCount = Truncate.MAX_BYTES + 10000 - const result = await Effect.runPromise( - bash.execute( - { - command: fill("bytes", byteCount), - description: "Generate bytes exceeding limit", - }, - ctx, - ), - ) + const result = yield* run({ + command: fill("bytes", byteCount), + description: "Generate bytes exceeding limit", + }) mustTruncate(result) expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) expect(result.output).toMatch(/Full output saved to:\s+\S+/) - }, - }) - }) + }), + ), + ) - test("does not truncate small output", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() - const result = await Effect.runPromise( - bash.execute( - { - command: "echo hello", - description: "Echo hello", - }, - ctx, - ), - ) + it.live("does not truncate small output", () => + runIn( + projectRoot, + Effect.gen(function* () { + const result = yield* run({ + command: fill("lines", 1), + description: "Generate one line", + }) expect((result.metadata as { truncated?: boolean }).truncated).toBe(false) - expect(result.output).toContain("hello") - }, - }) - }) + expect(result.output).toContain("1") + }), + ), + ) - test("full output is saved to file when truncated", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + it.live("full output is saved to file when truncated", () => + runIn( + projectRoot, + Effect.gen(function* () { const lineCount = Truncate.MAX_LINES + 100 - const result = await Effect.runPromise( - bash.execute( - { - command: fill("lines", lineCount), - description: "Generate lines for file check", - }, - ctx, - ), - ) + const result = yield* run({ + command: fill("lines", lineCount), + description: "Generate lines for file check", + }) mustTruncate(result) const filepath = (result.metadata as { outputPath?: string }).outputPath expect(filepath).toBeTruthy() - const saved = await Filesystem.readText(filepath!) + const saved = yield* Effect.promise(() => Filesystem.readText(filepath!)) const lines = saved.trim().split(/\r?\n/) expect(lines.length).toBe(lineCount) expect(lines[0]).toBe("1") expect(lines[lineCount - 1]).toBe(String(lineCount)) - }, - }) - }) + }), + ), + ) }) From 0fd0facc442b1c9dd10264437d3439207c18653c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 13:31:03 +0000 Subject: [PATCH 087/188] chore: generate --- packages/opencode/test/tool/shell.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 4b4cc8c88..6ce0e5c08 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -27,7 +27,9 @@ const shellLayer = Layer.mergeAll( Agent.defaultLayer, ) const it = testEffect(shellLayer) -type ShellTestServices = (typeof shellLayer extends Layer.Layer ? ROut : never) | Scope.Scope +type ShellTestServices = + | (typeof shellLayer extends Layer.Layer ? ROut : never) + | Scope.Scope const initShell = Effect.fn("ShellToolTest.init")(function* () { const info = yield* ShellTool From 04aafe2bfcd42ce6fb9b8f118225d92a93e858d1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 10:19:49 -0400 Subject: [PATCH 088/188] test(provider): migrate more config-backed cases (#27067) --- .../opencode/test/provider/provider.test.ts | 90 ++++++++----------- 1 file changed, 39 insertions(+), 51 deletions(-) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index ea65c90c4..2270418be 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -582,64 +582,52 @@ it.instance( }, ) -test("model options are merged from existing model", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - provider: { - anthropic: { - models: { - "claude-sonnet-4-20250514": { - options: { - customOption: "custom-value", - }, - }, +it.instance( + "model options are merged from existing model", + Effect.gen(function* () { + const providers = yield* Provider.Service.use((provider) => provider.list()) + const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] + expect(model.options.customOption).toBe("custom-value") + }), + { + config: { + provider: { + anthropic: { + options: { + apiKey: "test-api-key", + }, + models: { + "claude-sonnet-4-20250514": { + options: { + customOption: "custom-value", }, }, }, - }), - ) + }, + }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() - const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] - expect(model.options.customOption).toBe("custom-value") - }, - }) -}) + }, +) -test("provider removed when all models filtered out", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - provider: { - anthropic: { - whitelist: ["nonexistent-model"], - }, +it.instance( + "provider removed when all models filtered out", + Effect.gen(function* () { + const providers = yield* Provider.Service.use((provider) => provider.list()) + expect(providers[ProviderID.anthropic]).toBeUndefined() + }), + { + config: { + provider: { + anthropic: { + options: { + apiKey: "test-api-key", }, - }), - ) + whitelist: ["nonexistent-model"], + }, + }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() - expect(providers[ProviderID.anthropic]).toBeUndefined() - }, - }) -}) + }, +) test("closest finds model by partial match", async () => { await using tmp = await tmpdir({ From 257fcafc8302d7a6f31d8d5d0e733bf9a5ff9181 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 11:52:31 -0400 Subject: [PATCH 089/188] test(tool): migrate edit concurrency test (#26983) --- packages/opencode/test/tool/edit.test.ts | 110 +++++++++-------------- 1 file changed, 43 insertions(+), 67 deletions(-) diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 572fcd9aa..6a1682826 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -1,10 +1,9 @@ -import { afterAll, afterEach, describe, test, expect } from "bun:test" +import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" -import { Cause, Deferred, Effect, Exit, Layer, ManagedRuntime } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { EditTool } from "../../src/tool/edit" -import { WithInstance } from "../../src/project/with-instance" -import { disposeAllInstances, TestInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" @@ -42,20 +41,6 @@ const layer = Layer.mergeAll( const it = testEffect(layer) -const runtime = ManagedRuntime.make(layer) - -afterAll(async () => { - await runtime.dispose() -}) - -const resolve = () => - runtime.runPromise( - Effect.gen(function* () { - const info = yield* EditTool - return yield* info.init() - }), - ) - const init = Effect.fn("EditToolTest.init")(function* () { const info = yield* EditTool return yield* info.init() @@ -500,58 +485,49 @@ describe("tool.edit", () => { }) describe("concurrent editing", () => { - test("preserves concurrent edits to different sections of the same file", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "top = 0\nmiddle = keep\nbottom = 0\n", "utf-8") + it.instance("preserves concurrent edits to different sections of the same file", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* put(filepath, "top = 0\nmiddle = keep\nbottom = 0\n") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - let asks = 0 - const firstAsk = Promise.withResolvers() - const delayedCtx = { - ...ctx, - ask: () => - Effect.gen(function* () { - asks++ - if (asks !== 1) return - firstAsk.resolve() - yield* Effect.promise(() => Bun.sleep(50)) - }), - } + const firstAsk = yield* Deferred.make() + let asks = 0 + const delayedCtx = { + ...ctx, + ask: () => + Effect.gen(function* () { + asks++ + if (asks !== 1) return + yield* Deferred.succeed(firstAsk, undefined) + yield* Effect.promise(() => Bun.sleep(50)) + }), + } - const promise1 = Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "top = 0", - newString: "top = 1", - }, - delayedCtx, - ), - ) + const first = yield* run( + { + filePath: filepath, + oldString: "top = 0", + newString: "top = 1", + }, + delayedCtx, + ).pipe(Effect.forkScoped) - await firstAsk.promise + yield* Deferred.await(firstAsk) + yield* Effect.all([ + Fiber.join(first), + run( + { + filePath: filepath, + oldString: "bottom = 0", + newString: "bottom = 2", + }, + delayedCtx, + ), + ]) - const promise2 = Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "bottom = 0", - newString: "bottom = 2", - }, - delayedCtx, - ), - ) - - const results = await Promise.allSettled([promise1, promise2]) - expect(results[0]?.status).toBe("fulfilled") - expect(results[1]?.status).toBe("fulfilled") - expect(await fs.readFile(filepath, "utf-8")).toBe("top = 1\nmiddle = keep\nbottom = 2\n") - }, - }) - }) + expect(yield* load(filepath)).toBe("top = 1\nmiddle = keep\nbottom = 2\n") + }), + ) }) }) From c7d8b0d56562209d69ef6a20424a4681827766bd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 12:04:28 -0400 Subject: [PATCH 090/188] Delete named schema error wrapper (#27066) --- packages/opencode/src/provider/auth.ts | 10 ++++----- packages/opencode/src/provider/provider.ts | 6 +++--- .../opencode/src/session/message-error.ts | 14 +++++++++++++ packages/opencode/src/session/message-v2.ts | 21 +++++++------------ packages/opencode/src/session/message.ts | 15 ++++--------- .../opencode/src/util/named-schema-error.ts | 9 -------- packages/opencode/test/util/error.test.ts | 9 ++++---- 7 files changed, 38 insertions(+), 46 deletions(-) create mode 100644 packages/opencode/src/session/message-error.ts delete mode 100644 packages/opencode/src/util/named-schema-error.ts diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 42b94ffcc..ba2a8c744 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,7 +1,7 @@ import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin" import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" -import { namedSchemaError } from "@/util/named-schema-error" +import { NamedError } from "@opencode-ai/core/util/error" import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { Plugin } from "../plugin" import { ProviderID } from "./schema" @@ -64,13 +64,13 @@ export const CallbackInput = Schema.Struct({ }) export type CallbackInput = Schema.Schema.Type -export const OauthMissing = namedSchemaError("ProviderAuthOauthMissing", { providerID: ProviderID }) +export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", { providerID: ProviderID }) -export const OauthCodeMissing = namedSchemaError("ProviderAuthOauthCodeMissing", { providerID: ProviderID }) +export const OauthCodeMissing = NamedError.create("ProviderAuthOauthCodeMissing", { providerID: ProviderID }) -export const OauthCallbackFailed = namedSchemaError("ProviderAuthOauthCallbackFailed", {}) +export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", {}) -export const ValidationFailed = namedSchemaError("ProviderAuthValidationFailed", { +export const ValidationFailed = NamedError.create("ProviderAuthValidationFailed", { field: Schema.String, message: Schema.String, }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 236f14de7..f381e848d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -13,7 +13,7 @@ import { Auth } from "../auth" import { Env } from "../env" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Flag } from "@opencode-ai/core/flag/flag" -import { namedSchemaError } from "@/util/named-schema-error" +import { NamedError } from "@opencode-ai/core/util/error" import { iife } from "@/util/iife" import { Global } from "@opencode-ai/core/global" import path from "path" @@ -1749,13 +1749,13 @@ export function parseModel(model: string) { } } -export const ModelNotFoundError = namedSchemaError("ProviderModelNotFoundError", { +export const ModelNotFoundError = NamedError.create("ProviderModelNotFoundError", { providerID: ProviderID, modelID: ModelID, suggestions: Schema.optional(Schema.Array(Schema.String)), }) -export const InitError = namedSchemaError("ProviderInitError", { +export const InitError = NamedError.create("ProviderInitError", { providerID: ProviderID, }) diff --git a/packages/opencode/src/session/message-error.ts b/packages/opencode/src/session/message-error.ts new file mode 100644 index 000000000..bf40d45be --- /dev/null +++ b/packages/opencode/src/session/message-error.ts @@ -0,0 +1,14 @@ +import { Schema } from "effect" +import { NamedError } from "@opencode-ai/core/util/error" + +export const OutputLengthError = NamedError.create("MessageOutputLengthError", {}) + +export const AuthError = NamedError.create("ProviderAuthError", { + providerID: Schema.String, + message: Schema.String, +}) + +export const Shared = [AuthError.EffectSchema, NamedError.Unknown.EffectSchema, OutputLengthError.EffectSchema] as const +export const SharedSchema = Schema.Union(Shared) + +export * as MessageError from "./message-error" diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index f797e2dc3..626261d0f 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -23,8 +23,10 @@ import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Schema, Types } from "effect" import { NonNegativeInt } from "@opencode-ai/core/schema" -import { namedSchemaError } from "@/util/named-schema-error" import * as EffectLogger from "@opencode-ai/core/effect/logger" +import { MessageError } from "./message-error" +import { AuthError, OutputLengthError } from "./message-error" +export { AuthError, OutputLengthError } from "./message-error" /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ interface FetchDecompressionError extends Error { @@ -36,17 +38,12 @@ interface FetchDecompressionError extends Error { export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:" export { isMedia } -export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) -export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String }) -export const StructuredOutputError = namedSchemaError("StructuredOutputError", { +export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String }) +export const StructuredOutputError = NamedError.create("StructuredOutputError", { message: Schema.String, retries: NonNegativeInt, }) -export const AuthError = namedSchemaError("ProviderAuthError", { - providerID: Schema.String, - message: Schema.String, -}) -export const APIError = namedSchemaError("APIError", { +export const APIError = NamedError.create("APIError", { message: Schema.String, statusCode: Schema.optional(NonNegativeInt), isRetryable: Schema.Boolean, @@ -55,7 +52,7 @@ export const APIError = namedSchemaError("APIError", { metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), }) export type APIError = Schema.Schema.Type -export const ContextOverflowError = namedSchemaError("ContextOverflowError", { +export const ContextOverflowError = NamedError.create("ContextOverflowError", { message: Schema.String, responseBody: Schema.optional(Schema.String), }) @@ -381,9 +378,7 @@ export type Part = | CompactionPart const AssistantErrorSchema = Schema.Union([ - AuthError.EffectSchema, - NamedError.Unknown.EffectSchema, - OutputLengthError.EffectSchema, + ...MessageError.Shared, AbortedError.EffectSchema, StructuredOutputError.EffectSchema, ContextOverflowError.EffectSchema, diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index 6a859ffaa..39c842f94 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -2,14 +2,9 @@ import { Schema } from "effect" import { SessionID } from "./schema" import { ModelID, ProviderID } from "../provider/schema" import { NonNegativeInt } from "@opencode-ai/core/schema" -import { namedSchemaError } from "@/util/named-schema-error" -import { NamedError } from "@opencode-ai/core/util/error" - -export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) -export const AuthError = namedSchemaError("ProviderAuthError", { - providerID: Schema.String, - message: Schema.String, -}) +import { MessageError } from "./message-error" +import { AuthError, OutputLengthError } from "./message-error" +export { AuthError, OutputLengthError } from "./message-error" export const ToolCall = Schema.Struct({ state: Schema.Literal("call"), @@ -105,9 +100,7 @@ export const Info = Schema.Struct({ created: NonNegativeInt, completed: Schema.optional(NonNegativeInt), }), - error: Schema.optional( - Schema.Union([AuthError.EffectSchema, NamedError.Unknown.EffectSchema, OutputLengthError.EffectSchema]), - ), + error: Schema.optional(MessageError.SharedSchema), sessionID: SessionID, tool: Schema.Record( Schema.String, diff --git a/packages/opencode/src/util/named-schema-error.ts b/packages/opencode/src/util/named-schema-error.ts deleted file mode 100644 index cc02c3731..000000000 --- a/packages/opencode/src/util/named-schema-error.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Schema } from "effect" -import { NamedError } from "@opencode-ai/core/util/error" - -/** - * Create a Schema-backed NamedError-shaped class. - */ -export function namedSchemaError(tag: Tag, fields: Fields) { - return NamedError.create(tag, fields) -} diff --git a/packages/opencode/test/util/error.test.ts b/packages/opencode/test/util/error.test.ts index bc966133a..8d077b1f2 100644 --- a/packages/opencode/test/util/error.test.ts +++ b/packages/opencode/test/util/error.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test" import { Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import { errorData, errorFormat, errorMessage } from "../../src/util/error" -import { namedSchemaError } from "../../src/util/named-schema-error" import { UI } from "../../src/cli/ui" +import { MessageError } from "../../src/session/message-error" describe("util.error", () => { test("formats native Error instances", () => { @@ -53,12 +53,11 @@ describe("util.error", () => { expect(String(data.formatted)).toContain("ResolveMessage") }) - test("named schema errors are real NamedError instances", () => { - const ExampleError = namedSchemaError("ExampleError", { message: Schema.String }) - const error = new ExampleError({ message: "boom" }) + test("schema-backed named errors are real NamedError instances", () => { + const error = new MessageError.AuthError({ providerID: "anthropic", message: "boom" }) expect(error).toBeInstanceOf(NamedError) - expect(error.toObject()).toEqual({ name: "ExampleError", data: { message: "boom" } }) + expect(error.toObject()).toEqual({ name: "ProviderAuthError", data: { providerID: "anthropic", message: "boom" } }) }) test("void named errors accept JSON without data", () => { From 23f8b3eb3e55a0780e4d7bccc4d99825c2c3acc1 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 12 May 2026 11:31:18 -0500 Subject: [PATCH 091/188] fix: annotate Effect log metadata (#27093) --- packages/opencode/src/data-migration.ts | 2 +- packages/opencode/src/project/bootstrap.ts | 2 +- packages/opencode/src/project/instance-store.ts | 4 +++- packages/opencode/src/provider/models.ts | 2 +- packages/opencode/src/reference/reference.ts | 4 +++- .../src/server/routes/instance/httpapi/handlers/session.ts | 4 +++- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/data-migration.ts b/packages/opencode/src/data-migration.ts index 53e3196b7..331e0f750 100644 --- a/packages/opencode/src/data-migration.ts +++ b/packages/opencode/src/data-migration.ts @@ -145,7 +145,7 @@ export const layer = Layer.effect( ) } }).pipe( - Effect.tapCause((cause) => Effect.logError("failed to run data migrations", { cause })), + Effect.tapCause((cause) => Effect.logError("failed to run data migrations").pipe(Effect.annotateLogs("cause", cause))), Effect.ignore, Effect.forkScoped, ) diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 6103a9efb..a7e67d45e 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -37,7 +37,7 @@ export const layer = Layer.effect( const run = Effect.gen(function* () { const ctx = yield* InstanceState.context - yield* Effect.logInfo("bootstrapping", { directory: ctx.directory }) + yield* Effect.logInfo("bootstrapping").pipe(Effect.annotateLogs("directory", ctx.directory)) // everything depends on config so eager load it for nice traces yield* config.get() // Plugin can mutate config so it has to be initialized before anything else. diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 9707305f9..faa56668a 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -156,7 +156,9 @@ export const layer: Layer.Layer Effect.logError("Failed to fetch models.dev", { cause })), + Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev").pipe(Effect.annotateLogs("cause", cause))), Effect.ignore, ) }) diff --git a/packages/opencode/src/reference/reference.ts b/packages/opencode/src/reference/reference.ts index 09e0a825d..748c3b238 100644 --- a/packages/opencode/src/reference/reference.ts +++ b/packages/opencode/src/reference/reference.ts @@ -169,7 +169,9 @@ export const layer = Layer.effect( ).pipe( Effect.asVoid, Effect.catchCause((cause) => - Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }), + Effect.logWarning("failed to materialize reference repository").pipe( + Effect.annotateLogs({ name: reference.name, cause }), + ), ), ), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 99645f3da..236eee13f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -299,7 +299,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe( Effect.catchCause((cause) => Effect.gen(function* () { - yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause }) + yield* Effect.logError("prompt_async failed").pipe( + Effect.annotateLogs({ sessionID: ctx.params.sessionID, cause }), + ) yield* bus.publish(Session.Event.Error, { sessionID: ctx.params.sessionID, error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(), From 30e3fa1de966e828db43a8f00f265beaad92a455 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 16:33:06 +0000 Subject: [PATCH 092/188] chore: generate --- packages/opencode/src/data-migration.ts | 4 +++- packages/opencode/src/provider/models.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/data-migration.ts b/packages/opencode/src/data-migration.ts index 331e0f750..bec4c9c8a 100644 --- a/packages/opencode/src/data-migration.ts +++ b/packages/opencode/src/data-migration.ts @@ -145,7 +145,9 @@ export const layer = Layer.effect( ) } }).pipe( - Effect.tapCause((cause) => Effect.logError("failed to run data migrations").pipe(Effect.annotateLogs("cause", cause))), + Effect.tapCause((cause) => + Effect.logError("failed to run data migrations").pipe(Effect.annotateLogs("cause", cause)), + ), Effect.ignore, Effect.forkScoped, ) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index d3d04b552..e9d2bac1a 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -177,7 +177,9 @@ export const layer: Layer.Layer Effect.logError("Failed to fetch models.dev").pipe(Effect.annotateLogs("cause", cause))), + Effect.tapCause((cause) => + Effect.logError("Failed to fetch models.dev").pipe(Effect.annotateLogs("cause", cause)), + ), Effect.ignore, ) }) From d658e1e3506f55093ecb894e53fa9ac3cb7cd98a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 12:38:27 -0400 Subject: [PATCH 093/188] Remove local MCP Zod schema (#27095) --- packages/opencode/src/mcp/index.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 992825dd6..832811b28 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -6,6 +6,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { CallToolResultSchema, + ListToolsResultSchema, ToolSchema, type Tool as MCPToolDef, ToolListChangedNotificationSchema, @@ -14,7 +15,6 @@ import { Config } from "@/config/config" import { ConfigMCP } from "../config/mcp" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" -import z from "zod/v4" import { Installation } from "../installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { withTimeout } from "@/util/timeout" @@ -35,13 +35,8 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" const log = Log.create({ service: "mcp" }) const DEFAULT_TIMEOUT = 30_000 -const TolerantToolSchema = ToolSchema.extend({ - outputSchema: z.unknown().optional(), -}) - -const TolerantListToolsResultSchema = z.looseObject({ - tools: z.array(TolerantToolSchema), - nextCursor: z.string().optional(), +const TolerantListToolsResultSchema = ListToolsResultSchema.extend({ + tools: ToolSchema.omit({ outputSchema: true }).array(), }) export const Resource = Schema.Struct({ @@ -137,7 +132,10 @@ function listTools(key: string, client: MCPClient, timeout: number) { log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { key, error }) return Effect.tryPromise({ - try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }), + try: () => + client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { + timeout, + }), catch: (err) => (err instanceof Error ? err : new Error(String(err))), }).pipe( Effect.map((result) => From 3dc2c1d81c3ac6a0e508ddf3cf918e76888faf08 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 12 May 2026 22:10:28 +0530 Subject: [PATCH 094/188] fix(session): preserve usage update timestamps (#27094) --- packages/opencode/src/data-migration.ts | 1 + packages/opencode/src/session/projectors.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/data-migration.ts b/packages/opencode/src/data-migration.ts index bec4c9c8a..b6956032a 100644 --- a/packages/opencode/src/data-migration.ts +++ b/packages/opencode/src/data-migration.ts @@ -94,6 +94,7 @@ export const layer = Layer.effect( tokens_reasoning: value.tokens.reasoning, tokens_cache_read: value.tokens.cache.read, tokens_cache_write: value.tokens.cache.write, + time_updated: sql`${SessionTable.time_updated}`, }) .where(eq(SessionTable.id, sessionID)) .run() diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index 8b5cc2bdc..3dd848c5b 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -38,6 +38,7 @@ function applyUsage(db: TxOrDb, sessionID: Session.Info["id"], value: Usage, sig tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`, tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`, tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`, + time_updated: sql`${SessionTable.time_updated}`, }) .where(eq(SessionTable.id, sessionID)) .run() @@ -110,7 +111,7 @@ export default [ const info = data.info const row = db .update(SessionTable) - .set(toPartialRow(info as Session.Patch)) + .set({ time_updated: sql`${SessionTable.time_updated}`, ...toPartialRow(info as Session.Patch) }) .where(eq(SessionTable.id, data.sessionID)) .returning() .get() From ec4fdaf8e9275ac0f89d0c1734f4806cb6c7d37a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 12:42:19 -0400 Subject: [PATCH 095/188] test(tool): migrate tool define tests to Effect runner (#27097) --- .../opencode/test/tool/tool-define.test.ts | 115 +++++++++--------- 1 file changed, 60 insertions(+), 55 deletions(-) diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index a291b9f7f..ca351cca4 100644 --- a/packages/opencode/test/tool/tool-define.test.ts +++ b/packages/opencode/test/tool/tool-define.test.ts @@ -1,11 +1,12 @@ -import { describe, test, expect } from "bun:test" -import { Effect, Layer, ManagedRuntime, Schema } from "effect" +import { describe, expect } from "bun:test" +import { Effect, Layer, Schema } from "effect" import { Agent } from "../../src/agent/agent" import { MessageID, SessionID } from "../../src/session/schema" import { Tool } from "@/tool/tool" import { Truncate } from "@/tool/truncate" +import { testEffect } from "../lib/effect" -const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)) +const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)) const params = Schema.Struct({ input: Schema.String }) @@ -21,49 +22,53 @@ function makeTool(id: string, executeFn?: () => void) { } describe("Tool.define", () => { - test("object-defined tool does not mutate the original init object", async () => { - const original = makeTool("test") - const originalExecute = original.execute + it.effect("object-defined tool does not mutate the original init object", () => + Effect.gen(function* () { + const original = makeTool("test") + const originalExecute = original.execute - const info = await runtime.runPromise(Tool.define("test-tool", Effect.succeed(original))) + const info = yield* Tool.define("test-tool", Effect.succeed(original)) - await Effect.runPromise(info.init()) - await Effect.runPromise(info.init()) - await Effect.runPromise(info.init()) + yield* info.init() + yield* info.init() + yield* info.init() - expect(original.execute).toBe(originalExecute) - }) + expect(original.execute).toBe(originalExecute) + }), + ) - test("effect-defined tool returns fresh objects and is unaffected", async () => { - const info = await runtime.runPromise( - Tool.define( + it.effect("effect-defined tool returns fresh objects and is unaffected", () => + Effect.gen(function* () { + const info = yield* Tool.define( "test-fn-tool", Effect.succeed(() => Effect.succeed(makeTool("test"))), - ), - ) + ) - const first = await Effect.runPromise(info.init()) - const second = await Effect.runPromise(info.init()) + const first = yield* info.init() + const second = yield* info.init() - expect(first).not.toBe(second) - }) + expect(first).not.toBe(second) + }), + ) - test("object-defined tool returns distinct objects per init() call", async () => { - const info = await runtime.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test")))) + it.effect("object-defined tool returns distinct objects per init() call", () => + Effect.gen(function* () { + const info = yield* Tool.define("test-copy", Effect.succeed(makeTool("test"))) - const first = await Effect.runPromise(info.init()) - const second = await Effect.runPromise(info.init()) + const first = yield* info.init() + const second = yield* info.init() - expect(first).not.toBe(second) - }) + expect(first).not.toBe(second) + }), + ) - test("execute receives decoded parameters", async () => { - const parameters = Schema.Struct({ - count: Schema.NumberFromString.pipe(Schema.optional, Schema.withDecodingDefaultType(Effect.succeed(5))), - }) - const calls: Array> = [] - const info = await runtime.runPromise( - Tool.define( + it.effect("execute receives decoded parameters", () => + Effect.gen(function* () { + const parameters = Schema.Struct({ + count: Schema.NumberFromString.pipe(Schema.optional, Schema.withDecodingDefaultType(Effect.succeed(5))), + }) + const calls: Array> = [] + const info = yield* Tool.define( "test-decoded", Effect.succeed({ description: "test tool", @@ -73,27 +78,27 @@ describe("Tool.define", () => { return Effect.succeed({ title: "test", output: "ok", metadata: { truncated: false } }) }, }), - ), - ) - const ctx: Tool.Context = { - sessionID: SessionID.descending(), - messageID: MessageID.ascending(), - agent: "build", - abort: new AbortController().signal, - messages: [], - metadata() { - return Effect.void - }, - ask() { - return Effect.void - }, - } - const tool = await Effect.runPromise(info.init()) - const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType + ) + const ctx: Tool.Context = { + sessionID: SessionID.descending(), + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata() { + return Effect.void + }, + ask() { + return Effect.void + }, + } + const tool = yield* info.init() + const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType - await Effect.runPromise(execute({}, ctx)) - await Effect.runPromise(execute({ count: "7" }, ctx)) + yield* execute({}, ctx) + yield* execute({ count: "7" }, ctx) - expect(calls).toEqual([{ count: 5 }, { count: 7 }]) - }) + expect(calls).toEqual([{ count: 5 }, { count: 7 }]) + }), + ) }) From 8115004c73559fc3ad0f9eda4066f8d47aa2e8cf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 12:42:49 -0400 Subject: [PATCH 096/188] test(file): migrate path traversal tests to Effect runner (#27098) --- .../opencode/test/file/path-traversal.test.ts | 295 ++++++++---------- 1 file changed, 133 insertions(+), 162 deletions(-) diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 5b59929ea..28bd34978 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -1,42 +1,55 @@ -import { test, expect, describe } from "bun:test" -import { Effect } from "effect" +import { expect, describe } from "bun:test" +import { Cause, Effect, Exit } from "effect" import path from "path" import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import { File } from "../../src/file" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" +import { InstanceState } from "../../src/effect/instance-state" import { containsPath } from "../../src/project/instance-context" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -const run = (eff: Effect.Effect) => - Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer)))) -const read = (file: string) => run(File.Service.use((svc) => svc.read(file))) -const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir))) +const it = testEffect(File.defaultLayer) +const read = (file: string) => File.Service.use((svc) => svc.read(file)) +const list = (dir?: string) => File.Service.use((svc) => svc.list(dir)) +const expectAccessDenied = (effect: Effect.Effect) => + Effect.gen(function* () { + const exit = yield* effect.pipe(Effect.exit) + if (Exit.isSuccess(exit)) throw new Error("expected access denied") + expect(Cause.squash(exit.cause)).toHaveProperty("message", "Access denied: path escapes project directory") + }) describe("Filesystem.contains", () => { - test("allows paths within project", () => { - expect(Filesystem.contains("/project", "/project/src")).toBe(true) - expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true) - expect(Filesystem.contains("/project", "/project")).toBe(true) - }) + it.effect("allows paths within project", () => + Effect.sync(() => { + expect(Filesystem.contains("/project", "/project/src")).toBe(true) + expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true) + expect(Filesystem.contains("/project", "/project")).toBe(true) + }), + ) - test("blocks ../ traversal", () => { - expect(Filesystem.contains("/project", "/project/../etc")).toBe(false) - expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false) - expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) - }) + it.effect("blocks ../ traversal", () => + Effect.sync(() => { + expect(Filesystem.contains("/project", "/project/../etc")).toBe(false) + expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false) + expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) + }), + ) - test("blocks absolute paths outside project", () => { - expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) - expect(Filesystem.contains("/project", "/tmp/file")).toBe(false) - expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false) - }) + it.effect("blocks absolute paths outside project", () => + Effect.sync(() => { + expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) + expect(Filesystem.contains("/project", "/tmp/file")).toBe(false) + expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false) + }), + ) - test("handles prefix collision edge cases", () => { - expect(Filesystem.contains("/project", "/project-other/file")).toBe(false) - expect(Filesystem.contains("/project", "/projectfile")).toBe(false) - }) + it.effect("handles prefix collision edge cases", () => + Effect.sync(() => { + expect(Filesystem.contains("/project", "/project-other/file")).toBe(false) + expect(Filesystem.contains("/project", "/projectfile")).toBe(false) + }), + ) }) /* @@ -49,158 +62,116 @@ describe("Filesystem.contains", () => { * This is a SEPARATE code path from ReadTool, which has its own checks. */ describe("File.read path traversal protection", () => { - test("rejects ../ traversal attempting to read /etc/passwd", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "allowed.txt"), "allowed content") - }, - }) + it.instance("rejects ../ traversal attempting to read /etc/passwd", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "allowed.txt"), "allowed content")) + yield* expectAccessDenied(read("../../../etc/passwd")) + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory") - }, - }) - }) + it.instance("rejects deeply nested traversal", () => + Effect.gen(function* () { + yield* expectAccessDenied(read("src/nested/../../../../../../../etc/passwd")) + }), + ) - test("rejects deeply nested traversal", async () => { - await using tmp = await tmpdir() + it.instance("allows valid paths within project", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "valid.txt"), "valid content")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow( - "Access denied: path escapes project directory", - ) - }, - }) - }) - - test("allows valid paths within project", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "valid.txt"), "valid content") - }, - }) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("valid.txt") - expect(result.content).toBe("valid content") - }, - }) - }) + const result = yield* read("valid.txt") + expect(result.content).toBe("valid content") + }), + ) }) describe("File.list path traversal protection", () => { - test("rejects ../ traversal attempting to list /etc", async () => { - await using tmp = await tmpdir() + it.instance("rejects ../ traversal attempting to list /etc", () => + Effect.gen(function* () { + yield* expectAccessDenied(list("../../../etc")) + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory") - }, - }) - }) + it.instance("allows valid subdirectory listing", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "subdir", "file.txt"), "content")) - test("allows valid subdirectory listing", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "subdir", "file.txt"), "content") - }, - }) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await list("subdir") - expect(Array.isArray(result)).toBe(true) - }, - }) - }) + const result = yield* list("subdir") + expect(Array.isArray(result)).toBe(true) + }), + ) }) describe("containsPath", () => { - test("returns true for path inside directory", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance("returns true for path inside directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + const ctx = yield* InstanceState.context + expect(containsPath(path.join(test.directory, "foo.txt"), ctx)).toBe(true) + expect(containsPath(path.join(test.directory, "src", "file.ts"), ctx)).toBe(true) + }), + { git: true }, + ) - await WithInstance.provide({ - directory: tmp.path, - fn: () => { - expect(containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true) - expect(containsPath(path.join(tmp.path, "src", "file.ts"), Instance.current)).toBe(true) - }, - }) - }) + it.instance( + "returns true for path inside worktree but outside directory (monorepo subdirectory scenario)", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const subdir = path.join(test.directory, "packages", "lib") + yield* Effect.promise(() => fs.mkdir(subdir, { recursive: true })) + const ctx = { ...(yield* InstanceState.context), directory: subdir } - test("returns true for path inside worktree but outside directory (monorepo subdirectory scenario)", async () => { - await using tmp = await tmpdir({ git: true }) - const subdir = path.join(tmp.path, "packages", "lib") - await fs.mkdir(subdir, { recursive: true }) - - await WithInstance.provide({ - directory: subdir, - fn: () => { // .opencode at worktree root, but we're running from packages/lib - expect(containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true) + expect(containsPath(path.join(test.directory, ".opencode", "state"), ctx)).toBe(true) // sibling package should also be accessible - expect(containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true) + expect(containsPath(path.join(test.directory, "packages", "other", "file.ts"), ctx)).toBe(true) // worktree root itself - expect(containsPath(tmp.path, Instance.current)).toBe(true) - }, - }) - }) + expect(containsPath(test.directory, ctx)).toBe(true) + }), + { git: true }, + ) - test("returns false for path outside both directory and worktree", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance("returns false for path outside both directory and worktree", () => + Effect.gen(function* () { + const ctx = yield* InstanceState.context + expect(containsPath("/etc/passwd", ctx)).toBe(false) + expect(containsPath("/tmp/other-project", ctx)).toBe(false) + }), + { git: true }, + ) - await WithInstance.provide({ - directory: tmp.path, - fn: () => { - expect(containsPath("/etc/passwd", Instance.current)).toBe(false) - expect(containsPath("/tmp/other-project", Instance.current)).toBe(false) - }, - }) - }) + it.instance("returns false for path with .. escaping worktree", () => + Effect.gen(function* () { + const test = yield* TestInstance + const ctx = yield* InstanceState.context + expect(containsPath(path.join(test.directory, "..", "escape.txt"), ctx)).toBe(false) + }), + { git: true }, + ) - test("returns false for path with .. escaping worktree", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance("handles directory === worktree (running from repo root)", () => + Effect.gen(function* () { + const test = yield* TestInstance + const ctx = yield* InstanceState.context + expect(ctx.directory).toBe(ctx.worktree) + expect(containsPath(path.join(test.directory, "file.txt"), ctx)).toBe(true) + expect(containsPath("/etc/passwd", ctx)).toBe(false) + }), + { git: true }, + ) - await WithInstance.provide({ - directory: tmp.path, - fn: () => { - expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false) - }, - }) - }) - - test("handles directory === worktree (running from repo root)", async () => { - await using tmp = await tmpdir({ git: true }) - - await WithInstance.provide({ - directory: tmp.path, - fn: () => { - expect(Instance.directory).toBe(Instance.worktree) - expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) - expect(containsPath("/etc/passwd", Instance.current)).toBe(false) - }, - }) - }) - - test("non-git project does not allow arbitrary paths via worktree='/'", async () => { - await using tmp = await tmpdir() // no git: true - - await WithInstance.provide({ - directory: tmp.path, - fn: () => { - // worktree is "/" for non-git projects, but containsPath should NOT allow all paths - expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) - expect(containsPath("/etc/passwd", Instance.current)).toBe(false) - expect(containsPath("/tmp/other", Instance.current)).toBe(false) - }, - }) - }) + it.instance("non-git project does not allow arbitrary paths via worktree='/'", () => + Effect.gen(function* () { + const test = yield* TestInstance + const ctx = yield* InstanceState.context + // worktree is "/" for non-git projects, but containsPath should NOT allow all paths + expect(containsPath(path.join(test.directory, "file.txt"), ctx)).toBe(true) + expect(containsPath("/etc/passwd", ctx)).toBe(false) + expect(containsPath("/tmp/other", ctx)).toBe(false) + }), + ) }) From a16789dfdd47e208054f0d5fdb1d4a3be53b9ada Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 12:43:33 -0400 Subject: [PATCH 097/188] test(tool): migrate apply patch tests to Effect runner (#27100) --- .../opencode/test/tool/apply_patch.test.ts | 871 ++++++++---------- 1 file changed, 392 insertions(+), 479 deletions(-) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 3fc034e4e..190254866 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -1,20 +1,19 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import path from "path" import * as fs from "fs/promises" -import { Effect, ManagedRuntime, Layer } from "effect" +import { Cause, Effect, Exit, Layer } from "effect" import { ApplyPatchTool } from "../../src/tool/apply_patch" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Truncate } from "@/tool/truncate" -import { tmpdir } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" +import { testEffect } from "../lib/effect" -const runtime = ManagedRuntime.make( +const it = testEffect( Layer.mergeAll( LSP.defaultLayer, AppFileSystem.defaultLayer, @@ -58,11 +57,11 @@ type ToolCtx = typeof baseCtx & { ask: (input: AskInput) => Effect.Effect } -const execute = async (params: { patchText: string }, ctx: ToolCtx) => { - const info = await runtime.runPromise(ApplyPatchTool) - const tool = await runtime.runPromise(info.init()) - return Effect.runPromise(tool.execute(params, ctx)) -} +const execute = Effect.fn("ApplyPatchToolTest.execute")(function* (params: { patchText: string }, ctx: ToolCtx) { + const info = yield* ApplyPatchTool + const tool = yield* info.init() + return yield* tool.execute(params, ctx) +}) const makeCtx = () => { const calls: AskInput[] = [] @@ -77,39 +76,56 @@ const makeCtx = () => { return { ctx, calls } } +const readText = (filepath: string) => Effect.promise(() => fs.readFile(filepath, "utf-8")) +const writeText = (filepath: string, content: string) => Effect.promise(() => fs.writeFile(filepath, content, "utf-8")) +const makeDir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true })) + +const expectFailure = (effect: Effect.Effect, message?: string) => + Effect.gen(function* () { + const exit = yield* Effect.exit(effect) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit) && message) expect(Cause.pretty(exit.cause)).toContain(message) + }) + +const expectReadFailure = (filepath: string) => expectFailure(readText(filepath)) + describe("tool.apply_patch freeform", () => { - test("requires patchText", async () => { - const { ctx } = makeCtx() - await expect(execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required") - }) + it.live("requires patchText", () => + Effect.gen(function* () { + const { ctx } = makeCtx() + yield* expectFailure(execute({ patchText: "" }, ctx), "patchText is required") + }), + ) - test("rejects invalid patch format", async () => { - const { ctx } = makeCtx() - await expect(execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("apply_patch verification failed") - }) + it.live("rejects invalid patch format", () => + Effect.gen(function* () { + const { ctx } = makeCtx() + yield* expectFailure(execute({ patchText: "invalid patch" }, ctx), "apply_patch verification failed") + }), + ) - test("rejects empty patch", async () => { - const { ctx } = makeCtx() - const emptyPatch = "*** Begin Patch\n*** End Patch" - await expect(execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("patch rejected: empty patch") - }) + it.live("rejects empty patch", () => + Effect.gen(function* () { + const { ctx } = makeCtx() + yield* expectFailure(execute({ patchText: "*** Begin Patch\n*** End Patch" }, ctx), "patch rejected: empty patch") + }), + ) - test("applies add/update/delete in one patch", async () => { - await using fixture = await tmpdir({ git: true }) - const { ctx, calls } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const modifyPath = path.join(fixture.path, "modify.txt") - const deletePath = path.join(fixture.path, "delete.txt") - await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8") - await fs.writeFile(deletePath, "obsolete\n", "utf-8") + it.instance( + "applies add/update/delete in one patch", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx, calls } = makeCtx() + const modifyPath = path.join(test.directory, "modify.txt") + const deletePath = path.join(test.directory, "delete.txt") + yield* writeText(modifyPath, "line1\nline2\n") + yield* writeText(deletePath, "obsolete\n") const patchText = "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch" - const result = await execute({ patchText }, ctx) + const result = yield* execute({ patchText }, ctx) expect(result.title).toContain("Success. Updated the following files") expect(result.output).toContain("Success. Updated the following files") @@ -129,38 +145,34 @@ describe("tool.apply_patch freeform", () => { expect(permissionCall.metadata.files.map((f) => f.type).sort()).toEqual(["add", "delete", "update"]) const addFile = permissionCall.metadata.files.find((f) => f.type === "add") - expect(addFile).toBeDefined() - expect(addFile!.relativePath).toBe("nested/new.txt") - expect(addFile!.patch).toContain("+created") + expect(addFile?.relativePath).toBe("nested/new.txt") + expect(addFile?.patch).toContain("+created") const updateFile = permissionCall.metadata.files.find((f) => f.type === "update") - expect(updateFile).toBeDefined() - expect(updateFile!.patch).toContain("-line2") - expect(updateFile!.patch).toContain("+changed") + expect(updateFile?.patch).toContain("-line2") + expect(updateFile?.patch).toContain("+changed") - const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8") - expect(added).toBe("created\n") - expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n") - await expect(fs.readFile(deletePath, "utf-8")).rejects.toThrow() - }, - }) - }) + expect(yield* readText(path.join(test.directory, "nested", "new.txt"))).toBe("created\n") + expect(yield* readText(modifyPath)).toBe("line1\nchanged\n") + yield* expectReadFailure(deletePath) + }), + { git: true }, + ) - test("permission metadata includes move file info", async () => { - await using fixture = await tmpdir({ git: true }) - const { ctx, calls } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const original = path.join(fixture.path, "old", "name.txt") - await fs.mkdir(path.dirname(original), { recursive: true }) - await fs.writeFile(original, "old content\n", "utf-8") + it.instance( + "permission metadata includes move file info", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx, calls } = makeCtx() + const original = path.join(test.directory, "old", "name.txt") + yield* makeDir(path.dirname(original)) + yield* writeText(original, "old content\n") const patchText = "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch" - await execute({ patchText }, ctx) + yield* execute({ patchText }, ctx) expect(calls.length).toBe(1) const permissionCall = calls[0] @@ -169,447 +181,348 @@ describe("tool.apply_patch freeform", () => { const moveFile = permissionCall.metadata.files[0] expect(moveFile.type).toBe("move") expect(moveFile.relativePath).toBe("renamed/dir/name.txt") - expect(moveFile.movePath).toBe(path.join(fixture.path, "renamed/dir/name.txt")) + expect(moveFile.movePath).toBe(path.join(test.directory, "renamed/dir/name.txt")) expect(moveFile.patch).toContain("-old content") expect(moveFile.patch).toContain("+new content") - }, - }) - }) - - test("applies multiple hunks to one file", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "multi.txt") - await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8") - - const patchText = - "*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch" - - await execute({ patchText }, ctx) - - expect(await fs.readFile(target, "utf-8")).toBe("line1\nchanged2\nline3\nchanged4\n") - }, - }) - }) - - test("does not invent a first-line diff for BOM files", async () => { - await using fixture = await tmpdir() - const { ctx, calls } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const bom = String.fromCharCode(0xfeff) - const target = path.join(fixture.path, "example.cs") - await fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`, "utf-8") - - const patchText = - "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch" - - await execute({ patchText }, ctx) - - expect(calls.length).toBe(1) - const shown = calls[0].metadata.files[0]?.patch ?? "" - expect(shown).not.toContain(bom) - expect(shown).not.toContain("-using System;") - expect(shown).not.toContain("+using System;") - - const content = await fs.readFile(target, "utf-8") - expect(content.charCodeAt(0)).toBe(0xfeff) - expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n") - }, - }) - }) - - test("inserts lines with insert-only hunk", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "insert_only.txt") - await fs.writeFile(target, "alpha\nomega\n", "utf-8") - - const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch" - - await execute({ patchText }, ctx) - - expect(await fs.readFile(target, "utf-8")).toBe("alpha\nbeta\nomega\n") - }, - }) - }) - - test("appends trailing newline on update", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "no_newline.txt") - await fs.writeFile(target, "no newline at end", "utf-8") - - const patchText = - "*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch" - - await execute({ patchText }, ctx) - - const contents = await fs.readFile(target, "utf-8") - expect(contents.endsWith("\n")).toBe(true) - expect(contents).toBe("first line\nsecond line\n") - }, - }) - }) - - test("moves file to a new directory", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const original = path.join(fixture.path, "old", "name.txt") - await fs.mkdir(path.dirname(original), { recursive: true }) - await fs.writeFile(original, "old content\n", "utf-8") - - const patchText = - "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch" - - await execute({ patchText }, ctx) - - const moved = path.join(fixture.path, "renamed", "dir", "name.txt") - await expect(fs.readFile(original, "utf-8")).rejects.toThrow() - expect(await fs.readFile(moved, "utf-8")).toBe("new content\n") - }, - }) - }) - - test("moves file overwriting existing destination", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const original = path.join(fixture.path, "old", "name.txt") - const destination = path.join(fixture.path, "renamed", "dir", "name.txt") - await fs.mkdir(path.dirname(original), { recursive: true }) - await fs.mkdir(path.dirname(destination), { recursive: true }) - await fs.writeFile(original, "from\n", "utf-8") - await fs.writeFile(destination, "existing\n", "utf-8") - - const patchText = - "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch" - - await execute({ patchText }, ctx) - - await expect(fs.readFile(original, "utf-8")).rejects.toThrow() - expect(await fs.readFile(destination, "utf-8")).toBe("new\n") - }, - }) - }) - - test("adds file overwriting existing file", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "duplicate.txt") - await fs.writeFile(target, "old content\n", "utf-8") - - const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch" - - await execute({ patchText }, ctx) - expect(await fs.readFile(target, "utf-8")).toBe("new content\n") - }, - }) - }) - - test("rejects update when target file is missing", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch" - - await expect(execute({ patchText }, ctx)).rejects.toThrow( - "apply_patch verification failed: Failed to read file to update", - ) - }, - }) - }) - - test("rejects delete when file is missing", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch" - - await expect(execute({ patchText }, ctx)).rejects.toThrow() - }, - }) - }) - - test("rejects delete when target is a directory", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const dirPath = path.join(fixture.path, "dir") - await fs.mkdir(dirPath) - - const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch" - - await expect(execute({ patchText }, ctx)).rejects.toThrow() - }, - }) - }) - - test("rejects invalid hunk header", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch" - - await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed") - }, - }) - }) - - test("rejects update with missing context", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "modify.txt") - await fs.writeFile(target, "line1\nline2\n", "utf-8") - - const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch" - - await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed") - expect(await fs.readFile(target, "utf-8")).toBe("line1\nline2\n") - }, - }) - }) - - test("verification failure leaves no side effects", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = - "*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch" - - await expect(execute({ patchText }, ctx)).rejects.toThrow() - - const createdPath = path.join(fixture.path, "created.txt") - await expect(fs.readFile(createdPath, "utf-8")).rejects.toThrow() - }, - }) - }) - - test("supports end of file anchor", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "tail.txt") - await fs.writeFile(target, "alpha\nlast\n", "utf-8") - - const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch" - - await execute({ patchText }, ctx) - expect(await fs.readFile(target, "utf-8")).toBe("alpha\nend\n") - }, - }) - }) - - test("rejects missing second chunk context", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "two_chunks.txt") - await fs.writeFile(target, "a\nb\nc\nd\n", "utf-8") - - const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch" - - await expect(execute({ patchText }, ctx)).rejects.toThrow() - expect(await fs.readFile(target, "utf-8")).toBe("a\nb\nc\nd\n") - }, - }) - }) - - test("disambiguates change context with @@ header", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "multi_ctx.txt") - await fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n", "utf-8") - - const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch" - - await execute({ patchText }, ctx) - expect(await fs.readFile(target, "utf-8")).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n") - }, - }) - }) - - test("EOF anchor matches from end of file first", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "eof_anchor.txt") - // File has duplicate "marker" lines - one in middle, one at end - await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8") - - // With EOF anchor, should match the LAST "marker" line, not the first - const patchText = - "*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch" - - await execute({ patchText }, ctx) - // First marker unchanged, second marker changed - expect(await fs.readFile(target, "utf-8")).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n") - }, - }) - }) - - test("parses heredoc-wrapped patch", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `cat <<'EOF' + }), + { git: true }, + ) + + it.instance("applies multiple hunks to one file", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "multi.txt") + yield* writeText(target, "line1\nline2\nline3\nline4\n") + + const patchText = + "*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch" + + yield* execute({ patchText }, ctx) + + expect(yield* readText(target)).toBe("line1\nchanged2\nline3\nchanged4\n") + }), + ) + + it.instance("does not invent a first-line diff for BOM files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx, calls } = makeCtx() + const bom = String.fromCharCode(0xfeff) + const target = path.join(test.directory, "example.cs") + yield* writeText(target, `${bom}using System;\n\nclass Test {}\n`) + + const patchText = "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch" + + yield* execute({ patchText }, ctx) + + expect(calls.length).toBe(1) + const shown = calls[0].metadata.files[0]?.patch ?? "" + expect(shown).not.toContain(bom) + expect(shown).not.toContain("-using System;") + expect(shown).not.toContain("+using System;") + + const content = yield* readText(target) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n") + }), + ) + + it.instance("inserts lines with insert-only hunk", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "insert_only.txt") + yield* writeText(target, "alpha\nomega\n") + + const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch" + + yield* execute({ patchText }, ctx) + + expect(yield* readText(target)).toBe("alpha\nbeta\nomega\n") + }), + ) + + it.instance("appends trailing newline on update", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "no_newline.txt") + yield* writeText(target, "no newline at end") + + const patchText = + "*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch" + + yield* execute({ patchText }, ctx) + + const contents = yield* readText(target) + expect(contents.endsWith("\n")).toBe(true) + expect(contents).toBe("first line\nsecond line\n") + }), + ) + + it.instance("moves file to a new directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const original = path.join(test.directory, "old", "name.txt") + yield* makeDir(path.dirname(original)) + yield* writeText(original, "old content\n") + + const patchText = + "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch" + + yield* execute({ patchText }, ctx) + + const moved = path.join(test.directory, "renamed", "dir", "name.txt") + yield* expectReadFailure(original) + expect(yield* readText(moved)).toBe("new content\n") + }), + ) + + it.instance("moves file overwriting existing destination", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const original = path.join(test.directory, "old", "name.txt") + const destination = path.join(test.directory, "renamed", "dir", "name.txt") + yield* makeDir(path.dirname(original)) + yield* makeDir(path.dirname(destination)) + yield* writeText(original, "from\n") + yield* writeText(destination, "existing\n") + + const patchText = + "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch" + + yield* execute({ patchText }, ctx) + + yield* expectReadFailure(original) + expect(yield* readText(destination)).toBe("new\n") + }), + ) + + it.instance("adds file overwriting existing file", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "duplicate.txt") + yield* writeText(target, "old content\n") + + const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch" + + yield* execute({ patchText }, ctx) + expect(yield* readText(target)).toBe("new content\n") + }), + ) + + it.instance("rejects update when target file is missing", () => + Effect.gen(function* () { + const { ctx } = makeCtx() + const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch" + + yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed: Failed to read file to update") + }), + ) + + it.instance("rejects delete when file is missing", () => + Effect.gen(function* () { + const { ctx } = makeCtx() + const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch" + + yield* expectFailure(execute({ patchText }, ctx)) + }), + ) + + it.instance("rejects delete when target is a directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const dirPath = path.join(test.directory, "dir") + yield* makeDir(dirPath) + + const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch" + + yield* expectFailure(execute({ patchText }, ctx)) + }), + ) + + it.instance("rejects invalid hunk header", () => + Effect.gen(function* () { + const { ctx } = makeCtx() + const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch" + + yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed") + }), + ) + + it.instance("rejects update with missing context", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "modify.txt") + yield* writeText(target, "line1\nline2\n") + + const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch" + + yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed") + expect(yield* readText(target)).toBe("line1\nline2\n") + }), + ) + + it.instance("verification failure leaves no side effects", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const patchText = + "*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch" + + yield* expectFailure(execute({ patchText }, ctx)) + yield* expectReadFailure(path.join(test.directory, "created.txt")) + }), + ) + + it.instance("supports end of file anchor", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "tail.txt") + yield* writeText(target, "alpha\nlast\n") + + const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch" + + yield* execute({ patchText }, ctx) + expect(yield* readText(target)).toBe("alpha\nend\n") + }), + ) + + it.instance("rejects missing second chunk context", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "two_chunks.txt") + yield* writeText(target, "a\nb\nc\nd\n") + + const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch" + + yield* expectFailure(execute({ patchText }, ctx)) + expect(yield* readText(target)).toBe("a\nb\nc\nd\n") + }), + ) + + it.instance("disambiguates change context with @@ header", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "multi_ctx.txt") + yield* writeText(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n") + + const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch" + + yield* execute({ patchText }, ctx) + expect(yield* readText(target)).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n") + }), + ) + + it.instance("EOF anchor matches from end of file first", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "eof_anchor.txt") + // File has duplicate "marker" lines - one in middle, one at end + yield* writeText(target, "start\nmarker\nmiddle\nmarker\nend\n") + + // With EOF anchor, should match the LAST "marker" line, not the first + const patchText = + "*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch" + + yield* execute({ patchText }, ctx) + // First marker unchanged, second marker changed + expect(yield* readText(target)).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n") + }), + ) + + it.instance("parses heredoc-wrapped patch", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const patchText = `cat <<'EOF' *** Begin Patch *** Add File: heredoc_test.txt +heredoc content *** End Patch EOF` - await execute({ patchText }, ctx) - const content = await fs.readFile(path.join(fixture.path, "heredoc_test.txt"), "utf-8") - expect(content).toBe("heredoc content\n") - }, - }) - }) + yield* execute({ patchText }, ctx) + expect(yield* readText(path.join(test.directory, "heredoc_test.txt"))).toBe("heredoc content\n") + }), + ) - test("parses heredoc-wrapped patch without cat", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `< + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const patchText = `< { - await using fixture = await tmpdir() - const { ctx } = makeCtx() + it.instance("matches with trailing whitespace differences", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "trailing_ws.txt") + // File has trailing spaces on some lines + yield* writeText(target, "line1 \nline2\nline3 \n") - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "trailing_ws.txt") - // File has trailing spaces on some lines - await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8") + // Patch doesn't have trailing spaces - should still match via rstrip pass + const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch" - // Patch doesn't have trailing spaces - should still match via rstrip pass - const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch" + yield* execute({ patchText }, ctx) + expect(yield* readText(target)).toBe("line1 \nchanged\nline3 \n") + }), + ) - await execute({ patchText }, ctx) - expect(await fs.readFile(target, "utf-8")).toBe("line1 \nchanged\nline3 \n") - }, - }) - }) + it.instance("matches with leading whitespace differences", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "leading_ws.txt") + // File has leading spaces + yield* writeText(target, " line1\nline2\n line3\n") - test("matches with leading whitespace differences", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() + // Patch without leading spaces - should match via trim pass + const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch" - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "leading_ws.txt") - // File has leading spaces - await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8") + yield* execute({ patchText }, ctx) + expect(yield* readText(target)).toBe(" line1\nchanged\n line3\n") + }), + ) - // Patch without leading spaces - should match via trim pass - const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch" + it.instance("matches with Unicode punctuation differences", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "unicode.txt") + // File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014) + const leftQuote = "\u201C" + const rightQuote = "\u201D" + const emDash = "\u2014" + yield* writeText(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`) - await execute({ patchText }, ctx) - expect(await fs.readFile(target, "utf-8")).toBe(" line1\nchanged\n line3\n") - }, - }) - }) + // Patch uses ASCII equivalents - should match via normalized pass + // The replacement uses ASCII quotes from the patch (not preserving Unicode) + const patchText = '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch' - test("matches with Unicode punctuation differences", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "unicode.txt") - // File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014) - const leftQuote = "\u201C" - const rightQuote = "\u201D" - const emDash = "\u2014" - await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8") - - // Patch uses ASCII equivalents - should match via normalized pass - // The replacement uses ASCII quotes from the patch (not preserving Unicode) - const patchText = - '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch' - - await execute({ patchText }, ctx) - // Result has ASCII quotes because that's what the patch specifies - expect(await fs.readFile(target, "utf-8")).toBe(`He said "hi"\nsome${emDash}dash\nend\n`) - }, - }) - }) + yield* execute({ patchText }, ctx) + // Result has ASCII quotes because that's what the patch specifies + expect(yield* readText(target)).toBe(`He said "hi"\nsome${emDash}dash\nend\n`) + }), + ) }) From a7b50416745122c3f7134a32813a937e1daab78a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 12:44:04 -0400 Subject: [PATCH 098/188] test(file): migrate fsmonitor tests to Effect runner (#27099) --- packages/opencode/test/file/fsmonitor.test.ts | 103 +++++++++--------- 1 file changed, 52 insertions(+), 51 deletions(-) diff --git a/packages/opencode/test/file/fsmonitor.test.ts b/packages/opencode/test/file/fsmonitor.test.ts index f345cd085..3e025825b 100644 --- a/packages/opencode/test/file/fsmonitor.test.ts +++ b/packages/opencode/test/file/fsmonitor.test.ts @@ -3,67 +3,68 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import fs from "fs/promises" import path from "path" -import { File } from "../../src/file" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" -import { provideInstance, tmpdir } from "../fixture/fixture" -const run = (eff: Effect.Effect) => - Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer)))) -const status = () => run(File.Service.use((svc) => svc.status())) -const read = (file: string) => run(File.Service.use((svc) => svc.read(file))) - -const wintest = process.platform === "win32" ? test : test.skip +const it = process.platform === "win32" ? (await import("../lib/effect")).testEffect((await import("../../src/file")).File.defaultLayer) : undefined describe("file fsmonitor", () => { - wintest("status does not start fsmonitor for readonly git checks", async () => { - await using tmp = await tmpdir({ git: true }) - const target = path.join(tmp.path, "tracked.txt") + if (!it) { + test.skip("status does not start fsmonitor for readonly git checks", () => {}) + test.skip("read does not start fsmonitor for git diffs", () => {}) + return + } - await fs.writeFile(target, "base\n") - await $`git add tracked.txt`.cwd(tmp.path).quiet() - await $`git commit -m init`.cwd(tmp.path).quiet() - await $`git config core.fsmonitor true`.cwd(tmp.path).quiet() - await $`git fsmonitor--daemon stop`.cwd(tmp.path).quiet().nothrow() - await fs.writeFile(target, "next\n") - await fs.writeFile(path.join(tmp.path, "new.txt"), "new\n") + it.instance( + "status does not start fsmonitor for readonly git checks", + () => + Effect.gen(function* () { + const { File } = yield* Effect.promise(() => import("../../src/file")) + const { TestInstance } = yield* Effect.promise(() => import("../fixture/fixture")) + const directory = (yield* TestInstance).directory + const target = path.join(directory, "tracked.txt") - const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() - expect(before.exitCode).not.toBe(0) + yield* Effect.promise(() => fs.writeFile(target, "base\n")) + yield* Effect.promise(() => $`git add tracked.txt`.cwd(directory).quiet()) + yield* Effect.promise(() => $`git commit -m init`.cwd(directory).quiet()) + yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(directory).quiet()) + yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(directory).quiet().nothrow()) + yield* Effect.promise(() => fs.writeFile(target, "next\n")) + yield* Effect.promise(() => fs.writeFile(path.join(directory, "new.txt"), "new\n")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await status() - }, - }) + const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow()) + expect(before.exitCode).not.toBe(0) - const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() - expect(after.exitCode).not.toBe(0) - }) + yield* File.Service.use((svc) => svc.status()) - wintest("read does not start fsmonitor for git diffs", async () => { - await using tmp = await tmpdir({ git: true }) - const target = path.join(tmp.path, "tracked.txt") + const after = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow()) + expect(after.exitCode).not.toBe(0) + }), + { git: true }, + ) - await fs.writeFile(target, "base\n") - await $`git add tracked.txt`.cwd(tmp.path).quiet() - await $`git commit -m init`.cwd(tmp.path).quiet() - await $`git config core.fsmonitor true`.cwd(tmp.path).quiet() - await $`git fsmonitor--daemon stop`.cwd(tmp.path).quiet().nothrow() - await fs.writeFile(target, "next\n") + it.instance( + "read does not start fsmonitor for git diffs", + () => + Effect.gen(function* () { + const { File } = yield* Effect.promise(() => import("../../src/file")) + const { TestInstance } = yield* Effect.promise(() => import("../fixture/fixture")) + const directory = (yield* TestInstance).directory + const target = path.join(directory, "tracked.txt") - const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() - expect(before.exitCode).not.toBe(0) + yield* Effect.promise(() => fs.writeFile(target, "base\n")) + yield* Effect.promise(() => $`git add tracked.txt`.cwd(directory).quiet()) + yield* Effect.promise(() => $`git commit -m init`.cwd(directory).quiet()) + yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(directory).quiet()) + yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(directory).quiet().nothrow()) + yield* Effect.promise(() => fs.writeFile(target, "next\n")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await read("tracked.txt") - }, - }) + const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow()) + expect(before.exitCode).not.toBe(0) - const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() - expect(after.exitCode).not.toBe(0) - }) + yield* File.Service.use((svc) => svc.read("tracked.txt")) + + const after = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow()) + expect(after.exitCode).not.toBe(0) + }), + { git: true }, + ) }) From e8125e9b428cd68260074e0de087cb1ae3ec3cf1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 12:45:05 -0400 Subject: [PATCH 099/188] test(server): migrate session list tests to Effect runner (#27101) --- .../opencode/test/server/session-list.test.ts | 332 +++++++++--------- 1 file changed, 157 insertions(+), 175 deletions(-) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 20478dde8..7a4eb61a4 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -1,33 +1,25 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect } from "bun:test" import { Effect } from "effect" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture" import { Flag } from "@opencode-ai/core/flag/flag" import { mkdir } from "fs/promises" import path from "path" import { Database } from "@/storage/db" import { SessionTable } from "@/session/session.sql" import { eq } from "drizzle-orm" +import { testEffect } from "../lib/effect" void Log.init({ print: false }) const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES +const it = testEffect(SessionNs.defaultLayer) -function run(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) -} - -const svc = { - ...SessionNs, - create(input?: SessionNs.CreateInput) { - return run(SessionNs.Service.use((svc) => svc.create(input))) - }, - list(input?: SessionNs.ListInput) { - return run(SessionNs.Service.use((svc) => svc.list(input))) - }, -} +const withSession = (input?: Parameters[0]) => + Effect.acquireRelease( + SessionNs.Service.use((session) => session.create(input)), + (created) => SessionNs.Service.use((session) => session.remove(created.id).pipe(Effect.ignore)), + ) afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces @@ -35,205 +27,195 @@ afterEach(async () => { }) describe("session.list", () => { - test("does not filter by directory when directory is omitted", async () => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false - await using tmp = await tmpdir({ git: true }) - await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true }) - await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) + it.instance( + "does not filter by directory when directory is omitted", + () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false + const test = yield* TestInstance + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true })) + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const root = await svc.create({ title: "root" }) + const root = yield* withSession({ title: "root" }) + const parent = yield* withSession({ title: "parent" }).pipe(provideInstance(path.join(test.directory, "packages"))) + const current = yield* withSession({ title: "current" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode")), + ) + const sibling = yield* withSession({ title: "sibling" }).pipe( + provideInstance(path.join(test.directory, "packages", "app")), + ) - const parent = await WithInstance.provide({ - directory: path.join(tmp.path, "packages"), - fn: async () => svc.create({ title: "parent" }), - }) - const current = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "opencode"), - fn: async () => svc.create({ title: "current" }), - }) - const sibling = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "app"), - fn: async () => svc.create({ title: "sibling" }), - }) - - const ids = (await svc.list()).map((s) => s.id) + const ids = (yield* SessionNs.Service.use((session) => session.list())).map((session) => session.id) expect(ids).toContain(root.id) expect(ids).toContain(parent.id) expect(ids).toContain(current.id) expect(ids).toContain(sibling.id) - }, - }) - }) + }), + { git: true }, + ) - test("filters by directory when directory is provided", async () => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false - await using tmp = await tmpdir({ git: true }) - await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true }) - await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) + it.instance( + "filters by directory when directory is provided", + () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false + const test = yield* TestInstance + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true })) + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const root = await svc.create({ title: "root" }) + const root = yield* withSession({ title: "root" }) + const parent = yield* withSession({ title: "parent" }).pipe(provideInstance(path.join(test.directory, "packages"))) + const current = yield* withSession({ title: "current" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode")), + ) + const sibling = yield* withSession({ title: "sibling" }).pipe( + provideInstance(path.join(test.directory, "packages", "app")), + ) - const parent = await WithInstance.provide({ - directory: path.join(tmp.path, "packages"), - fn: async () => svc.create({ title: "parent" }), - }) - const current = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "opencode"), - fn: async () => svc.create({ title: "current" }), - }) - const sibling = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "app"), - fn: async () => svc.create({ title: "sibling" }), - }) - - const ids = (await svc.list({ directory: path.join(tmp.path, "packages", "opencode") })).map((s) => s.id) + const ids = ( + yield* SessionNs.Service.use((session) => + session.list({ directory: path.join(test.directory, "packages", "opencode") }), + ) + ).map((session) => session.id) expect(ids).not.toContain(root.id) expect(ids).not.toContain(parent.id) expect(ids).toContain(current.id) expect(ids).not.toContain(sibling.id) - }, - }) - }) + }), + { git: true }, + ) - test("filters by path and ignores directory when path is provided", async () => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false - await using tmp = await tmpdir({ git: true }) - await mkdir(path.join(tmp.path, "packages", "opencode", "src", "deep"), { recursive: true }) - await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) + it.instance( + "filters by path and ignores directory when path is provided", + () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false + const test = yield* TestInstance + yield* Effect.promise(() => + mkdir(path.join(test.directory, "packages", "opencode", "src", "deep"), { recursive: true }), + ) + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const parent = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "opencode"), - fn: async () => svc.create({ title: "parent" }), - }) - const current = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "opencode", "src"), - fn: async () => svc.create({ title: "current" }), - }) - const deeper = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "opencode", "src", "deep"), - fn: async () => svc.create({ title: "deeper" }), - }) - const sibling = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "app"), - fn: async () => svc.create({ title: "sibling" }), - }) + const parent = yield* withSession({ title: "parent" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode")), + ) + const current = yield* withSession({ title: "current" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode", "src")), + ) + const deeper = yield* withSession({ title: "deeper" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode", "src", "deep")), + ) + const sibling = yield* withSession({ title: "sibling" }).pipe( + provideInstance(path.join(test.directory, "packages", "app")), + ) const pathIDs = ( - await svc.list({ - directory: path.join(tmp.path, "packages", "app"), - path: "packages/opencode/src", - }) - ).map((s) => s.id) + yield* SessionNs.Service.use((session) => + session.list({ + directory: path.join(test.directory, "packages", "app"), + path: "packages/opencode/src", + }), + ) + ).map((session) => session.id) expect(pathIDs).not.toContain(parent.id) expect(pathIDs).toContain(current.id) expect(pathIDs).toContain(deeper.id) expect(pathIDs).not.toContain(sibling.id) - }, - }) - }) + }), + { git: true }, + ) - test("falls back to directory when filtering legacy sessions without path", async () => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false - await using tmp = await tmpdir({ git: true }) - await mkdir(path.join(tmp.path, "packages", "opencode", "src"), { recursive: true }) - await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) + it.instance( + "falls back to directory when filtering legacy sessions without path", + () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false + const test = yield* TestInstance + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode", "src"), { recursive: true })) + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const current = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "opencode", "src"), - fn: async () => svc.create({ title: "legacy-current" }), - }) - const sibling = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "app"), - fn: async () => svc.create({ title: "legacy-sibling" }), - }) + const current = yield* withSession({ title: "legacy-current" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode", "src")), + ) + const sibling = yield* withSession({ title: "legacy-sibling" }).pipe( + provideInstance(path.join(test.directory, "packages", "app")), + ) - Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run()) - Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run()) + yield* Effect.sync(() => + Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run()), + ) + yield* Effect.sync(() => + Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run()), + ) const pathIDs = ( - await svc.list({ - directory: path.join(tmp.path, "packages", "opencode", "src"), - path: "packages/opencode/src", - }) - ).map((s) => s.id) + yield* SessionNs.Service.use((session) => + session.list({ + directory: path.join(test.directory, "packages", "opencode", "src"), + path: "packages/opencode/src", + }), + ) + ).map((session) => session.id) expect(pathIDs).toContain(current.id) expect(pathIDs).not.toContain(sibling.id) - }, - }) - }) + }), + { git: true }, + ) - test("filters root sessions", async () => { - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const root = await svc.create({ title: "root-session" }) - const child = await svc.create({ title: "child-session", parentID: root.id }) + it.instance( + "filters root sessions", + () => + Effect.gen(function* () { + const root = yield* withSession({ title: "root-session" }) + const child = yield* withSession({ title: "child-session", parentID: root.id }) - const sessions = await svc.list({ roots: true }) - const ids = sessions.map((s) => s.id) + const sessions = yield* SessionNs.Service.use((session) => session.list({ roots: true })) + const ids = sessions.map((session) => session.id) expect(ids).toContain(root.id) expect(ids).not.toContain(child.id) - }, - }) - }) + }), + { git: true }, + ) - test("filters by start time", async () => { - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await svc.create({ title: "new-session" }) - const futureStart = Date.now() + 86400000 - - const sessions = await svc.list({ start: futureStart }) + it.instance( + "filters by start time", + () => + Effect.gen(function* () { + yield* withSession({ title: "new-session" }) + const sessions = yield* SessionNs.Service.use((session) => session.list({ start: Date.now() + 86400000 })) expect(sessions.length).toBe(0) - }, - }) - }) + }), + { git: true }, + ) - test("filters by search term", async () => { - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await svc.create({ title: "unique-search-term-abc" }) - await svc.create({ title: "other-session-xyz" }) + it.instance( + "filters by search term", + () => + Effect.gen(function* () { + yield* withSession({ title: "unique-search-term-abc" }) + yield* withSession({ title: "other-session-xyz" }) - const sessions = await svc.list({ search: "unique-search" }) - const titles = sessions.map((s) => s.title) + const sessions = yield* SessionNs.Service.use((session) => session.list({ search: "unique-search" })) + const titles = sessions.map((session) => session.title) expect(titles).toContain("unique-search-term-abc") expect(titles).not.toContain("other-session-xyz") - }, - }) - }) + }), + { git: true }, + ) - test("respects limit parameter", async () => { - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await svc.create({ title: "session-1" }) - await svc.create({ title: "session-2" }) - await svc.create({ title: "session-3" }) + it.instance( + "respects limit parameter", + () => + Effect.gen(function* () { + yield* withSession({ title: "session-1" }) + yield* withSession({ title: "session-2" }) + yield* withSession({ title: "session-3" }) - const sessions = await svc.list({ limit: 2 }) + const sessions = yield* SessionNs.Service.use((session) => session.list({ limit: 2 })) expect(sessions.length).toBe(2) - }, - }) - }) + }), + { git: true }, + ) }) From 0ce614a280aab5ceb26a449577ba88c9ec9941cb Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 16:46:35 +0000 Subject: [PATCH 100/188] chore: generate --- packages/opencode/test/file/fsmonitor.test.ts | 5 +- .../opencode/test/file/path-traversal.test.ts | 62 +++++++++++-------- .../opencode/test/server/session-list.test.ts | 56 +++++++++-------- .../opencode/test/tool/apply_patch.test.ts | 11 +++- 4 files changed, 77 insertions(+), 57 deletions(-) diff --git a/packages/opencode/test/file/fsmonitor.test.ts b/packages/opencode/test/file/fsmonitor.test.ts index 3e025825b..b8d3bd605 100644 --- a/packages/opencode/test/file/fsmonitor.test.ts +++ b/packages/opencode/test/file/fsmonitor.test.ts @@ -4,7 +4,10 @@ import { Effect } from "effect" import fs from "fs/promises" import path from "path" -const it = process.platform === "win32" ? (await import("../lib/effect")).testEffect((await import("../../src/file")).File.defaultLayer) : undefined +const it = + process.platform === "win32" + ? (await import("../lib/effect")).testEffect((await import("../../src/file")).File.defaultLayer) + : undefined describe("file fsmonitor", () => { if (!it) { diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 28bd34978..336f214d1 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -106,13 +106,15 @@ describe("File.list path traversal protection", () => { }) describe("containsPath", () => { - it.instance("returns true for path inside directory", () => - Effect.gen(function* () { - const test = yield* TestInstance - const ctx = yield* InstanceState.context - expect(containsPath(path.join(test.directory, "foo.txt"), ctx)).toBe(true) - expect(containsPath(path.join(test.directory, "src", "file.ts"), ctx)).toBe(true) - }), + it.instance( + "returns true for path inside directory", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const ctx = yield* InstanceState.context + expect(containsPath(path.join(test.directory, "foo.txt"), ctx)).toBe(true) + expect(containsPath(path.join(test.directory, "src", "file.ts"), ctx)).toBe(true) + }), { git: true }, ) @@ -135,32 +137,38 @@ describe("containsPath", () => { { git: true }, ) - it.instance("returns false for path outside both directory and worktree", () => - Effect.gen(function* () { - const ctx = yield* InstanceState.context - expect(containsPath("/etc/passwd", ctx)).toBe(false) - expect(containsPath("/tmp/other-project", ctx)).toBe(false) - }), + it.instance( + "returns false for path outside both directory and worktree", + () => + Effect.gen(function* () { + const ctx = yield* InstanceState.context + expect(containsPath("/etc/passwd", ctx)).toBe(false) + expect(containsPath("/tmp/other-project", ctx)).toBe(false) + }), { git: true }, ) - it.instance("returns false for path with .. escaping worktree", () => - Effect.gen(function* () { - const test = yield* TestInstance - const ctx = yield* InstanceState.context - expect(containsPath(path.join(test.directory, "..", "escape.txt"), ctx)).toBe(false) - }), + it.instance( + "returns false for path with .. escaping worktree", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const ctx = yield* InstanceState.context + expect(containsPath(path.join(test.directory, "..", "escape.txt"), ctx)).toBe(false) + }), { git: true }, ) - it.instance("handles directory === worktree (running from repo root)", () => - Effect.gen(function* () { - const test = yield* TestInstance - const ctx = yield* InstanceState.context - expect(ctx.directory).toBe(ctx.worktree) - expect(containsPath(path.join(test.directory, "file.txt"), ctx)).toBe(true) - expect(containsPath("/etc/passwd", ctx)).toBe(false) - }), + it.instance( + "handles directory === worktree (running from repo root)", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const ctx = yield* InstanceState.context + expect(ctx.directory).toBe(ctx.worktree) + expect(containsPath(path.join(test.directory, "file.txt"), ctx)).toBe(true) + expect(containsPath("/etc/passwd", ctx)).toBe(false) + }), { git: true }, ) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 7a4eb61a4..e5dc72546 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -37,7 +37,9 @@ describe("session.list", () => { yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) const root = yield* withSession({ title: "root" }) - const parent = yield* withSession({ title: "parent" }).pipe(provideInstance(path.join(test.directory, "packages"))) + const parent = yield* withSession({ title: "parent" }).pipe( + provideInstance(path.join(test.directory, "packages")), + ) const current = yield* withSession({ title: "current" }).pipe( provideInstance(path.join(test.directory, "packages", "opencode")), ) @@ -64,7 +66,9 @@ describe("session.list", () => { yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) const root = yield* withSession({ title: "root" }) - const parent = yield* withSession({ title: "parent" }).pipe(provideInstance(path.join(test.directory, "packages"))) + const parent = yield* withSession({ title: "parent" }).pipe( + provideInstance(path.join(test.directory, "packages")), + ) const current = yield* withSession({ title: "current" }).pipe( provideInstance(path.join(test.directory, "packages", "opencode")), ) @@ -72,11 +76,9 @@ describe("session.list", () => { provideInstance(path.join(test.directory, "packages", "app")), ) - const ids = ( - yield* SessionNs.Service.use((session) => - session.list({ directory: path.join(test.directory, "packages", "opencode") }), - ) - ).map((session) => session.id) + const ids = (yield* SessionNs.Service.use((session) => + session.list({ directory: path.join(test.directory, "packages", "opencode") }), + )).map((session) => session.id) expect(ids).not.toContain(root.id) expect(ids).not.toContain(parent.id) expect(ids).toContain(current.id) @@ -109,14 +111,12 @@ describe("session.list", () => { provideInstance(path.join(test.directory, "packages", "app")), ) - const pathIDs = ( - yield* SessionNs.Service.use((session) => - session.list({ - directory: path.join(test.directory, "packages", "app"), - path: "packages/opencode/src", - }), - ) - ).map((session) => session.id) + const pathIDs = (yield* SessionNs.Service.use((session) => + session.list({ + directory: path.join(test.directory, "packages", "app"), + path: "packages/opencode/src", + }), + )).map((session) => session.id) expect(pathIDs).not.toContain(parent.id) expect(pathIDs).toContain(current.id) expect(pathIDs).toContain(deeper.id) @@ -131,7 +131,9 @@ describe("session.list", () => { Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false const test = yield* TestInstance - yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode", "src"), { recursive: true })) + yield* Effect.promise(() => + mkdir(path.join(test.directory, "packages", "opencode", "src"), { recursive: true }), + ) yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) const current = yield* withSession({ title: "legacy-current" }).pipe( @@ -142,20 +144,22 @@ describe("session.list", () => { ) yield* Effect.sync(() => - Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run()), + Database.use((db) => + db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run(), + ), ) yield* Effect.sync(() => - Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run()), + Database.use((db) => + db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run(), + ), ) - const pathIDs = ( - yield* SessionNs.Service.use((session) => - session.list({ - directory: path.join(test.directory, "packages", "opencode", "src"), - path: "packages/opencode/src", - }), - ) - ).map((session) => session.id) + const pathIDs = (yield* SessionNs.Service.use((session) => + session.list({ + directory: path.join(test.directory, "packages", "opencode", "src"), + path: "packages/opencode/src", + }), + )).map((session) => session.id) expect(pathIDs).toContain(current.id) expect(pathIDs).not.toContain(sibling.id) }), diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 190254866..be5754f3b 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -212,7 +212,8 @@ describe("tool.apply_patch freeform", () => { const target = path.join(test.directory, "example.cs") yield* writeText(target, `${bom}using System;\n\nclass Test {}\n`) - const patchText = "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch" + const patchText = + "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch" yield* execute({ patchText }, ctx) @@ -320,7 +321,10 @@ describe("tool.apply_patch freeform", () => { const { ctx } = makeCtx() const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch" - yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed: Failed to read file to update") + yield* expectFailure( + execute({ patchText }, ctx), + "apply_patch verification failed: Failed to read file to update", + ) }), ) @@ -518,7 +522,8 @@ EOF` // Patch uses ASCII equivalents - should match via normalized pass // The replacement uses ASCII quotes from the patch (not preserving Unicode) - const patchText = '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch' + const patchText = + '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch' yield* execute({ patchText }, ctx) // Result has ASCII quotes because that's what the patch specifies From 5a4596c879a69aa20343060e5b5d5efeedfbbc0e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 12 May 2026 12:50:32 -0400 Subject: [PATCH 101/188] core: Wait 3 days before installing new package versions to reduce supply chain risk --- bunfig.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bunfig.toml b/bunfig.toml index 36a21d933..363579bbf 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,6 +1,7 @@ [install] exact = true +# Only install newly resolved package versions published at least 3 days ago. +minimumReleaseAge = 259200 [test] root = "./do-not-run-tests-from-root" - From 53a3f95088941aa0d3979af90b28b071c40fd866 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 12:58:50 -0400 Subject: [PATCH 102/188] Make core fn Zod import type-only (#27103) --- packages/core/src/util/fn.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/util/fn.ts b/packages/core/src/util/fn.ts index 9efe4622f..828baf3bd 100644 --- a/packages/core/src/util/fn.ts +++ b/packages/core/src/util/fn.ts @@ -1,4 +1,4 @@ -import { z } from "zod" +import type { z } from "zod" export function fn(schema: T, cb: (input: z.infer) => Result) { const result = (input: z.infer) => { From 2b9af91568d6c7e65f644e52be295a955bf9a7ca Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 13:08:57 -0400 Subject: [PATCH 103/188] Remove Zod from core log (#27102) --- packages/core/src/util/log.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/core/src/util/log.ts b/packages/core/src/util/log.ts index e1962aed4..83060b29c 100644 --- a/packages/core/src/util/log.ts +++ b/packages/core/src/util/log.ts @@ -4,11 +4,14 @@ import path from "path" import fs from "fs/promises" import { createWriteStream } from "fs" import * as Global from "../global" -import z from "zod" +import { Schema } from "effect" import { Glob } from "./glob" -export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" }) -export type Level = z.infer +export const Level = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({ + identifier: "LogLevel", + description: "Log level", +}) +export type Level = Schema.Schema.Type const levelPriority: Record = { DEBUG: 0, From bc4fdb837023a1b293ebf69f9729febd0ca13353 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 13:09:23 -0400 Subject: [PATCH 104/188] Remove unused app ID schema (#27105) --- packages/app/src/utils/id.ts | 6 ------ .../.storybook/mocks/app/context/global-sync.ts | 13 +++++++++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/app/src/utils/id.ts b/packages/app/src/utils/id.ts index fa27cf4c5..dba7a8d95 100644 --- a/packages/app/src/utils/id.ts +++ b/packages/app/src/utils/id.ts @@ -1,5 +1,3 @@ -import z from "zod" - const prefixes = { session: "ses", message: "msg", @@ -15,10 +13,6 @@ let counter = 0 type Prefix = keyof typeof prefixes export namespace Identifier { - export function schema(prefix: Prefix) { - return z.string().startsWith(prefixes[prefix]) - } - export function ascending(prefix: Prefix, given?: string) { return generateID(prefix, false, given) } diff --git a/packages/storybook/.storybook/mocks/app/context/global-sync.ts b/packages/storybook/.storybook/mocks/app/context/global-sync.ts index 2eb134d37..92622c04a 100644 --- a/packages/storybook/.storybook/mocks/app/context/global-sync.ts +++ b/packages/storybook/.storybook/mocks/app/context/global-sync.ts @@ -40,3 +40,16 @@ export function useGlobalSync() { }, } } + +export function useQueryOptions() { + return { + agents: (directory: string) => ({ + queryKey: [directory, "agents"], + queryFn: async () => [], + }), + providers: (directory: string | null) => ({ + queryKey: [directory, "providers"], + queryFn: async () => provider, + }), + } +} From 6b950b666a4cf4d9a238cd4727bd0d39ffeed063 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 13:51:08 -0400 Subject: [PATCH 105/188] Remove Zod from core dependencies (#27107) --- bun.lock | 1 - packages/core/package.json | 3 +-- packages/core/src/util/fn.ts | 11 ----------- packages/enterprise/src/core/share.ts | 5 ++++- 4 files changed, 5 insertions(+), 15 deletions(-) delete mode 100644 packages/core/src/util/fn.ts diff --git a/bun.lock b/bun.lock index 4268e5fb7..9ab93237d 100644 --- a/bun.lock +++ b/bun.lock @@ -214,7 +214,6 @@ "npm-package-arg": "13.0.2", "semver": "^7.6.3", "xdg-basedir": "5.1.0", - "zod": "catalog:", }, "devDependencies": { "@tsconfig/bun": "catalog:", diff --git a/packages/core/package.json b/packages/core/package.json index e2ffa31d8..6bcef68dc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,8 +41,7 @@ "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "semver": "^7.6.3", - "xdg-basedir": "5.1.0", - "zod": "catalog:" + "xdg-basedir": "5.1.0" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/core/src/util/fn.ts b/packages/core/src/util/fn.ts deleted file mode 100644 index 828baf3bd..000000000 --- a/packages/core/src/util/fn.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { z } from "zod" - -export function fn(schema: T, cb: (input: z.infer) => Result) { - const result = (input: z.infer) => { - const parsed = schema.parse(input) - return cb(parsed) - } - result.force = (input: z.infer) => cb(input) - result.schema = schema - return result -} diff --git a/packages/enterprise/src/core/share.ts b/packages/enterprise/src/core/share.ts index fb8cd3029..a39171462 100644 --- a/packages/enterprise/src/core/share.ts +++ b/packages/enterprise/src/core/share.ts @@ -1,9 +1,12 @@ import { Message, Model, Part, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2" -import { fn } from "@opencode-ai/core/util/fn" import { iife } from "@opencode-ai/core/util/iife" import z from "zod" import { Storage } from "./storage" +function fn(schema: T, cb: (input: z.infer) => Result) { + return (input: z.infer) => cb(schema.parse(input)) +} + export namespace Share { export const Info = z.object({ id: z.string(), From fda37b3609954a0f09991f1c76d9238044381e02 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 14:02:12 -0400 Subject: [PATCH 106/188] Remove Zod from app global SDK (#27111) --- bun.lock | 1 - packages/app/package.json | 3 +-- packages/app/src/context/global-sdk.tsx | 8 +++----- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/bun.lock b/bun.lock index 9ab93237d..5da588910 100644 --- a/bun.lock +++ b/bun.lock @@ -65,7 +65,6 @@ "solid-list": "catalog:", "tailwindcss": "catalog:", "virtua": "catalog:", - "zod": "catalog:", }, "devDependencies": { "@happy-dom/global-registrator": "20.0.11", diff --git a/packages/app/package.json b/packages/app/package.json index 9eb408372..86999ed45 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -73,7 +73,6 @@ "solid-js": "catalog:", "solid-list": "catalog:", "tailwindcss": "catalog:", - "virtua": "catalog:", - "zod": "catalog:" + "virtua": "catalog:" } } diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index e53d60d5a..001b90b42 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -3,15 +3,13 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { makeEventListener } from "@solid-primitives/event-listener" import { batch, onCleanup, onMount } from "solid-js" -import z from "zod" import { createSdkForServer } from "@/utils/server" import { useLanguage } from "./language" import { usePlatform } from "./platform" import { useServer } from "./server" -const abortError = z.object({ - name: z.literal("AbortError"), -}) +const isAbortError = (error: unknown) => + error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({ name: "GlobalSDK", @@ -103,7 +101,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo let streamErrorLogged = false const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - const aborted = (error: unknown) => abortError.safeParse(error).success + const aborted = isAbortError let attempt: AbortController | undefined let run: Promise | undefined From 3974520742e1f2a64209429b4e8c6c845f34f6b5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 14:09:00 -0400 Subject: [PATCH 107/188] Migrate UI cancel error to tagged error (#27112) --- packages/opencode/src/cli/error.ts | 4 ++-- packages/opencode/src/cli/ui.ts | 3 +-- packages/opencode/test/cli/error.test.ts | 5 +++++ packages/opencode/test/util/error.test.ts | 8 ++------ 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 628aa9569..6fd7b573e 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -78,8 +78,8 @@ export function FormatError(input: unknown) { ].join("\n") } - // UICancelledError: void (no data) - if (NamedError.hasName(input, "UICancelledError")) { + // UICancelledError: user cancelled an interactive CLI prompt + if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) { return "" } } diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index 69e04b925..6ad6495cf 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/packages/opencode/src/cli/ui.ts @@ -1,5 +1,4 @@ import { EOL } from "os" -import { NamedError } from "@opencode-ai/core/util/error" import { Schema } from "effect" import { logo as glyphs } from "./logo" @@ -10,7 +9,7 @@ const wordmark = [ `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`, ] -export const CancelledError = NamedError.create("UICancelledError", Schema.optional(Schema.Void)) +export class CancelledError extends Schema.TaggedErrorClass()("UICancelledError", {}) {} export const Style = { TEXT_HIGHLIGHT: "\x1b[96m", diff --git a/packages/opencode/test/cli/error.test.ts b/packages/opencode/test/cli/error.test.ts index 6af2633ce..b4d1dbeda 100644 --- a/packages/opencode/test/cli/error.test.ts +++ b/packages/opencode/test/cli/error.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { AccountTransportError } from "../../src/account/schema" import { FormatError } from "../../src/cli/error" +import { UI } from "../../src/cli/ui" describe("cli.error", () => { test("formats account transport errors clearly", () => { @@ -15,4 +16,8 @@ describe("cli.error", () => { expect(formatted).toContain("This failed before the server returned an HTTP response.") expect(formatted).toContain("Check your network, proxy, or VPN configuration and try again.") }) + + test("formats cancelled UI errors as empty output", () => { + expect(FormatError(new UI.CancelledError())).toBe("") + }) }) diff --git a/packages/opencode/test/util/error.test.ts b/packages/opencode/test/util/error.test.ts index 8d077b1f2..fdb559a23 100644 --- a/packages/opencode/test/util/error.test.ts +++ b/packages/opencode/test/util/error.test.ts @@ -1,8 +1,6 @@ import { describe, expect, test } from "bun:test" -import { Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import { errorData, errorFormat, errorMessage } from "../../src/util/error" -import { UI } from "../../src/cli/ui" import { MessageError } from "../../src/session/message-error" describe("util.error", () => { @@ -60,9 +58,7 @@ describe("util.error", () => { expect(error.toObject()).toEqual({ name: "ProviderAuthError", data: { providerID: "anthropic", message: "boom" } }) }) - test("void named errors accept JSON without data", () => { - const serialized = JSON.parse(JSON.stringify(new UI.CancelledError(undefined).toObject())) - - expect(Schema.decodeUnknownOption(UI.CancelledError.Schema)(serialized)._tag).toBe("Some") + test("named errors without fields serialize data", () => { + expect(new MessageError.OutputLengthError({}).toObject()).toEqual({ name: "MessageOutputLengthError", data: {} }) }) }) From 822eec0d62468cb2927529ba1e706e00c407db54 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 14:22:56 -0400 Subject: [PATCH 108/188] Fix runner cancel completion (#27115) --- packages/opencode/src/effect/runner.ts | 2 +- packages/opencode/test/effect/runner.test.ts | 39 +++++++++++--------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 1e7d4c296..5d7e8778d 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -181,7 +181,7 @@ export const make = ( return [ Effect.gen(function* () { yield* Fiber.interrupt(st.run.fiber) - yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid) + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) yield* idleIfCurrent() }), { _tag: "Idle" } as const, diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index c37cb276b..3030ca64e 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -3,6 +3,11 @@ import { Deferred, Effect, Exit, Fiber, Latch, Ref, Scope } from "effect" import { Runner } from "@/effect/runner" import { it } from "../lib/effect" +const waitForState = (runner: Runner.Runner, tag: Runner.State["_tag"]) => + Effect.gen(function* () { + while (runner.state._tag !== tag) yield* Effect.yieldNow + }).pipe(Effect.timeout("1 second")) + describe("Runner", () => { // --- ensureRunning semantics --- @@ -152,7 +157,7 @@ describe("Runner", () => { const s = yield* Scope.Scope const runner = Runner.make(s, { onInterrupt: Effect.succeed("fallback") }) const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Running") yield* runner.cancel @@ -169,9 +174,9 @@ describe("Runner", () => { const runner = Runner.make(s, { onInterrupt: Effect.succeed("fallback") }) const a = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Running") const b = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* Effect.yieldNow yield* runner.cancel @@ -189,7 +194,7 @@ describe("Runner", () => { const s = yield* Scope.Scope const runner = Runner.make(s) const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Running") yield* runner.cancel yield* Fiber.await(fiber) @@ -215,7 +220,7 @@ describe("Runner", () => { ) const a = yield* runner.ensureRunning(first).pipe(Effect.exit, Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Running") const stop = yield* runner.cancel.pipe(Effect.forkChild) yield* Deferred.await(hit).pipe(Effect.timeout("250 millis")) @@ -293,7 +298,7 @@ describe("Runner", () => { const gate = yield* Deferred.make() const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("first"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Shell") const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) @@ -314,7 +319,7 @@ describe("Runner", () => { }) const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Shell") const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) @@ -333,7 +338,7 @@ describe("Runner", () => { const gate = yield* Deferred.make() const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ignored"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Shell") const stop = yield* runner.cancel.pipe(Effect.forkChild) const stopExit = yield* Fiber.await(stop).pipe(Effect.timeout("250 millis")) @@ -380,11 +385,11 @@ describe("Runner", () => { const gate = yield* Deferred.make() const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell-result"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Shell") expect(runner.state._tag).toBe("Shell") const run = yield* runner.ensureRunning(Effect.succeed("run-result")).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "ShellThenRun") expect(runner.state._tag).toBe("ShellThenRun") yield* Deferred.succeed(gate, undefined) @@ -406,7 +411,7 @@ describe("Runner", () => { const gate = yield* Deferred.make() const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Shell") const work = Effect.gen(function* () { yield* Ref.update(calls, (n) => n + 1) @@ -414,7 +419,7 @@ describe("Runner", () => { }) const a = yield* runner.ensureRunning(work).pipe(Effect.forkChild) const b = yield* runner.ensureRunning(work).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "ShellThenRun") yield* Deferred.succeed(gate, undefined) yield* Fiber.await(sh) @@ -433,10 +438,10 @@ describe("Runner", () => { const runner = Runner.make(s) const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Shell") const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "ShellThenRun") expect(runner.state._tag).toBe("ShellThenRun") yield* runner.cancel @@ -472,7 +477,7 @@ describe("Runner", () => { onIdle: Ref.update(count, (n) => n + 1), }) const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Running") yield* runner.cancel yield* Fiber.await(fiber) expect(yield* Ref.get(count)).toBeGreaterThanOrEqual(1) @@ -502,7 +507,7 @@ describe("Runner", () => { const gate = yield* Deferred.make() const fiber = yield* runner.ensureRunning(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Running") expect(runner.busy).toBe(true) yield* Deferred.succeed(gate, undefined) @@ -519,7 +524,7 @@ describe("Runner", () => { const gate = yield* Deferred.make() const fiber = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Shell") expect(runner.busy).toBe(true) yield* Deferred.succeed(gate, undefined) From a3714d4399bc7002a111519ad7c5bb9c97a11382 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 18:27:42 +0000 Subject: [PATCH 109/188] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 33003919a..ce8cded23 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-Q9r1S15YL9LQK7DRhuOpw3Fxi24BPovEM995GZJayKw=", - "aarch64-linux": "sha256-C0rRTLnxxuuEkCBc3JZbkR66TUVwpcPFif3BU9GRAuA=", - "aarch64-darwin": "sha256-1HvalOO/pOkRlYH8CZ93psapt90C+pYzui1JCadBE1Q=", - "x86_64-darwin": "sha256-RrndyLWfhWm4mZ88XytFF2NI+ly8la550Z5LBN/g5u4=" + "x86_64-linux": "sha256-MUHog06sZEi6bXR1m8exdkjSNW9bHEv9bPQXACJ7SFw=", + "aarch64-linux": "sha256-3dwdZ3It++OsdGT8xMOQ10Arz8eeODp/LXOrI4DLEhY=", + "aarch64-darwin": "sha256-TmUPGDCewjsrT13npVH6B55J43NKKut67p/HgPJpQNM=", + "x86_64-darwin": "sha256-j8I7t3MZoUQUMFRWyaFO75TRbAw5TauSZAa4yKOHFMA=" } } From ec30ff9120ade9032c94f899e10274fd45d4ec93 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 14:49:30 -0400 Subject: [PATCH 110/188] test(agent): migrate agent tests to Effect runner (#27118) --- packages/opencode/test/agent/agent.test.ts | 1038 +++++++++----------- 1 file changed, 478 insertions(+), 560 deletions(-) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index df68fdfdc..7fd489150 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -1,12 +1,15 @@ -import { afterEach, test, expect } from "bun:test" -import { Effect } from "effect" +import { afterEach, expect } from "bun:test" +import { Cause, Effect, Exit } from "effect" import path from "path" -import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" -import { WithInstance } from "../../src/project/with-instance" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" import { Agent } from "../../src/agent/agent" import { Global } from "@opencode-ai/core/global" import { Flag } from "@opencode-ai/core/flag/flag" import { Permission } from "../../src/permission" +import { Truncate } from "../../src/tool/truncate" + +const it = testEffect(Agent.defaultLayer) // Helper to evaluate permission for a tool with wildcard pattern function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined { @@ -14,196 +17,189 @@ function evalPerm(agent: Agent.Info | undefined, permission: string): Permission return Permission.evaluate(permission, "*", agent.permission).action } -function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { - return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer))) +function load(fn: (svc: Agent.Interface) => Effect.Effect) { + return Agent.Service.use(fn) } -async function withExperimentalScout(enabled: boolean, fn: () => Promise) { - const original = Flag.OPENCODE_EXPERIMENTAL_SCOUT - Flag.OPENCODE_EXPERIMENTAL_SCOUT = enabled - try { - await fn() - } finally { - Flag.OPENCODE_EXPERIMENTAL_SCOUT = original - } +function withExperimentalScout(enabled: boolean, self: Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const original = Flag.OPENCODE_EXPERIMENTAL_SCOUT + Flag.OPENCODE_EXPERIMENTAL_SCOUT = enabled + return original + }), + () => self, + (original) => + Effect.sync(() => { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = original + }), + ) } +const expectDefaultAgentError = Effect.fn("AgentTest.expectDefaultAgentError")(function* (message: string) { + const exit = yield* load((svc) => svc.defaultAgent()).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain(message) +}) + afterEach(async () => { await disposeAllInstances() }) -test("returns default native agents when no config", async () => { - await withExperimentalScout(false, async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agents = await load(tmp.path, (svc) => svc.list()) - const names = agents.map((a) => a.name) - expect(names).toContain("build") - expect(names).toContain("plan") - expect(names).toContain("general") - expect(names).toContain("explore") - expect(names).not.toContain("scout") - expect(names).toContain("compaction") - expect(names).toContain("title") - expect(names).toContain("summary") - }, - }) - }) -}) +it.instance("returns default native agents when no config", () => + withExperimentalScout( + false, + Effect.gen(function* () { + const agents = yield* load((svc) => svc.list()) + const names = agents.map((a) => a.name) + expect(names).toContain("build") + expect(names).toContain("plan") + expect(names).toContain("general") + expect(names).toContain("explore") + expect(names).not.toContain("scout") + expect(names).toContain("compaction") + expect(names).toContain("title") + expect(names).toContain("summary") + }), + ), +) -test("build agent has correct default properties", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(build).toBeDefined() - expect(build?.mode).toBe("primary") - expect(build?.native).toBe(true) - expect(evalPerm(build, "edit")).toBe("allow") - expect(evalPerm(build, "bash")).toBe("allow") - expect(evalPerm(build, "repo_clone")).toBe("deny") - expect(evalPerm(build, "repo_overview")).toBe("deny") - }, - }) -}) +it.instance("build agent has correct default properties", () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build).toBeDefined() + expect(build?.mode).toBe("primary") + expect(build?.native).toBe(true) + expect(evalPerm(build, "edit")).toBe("allow") + expect(evalPerm(build, "bash")).toBe("allow") + expect(evalPerm(build, "repo_clone")).toBe("deny") + expect(evalPerm(build, "repo_overview")).toBe("deny") + }), +) -test("plan agent denies edits except .opencode/plans/*", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const plan = await load(tmp.path, (svc) => svc.get("plan")) - expect(plan).toBeDefined() - // Wildcard is denied - expect(evalPerm(plan, "edit")).toBe("deny") - // But specific path is allowed - expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow") - }, - }) -}) +it.instance("plan agent denies edits except .opencode/plans/*", () => + Effect.gen(function* () { + const plan = yield* load((svc) => svc.get("plan")) + expect(plan).toBeDefined() + // Wildcard is denied + expect(evalPerm(plan, "edit")).toBe("deny") + // But specific path is allowed + expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow") + }), +) -test("explore agent denies edit and write", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const explore = await load(tmp.path, (svc) => svc.get("explore")) - expect(explore).toBeDefined() - expect(explore?.mode).toBe("subagent") - expect(evalPerm(explore, "edit")).toBe("deny") - expect(evalPerm(explore, "write")).toBe("deny") - expect(evalPerm(explore, "todowrite")).toBe("deny") - }, - }) -}) +it.instance("explore agent denies edit and write", () => + Effect.gen(function* () { + const explore = yield* load((svc) => svc.get("explore")) + expect(explore).toBeDefined() + expect(explore?.mode).toBe("subagent") + expect(evalPerm(explore, "edit")).toBe("deny") + expect(evalPerm(explore, "write")).toBe("deny") + expect(evalPerm(explore, "todowrite")).toBe("deny") + }), +) -test("explore agent asks for external directories and allows whitelisted external paths", async () => { - const { Truncate } = await import("../../src/tool/truncate") - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const explore = await load(tmp.path, (svc) => svc.get("explore")) - expect(explore).toBeDefined() - expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") - expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") +it.instance("explore agent asks for external directories and allows whitelisted external paths", () => + Effect.gen(function* () { + const explore = yield* load((svc) => svc.get("explore")) + expect(explore).toBeDefined() + expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") + expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") + expect(Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action).toBe( + "allow", + ) + }), +) + +it.instance("scout agent allows repo cloning and repo cache reads", () => + withExperimentalScout( + true, + Effect.gen(function* () { + const scout = yield* load((svc) => svc.get("scout")) + expect(scout).toBeDefined() + expect(scout?.mode).toBe("subagent") + expect(evalPerm(scout, "repo_clone")).toBe("allow") + expect(evalPerm(scout, "repo_overview")).toBe("allow") + expect(evalPerm(scout, "edit")).toBe("deny") expect( - Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action, + Permission.evaluate( + "external_directory", + path.join(Global.Path.repos, "github.com", "owner", "repo", "README.md"), + scout!.permission, + ).action, ).toBe("allow") - }, - }) -}) + }), + ), +) -test("scout agent allows repo cloning and repo cache reads", async () => { - await withExperimentalScout(true, async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const scout = await load(tmp.path, (svc) => svc.get("scout")) - expect(scout).toBeDefined() - expect(scout?.mode).toBe("subagent") - expect(evalPerm(scout, "repo_clone")).toBe("allow") - expect(evalPerm(scout, "repo_overview")).toBe("allow") - expect(evalPerm(scout, "edit")).toBe("deny") - expect( - Permission.evaluate( - "external_directory", - path.join(Global.Path.repos, "github.com", "owner", "repo", "README.md"), - scout!.permission, - ).action, - ).toBe("allow") - }, - }) - }) -}) - -test("reference config does not create subagents", async () => { - await withExperimentalScout(true, async () => { - await using tmp = await tmpdir({ - config: { - reference: { - effect: "github.com/effect/effect-smol", - effectFull: { - repository: "Effect-TS/effect", - branch: "main", - }, - localdocs: "../docs", - localdocsFull: { - path: "../local-docs", - }, - }, - }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agents = await load(tmp.path, (svc) => svc.list()) +it.instance( + "reference config does not create subagents", + () => + withExperimentalScout( + true, + Effect.gen(function* () { + const agents = yield* load((svc) => svc.list()) const names = agents.map((agent) => agent.name) expect(names).toContain("scout") expect(names).not.toContain("effect") expect(names).not.toContain("effectFull") expect(names).not.toContain("localdocs") expect(names).not.toContain("localdocsFull") + }), + ), + { + config: { + reference: { + effect: "github.com/effect/effect-smol", + effectFull: { + repository: "Effect-TS/effect", + branch: "main", + }, + localdocs: "../docs", + localdocsFull: { + path: "../local-docs", + }, }, - }) - }) -}) - -test("general agent denies todo tools", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const general = await load(tmp.path, (svc) => svc.get("general")) - expect(general).toBeDefined() - expect(general?.mode).toBe("subagent") - expect(general?.hidden).toBeUndefined() - expect(evalPerm(general, "todowrite")).toBe("deny") }, - }) -}) + }, +) -test("compaction agent denies all permissions", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const compaction = await load(tmp.path, (svc) => svc.get("compaction")) - expect(compaction).toBeDefined() - expect(compaction?.hidden).toBe(true) - expect(evalPerm(compaction, "bash")).toBe("deny") - expect(evalPerm(compaction, "edit")).toBe("deny") - expect(evalPerm(compaction, "read")).toBe("deny") - }, - }) -}) +it.instance("general agent denies todo tools", () => + Effect.gen(function* () { + const general = yield* load((svc) => svc.get("general")) + expect(general).toBeDefined() + expect(general?.mode).toBe("subagent") + expect(general?.hidden).toBeUndefined() + expect(evalPerm(general, "todowrite")).toBe("deny") + }), +) -test("custom agent from config creates new agent", async () => { - await using tmp = await tmpdir({ +it.instance("compaction agent denies all permissions", () => + Effect.gen(function* () { + const compaction = yield* load((svc) => svc.get("compaction")) + expect(compaction).toBeDefined() + expect(compaction?.hidden).toBe(true) + expect(evalPerm(compaction, "bash")).toBe("deny") + expect(evalPerm(compaction, "edit")).toBe("deny") + expect(evalPerm(compaction, "read")).toBe("deny") + }), +) + +it.instance( + "custom agent from config creates new agent", + () => + Effect.gen(function* () { + const custom = yield* load((svc) => svc.get("my_custom_agent")) + expect(custom).toBeDefined() + expect(String(custom?.model?.providerID)).toBe("openai") + expect(String(custom?.model?.modelID)).toBe("gpt-4") + expect(custom?.description).toBe("My custom agent") + expect(custom?.temperature).toBe(0.5) + expect(custom?.topP).toBe(0.9) + expect(custom?.native).toBe(false) + expect(custom?.mode).toBe("all") + }), + { config: { agent: { my_custom_agent: { @@ -214,25 +210,23 @@ test("custom agent from config creates new agent", async () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const custom = await load(tmp.path, (svc) => svc.get("my_custom_agent")) - expect(custom).toBeDefined() - expect(String(custom?.model?.providerID)).toBe("openai") - expect(String(custom?.model?.modelID)).toBe("gpt-4") - expect(custom?.description).toBe("My custom agent") - expect(custom?.temperature).toBe(0.5) - expect(custom?.topP).toBe(0.9) - expect(custom?.native).toBe(false) - expect(custom?.mode).toBe("all") - }, - }) -}) + }, +) -test("custom agent config overrides native agent properties", async () => { - await using tmp = await tmpdir({ +it.instance( + "custom agent config overrides native agent properties", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build).toBeDefined() + expect(String(build?.model?.providerID)).toBe("anthropic") + expect(String(build?.model?.modelID)).toBe("claude-3") + expect(build?.description).toBe("Custom build agent") + expect(build?.temperature).toBe(0.7) + expect(build?.color).toBe("#FF0000") + expect(build?.native).toBe(true) + }), + { config: { agent: { build: { @@ -243,44 +237,40 @@ test("custom agent config overrides native agent properties", async () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(build).toBeDefined() - expect(String(build?.model?.providerID)).toBe("anthropic") - expect(String(build?.model?.modelID)).toBe("claude-3") - expect(build?.description).toBe("Custom build agent") - expect(build?.temperature).toBe(0.7) - expect(build?.color).toBe("#FF0000") - expect(build?.native).toBe(true) - }, - }) -}) + }, +) -test("agent disable removes agent from list", async () => { - await using tmp = await tmpdir({ +it.instance( + "agent disable removes agent from list", + () => + Effect.gen(function* () { + const explore = yield* load((svc) => svc.get("explore")) + expect(explore).toBeUndefined() + const agents = yield* load((svc) => svc.list()) + const names = agents.map((a) => a.name) + expect(names).not.toContain("explore") + }), + { config: { agent: { explore: { disable: true }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const explore = await load(tmp.path, (svc) => svc.get("explore")) - expect(explore).toBeUndefined() - const agents = await load(tmp.path, (svc) => svc.list()) - const names = agents.map((a) => a.name) - expect(names).not.toContain("explore") - }, - }) -}) + }, +) -test("agent permission config merges with defaults", async () => { - await using tmp = await tmpdir({ +it.instance( + "agent permission config merges with defaults", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build).toBeDefined() + // Specific pattern is denied + expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny") + // Edit still allowed + expect(evalPerm(build, "edit")).toBe("allow") + }), + { config: { agent: { build: { @@ -292,111 +282,102 @@ test("agent permission config merges with defaults", async () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(build).toBeDefined() - // Specific pattern is denied - expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny") - // Edit still allowed - expect(evalPerm(build, "edit")).toBe("allow") - }, - }) -}) + }, +) -test("global permission config applies to all agents", async () => { - await using tmp = await tmpdir({ +it.instance( + "global permission config applies to all agents", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build).toBeDefined() + expect(evalPerm(build, "bash")).toBe("deny") + }), + { config: { permission: { bash: "deny", }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(build).toBeDefined() - expect(evalPerm(build, "bash")).toBe("deny") - }, - }) -}) + }, +) -test("agent steps/maxSteps config sets steps property", async () => { - await using tmp = await tmpdir({ +it.instance( + "agent steps/maxSteps config sets steps property", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + const plan = yield* load((svc) => svc.get("plan")) + expect(build?.steps).toBe(50) + expect(plan?.steps).toBe(100) + }), + { config: { agent: { build: { steps: 50 }, plan: { maxSteps: 100 }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - const plan = await load(tmp.path, (svc) => svc.get("plan")) - expect(build?.steps).toBe(50) - expect(plan?.steps).toBe(100) - }, - }) -}) + }, +) -test("agent mode can be overridden", async () => { - await using tmp = await tmpdir({ +it.instance( + "agent mode can be overridden", + () => + Effect.gen(function* () { + const explore = yield* load((svc) => svc.get("explore")) + expect(explore?.mode).toBe("primary") + }), + { config: { agent: { explore: { mode: "primary" }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const explore = await load(tmp.path, (svc) => svc.get("explore")) - expect(explore?.mode).toBe("primary") - }, - }) -}) + }, +) -test("agent name can be overridden", async () => { - await using tmp = await tmpdir({ +it.instance( + "agent name can be overridden", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build?.name).toBe("Builder") + }), + { config: { agent: { build: { name: "Builder" }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(build?.name).toBe("Builder") - }, - }) -}) + }, +) -test("agent prompt can be set from config", async () => { - await using tmp = await tmpdir({ +it.instance( + "agent prompt can be set from config", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build?.prompt).toBe("Custom system prompt") + }), + { config: { agent: { build: { prompt: "Custom system prompt" }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(build?.prompt).toBe("Custom system prompt") - }, - }) -}) + }, +) -test("unknown agent properties are placed into options", async () => { - await using tmp = await tmpdir({ +it.instance( + "unknown agent properties are placed into options", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build?.options.random_property).toBe("hello") + expect(build?.options.another_random).toBe(123) + }), + { config: { agent: { build: { @@ -405,19 +386,18 @@ test("unknown agent properties are placed into options", async () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(build?.options.random_property).toBe("hello") - expect(build?.options.another_random).toBe(123) - }, - }) -}) + }, +) -test("agent options merge correctly", async () => { - await using tmp = await tmpdir({ +it.instance( + "agent options merge correctly", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build?.options.custom_option).toBe(true) + expect(build?.options.another_option).toBe("value") + }), + { config: { agent: { build: { @@ -428,19 +408,21 @@ test("agent options merge correctly", async () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(build?.options.custom_option).toBe(true) - expect(build?.options.another_option).toBe("value") - }, - }) -}) + }, +) -test("multiple custom agents can be defined", async () => { - await using tmp = await tmpdir({ +it.instance( + "multiple custom agents can be defined", + () => + Effect.gen(function* () { + const agentA = yield* load((svc) => svc.get("agent_a")) + const agentB = yield* load((svc) => svc.get("agent_b")) + expect(agentA?.description).toBe("Agent A") + expect(agentA?.mode).toBe("subagent") + expect(agentB?.description).toBe("Agent B") + expect(agentB?.mode).toBe("primary") + }), + { config: { agent: { agent_a: { @@ -453,22 +435,18 @@ test("multiple custom agents can be defined", async () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agentA = await load(tmp.path, (svc) => svc.get("agent_a")) - const agentB = await load(tmp.path, (svc) => svc.get("agent_b")) - expect(agentA?.description).toBe("Agent A") - expect(agentA?.mode).toBe("subagent") - expect(agentB?.description).toBe("Agent B") - expect(agentB?.mode).toBe("primary") - }, - }) -}) + }, +) -test("Agent.list keeps the default agent first and sorts the rest by name", async () => { - await using tmp = await tmpdir({ +it.instance( + "Agent.list keeps the default agent first and sorts the rest by name", + () => + Effect.gen(function* () { + const names = (yield* load((svc) => svc.list())).map((a) => a.name) + expect(names[0]).toBe("plan") + expect(names.slice(1)).toEqual(names.slice(1).toSorted((a, b) => a.localeCompare(b))) + }), + { config: { default_agent: "plan", agent: { @@ -482,53 +460,40 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const names = (await load(tmp.path, (svc) => svc.list())).map((a) => a.name) - expect(names[0]).toBe("plan") - expect(names.slice(1)).toEqual(names.slice(1).toSorted((a, b) => a.localeCompare(b))) - }, - }) -}) + }, +) -test("Agent.get returns undefined for non-existent agent", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const nonExistent = await load(tmp.path, (svc) => svc.get("does_not_exist")) - expect(nonExistent).toBeUndefined() - }, - }) -}) +it.instance("Agent.get returns undefined for non-existent agent", () => + Effect.gen(function* () { + const nonExistent = yield* load((svc) => svc.get("does_not_exist")) + expect(nonExistent).toBeUndefined() + }), +) -test("default permission includes doom_loop and external_directory as ask", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(evalPerm(build, "doom_loop")).toBe("ask") - expect(evalPerm(build, "external_directory")).toBe("ask") - }, - }) -}) +it.instance("default permission includes doom_loop and external_directory as ask", () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(evalPerm(build, "doom_loop")).toBe("ask") + expect(evalPerm(build, "external_directory")).toBe("ask") + }), +) -test("webfetch is allowed by default", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(evalPerm(build, "webfetch")).toBe("allow") - }, - }) -}) +it.instance("webfetch is allowed by default", () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(evalPerm(build, "webfetch")).toBe("allow") + }), +) -test("legacy tools config converts to permissions", async () => { - await using tmp = await tmpdir({ +it.instance( + "legacy tools config converts to permissions", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(evalPerm(build, "bash")).toBe("deny") + expect(evalPerm(build, "read")).toBe("deny") + }), + { config: { agent: { build: { @@ -539,19 +504,17 @@ test("legacy tools config converts to permissions", async () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(evalPerm(build, "bash")).toBe("deny") - expect(evalPerm(build, "read")).toBe("deny") - }, - }) -}) + }, +) -test("legacy tools config maps write/edit/patch to edit permission", async () => { - await using tmp = await tmpdir({ +it.instance( + "legacy tools config maps write/edit/patch to edit permission", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(evalPerm(build, "edit")).toBe("deny") + }), + { config: { agent: { build: { @@ -561,53 +524,47 @@ test("legacy tools config maps write/edit/patch to edit permission", async () => }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(evalPerm(build, "edit")).toBe("deny") - }, - }) -}) + }, +) -test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => { - const { Truncate } = await import("../../src/tool/truncate") - await using tmp = await tmpdir({ +it.instance( + "Truncate.GLOB is allowed even when user denies external_directory globally", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") + expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") + }), + { config: { permission: { external_directory: "deny", }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) + }, +) + +it.instance("global tmp directory children are allowed for external_directory", () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action).toBe( + "allow", + ) + expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("ask") + }), +) + +it.instance( + "Truncate.GLOB is allowed even when user denies external_directory per-agent", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") - }, - }) -}) - -test("global tmp directory children are allowed for external_directory", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect( - Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action, - ).toBe("allow") - expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("ask") - }, - }) -}) - -test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => { - const { Truncate } = await import("../../src/tool/truncate") - await using tmp = await tmpdir({ + }), + { config: { agent: { build: { @@ -617,21 +574,18 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") - expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") - expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") - }, - }) -}) + }, +) -test("explicit Truncate.GLOB deny is respected", async () => { - const { Truncate } = await import("../../src/tool/truncate") - await using tmp = await tmpdir({ +it.instance( + "explicit Truncate.GLOB deny is respected", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") + }), + { config: { permission: { external_directory: { @@ -640,81 +594,72 @@ test("explicit Truncate.GLOB deny is respected", async () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny") - expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") - }, - }) -}) + }, +) -test("skill directories are allowed for external_directory", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - const skillDir = path.join(dir, ".opencode", "skill", "perm-skill") - await Bun.write( - path.join(skillDir, "SKILL.md"), - `--- +it.instance( + "skill directories are allowed for external_directory", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const skillDir = path.join(test.directory, ".opencode", "skill", "perm-skill") + yield* Effect.promise(() => + Bun.write( + path.join(skillDir, "SKILL.md"), + `--- name: perm-skill description: Permission skill. --- # Permission Skill `, + ), ) - }, - }) - const home = process.env.OPENCODE_TEST_HOME - process.env.OPENCODE_TEST_HOME = tmp.path + const home = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = test.directory + yield* Effect.addFinalizer(() => + Effect.sync(() => { + process.env.OPENCODE_TEST_HOME = home + }), + ) - try { - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill") - const target = path.join(skillDir, "reference", "notes.md") - expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow") - }, - }) - } finally { - process.env.OPENCODE_TEST_HOME = home - } -}) + const build = yield* load((svc) => svc.get("build")) + const target = path.join(skillDir, "reference", "notes.md") + expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow") + }), + { git: true }, +) -test("defaultAgent returns build when no default_agent config", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agent = await load(tmp.path, (svc) => svc.defaultAgent()) - expect(agent).toBe("build") - }, - }) -}) +it.instance("defaultAgent returns build when no default_agent config", () => + Effect.gen(function* () { + const agent = yield* load((svc) => svc.defaultAgent()) + expect(agent).toBe("build") + }), +) -test("defaultAgent respects default_agent config set to plan", async () => { - await using tmp = await tmpdir({ +it.instance( + "defaultAgent respects default_agent config set to plan", + () => + Effect.gen(function* () { + const agent = yield* load((svc) => svc.defaultAgent()) + expect(agent).toBe("plan") + }), + { config: { default_agent: "plan", }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agent = await load(tmp.path, (svc) => svc.defaultAgent()) - expect(agent).toBe("plan") - }, - }) -}) + }, +) -test("defaultAgent respects default_agent config set to custom agent with mode all", async () => { - await using tmp = await tmpdir({ +it.instance( + "defaultAgent respects default_agent config set to custom agent with mode all", + () => + Effect.gen(function* () { + const agent = yield* load((svc) => svc.defaultAgent()) + expect(agent).toBe("my_custom") + }), + { config: { default_agent: "my_custom", agent: { @@ -723,92 +668,65 @@ test("defaultAgent respects default_agent config set to custom agent with mode a }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agent = await load(tmp.path, (svc) => svc.defaultAgent()) - expect(agent).toBe("my_custom") - }, - }) -}) + }, +) -test("defaultAgent throws when default_agent points to subagent", async () => { - await using tmp = await tmpdir({ +it.instance( + "defaultAgent throws when default_agent points to subagent", + () => expectDefaultAgentError('default agent "explore" is a subagent'), + { config: { default_agent: "explore", }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "explore" is a subagent') - }, - }) -}) + }, +) -test("defaultAgent throws when default_agent points to hidden agent", async () => { - await using tmp = await tmpdir({ +it.instance( + "defaultAgent throws when default_agent points to hidden agent", + () => expectDefaultAgentError('default agent "compaction" is hidden'), + { config: { default_agent: "compaction", }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "compaction" is hidden') - }, - }) -}) + }, +) -test("defaultAgent throws when default_agent points to non-existent agent", async () => { - await using tmp = await tmpdir({ +it.instance( + "defaultAgent throws when default_agent points to non-existent agent", + () => expectDefaultAgentError('default agent "does_not_exist" not found'), + { config: { default_agent: "does_not_exist", }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow( - 'default agent "does_not_exist" not found', - ) - }, - }) -}) + }, +) -test("defaultAgent returns plan when build is disabled and default_agent not set", async () => { - await using tmp = await tmpdir({ +it.instance( + "defaultAgent returns plan when build is disabled and default_agent not set", + () => + Effect.gen(function* () { + const agent = yield* load((svc) => svc.defaultAgent()) + // build is disabled, so it should return plan (next primary agent) + expect(agent).toBe("plan") + }), + { config: { agent: { build: { disable: true }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agent = await load(tmp.path, (svc) => svc.defaultAgent()) - // build is disabled, so it should return plan (next primary agent) - expect(agent).toBe("plan") - }, - }) -}) + }, +) -test("defaultAgent throws when all primary agents are disabled", async () => { - await using tmp = await tmpdir({ +it.instance( + "defaultAgent throws when all primary agents are disabled", + () => expectDefaultAgentError("no primary visible agent found"), + { config: { agent: { build: { disable: true }, plan: { disable: true }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - // build and plan are disabled, no primary-capable agents remain - await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow("no primary visible agent found") - }, - }) -}) + }, +) From b668af29dd7c2117ed4e5868a6d2f1d73237fbcb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 14:50:07 -0400 Subject: [PATCH 111/188] test(git): migrate git tests to Effect runner (#27121) --- packages/opencode/test/git/git.test.ts | 199 +++++++++++++------------ 1 file changed, 101 insertions(+), 98 deletions(-) diff --git a/packages/opencode/test/git/git.test.ts b/packages/opencode/test/git/git.test.ts index 1e56865d7..e80b8fa90 100644 --- a/packages/opencode/test/git/git.test.ts +++ b/packages/opencode/test/git/git.test.ts @@ -1,71 +1,70 @@ import { $ } from "bun" -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" -import { ManagedRuntime } from "effect" +import { Effect } from "effect" import { Git } from "../../src/git" import { tmpdir } from "../fixture/fixture" +import { testEffect } from "../lib/effect" const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" +const it = testEffect(Git.defaultLayer) -async function withGit(body: (rt: ManagedRuntime.ManagedRuntime) => Promise) { - const rt = ManagedRuntime.make(Git.defaultLayer) - try { - return await body(rt) - } finally { - await rt.dispose() - } -} +const scopedTmpdir = (options?: Parameters[0]) => + Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) describe("Git", () => { - test("branch() returns current branch name", async () => { - await using tmp = await tmpdir({ git: true }) - - await withGit(async (rt) => { - const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path))) + it.live("branch() returns current branch name", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + const git = yield* Git.Service + const branch = yield* git.branch(tmp.path) expect(branch).toBeDefined() expect(typeof branch).toBe("string") - }) - }) + }), + ) - test("branch() returns undefined for non-git directories", async () => { - await using tmp = await tmpdir() - - await withGit(async (rt) => { - const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path))) + it.live("branch() returns undefined for non-git directories", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir() + const git = yield* Git.Service + const branch = yield* git.branch(tmp.path) expect(branch).toBeUndefined() - }) - }) + }), + ) - test("branch() returns undefined for detached HEAD", async () => { - await using tmp = await tmpdir({ git: true }) - const hash = (await $`git rev-parse HEAD`.cwd(tmp.path).quiet().text()).trim() - await $`git checkout --detach ${hash}`.cwd(tmp.path).quiet() - - await withGit(async (rt) => { - const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path))) + it.live("branch() returns undefined for detached HEAD", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + const hash = (yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(tmp.path).quiet().text())).trim() + yield* Effect.promise(() => $`git checkout --detach ${hash}`.cwd(tmp.path).quiet()) + const git = yield* Git.Service + const branch = yield* git.branch(tmp.path) expect(branch).toBeUndefined() - }) - }) + }), + ) - test("defaultBranch() uses init.defaultBranch when available", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M trunk`.cwd(tmp.path).quiet() - await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet() - - await withGit(async (rt) => { - const branch = await rt.runPromise(Git.Service.use((git) => git.defaultBranch(tmp.path))) + it.live("defaultBranch() uses init.defaultBranch when available", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(() => $`git branch -M trunk`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()) + const git = yield* Git.Service + const branch = yield* git.defaultBranch(tmp.path) expect(branch?.name).toBe("trunk") expect(branch?.ref).toBe("trunk") - }) - }) + }), + ) - test("status() handles special filenames", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8") - - await withGit(async (rt) => { - const status = await rt.runPromise(Git.Service.use((git) => git.status(tmp.path))) + it.live("status() handles special filenames", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")) + const git = yield* Git.Service + const status = yield* git.status(tmp.path) expect(status).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -74,23 +73,24 @@ describe("Git", () => { }), ]), ) - }) - }) + }), + ) - test("diff(), stats(), and mergeBase() parse tracked changes", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M main`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() - await $`git checkout -b feature/test`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8") + it.live("diff(), stats(), and mergeBase() parse tracked changes", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(() => $`git branch -M main`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8")) + yield* Effect.promise(() => $`git add .`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => $`git checkout -b feature/test`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8")) - await withGit(async (rt) => { - const [base, diff, stats] = await Promise.all([ - rt.runPromise(Git.Service.use((git) => git.mergeBase(tmp.path, "main"))), - rt.runPromise(Git.Service.use((git) => git.diff(tmp.path, "HEAD"))), - rt.runPromise(Git.Service.use((git) => git.stats(tmp.path, "HEAD"))), + const git = yield* Git.Service + const [base, diff, stats] = yield* Effect.all([ + git.mergeBase(tmp.path, "main"), + git.diff(tmp.path, "HEAD"), + git.stats(tmp.path, "HEAD"), ]) expect(base).toBeTruthy() @@ -111,23 +111,24 @@ describe("Git", () => { }), ]), ) - }) - }) + }), + ) - test("patch() returns capped native patch output", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8") - await fs.writeFile(path.join(tmp.path, "other.txt"), "old\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8") - await fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8") + it.live("patch() returns capped native patch output", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8")) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "other.txt"), "old\n", "utf-8")) + yield* Effect.promise(() => $`git add .`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8")) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8")) - await withGit(async (rt) => { - const [patch, all, capped] = await Promise.all([ - rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }))), - rt.runPromise(Git.Service.use((git) => git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }))), - rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }))), + const git = yield* Git.Service + const [patch, all, capped] = yield* Effect.all([ + git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }), + git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }), + git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }), ]) expect(patch.truncated).toBe(false) @@ -140,17 +141,18 @@ describe("Git", () => { expect(all.text).toContain("+new") expect(capped.truncated).toBe(true) expect(capped.text).toBe("") - }) - }) + }), + ) - test("patchUntracked() and statUntracked() handle added files", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8") + it.live("patchUntracked() and statUntracked() handle added files", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8")) - await withGit(async (rt) => { - const [patch, stat] = await Promise.all([ - rt.runPromise(Git.Service.use((git) => git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }))), - rt.runPromise(Git.Service.use((git) => git.statUntracked(tmp.path, weird))), + const git = yield* Git.Service + const [patch, stat] = yield* Effect.all([ + git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }), + git.statUntracked(tmp.path, weird), ]) expect(patch.truncated).toBe(false) @@ -158,18 +160,19 @@ describe("Git", () => { expect(patch.text).toContain("+one") expect(patch.text).toContain("+two") expect(stat).toEqual(expect.objectContaining({ file: weird, additions: 2, deletions: 0 })) - }) - }) + }), + ) - test("show() returns empty text for binary blobs", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3])) - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet() + it.live("show() returns empty text for binary blobs", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3]))) + yield* Effect.promise(() => $`git add .`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet()) - await withGit(async (rt) => { - const text = await rt.runPromise(Git.Service.use((git) => git.show(tmp.path, "HEAD", "bin.dat"))) + const git = yield* Git.Service + const text = yield* git.show(tmp.path, "HEAD", "bin.dat") expect(text).toBe("") - }) - }) + }), + ) }) From 8f1ded9e0812e5bc1288a850d58290d76715105c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 14:50:26 -0400 Subject: [PATCH 112/188] test(file): migrate ripgrep tests to Effect runner (#27120) --- packages/opencode/test/file/ripgrep.test.ts | 394 ++++++++++---------- 1 file changed, 200 insertions(+), 194 deletions(-) diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts index a76c7ebe2..d71ce205e 100644 --- a/packages/opencode/test/file/ripgrep.test.ts +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -1,214 +1,220 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import { Effect } from "effect" import * as Stream from "effect/Stream" import fs from "fs/promises" +import os from "os" import path from "path" -import { tmpdir } from "../fixture/fixture" import { Ripgrep } from "../../src/file/ripgrep" +import { testEffect } from "../lib/effect" -const run = (effect: Effect.Effect) => - effect.pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise) +const it = testEffect(Ripgrep.defaultLayer) + +const tmpdir = (init?: (dir: string) => Effect.Effect) => + Effect.acquireRelease( + Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))), + (dir) => + Effect.promise(() => + fs.rm(dir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }), + ).pipe(Effect.ignore), + ).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void)) + +const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data)) +const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true })) +const collectFiles = (input: Ripgrep.FilesInput) => + Ripgrep.Service.use((rg) => + rg.files(input).pipe( + Stream.runCollect, + Effect.map((c) => [...c]), + ), + ) + +const withRipgrepConfig = (value: string, effect: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const prev = process.env["RIPGREP_CONFIG_PATH"] + process.env["RIPGREP_CONFIG_PATH"] = value + return prev + }), + () => effect, + (prev) => + Effect.sync(() => { + if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"] + else process.env["RIPGREP_CONFIG_PATH"] = prev + }), + ) describe("file.ripgrep", () => { - test("defaults to include hidden", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "visible.txt"), "hello") - await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) - await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}") - }, - }) + it.live("defaults to include hidden", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => + Effect.gen(function* () { + yield* write(path.join(dir, "visible.txt"), "hello") + yield* mkdir(path.join(dir, ".opencode")) + yield* write(path.join(dir, ".opencode", "thing.json"), "{}") + }), + ) - const files = await run( - Ripgrep.Service.use((rg) => - rg.files({ cwd: tmp.path }).pipe( - Stream.runCollect, - Effect.map((c) => [...c]), - ), - ), - ) - expect(files.includes("visible.txt")).toBe(true) - expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true) - }) + const files = yield* collectFiles({ cwd: dir }) + expect(files.includes("visible.txt")).toBe(true) + expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true) + }), + ) - test("hidden false excludes hidden", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "visible.txt"), "hello") - await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) - await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}") - }, - }) + it.live("hidden false excludes hidden", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => + Effect.gen(function* () { + yield* write(path.join(dir, "visible.txt"), "hello") + yield* mkdir(path.join(dir, ".opencode")) + yield* write(path.join(dir, ".opencode", "thing.json"), "{}") + }), + ) - const files = await run( - Ripgrep.Service.use((rg) => - rg.files({ cwd: tmp.path, hidden: false }).pipe( - Stream.runCollect, - Effect.map((c) => [...c]), - ), - ), - ) - expect(files.includes("visible.txt")).toBe(true) - expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false) - }) + const files = yield* collectFiles({ cwd: dir, hidden: false }) + expect(files.includes("visible.txt")).toBe(true) + expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false) + }), + ) - test("search returns empty when nothing matches", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "match.ts"), "const value = 'other'\n") - }, - }) + it.live("search returns empty when nothing matches", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const value = 'other'\n")) - const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" }))) - expect(result.partial).toBe(false) - expect(result.items).toEqual([]) - }) + const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle" })) + expect(result.partial).toBe(false) + expect(result.items).toEqual([]) + }), + ) - test("search returns match metadata with normalized path", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await fs.mkdir(path.join(dir, "src"), { recursive: true }) - await Bun.write(path.join(dir, "src", "match.ts"), "const needle = 1\n") - }, - }) + it.live("search returns match metadata with normalized path", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => + Effect.gen(function* () { + yield* mkdir(path.join(dir, "src")) + yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n") + }), + ) - const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" }))) - expect(result.partial).toBe(false) - expect(result.items).toHaveLength(1) - expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts")) - expect(result.items[0]?.line_number).toBe(1) - expect(result.items[0]?.lines.text).toContain("needle") - }) - - test("search returns matched rows with glob filter", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n") - await Bun.write(path.join(dir, "skip.txt"), "const value = 'other'\n") - }, - }) - - const result = await run( - Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] })), - ) - expect(result.partial).toBe(false) - expect(result.items).toHaveLength(1) - expect(result.items[0]?.path.text).toContain("match.ts") - expect(result.items[0]?.lines.text).toContain("needle") - }) - - test("search supports explicit file targets", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n") - await Bun.write(path.join(dir, "skip.ts"), "const value = 'needle'\n") - }, - }) - - const file = path.join(tmp.path, "match.ts") - const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle", file: [file] }))) - expect(result.partial).toBe(false) - expect(result.items).toHaveLength(1) - expect(result.items[0]?.path.text).toBe(file) - }) - - test("files returns empty when glob matches no files", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await fs.mkdir(path.join(dir, "packages", "console"), { recursive: true }) - await Bun.write(path.join(dir, "packages", "console", "package.json"), "{}") - }, - }) - - const files = await run( - Ripgrep.Service.use((rg) => - rg.files({ cwd: tmp.path, glob: ["packages/*"] }).pipe( - Stream.runCollect, - Effect.map((c) => [...c]), - ), - ), - ) - expect(files).toEqual([]) - }) - - test("files returns stream of filenames", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "a.txt"), "hello") - await Bun.write(path.join(dir, "b.txt"), "world") - }, - }) - - const files = await run( - Ripgrep.Service.use((rg) => - rg.files({ cwd: tmp.path }).pipe( - Stream.runCollect, - Effect.map((c) => [...c].sort()), - ), - ), - ) - expect(files).toEqual(["a.txt", "b.txt"]) - }) - - test("files respects glob filter", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "keep.ts"), "yes") - await Bun.write(path.join(dir, "skip.txt"), "no") - }, - }) - - const files = await run( - Ripgrep.Service.use((rg) => - rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe( - Stream.runCollect, - Effect.map((c) => [...c]), - ), - ), - ) - expect(files).toEqual(["keep.ts"]) - }) - - test("files dies on nonexistent directory", async () => { - const exit = await Ripgrep.Service.use((rg) => - rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect), - ).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit) - expect(exit._tag).toBe("Failure") - }) - - test("ignores RIPGREP_CONFIG_PATH in direct mode", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n") - }, - }) - - const prev = process.env["RIPGREP_CONFIG_PATH"] - process.env["RIPGREP_CONFIG_PATH"] = path.join(tmp.path, "missing-ripgreprc") - try { - const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" }))) + const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle" })) + expect(result.partial).toBe(false) expect(result.items).toHaveLength(1) - } finally { - if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"] - else process.env["RIPGREP_CONFIG_PATH"] = prev - } - }) + expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts")) + expect(result.items[0]?.line_number).toBe(1) + expect(result.items[0]?.lines.text).toContain("needle") + }), + ) - test("ignores RIPGREP_CONFIG_PATH in worker mode", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n") - }, - }) + it.live("search returns matched rows with glob filter", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => + Effect.gen(function* () { + yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n") + yield* write(path.join(dir, "skip.txt"), "const value = 'other'\n") + }), + ) - const prev = process.env["RIPGREP_CONFIG_PATH"] - process.env["RIPGREP_CONFIG_PATH"] = path.join(tmp.path, "missing-ripgreprc") - try { - const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" }))) + const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle", glob: ["*.ts"] })) + expect(result.partial).toBe(false) expect(result.items).toHaveLength(1) - } finally { - if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"] - else process.env["RIPGREP_CONFIG_PATH"] = prev - } - }) + expect(result.items[0]?.path.text).toContain("match.ts") + expect(result.items[0]?.lines.text).toContain("needle") + }), + ) + + it.live("search supports explicit file targets", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => + Effect.gen(function* () { + yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n") + yield* write(path.join(dir, "skip.ts"), "const value = 'needle'\n") + }), + ) + + const file = path.join(dir, "match.ts") + const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle", file: [file] })) + expect(result.partial).toBe(false) + expect(result.items).toHaveLength(1) + expect(result.items[0]?.path.text).toBe(file) + }), + ) + + it.live("files returns empty when glob matches no files", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => + Effect.gen(function* () { + yield* mkdir(path.join(dir, "packages", "console")) + yield* write(path.join(dir, "packages", "console", "package.json"), "{}") + }), + ) + + const files = yield* collectFiles({ cwd: dir, glob: ["packages/*"] }) + expect(files).toEqual([]) + }), + ) + + it.live("files returns stream of filenames", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => + Effect.gen(function* () { + yield* write(path.join(dir, "a.txt"), "hello") + yield* write(path.join(dir, "b.txt"), "world") + }), + ) + + const files = yield* collectFiles({ cwd: dir }).pipe(Effect.map((files) => files.sort())) + expect(files).toEqual(["a.txt", "b.txt"]) + }), + ) + + it.live("files respects glob filter", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => + Effect.gen(function* () { + yield* write(path.join(dir, "keep.ts"), "yes") + yield* write(path.join(dir, "skip.txt"), "no") + }), + ) + + const files = yield* collectFiles({ cwd: dir, glob: ["*.ts"] }) + expect(files).toEqual(["keep.ts"]) + }), + ) + + it.live("files dies on nonexistent directory", () => + Effect.gen(function* () { + const exit = yield* Ripgrep.Service.use((rg) => + rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect), + ).pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + }), + ) + + it.live("ignores RIPGREP_CONFIG_PATH in direct mode", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n")) + + const result = yield* withRipgrepConfig( + path.join(dir, "missing-ripgreprc"), + Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle" })), + ) + expect(result.items).toHaveLength(1) + }), + ) + + it.live("ignores RIPGREP_CONFIG_PATH in worker mode", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n")) + + const result = yield* withRipgrepConfig( + path.join(dir, "missing-ripgreprc"), + Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle" })), + ) + expect(result.items).toHaveLength(1) + }), + ) }) From 3c7569d852a07e245357eaaf73c4399765c5b68e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 14:50:56 -0400 Subject: [PATCH 113/188] test(tool): migrate external directory tests to Effect runner (#27122) --- .../test/tool/external-directory.test.ts | 201 ++++++++---------- 1 file changed, 92 insertions(+), 109 deletions(-) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 0560ea030..7585ff98f 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -1,14 +1,16 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import path from "path" import { Effect } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import type { Tool } from "@/tool/tool" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" -import { assertExternalDirectory } from "../../src/tool/external-directory" +import { assertExternalDirectoryEffect } from "../../src/tool/external-directory" import { Filesystem } from "@/util/filesystem" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import type { Permission } from "../../src/permission" import { SessionID, MessageID } from "../../src/session/schema" +import { testEffect } from "../lib/effect" + +const it = testEffect(CrossSpawnSpawner.defaultLayer) const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), @@ -36,135 +38,116 @@ function makeCtx() { } describe("tool.assertExternalDirectory", () => { - test("no-ops for empty target", async () => { - const { requests, ctx } = makeCtx() + it.live("no-ops for empty target", () => + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - await WithInstance.provide({ - directory: "/tmp", - fn: async () => { - await assertExternalDirectory(ctx) - }, - }) + yield* assertExternalDirectoryEffect(ctx) - expect(requests.length).toBe(0) - }) + expect(requests.length).toBe(0) + }), + ) - test("no-ops for paths inside Instance.directory", async () => { - const { requests, ctx } = makeCtx() + it.live("no-ops for paths inside Instance.directory", () => + provideInstance("/tmp/project")( + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - await WithInstance.provide({ - directory: "/tmp/project", - fn: async () => { - await assertExternalDirectory(ctx, path.join("/tmp/project", "file.txt")) - }, - }) + yield* assertExternalDirectoryEffect(ctx, path.join("/tmp/project", "file.txt")) - expect(requests.length).toBe(0) - }) + expect(requests.length).toBe(0) + }), + ), + ) - test("asks with a single canonical glob", async () => { - const { requests, ctx } = makeCtx() + it.live("asks with a single canonical glob", () => + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - const directory = "/tmp/project" - const target = "/tmp/outside/file.txt" - const expected = glob(path.join(path.dirname(target), "*")) + const directory = "/tmp/project" + const target = "/tmp/outside/file.txt" + const expected = glob(path.join(path.dirname(target), "*")) - await WithInstance.provide({ - directory, - fn: async () => { - await assertExternalDirectory(ctx, target) - }, - }) + yield* provideInstance(directory)(assertExternalDirectoryEffect(ctx, target)) - const req = requests.find((r) => r.permission === "external_directory") - expect(req).toBeDefined() - expect(req!.patterns).toEqual([expected]) - expect(req!.always).toEqual([expected]) - }) + const req = requests.find((r) => r.permission === "external_directory") + expect(req).toBeDefined() + expect(req!.patterns).toEqual([expected]) + expect(req!.always).toEqual([expected]) + }), + ) - test("uses target directory when kind=directory", async () => { - const { requests, ctx } = makeCtx() + it.live("uses target directory when kind=directory", () => + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - const directory = "/tmp/project" - const target = "/tmp/outside" - const expected = glob(path.join(target, "*")) + const directory = "/tmp/project" + const target = "/tmp/outside" + const expected = glob(path.join(target, "*")) - await WithInstance.provide({ - directory, - fn: async () => { - await assertExternalDirectory(ctx, target, { kind: "directory" }) - }, - }) + yield* provideInstance(directory)(assertExternalDirectoryEffect(ctx, target, { kind: "directory" })) - const req = requests.find((r) => r.permission === "external_directory") - expect(req).toBeDefined() - expect(req!.patterns).toEqual([expected]) - expect(req!.always).toEqual([expected]) - }) + const req = requests.find((r) => r.permission === "external_directory") + expect(req).toBeDefined() + expect(req!.patterns).toEqual([expected]) + expect(req!.always).toEqual([expected]) + }), + ) - test("skips prompting when bypass=true", async () => { - const { requests, ctx } = makeCtx() + it.live("skips prompting when bypass=true", () => + provideInstance("/tmp/project")( + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - await WithInstance.provide({ - directory: "/tmp/project", - fn: async () => { - await assertExternalDirectory(ctx, "/tmp/outside/file.txt", { bypass: true }) - }, - }) + yield* assertExternalDirectoryEffect(ctx, "/tmp/outside/file.txt", { bypass: true }) - expect(requests.length).toBe(0) - }) + expect(requests.length).toBe(0) + }), + ), + ) if (process.platform === "win32") { - test("normalizes Windows path variants to one glob", async () => { - const { requests, ctx } = makeCtx() + it.instance("normalizes Windows path variants to one glob", () => + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - await using outerTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "outside.txt"), "x") - }, - }) - await using tmp = await tmpdir({ git: true }) + const outerTmp = yield* tmpdirScoped() + yield* Effect.promise(() => Bun.write(path.join(outerTmp, "outside.txt"), "x")) - const target = path.join(outerTmp.path, "outside.txt") - const alt = target - .replace(/^[A-Za-z]:/, "") - .replaceAll("\\", "/") - .toLowerCase() + const target = path.join(outerTmp, "outside.txt") + const alt = target + .replace(/^[A-Za-z]:/, "") + .replaceAll("\\", "/") + .toLowerCase() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await assertExternalDirectory(ctx, alt) - }, - }) + yield* assertExternalDirectoryEffect(ctx, alt) - const req = requests.find((r) => r.permission === "external_directory") - const expected = glob(path.join(outerTmp.path, "*")) - expect(req).toBeDefined() - expect(req!.patterns).toEqual([expected]) - expect(req!.always).toEqual([expected]) - }) + const req = requests.find((r) => r.permission === "external_directory") + const expected = glob(path.join(outerTmp, "*")) + expect(req).toBeDefined() + expect(req!.patterns).toEqual([expected]) + expect(req!.always).toEqual([expected]) + }), + { git: true }, + ) - test("uses drive root glob for root files", async () => { - const { requests, ctx } = makeCtx() + it.instance("uses drive root glob for root files", () => + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - await using tmp = await tmpdir({ git: true }) - const root = path.parse(tmp.path).root - const target = path.join(root, "boot.ini") + const tmp = yield* TestInstance + const root = path.parse(tmp.directory).root + const target = path.join(root, "boot.ini") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await assertExternalDirectory(ctx, target) - }, - }) + yield* assertExternalDirectoryEffect(ctx, target) - const req = requests.find((r) => r.permission === "external_directory") - const expected = path.join(root, "*") - expect(req).toBeDefined() - expect(req!.patterns).toEqual([expected]) - expect(req!.always).toEqual([expected]) - }) + const req = requests.find((r) => r.permission === "external_directory") + const expected = path.join(root, "*") + expect(req).toBeDefined() + expect(req!.patterns).toEqual([expected]) + expect(req!.always).toEqual([expected]) + }), + { git: true }, + ) } }) From 549b146ea6529cc29dd556644543fcb48acf6d45 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 14:51:18 -0400 Subject: [PATCH 114/188] Stabilize session event tests (#27117) --- .../opencode/test/session/session.test.ts | 276 +++++++++--------- 1 file changed, 132 insertions(+), 144 deletions(-) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index bb69e459b..ada55d134 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -1,186 +1,174 @@ -import { describe, expect, test } from "bun:test" -import path from "path" +import { describe, expect } from "bun:test" +import { Deferred, Effect, Exit, Layer } from "effect" import { Session as SessionNs } from "@/session/session" -import { Bus } from "../../src/bus" +import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import * as Log from "@opencode-ai/core/util/log" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" +import { Flag } from "@opencode-ai/core/flag/flag" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" -import { AppRuntime } from "../../src/effect/app-runtime" -import { tmpdir } from "../fixture/fixture" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -const projectRoot = path.join(__dirname, "../..") void Log.init({ print: false }) -function create(input?: SessionNs.CreateInput) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input))) -} +const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer)) -function get(id: SessionID) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id))) -} +const awaitDeferred = (deferred: Deferred.Deferred, message: string) => + Effect.race( + Deferred.await(deferred), + Effect.sleep("2 seconds").pipe(Effect.flatMap(() => Effect.fail(new Error(message)))), + ) -function remove(id: SessionID) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id))) -} +const remove = (id: SessionID) => SessionNs.Service.use((svc) => svc.remove(id)) -function updateMessage(msg: T) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg))) -} - -function updatePart(part: T) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part))) +const subscribeGlobal = (type: string, callback: (event: NonNullable) => void) => { + const listener = (event: GlobalEvent) => { + if (event.payload?.type === type) callback(event.payload) + } + GlobalBus.on("event", listener) + return () => GlobalBus.off("event", listener) } describe("session.created event", () => { - test("should emit session.created event when session is created", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - let eventReceived = false - let receivedInfo: SessionNs.Info | undefined + it.instance("should emit session.created event when session is created", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const received = yield* Deferred.make() - const unsub = Bus.subscribe(SessionNs.Event.Created, (event) => { - eventReceived = true - receivedInfo = event.properties.info as SessionNs.Info - }) + const unsub = subscribeGlobal(SessionNs.Event.Created.type, (event) => { + Deferred.doneUnsafe(received, Effect.succeed(event.properties.info as SessionNs.Info)) + }) + yield* Effect.addFinalizer(() => Effect.sync(unsub)) - const info = await create({}) - await new Promise((resolve) => setTimeout(resolve, 100)) - unsub() + const info = yield* session.create({}) + const receivedInfo = yield* awaitDeferred(received, "timed out waiting for session.created") - expect(eventReceived).toBe(true) - expect(receivedInfo).toBeDefined() - expect(receivedInfo?.id).toBe(info.id) - expect(receivedInfo?.projectID).toBe(info.projectID) - expect(receivedInfo?.directory).toBe(info.directory) - expect(receivedInfo?.path).toBe(info.path) - expect(receivedInfo?.title).toBe(info.title) + expect(receivedInfo.id).toBe(info.id) + expect(receivedInfo.projectID).toBe(info.projectID) + expect(receivedInfo.directory).toBe(info.directory) + expect(receivedInfo.path).toBe(info.path) + expect(receivedInfo.title).toBe(info.title) - await remove(info.id) - }, - }) - }) + yield* session.remove(info.id) + }), + ) - test("session.created event should be emitted before session.updated", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const events: string[] = [] + it.instance("session.created event should be emitted before session.updated", () => + Effect.gen(function* () { + if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return - const unsubCreated = Bus.subscribe(SessionNs.Event.Created, () => { - events.push("created") - }) + const session = yield* SessionNs.Service + const events: string[] = [] + const received = yield* Deferred.make() + const push = (event: string) => { + events.push(event) + if (events.includes("created") && events.includes("updated")) { + Deferred.doneUnsafe(received, Effect.succeed(events)) + } + } - const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, () => { - events.push("updated") - }) + const unsubCreated = subscribeGlobal(SessionNs.Event.Created.type, () => { + push("created") + }) + yield* Effect.addFinalizer(() => Effect.sync(unsubCreated)) - const info = await create({}) - await new Promise((resolve) => setTimeout(resolve, 100)) - unsubCreated() - unsubUpdated() + const unsubUpdated = subscribeGlobal(SessionNs.Event.Updated.type, () => { + push("updated") + }) + yield* Effect.addFinalizer(() => Effect.sync(unsubUpdated)) - expect(events).toContain("created") - expect(events).toContain("updated") - expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated")) + const info = yield* session.create({}) + const receivedEvents = yield* awaitDeferred(received, "timed out waiting for session created/updated events") - await remove(info.id) - }, - }) - }) + expect(receivedEvents).toContain("created") + expect(receivedEvents).toContain("updated") + expect(receivedEvents.indexOf("created")).toBeLessThan(receivedEvents.indexOf("updated")) + + yield* session.remove(info.id) + }), + ) }) describe("step-finish token propagation via Bus event", () => { - test( + it.instance( "non-zero tokens propagate through PartUpdated event", - async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const info = await create({}) + () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const info = yield* session.create({}) - const messageID = MessageID.ascending() - await updateMessage({ - id: messageID, - sessionID: info.id, - role: "user", - time: { created: Date.now() }, - agent: "user", - model: { providerID: "test", modelID: "test" }, - tools: {}, - mode: "", - } as unknown as MessageV2.Info) + const messageID = MessageID.ascending() + yield* session.updateMessage({ + id: messageID, + sessionID: info.id, + role: "user", + time: { created: Date.now() }, + agent: "user", + model: { providerID: "test", modelID: "test" }, + tools: {}, + mode: "", + } as unknown as MessageV2.Info) - // Bus subscribers receive readonly Schema.Type payloads; `MessageV2.Part` - // is the mutable domain type. Cast bridges the two — safe because the - // test only reads the value afterwards. - let received: MessageV2.Part | undefined - const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, (event) => { - received = event.properties.part as MessageV2.Part - }) + // Bus subscribers receive readonly Schema.Type payloads; `MessageV2.Part` + // is the mutable domain type. Cast bridges the two — safe because the + // test only reads the value afterwards. + const received = yield* Deferred.make() + const unsub = subscribeGlobal(MessageV2.Event.PartUpdated.type, (event) => { + Deferred.doneUnsafe(received, Effect.succeed(event.properties.part as MessageV2.Part)) + }) + yield* Effect.addFinalizer(() => Effect.sync(unsub)) - const tokens = { - total: 1500, - input: 500, - output: 800, - reasoning: 200, - cache: { read: 100, write: 50 }, - } + const tokens = { + total: 1500, + input: 500, + output: 800, + reasoning: 200, + cache: { read: 100, write: 50 }, + } - const partInput = { - id: PartID.ascending(), - messageID, - sessionID: info.id, - type: "step-finish" as const, - reason: "stop", - cost: 0.005, - tokens, - } + const partInput = { + id: PartID.ascending(), + messageID, + sessionID: info.id, + type: "step-finish" as const, + reason: "stop", + cost: 0.005, + tokens, + } - await updatePart(partInput) - await new Promise((resolve) => setTimeout(resolve, 100)) + yield* session.updatePart(partInput) + const receivedPart = yield* awaitDeferred(received, "timed out waiting for message.part.updated") - expect(received).toBeDefined() - expect(received!.type).toBe("step-finish") - const finish = received as MessageV2.StepFinishPart - expect(finish.tokens.input).toBe(500) - expect(finish.tokens.output).toBe(800) - expect(finish.tokens.reasoning).toBe(200) - expect(finish.tokens.total).toBe(1500) - expect(finish.tokens.cache.read).toBe(100) - expect(finish.tokens.cache.write).toBe(50) - expect(finish.cost).toBe(0.005) - expect(received).not.toBe(partInput) + expect(receivedPart.type).toBe("step-finish") + const finish = receivedPart as MessageV2.StepFinishPart + expect(finish.tokens.input).toBe(500) + expect(finish.tokens.output).toBe(800) + expect(finish.tokens.reasoning).toBe(200) + expect(finish.tokens.total).toBe(1500) + expect(finish.tokens.cache.read).toBe(100) + expect(finish.tokens.cache.write).toBe(50) + expect(finish.cost).toBe(0.005) + expect(receivedPart).not.toBe(partInput) - unsub() - await remove(info.id) - }, - }) - }, + yield* session.remove(info.id) + }), { timeout: 30000 }, ) }) describe("Session", () => { - test("remove works without an instance", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("remove works without an instance", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const dir = yield* tmpdirScoped({ git: true }) + const info = yield* provideInstance(dir)(session.create({ title: "remove-without-instance" })) - const info = await WithInstance.provide({ - directory: tmp.path, - fn: () => create({ title: "remove-without-instance" }), - }) + const removeExit = yield* remove(info.id).pipe(Effect.exit) + expect(Exit.isSuccess(removeExit)).toBe(true) - await expect(async () => { - await remove(info.id) - }).not.toThrow() - - let missing = false - await get(info.id).catch(() => { - missing = true - }) - - expect(missing).toBe(true) - }) + const getExit = yield* session.get(info.id).pipe(Effect.exit) + expect(Exit.isFailure(getExit)).toBe(true) + }), + ) }) From f3c91c5f96dc959f8a9aab6b4793d116ae83ab8d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 18:52:25 +0000 Subject: [PATCH 115/188] chore: generate --- packages/opencode/test/agent/agent.test.ts | 12 ++-- .../test/tool/external-directory.test.ts | 64 ++++++++++--------- 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 7fd489150..9893ee822 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -105,9 +105,9 @@ it.instance("explore agent asks for external directories and allows whitelisted expect(explore).toBeDefined() expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") - expect(Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action).toBe( - "allow", - ) + expect( + Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action, + ).toBe("allow") }), ) @@ -548,9 +548,9 @@ it.instance( it.instance("global tmp directory children are allowed for external_directory", () => Effect.gen(function* () { const build = yield* load((svc) => svc.get("build")) - expect(Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action).toBe( - "allow", - ) + expect( + Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action, + ).toBe("allow") expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("ask") }), ) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 7585ff98f..04ef5c5d0 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -107,46 +107,50 @@ describe("tool.assertExternalDirectory", () => { ) if (process.platform === "win32") { - it.instance("normalizes Windows path variants to one glob", () => - Effect.gen(function* () { - const { requests, ctx } = makeCtx() + it.instance( + "normalizes Windows path variants to one glob", + () => + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - const outerTmp = yield* tmpdirScoped() - yield* Effect.promise(() => Bun.write(path.join(outerTmp, "outside.txt"), "x")) + const outerTmp = yield* tmpdirScoped() + yield* Effect.promise(() => Bun.write(path.join(outerTmp, "outside.txt"), "x")) - const target = path.join(outerTmp, "outside.txt") - const alt = target - .replace(/^[A-Za-z]:/, "") - .replaceAll("\\", "/") - .toLowerCase() + const target = path.join(outerTmp, "outside.txt") + const alt = target + .replace(/^[A-Za-z]:/, "") + .replaceAll("\\", "/") + .toLowerCase() - yield* assertExternalDirectoryEffect(ctx, alt) + yield* assertExternalDirectoryEffect(ctx, alt) - const req = requests.find((r) => r.permission === "external_directory") - const expected = glob(path.join(outerTmp, "*")) - expect(req).toBeDefined() - expect(req!.patterns).toEqual([expected]) - expect(req!.always).toEqual([expected]) - }), + const req = requests.find((r) => r.permission === "external_directory") + const expected = glob(path.join(outerTmp, "*")) + expect(req).toBeDefined() + expect(req!.patterns).toEqual([expected]) + expect(req!.always).toEqual([expected]) + }), { git: true }, ) - it.instance("uses drive root glob for root files", () => - Effect.gen(function* () { - const { requests, ctx } = makeCtx() + it.instance( + "uses drive root glob for root files", + () => + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - const tmp = yield* TestInstance - const root = path.parse(tmp.directory).root - const target = path.join(root, "boot.ini") + const tmp = yield* TestInstance + const root = path.parse(tmp.directory).root + const target = path.join(root, "boot.ini") - yield* assertExternalDirectoryEffect(ctx, target) + yield* assertExternalDirectoryEffect(ctx, target) - const req = requests.find((r) => r.permission === "external_directory") - const expected = path.join(root, "*") - expect(req).toBeDefined() - expect(req!.patterns).toEqual([expected]) - expect(req!.always).toEqual([expected]) - }), + const req = requests.find((r) => r.permission === "external_directory") + const expected = path.join(root, "*") + expect(req).toBeDefined() + expect(req!.patterns).toEqual([expected]) + expect(req!.always).toEqual([expected]) + }), { git: true }, ) } From e540daabc4ca21df3871cc0e2e3cc570d9157c60 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 14:56:01 -0400 Subject: [PATCH 116/188] test(agent): migrate plan bypass tests to Effect runner (#27119) --- .../agent/plan-mode-subagent-bypass.test.ts | 144 ++++++++---------- 1 file changed, 64 insertions(+), 80 deletions(-) diff --git a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts index 5ba6b5483..255aea12e 100644 --- a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts +++ b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts @@ -18,110 +18,85 @@ * permissions are passed through, and Plan Mode's restrictions live on the * agent, not the session. */ -import { test, expect, afterEach } from "bun:test" +import { expect } from "bun:test" import { Effect } from "effect" -import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" -import { WithInstance } from "../../src/project/with-instance" import { Agent } from "../../src/agent/agent" import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions" import { Permission } from "../../src/permission" +import { testEffect } from "../lib/effect" -afterEach(async () => { - await disposeAllInstances() -}) - -function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { - return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer))) -} +const it = testEffect(Agent.defaultLayer) // `deriveSubagentSessionPermission` is imported from production. The test // exercises the actual helper that task.ts uses to build the subagent's // session permission, so any regression in that helper trips this test. -test("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const planAgent = await load(tmp.path, (svc) => svc.get("plan")) - const generalAgent = await load(tmp.path, (svc) => svc.get("general")) +it.instance("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", () => + Effect.gen(function* () { + const planAgent = yield* Agent.Service.use((svc) => svc.get("plan")) + const generalAgent = yield* Agent.Service.use((svc) => svc.get("general")) - expect(planAgent).toBeDefined() - expect(generalAgent).toBeDefined() - // Sanity: the plan agent itself blocks edit. (Note: `write` and - // `apply_patch` route through the `edit` permission at the runtime - // tool layer — see Permission.disabled / EDIT_TOOLS.) - expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny") + expect(planAgent).toBeDefined() + expect(generalAgent).toBeDefined() + // Sanity: the plan agent itself blocks edit. (Note: `write` and + // `apply_patch` route through the `edit` permission at the runtime + // tool layer — see Permission.disabled / EDIT_TOOLS.) + expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny") - // Simulate the plan-mode parent session: in real flow the plan - // session's `permission` field is empty (Plan Mode lives on the agent - // ruleset, not the session). So we pass [] through as the parent - // session permission, exactly like the actual code path. - const parentSessionPermission: Permission.Ruleset = [] + // Simulate the plan-mode parent session: in real flow the plan + // session's `permission` field is empty (Plan Mode lives on the agent + // ruleset, not the session). So we pass [] through as the parent + // session permission, exactly like the actual code path. + const parentSessionPermission: Permission.Ruleset = [] - const subagentSessionPermission = deriveSubagentSessionPermission({ - parentSessionPermission, - parentAgent: planAgent, - subagent: generalAgent!, - }) + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: generalAgent!, + }) - // Mirror the runtime evaluation in session/prompt.ts (~line 410, 639): - // ruleset: Permission.merge(agent.permission, session.permission ?? []) - const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission) + // Mirror the runtime evaluation in session/prompt.ts (~line 410, 639): + // ruleset: Permission.merge(agent.permission, session.permission ?? []) + const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission) - expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") - expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny") - }, - }) -}) + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") + expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny") + }), +) -test("[#26514] explore subagent launched from plan mode also stays read-only", async () => { +it.instance("[#26514] explore subagent launched from plan mode also stays read-only", () => // Sibling check: even though `explore` is intrinsically read-only, the // bug surface is the same. Including this case to document that the fix // should propagate the parent **agent** permissions, not just deny edit // when the subagent happens to already deny it. - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const planAgent = await load(tmp.path, (svc) => svc.get("plan")) - const explore = await load(tmp.path, (svc) => svc.get("explore")) - expect(planAgent).toBeDefined() - expect(explore).toBeDefined() + Effect.gen(function* () { + const planAgent = yield* Agent.Service.use((svc) => svc.get("plan")) + const explore = yield* Agent.Service.use((svc) => svc.get("explore")) + expect(planAgent).toBeDefined() + expect(explore).toBeDefined() - const parentSessionPermission: Permission.Ruleset = [] - const subagentSessionPermission = deriveSubagentSessionPermission({ - parentSessionPermission, - parentAgent: planAgent, - subagent: explore!, - }) - const effective = Permission.merge(explore!.permission, subagentSessionPermission) + const parentSessionPermission: Permission.Ruleset = [] + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: explore!, + }) + const effective = Permission.merge(explore!.permission, subagentSessionPermission) - // Already deny — sanity check. - expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny") - }, - }) -}) + // Already deny — sanity check. + expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny") + }), +) -test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => { +it.instance( + "[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", // The most damaging case: a user-defined subagent with default // permissions (allow-by-default, like `general`). The subagent must NOT // be able to edit when the parent agent is `plan`. - await using tmp = await tmpdir({ - config: { - agent: { - my_subagent: { - description: "A user-defined subagent", - mode: "subagent", - }, - }, - }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const planAgent = await load(tmp.path, (svc) => svc.get("plan")) - const my = await load(tmp.path, (svc) => svc.get("my_subagent")) + () => + Effect.gen(function* () { + const planAgent = yield* Agent.Service.use((svc) => svc.get("plan")) + const my = yield* Agent.Service.use((svc) => svc.get("my_subagent")) expect(planAgent).toBeDefined() expect(my).toBeDefined() @@ -136,6 +111,15 @@ test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode r // BUG: on origin/dev edit resolves to "allow" because the plan // agent's `edit: deny *` rule never reaches the subagent. expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") + }), + { + config: { + agent: { + my_subagent: { + description: "A user-defined subagent", + mode: "subagent", + }, + }, }, - }) -}) + }, +) From 45de4975dec676e398a308149ae62bac5e017ce8 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 01:08:30 +0530 Subject: [PATCH 117/188] refactor(core): resolve default agent info (#27125) --- packages/opencode/src/acp/agent.ts | 6 +++--- packages/opencode/src/agent/agent.ts | 15 ++++++++++++--- .../instance/httpapi/handlers/experimental.ts | 2 +- packages/opencode/src/session/prompt.ts | 10 +++++----- packages/opencode/test/agent/agent.test.ts | 8 ++++++++ packages/opencode/test/tool/registry.test.ts | 2 +- 6 files changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 867b830cf..a8eacf835 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -1094,8 +1094,8 @@ export class Agent implements ACPAgent { const currentModeId = await (async () => { if (!availableModes.length) return undefined - const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())) - const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id + const defaultAgent = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo())) + const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id this.sessionManager.setMode(sessionId, resolvedModeId) return resolvedModeId })() @@ -1328,7 +1328,7 @@ export class Agent implements ACPAgent { if (!current) { this.sessionManager.setModel(session.id, model) } - const agent = session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))) + const agent = session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name const parts: Array< | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index c1a644282..423a51318 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -57,6 +57,7 @@ const GeneratedAgent = Schema.Struct({ export interface Interface { readonly get: (agent: string) => Effect.Effect readonly list: () => Effect.Effect + readonly defaultInfo: () => Effect.Effect readonly defaultAgent: () => Effect.Effect readonly generate: (input: { description: string @@ -333,23 +334,28 @@ export const layer = Layer.effect( ) }) - const defaultAgent = Effect.fnUntraced(function* () { + const defaultInfo = Effect.fnUntraced(function* () { const c = yield* config.get() if (c.default_agent) { const agent = agents[c.default_agent] if (!agent) throw new Error(`default agent "${c.default_agent}" not found`) if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`) if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`) - return agent.name + return agent } const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) if (!visible) throw new Error("no primary visible agent found") - return visible.name + return visible + }) + + const defaultAgent = Effect.fnUntraced(function* () { + return (yield* defaultInfo()).name }) return { get, list, + defaultInfo, defaultAgent, } satisfies State }), @@ -362,6 +368,9 @@ export const layer = Layer.effect( list: Effect.fn("Agent.list")(function* () { return yield* InstanceState.useEffect(state, (s) => s.list()) }), + defaultInfo: Effect.fn("Agent.defaultInfo")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.defaultInfo()) + }), defaultAgent: Effect.fn("Agent.defaultAgent")(function* () { return yield* InstanceState.useEffect(state, (s) => s.defaultAgent()) }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index 360daf54a..9cf668ceb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -79,7 +79,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const list = yield* registry.tools({ providerID: ctx.query.provider, modelID: ctx.query.model, - agent: yield* agents.get(yield* agents.defaultAgent()), + agent: yield* agents.defaultInfo(), }) return list.map((item) => ({ id: item.id, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 15246dac3..b89561d5d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1083,8 +1083,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) { - const agentName = input.agent || (yield* agents.defaultAgent()) - const ag = yield* agents.get(agentName) + const agentName = input.agent + const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo() if (!ag) { const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" @@ -1875,7 +1875,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) throw error } - const agentName = cmd.agent ?? input.agent ?? (yield* agents.defaultAgent()) + const agentName = cmd.agent ?? input.agent const raw = input.arguments.match(argsRegex) ?? [] const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) @@ -1928,7 +1928,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID) - const agent = yield* agents.get(agentName) + const agent = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo() if (!agent) { const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" @@ -1952,7 +1952,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the ] : [...templateParts, ...(input.parts ?? [])] - const userAgent = isSubtask ? (input.agent ?? (yield* agents.defaultAgent())) : agentName + const userAgent = isSubtask ? (input.agent ?? (yield* agents.defaultInfo()).name) : agent.name const userModel = isSubtask ? input.model ? Provider.parseModel(input.model) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 9893ee822..a781a855c 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -638,6 +638,14 @@ it.instance("defaultAgent returns build when no default_agent config", () => }), ) +it.instance("defaultInfo returns resolved build agent when no default_agent config", () => + Effect.gen(function* () { + const agent = yield* load((svc) => svc.defaultInfo()) + expect(agent.name).toBe("build") + expect(agent.mode).toBe("primary") + }), +) + it.instance( "defaultAgent respects default_agent config set to plan", () => diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 5ee56300c..fb4dd31a5 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -180,7 +180,7 @@ describe("tool.registry", () => { const promptTools = yield* registry.tools({ providerID: ProviderID.opencode, modelID: ModelID.make("test"), - agent: yield* agents.get(yield* agents.defaultAgent()), + agent: yield* agents.defaultInfo(), }) const promptTool = promptTools.find((tool) => tool.id === "sql") if (!promptTool) throw new Error("custom sql tool was not returned for prompts") From ec960da42a2f3fef7523cbe026208b8c85323d40 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 15:39:03 -0400 Subject: [PATCH 118/188] test(skill): migrate discovery tests to Effect runner (#27127) --- .../opencode/test/skill/discovery.test.ts | 131 ++++++++++-------- 1 file changed, 73 insertions(+), 58 deletions(-) diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index f4a17f25c..43018d9a4 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -1,10 +1,11 @@ -import { describe, test, expect, beforeAll, afterAll } from "bun:test" +import { describe, expect, beforeAll, afterAll } from "bun:test" import { Effect } from "effect" import { Discovery } from "../../src/skill/discovery" import { Global } from "@opencode-ai/core/global" import { Filesystem } from "@/util/filesystem" import { rm } from "fs/promises" import path from "path" +import { testEffect } from "../lib/effect" let CLOUDFLARE_SKILLS_URL: string let server: ReturnType @@ -12,6 +13,7 @@ let downloadCount = 0 const fixturePath = path.join(import.meta.dir, "../fixture/skills") const cacheDir = path.join(Global.Path.cache, "skills") +const it = testEffect(Discovery.defaultLayer) beforeAll(async () => { await rm(cacheDir, { recursive: true, force: true }) @@ -47,70 +49,83 @@ afterAll(async () => { }) describe("Discovery.pull", () => { - const pull = (url: string) => - Effect.runPromise(Discovery.Service.use((s) => s.pull(url)).pipe(Effect.provide(Discovery.defaultLayer))) - - test("downloads skills from cloudflare url", async () => { - const dirs = await pull(CLOUDFLARE_SKILLS_URL) - expect(dirs.length).toBeGreaterThan(0) - for (const dir of dirs) { - expect(dir).toStartWith(cacheDir) - const md = path.join(dir, "SKILL.md") - expect(await Filesystem.exists(md)).toBe(true) - } + const pull = Effect.fn("DiscoveryTest.pull")(function* (url: string) { + return yield* Discovery.Service.use((s) => s.pull(url)) }) - test("url without trailing slash works", async () => { - const dirs = await pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, "")) - expect(dirs.length).toBeGreaterThan(0) - for (const dir of dirs) { - const md = path.join(dir, "SKILL.md") - expect(await Filesystem.exists(md)).toBe(true) - } - }) + it.live("downloads skills from cloudflare url", () => + Effect.gen(function* () { + const dirs = yield* pull(CLOUDFLARE_SKILLS_URL) + expect(dirs.length).toBeGreaterThan(0) + for (const dir of dirs) { + expect(dir).toStartWith(cacheDir) + const md = path.join(dir, "SKILL.md") + expect(yield* Effect.promise(() => Filesystem.exists(md))).toBe(true) + } + }), + ) - test("returns empty array for invalid url", async () => { - const dirs = await pull(`http://localhost:${server.port}/invalid-url/`) - expect(dirs).toEqual([]) - }) + it.live("url without trailing slash works", () => + Effect.gen(function* () { + const dirs = yield* pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, "")) + expect(dirs.length).toBeGreaterThan(0) + for (const dir of dirs) { + const md = path.join(dir, "SKILL.md") + expect(yield* Effect.promise(() => Filesystem.exists(md))).toBe(true) + } + }), + ) - test("returns empty array for non-json response", async () => { - // any url not explicitly handled in server returns 404 text "Not Found" - const dirs = await pull(`http://localhost:${server.port}/some-other-path/`) - expect(dirs).toEqual([]) - }) + it.live("returns empty array for invalid url", () => + Effect.gen(function* () { + const dirs = yield* pull(`http://localhost:${server.port}/invalid-url/`) + expect(dirs).toEqual([]) + }), + ) - test("downloads reference files alongside SKILL.md", async () => { - const dirs = await pull(CLOUDFLARE_SKILLS_URL) - // find a skill dir that should have reference files (e.g. agents-sdk) - const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk")) - expect(agentsSdk).toBeDefined() - if (agentsSdk) { - const refs = path.join(agentsSdk, "references") - expect(await Filesystem.exists(path.join(agentsSdk, "SKILL.md"))).toBe(true) - // agents-sdk has reference files per the index - const refDir = await Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true })) - expect(refDir.length).toBeGreaterThan(0) - } - }) + it.live("returns empty array for non-json response", () => + Effect.gen(function* () { + // any url not explicitly handled in server returns 404 text "Not Found" + const dirs = yield* pull(`http://localhost:${server.port}/some-other-path/`) + expect(dirs).toEqual([]) + }), + ) - test("caches downloaded files on second pull", async () => { - // clear dir and downloadCount - await rm(cacheDir, { recursive: true, force: true }) - downloadCount = 0 + it.live("downloads reference files alongside SKILL.md", () => + Effect.gen(function* () { + const dirs = yield* pull(CLOUDFLARE_SKILLS_URL) + // find a skill dir that should have reference files (e.g. agents-sdk) + const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk")) + expect(agentsSdk).toBeDefined() + if (agentsSdk) { + const refs = path.join(agentsSdk, "references") + expect(yield* Effect.promise(() => Filesystem.exists(path.join(agentsSdk, "SKILL.md")))).toBe(true) + // agents-sdk has reference files per the index + const refDir = yield* Effect.promise(() => Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true }))) + expect(refDir.length).toBeGreaterThan(0) + } + }), + ) - // first pull to populate cache - const first = await pull(CLOUDFLARE_SKILLS_URL) - expect(first.length).toBeGreaterThan(0) - const firstCount = downloadCount - expect(firstCount).toBeGreaterThan(0) + it.live("caches downloaded files on second pull", () => + Effect.gen(function* () { + // clear dir and downloadCount + yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true })) + downloadCount = 0 - // second pull should return same results from cache - const second = await pull(CLOUDFLARE_SKILLS_URL) - expect(second.length).toBe(first.length) - expect(second.sort()).toEqual(first.sort()) + // first pull to populate cache + const first = yield* pull(CLOUDFLARE_SKILLS_URL) + expect(first.length).toBeGreaterThan(0) + const firstCount = downloadCount + expect(firstCount).toBeGreaterThan(0) - // second pull should NOT increment download count - expect(downloadCount).toBe(firstCount) - }) + // second pull should return same results from cache + const second = yield* pull(CLOUDFLARE_SKILLS_URL) + expect(second.length).toBe(first.length) + expect(second.sort()).toEqual(first.sort()) + + // second pull should NOT increment download count + expect(downloadCount).toBe(firstCount) + }), + ) }) From 3e2ec192cf06970bd51722918070134f5ab4e7b1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 15:40:01 -0400 Subject: [PATCH 119/188] test(question): remove WithInstance bridge (#27128) --- packages/opencode/test/question/question.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 4e2c8ef9b..a87907c57 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -2,7 +2,6 @@ import { afterEach, expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { QuestionID } from "../../src/question/schema" import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" @@ -398,9 +397,7 @@ it.live("pending question rejects on instance dispose", () => }).pipe(provideInstance(dir), Effect.forkScoped) expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) - yield* Effect.promise(() => - WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), - ) + yield* Effect.promise(() => InstanceRuntime.disposeInstance(Instance.current)).pipe(provideInstance(dir)) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) From fec78154b5449f8995d2676853769643eef8ce4d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 15:41:24 -0400 Subject: [PATCH 120/188] test(bus): migrate bus tests to Effect runner (#27131) --- packages/opencode/test/bus/bus.test.ts | 316 +++++++++++++------------ 1 file changed, 168 insertions(+), 148 deletions(-) diff --git a/packages/opencode/test/bus/bus.test.ts b/packages/opencode/test/bus/bus.test.ts index 876cb1ed7..084498616 100644 --- a/packages/opencode/test/bus/bus.test.ts +++ b/packages/opencode/test/bus/bus.test.ts @@ -1,220 +1,240 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { Schema } from "effect" +import { afterEach, describe, expect } from "bun:test" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Deferred, Effect, Layer, Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" const TestEvent = { Ping: BusEvent.define("test.ping", Schema.Struct({ value: Schema.Number })), Pong: BusEvent.define("test.pong", Schema.Struct({ message: Schema.String })), } -function withInstance(directory: string, fn: () => Promise) { - return WithInstance.provide({ directory, fn }) -} +const it = testEffect(Layer.mergeAll(Bus.layer, CrossSpawnSpawner.defaultLayer)) describe("Bus", () => { afterEach(() => disposeAllInstances()) describe("publish + subscribe", () => { - test("subscriber is live immediately after subscribe returns", async () => { - await using tmp = await tmpdir() - const received: number[] = [] + it.instance("subscriber is live immediately after subscribe returns", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: number[] = [] + const done = yield* Deferred.make() - await withInstance(tmp.path, async () => { - Bus.subscribe(TestEvent.Ping, (evt) => { + yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { received.push(evt.properties.value) + Deferred.doneUnsafe(done, Effect.void) }) - await Bus.publish(TestEvent.Ping, { value: 42 }) - await Bun.sleep(10) - }) + yield* bus.publish(TestEvent.Ping, { value: 42 }) + yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - expect(received).toEqual([42]) - }) + expect(received).toEqual([42]) + }), + ) - test("subscriber receives matching events", async () => { - await using tmp = await tmpdir() - const received: number[] = [] + it.instance("subscriber receives matching events", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: number[] = [] + const done = yield* Deferred.make() - await withInstance(tmp.path, async () => { - Bus.subscribe(TestEvent.Ping, (evt) => { + yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { received.push(evt.properties.value) + if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) }) - // Give the subscriber fiber time to start consuming - await Bun.sleep(10) - await Bus.publish(TestEvent.Ping, { value: 42 }) - await Bus.publish(TestEvent.Ping, { value: 99 }) - // Give subscriber time to process - await Bun.sleep(10) - }) + yield* bus.publish(TestEvent.Ping, { value: 42 }) + yield* bus.publish(TestEvent.Ping, { value: 99 }) + yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - expect(received).toEqual([42, 99]) - }) + expect(received).toEqual([42, 99]) + }), + ) - test("subscriber does not receive events of other types", async () => { - await using tmp = await tmpdir() - const pings: number[] = [] + it.instance("subscriber does not receive events of other types", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const pings: number[] = [] + const done = yield* Deferred.make() - await withInstance(tmp.path, async () => { - Bus.subscribe(TestEvent.Ping, (evt) => { + yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { pings.push(evt.properties.value) + Deferred.doneUnsafe(done, Effect.void) }) - await Bun.sleep(10) - await Bus.publish(TestEvent.Pong, { message: "hello" }) - await Bus.publish(TestEvent.Ping, { value: 1 }) - await Bun.sleep(10) - }) + yield* bus.publish(TestEvent.Pong, { message: "hello" }) + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - expect(pings).toEqual([1]) - }) + expect(pings).toEqual([1]) + }), + ) - test("publish with no subscribers does not throw", async () => { - await using tmp = await tmpdir() - - await withInstance(tmp.path, async () => { - await Bus.publish(TestEvent.Ping, { value: 1 }) - }) - }) + it.instance("publish with no subscribers does not throw", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + yield* bus.publish(TestEvent.Ping, { value: 1 }) + }), + ) }) describe("unsubscribe", () => { - test("unsubscribe stops delivery", async () => { - await using tmp = await tmpdir() - const received: number[] = [] + it.instance("unsubscribe stops delivery", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: number[] = [] + const first = yield* Deferred.make() - await withInstance(tmp.path, async () => { - const unsub = Bus.subscribe(TestEvent.Ping, (evt) => { + const unsub = yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { received.push(evt.properties.value) + if (evt.properties.value === 1) Deferred.doneUnsafe(first, Effect.void) }) - await Bun.sleep(10) - await Bus.publish(TestEvent.Ping, { value: 1 }) - await Bun.sleep(10) - unsub() - await Bun.sleep(10) - await Bus.publish(TestEvent.Ping, { value: 2 }) - await Bun.sleep(10) - }) + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* Deferred.await(first).pipe(Effect.timeout("2 seconds")) + yield* Effect.sync(unsub) + yield* bus.publish(TestEvent.Ping, { value: 2 }) + yield* Effect.sleep("10 millis") - expect(received).toEqual([1]) - }) + expect(received).toEqual([1]) + }), + ) }) describe("subscribeAll", () => { - test("subscribeAll is live immediately after subscribe returns", async () => { - await using tmp = await tmpdir() - const received: string[] = [] + it.instance("subscribeAll is live immediately after subscribe returns", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: string[] = [] + const done = yield* Deferred.make() - await withInstance(tmp.path, async () => { - Bus.subscribeAll((evt) => { + yield* bus.subscribeAllCallback((evt) => { received.push(evt.type) + Deferred.doneUnsafe(done, Effect.void) }) - await Bus.publish(TestEvent.Ping, { value: 1 }) - await Bun.sleep(10) - }) + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - expect(received).toEqual(["test.ping"]) - }) + expect(received).toEqual(["test.ping"]) + }), + ) - test("receives all event types", async () => { - await using tmp = await tmpdir() - const received: string[] = [] + it.instance("receives all event types", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: string[] = [] + const done = yield* Deferred.make() - await withInstance(tmp.path, async () => { - Bus.subscribeAll((evt) => { + yield* bus.subscribeAllCallback((evt) => { received.push(evt.type) + if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) }) - await Bun.sleep(10) - await Bus.publish(TestEvent.Ping, { value: 1 }) - await Bus.publish(TestEvent.Pong, { message: "hi" }) - await Bun.sleep(10) - }) + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* bus.publish(TestEvent.Pong, { message: "hi" }) + yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - expect(received).toContain("test.ping") - expect(received).toContain("test.pong") - }) + expect(received).toContain("test.ping") + expect(received).toContain("test.pong") + }), + ) }) describe("multiple subscribers", () => { - test("all subscribers for same event type are called", async () => { - await using tmp = await tmpdir() - const a: number[] = [] - const b: number[] = [] + it.instance("all subscribers for same event type are called", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const a: number[] = [] + const b: number[] = [] + const doneA = yield* Deferred.make() + const doneB = yield* Deferred.make() - await withInstance(tmp.path, async () => { - Bus.subscribe(TestEvent.Ping, (evt) => { + yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { a.push(evt.properties.value) + Deferred.doneUnsafe(doneA, Effect.void) }) - Bus.subscribe(TestEvent.Ping, (evt) => { + yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { b.push(evt.properties.value) + Deferred.doneUnsafe(doneB, Effect.void) }) - await Bun.sleep(10) - await Bus.publish(TestEvent.Ping, { value: 7 }) - await Bun.sleep(10) - }) + yield* bus.publish(TestEvent.Ping, { value: 7 }) + yield* Deferred.await(doneA).pipe(Effect.timeout("2 seconds")) + yield* Deferred.await(doneB).pipe(Effect.timeout("2 seconds")) - expect(a).toEqual([7]) - expect(b).toEqual([7]) - }) + expect(a).toEqual([7]) + expect(b).toEqual([7]) + }), + ) }) describe("instance isolation", () => { - test("events in one directory do not reach subscribers in another", async () => { - await using tmpA = await tmpdir() - await using tmpB = await tmpdir() - const receivedA: number[] = [] - const receivedB: number[] = [] + it.live("events in one directory do not reach subscribers in another", () => + Effect.gen(function* () { + const tmpA = yield* tmpdirScoped() + const tmpB = yield* tmpdirScoped() + const receivedA: number[] = [] + const receivedB: number[] = [] + const doneA = yield* Deferred.make() + const doneB = yield* Deferred.make() - await withInstance(tmpA.path, async () => { - Bus.subscribe(TestEvent.Ping, (evt) => { - receivedA.push(evt.properties.value) - }) - await Bun.sleep(10) - }) + yield* Effect.gen(function* () { + const bus = yield* Bus.Service + yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { + receivedA.push(evt.properties.value) + Deferred.doneUnsafe(doneA, Effect.void) + }) + }).pipe(provideInstance(tmpA)) - await withInstance(tmpB.path, async () => { - Bus.subscribe(TestEvent.Ping, (evt) => { - receivedB.push(evt.properties.value) - }) - await Bun.sleep(10) - }) + yield* Effect.gen(function* () { + const bus = yield* Bus.Service + yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { + receivedB.push(evt.properties.value) + Deferred.doneUnsafe(doneB, Effect.void) + }) + }).pipe(provideInstance(tmpB)) - await withInstance(tmpA.path, async () => { - await Bus.publish(TestEvent.Ping, { value: 1 }) - await Bun.sleep(10) - }) + yield* Effect.gen(function* () { + const bus = yield* Bus.Service + yield* bus.publish(TestEvent.Ping, { value: 1 }) + }).pipe(provideInstance(tmpA)) - await withInstance(tmpB.path, async () => { - await Bus.publish(TestEvent.Ping, { value: 2 }) - await Bun.sleep(10) - }) + yield* Effect.gen(function* () { + const bus = yield* Bus.Service + yield* bus.publish(TestEvent.Ping, { value: 2 }) + }).pipe(provideInstance(tmpB)) - expect(receivedA).toEqual([1]) - expect(receivedB).toEqual([2]) - }) + yield* Deferred.await(doneA).pipe(Effect.timeout("2 seconds")) + yield* Deferred.await(doneB).pipe(Effect.timeout("2 seconds")) + + expect(receivedA).toEqual([1]) + expect(receivedB).toEqual([2]) + }), + ) }) describe("instance disposal", () => { - test("InstanceDisposed is delivered to wildcard subscribers before stream ends", async () => { - await using tmp = await tmpdir() - const received: string[] = [] + it.live("InstanceDisposed is delivered to wildcard subscribers before stream ends", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + const received: string[] = [] + const seen = yield* Deferred.make() + const disposed = yield* Deferred.make() - await withInstance(tmp.path, async () => { - Bus.subscribeAll((evt) => { - received.push(evt.type) - }) - await Bun.sleep(10) - await Bus.publish(TestEvent.Ping, { value: 1 }) - await Bun.sleep(10) - }) + yield* Effect.gen(function* () { + const bus = yield* Bus.Service + yield* bus.subscribeAllCallback((evt) => { + received.push(evt.type) + if (evt.type === TestEvent.Ping.type) Deferred.doneUnsafe(seen, Effect.void) + if (evt.type === Bus.InstanceDisposed.type) Deferred.doneUnsafe(disposed, Effect.void) + }) + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* Deferred.await(seen).pipe(Effect.timeout("2 seconds")) + }).pipe(provideInstance(tmp)) - // disposeAllInstances triggers the finalizer which publishes InstanceDisposed - await disposeAllInstances() - await Bun.sleep(50) + yield* Effect.promise(disposeAllInstances) + yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds")) - expect(received).toContain("test.ping") - expect(received).toContain(Bus.InstanceDisposed.type) - }) + expect(received).toContain("test.ping") + expect(received).toContain(Bus.InstanceDisposed.type) + }), + ) }) }) From 71040c54aa89acbda7987dd2244091ae05e10cc8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 15:41:44 -0400 Subject: [PATCH 121/188] test(plugin): migrate loader shared tests to Effect runner (#27129) --- .../test/plugin/loader-shared.test.ts | 1034 +++++++++-------- 1 file changed, 563 insertions(+), 471 deletions(-) diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 8c55950af..ffdb3291b 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -1,9 +1,11 @@ -import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test" +import { afterAll, afterEach, describe, expect, spyOn } from "bun:test" import { Effect, Layer } from "effect" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" -import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" import { Filesystem } from "@/util/filesystem" const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS @@ -28,38 +30,54 @@ afterEach(async () => { await disposeAllInstances() }) -async function load(dir: string) { - const source = path.join(dir, "opencode.json") - const config = (await Bun.file(source).json()) as { plugin?: Array]> } - const plugins = config.plugin ?? [] +const it = testEffect(CrossSpawnSpawner.defaultLayer) + +function withTmp( + init: (dir: string) => Promise, + body: (tmp: { path: string; extra: T }) => Effect.Effect, +) { return Effect.gen(function* () { - const plugin = yield* Plugin.Service - yield* plugin.list() - }).pipe( - Effect.provide( - Plugin.layer.pipe( - Layer.provide(Bus.layer), - Layer.provide( - TestConfig.layer({ - get: () => - Effect.succeed({ - plugin: plugins, - plugin_origins: plugins.map((plugin) => ({ spec: plugin, source, scope: "local" as const })), - }), - directories: () => Effect.succeed([dir]), - }), + const dir = yield* tmpdirScoped() + const extra = yield* Effect.promise(() => init(dir)) + return yield* body({ path: dir, extra }) + }) +} + +function load(dir: string) { + const source = path.join(dir, "opencode.json") + return Effect.gen(function* () { + const config = yield* Effect.promise( + () => Bun.file(source).json() as Promise<{ plugin?: Array]> }>, + ) + const plugins = config.plugin ?? [] + return yield* Effect.gen(function* () { + const plugin = yield* Plugin.Service + yield* plugin.list() + }).pipe( + Effect.provide( + Plugin.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide( + TestConfig.layer({ + get: () => + Effect.succeed({ + plugin: plugins, + plugin_origins: plugins.map((plugin) => ({ spec: plugin, source, scope: "local" as const })), + }), + directories: () => Effect.succeed([dir]), + }), + ), ), ), - ), - provideInstance(dir), - Effect.runPromise, - ) + provideInstance(dir), + ) + }) } describe("plugin.loader.shared", () => { - test("loads a file:// plugin function export", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("loads a file:// plugin function export", () => + withTmp( + async (dir) => { const file = path.join(dir, "plugin.ts") const mark = path.join(dir, "called.txt") await Bun.write( @@ -80,15 +98,17 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("called") + }), + ), + ) - await load(tmp.path) - expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called") - }) - - test("deduplicates same function exported as default and named", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("deduplicates same function exported as default and named", () => + withTmp( + async (dir) => { const file = path.join(dir, "plugin.ts") const mark = path.join(dir, "count.txt") await Bun.write(mark, "") @@ -113,15 +133,17 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("1") + }), + ), + ) - await load(tmp.path) - expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("1") - }) - - test("uses only default v1 server plugin when present", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("uses only default v1 server plugin when present", () => + withTmp( + async (dir) => { const file = path.join(dir, "plugin.ts") const mark = path.join(dir, "count.txt") await Bun.write( @@ -149,15 +171,17 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("default") + }), + ), + ) - await load(tmp.path) - expect(await Bun.file(tmp.extra.mark).text()).toBe("default") - }) - - test("rejects v1 file server plugin without id", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("rejects v1 file server plugin without id", () => + withTmp( + async (dir) => { const file = path.join(dir, "plugin.ts") const mark = path.join(dir, "called.txt") await Bun.write( @@ -180,20 +204,24 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + const called = yield* Effect.promise(() => + Bun.file(tmp.extra.mark) + .text() + .then(() => true) + .catch(() => false), + ) - await load(tmp.path) - const called = await Bun.file(tmp.extra.mark) - .text() - .then(() => true) - .catch(() => false) + expect(called).toBe(false) + }), + ), + ) - expect(called).toBe(false) - }) - - test("rejects v1 plugin that exports server and tui together", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("rejects v1 plugin that exports server and tui together", () => + withTmp( + async (dir) => { const file = path.join(dir, "plugin.ts") const mark = path.join(dir, "called.txt") await Bun.write( @@ -218,20 +246,24 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + const called = yield* Effect.promise(() => + Bun.file(tmp.extra.mark) + .text() + .then(() => true) + .catch(() => false), + ) - await load(tmp.path) - const called = await Bun.file(tmp.extra.mark) - .text() - .then(() => true) - .catch(() => false) + expect(called).toBe(false) + }), + ), + ) - expect(called).toBe(false) - }) - - test("resolves npm plugin specs with explicit and default versions", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("resolves npm plugin specs with explicit and default versions", () => + withTmp( + async (dir) => { const acme = path.join(dir, "node_modules", "acme-plugin") const scope = path.join(dir, "node_modules", "scope-plugin") await fs.mkdir(acme, { recursive: true }) @@ -254,26 +286,28 @@ describe("plugin.loader.shared", () => { return { acme, scope } }, - }) + (tmp) => + Effect.gen(function* () { + const add = spyOn(Npm, "add").mockImplementation(async (pkg) => { + if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined } + return { directory: tmp.extra.scope, entrypoint: undefined } + }) - const add = spyOn(Npm, "add").mockImplementation(async (pkg) => { - if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined } - return { directory: tmp.extra.scope, entrypoint: undefined } - }) + try { + yield* load(tmp.path) - try { - await load(tmp.path) + expect(add.mock.calls).toContainEqual(["acme-plugin@latest"]) + expect(add.mock.calls).toContainEqual(["scope-plugin@2.3.4"]) + } finally { + add.mockRestore() + } + }), + ), + ) - expect(add.mock.calls).toContainEqual(["acme-plugin@latest"]) - expect(add.mock.calls).toContainEqual(["scope-plugin@2.3.4"]) - } finally { - add.mockRestore() - } - }) - - test("loads npm server plugin from package ./server export", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("loads npm server plugin from package ./server export", () => + withTmp( + async (dir) => { const mod = path.join(dir, "mods", "acme-plugin") const mark = path.join(dir, "server-called.txt") await fs.mkdir(mod, { recursive: true }) @@ -317,21 +351,23 @@ describe("plugin.loader.shared", () => { mark, } }, - }) + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + try { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called") + } finally { + install.mockRestore() + } + }), + ), + ) - try { - await load(tmp.path) - expect(await Bun.file(tmp.extra.mark).text()).toBe("called") - } finally { - install.mockRestore() - } - }) - - test("loads npm server plugin from package server export without leading dot", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("loads npm server plugin from package server export without leading dot", () => + withTmp( + async (dir) => { const mod = path.join(dir, "mods", "acme-plugin") const dist = path.join(mod, "dist") const mark = path.join(dir, "server-called.txt") @@ -374,21 +410,23 @@ describe("plugin.loader.shared", () => { mark, } }, - }) + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + try { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called") + } finally { + install.mockRestore() + } + }), + ), + ) - try { - await load(tmp.path) - expect(await Bun.file(tmp.extra.mark).text()).toBe("called") - } finally { - install.mockRestore() - } - }) - - test("loads npm server plugin from package main without leading dot", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("loads npm server plugin from package main without leading dot", () => + withTmp( + async (dir) => { const mod = path.join(dir, "mods", "acme-plugin") const dist = path.join(mod, "dist") const mark = path.join(dir, "main-called.txt") @@ -426,21 +464,23 @@ describe("plugin.loader.shared", () => { mark, } }, - }) + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + try { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called") + } finally { + install.mockRestore() + } + }), + ), + ) - try { - await load(tmp.path) - expect(await Bun.file(tmp.extra.mark).text()).toBe("called") - } finally { - install.mockRestore() - } - }) - - test("does not use npm package exports dot for server entry", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("does not use npm package exports dot for server entry", () => + withTmp( + async (dir) => { const mod = path.join(dir, "mods", "acme-plugin") const mark = path.join(dir, "dot-server.txt") await fs.mkdir(mod, { recursive: true }) @@ -471,26 +511,30 @@ describe("plugin.loader.shared", () => { return { mod, mark } }, - }) + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + try { + yield* load(tmp.path) + const called = yield* Effect.promise(() => + Bun.file(tmp.extra.mark) + .text() + .then(() => true) + .catch(() => false), + ) - try { - await load(tmp.path) - const called = await Bun.file(tmp.extra.mark) - .text() - .then(() => true) - .catch(() => false) + expect(called).toBe(false) + } finally { + install.mockRestore() + } + }), + ), + ) - expect(called).toBe(false) - } finally { - install.mockRestore() - } - }) - - test("rejects npm server export that resolves outside plugin directory", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("rejects npm server export that resolves outside plugin directory", () => + withTmp( + async (dir) => { const mod = path.join(dir, "mods", "acme-plugin") const outside = path.join(dir, "outside") const mark = path.join(dir, "outside-server.txt") @@ -534,25 +578,29 @@ describe("plugin.loader.shared", () => { mark, } }, - }) + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + try { + yield* load(tmp.path) + const called = yield* Effect.promise(() => + Bun.file(tmp.extra.mark) + .text() + .then(() => true) + .catch(() => false), + ) + expect(called).toBe(false) + } finally { + install.mockRestore() + } + }), + ), + ) - try { - await load(tmp.path) - const called = await Bun.file(tmp.extra.mark) - .text() - .then(() => true) - .catch(() => false) - expect(called).toBe(false) - } finally { - install.mockRestore() - } - }) - - test("skips legacy codex and copilot auth plugin specs", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("skips legacy codex and copilot auth plugin specs", () => + withTmp( + async (dir) => { await Bun.write( path.join(dir, "opencode.json"), JSON.stringify( @@ -564,25 +612,27 @@ describe("plugin.loader.shared", () => { ), ) }, - }) + (_tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined }) + try { + yield* load(_tmp.path) - try { - await load(tmp.path) + const pkgs = install.mock.calls.map((call) => call[0]) + expect(pkgs).toContain("regular-plugin@1.0.0") + expect(pkgs).not.toContain("opencode-openai-codex-auth@1.0.0") + expect(pkgs).not.toContain("opencode-copilot-auth@1.0.0") + } finally { + install.mockRestore() + } + }), + ), + ) - const pkgs = install.mock.calls.map((call) => call[0]) - expect(pkgs).toContain("regular-plugin@1.0.0") - expect(pkgs).not.toContain("opencode-openai-codex-auth@1.0.0") - expect(pkgs).not.toContain("opencode-copilot-auth@1.0.0") - } finally { - install.mockRestore() - } - }) - - test("skips broken plugin when install fails", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("skips broken plugin when install fails", () => + withTmp( + async (dir) => { const ok = path.join(dir, "ok.ts") const mark = path.join(dir, "ok.txt") await Bun.write( @@ -604,22 +654,24 @@ describe("plugin.loader.shared", () => { ) return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom")) - const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom")) + try { + yield* load(tmp.path) + expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9") + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok") + } finally { + install.mockRestore() + } + }), + ), + ) - try { - await load(tmp.path) - expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9") - expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") - } finally { - install.mockRestore() - } - }) - - test("continues loading plugins when plugin init throws", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("continues loading plugins when plugin init throws", () => + withTmp( + async (dir) => { const file = pathToFileURL(path.join(dir, "throws.ts")).href const ok = pathToFileURL(path.join(dir, "ok.ts")).href const mark = path.join(dir, "ok.txt") @@ -653,15 +705,17 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok") + }), + ), + ) - await load(tmp.path) - expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") - }) - - test("continues loading plugins when plugin module has invalid export", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("continues loading plugins when plugin module has invalid export", () => + withTmp( + async (dir) => { const file = pathToFileURL(path.join(dir, "invalid.ts")).href const ok = pathToFileURL(path.join(dir, "ok.ts")).href const mark = path.join(dir, "ok.txt") @@ -687,15 +741,17 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok") + }), + ), + ) - await load(tmp.path) - expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") - }) - - test("continues loading plugins when plugin import fails", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("continues loading plugins when plugin import fails", () => + withTmp( + async (dir) => { const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href const ok = pathToFileURL(path.join(dir, "ok.ts")).href const mark = path.join(dir, "ok.txt") @@ -716,15 +772,17 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok") + }), + ), + ) - await load(tmp.path) - expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") - }) - - test("loads object plugin via plugin.server", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("loads object plugin via plugin.server", () => + withTmp( + async (dir) => { const file = path.join(dir, "object-plugin.ts") const mark = path.join(dir, "object-called.txt") await Bun.write( @@ -749,15 +807,17 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("called") + }), + ), + ) - await load(tmp.path) - expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called") - }) - - test("passes tuple plugin options into server plugin", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("passes tuple plugin options into server plugin", () => + withTmp( + async (dir) => { const file = path.join(dir, "options-plugin.ts") const mark = path.join(dir, "options.json") await Bun.write( @@ -782,18 +842,20 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark))).toEqual({ + source: "tuple", + enabled: true, + }) + }), + ), + ) - await load(tmp.path) - expect(await Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark)).toEqual({ - source: "tuple", - enabled: true, - }) - }) - - test("initializes server plugins in config order", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("initializes server plugins in config order", () => + withTmp( + async (dir) => { const a = path.join(dir, "a-plugin.ts") const b = path.join(dir, "b-plugin.ts") const marker = path.join(dir, "server-order.txt") @@ -833,16 +895,18 @@ export default { return { marker } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + const lines = (yield* Effect.promise(() => fs.readFile(tmp.extra.marker, "utf8"))).trim().split("\n") + expect(lines).toEqual(["a-start", "a-end", "b"]) + }), + ), + ) - await load(tmp.path) - const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n") - expect(lines).toEqual(["a-start", "a-end", "b"]) - }) - - test("skips external plugins in pure mode", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("skips external plugins in pure mode", () => + withTmp( + async (dir) => { const file = path.join(dir, "plugin.ts") const mark = path.join(dir, "called.txt") await Bun.write( @@ -866,30 +930,34 @@ export default { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + const pure = process.env.OPENCODE_PURE + process.env.OPENCODE_PURE = "1" - const pure = process.env.OPENCODE_PURE - process.env.OPENCODE_PURE = "1" + try { + yield* load(tmp.path) + const called = yield* Effect.promise(() => + fs + .readFile(tmp.extra.mark, "utf8") + .then(() => true) + .catch(() => false), + ) + expect(called).toBe(false) + } finally { + if (pure === undefined) { + delete process.env.OPENCODE_PURE + } else { + process.env.OPENCODE_PURE = pure + } + } + }), + ), + ) - try { - await load(tmp.path) - const called = await fs - .readFile(tmp.extra.mark, "utf8") - .then(() => true) - .catch(() => false) - expect(called).toBe(false) - } finally { - if (pure === undefined) { - delete process.env.OPENCODE_PURE - } else { - process.env.OPENCODE_PURE = pure - } - } - }) - - test("reads oc-themes from package manifest", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("reads oc-themes from package manifest", () => + withTmp( + async (dir) => { const mod = path.join(dir, "mod") await fs.mkdir(path.join(mod, "themes"), { recursive: true }) await Bun.write( @@ -907,25 +975,27 @@ export default { return { mod } }, - }) + (tmp) => + Effect.gen(function* () { + const file = path.join(tmp.extra.mod, "package.json") + const json = yield* Effect.promise(() => Filesystem.readJson>(file)) + const list = readPackageThemes("acme-plugin", { + dir: tmp.extra.mod, + pkg: file, + json, + }) - const file = path.join(tmp.extra.mod, "package.json") - const json = await Filesystem.readJson>(file) - const list = readPackageThemes("acme-plugin", { - dir: tmp.extra.mod, - pkg: file, - json, - }) + expect(list).toEqual([ + Filesystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")), + Filesystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")), + ]) + }), + ), + ) - expect(list).toEqual([ - Filesystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")), - Filesystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")), - ]) - }) - - test("handles no-entrypoint tui packages via missing callback", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("handles no-entrypoint tui packages via missing callback", () => + withTmp( + async (dir) => { const mod = path.join(dir, "mods", "acme-plugin") await fs.mkdir(path.join(mod, "themes"), { recursive: true }) await Bun.write( @@ -943,54 +1013,58 @@ export default { await Bun.write(path.join(mod, "themes", "night.json"), "{}\n") return { mod } }, - }) + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + const missing: string[] = [] - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) - const missing: string[] = [] + try { + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: [ + { + spec: "acme-plugin@1.0.0", + scope: "local" as const, + source: tmp.path, + }, + ], + kind: "tui", + missing: async (item) => { + if (!item.pkg) return + const themes = readPackageThemes(item.spec, item.pkg) + if (!themes.length) return + return { + spec: item.spec, + target: item.target, + themes, + } + }, + report: { + missing(_candidate, _retry, message) { + missing.push(message) + }, + }, + }), + ) - try { - const loaded = await PluginLoader.loadExternal({ - items: [ - { - spec: "acme-plugin@1.0.0", - scope: "local" as const, - source: tmp.path, - }, - ], - kind: "tui", - missing: async (item) => { - if (!item.pkg) return - const themes = readPackageThemes(item.spec, item.pkg) - if (!themes.length) return - return { - spec: item.spec, - target: item.target, - themes, + expect(loaded).toEqual([ + { + spec: "acme-plugin@1.0.0", + target: tmp.extra.mod, + themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))], + }, + ]) + expect(missing).toHaveLength(0) + } finally { + install.mockRestore() } - }, - report: { - missing(_candidate, _retry, message) { - missing.push(message) - }, - }, - }) + }), + ), + ) - expect(loaded).toEqual([ - { - spec: "acme-plugin@1.0.0", - target: tmp.extra.mod, - themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))], - }, - ]) - expect(missing).toHaveLength(0) - } finally { - install.mockRestore() - } - }) - - test("passes package metadata for entrypoint tui plugins", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("passes package metadata for entrypoint tui plugins", () => + withTmp( + async (dir) => { const mod = path.join(dir, "mods", "acme-plugin") await fs.mkdir(path.join(mod, "themes"), { recursive: true }) await Bun.write( @@ -1012,64 +1086,70 @@ export default { await Bun.write(path.join(mod, "themes", "night.json"), "{}\n") return { mod } }, - }) + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + try { + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: [ + { + spec: "acme-plugin@1.0.0", + scope: "local" as const, + source: tmp.path, + }, + ], + kind: "tui", + finish: async (item) => { + if (!item.pkg) return + return { + spec: item.spec, + themes: readPackageThemes(item.spec, item.pkg), + } + }, + }), + ) - try { - const loaded = await PluginLoader.loadExternal({ - items: [ - { - spec: "acme-plugin@1.0.0", - scope: "local" as const, - source: tmp.path, - }, - ], - kind: "tui", - finish: async (item) => { - if (!item.pkg) return - return { - spec: item.spec, - themes: readPackageThemes(item.spec, item.pkg), + expect(loaded).toEqual([ + { + spec: "acme-plugin@1.0.0", + themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))], + }, + ]) + } finally { + install.mockRestore() } - }, - }) + }), + ), + ) - expect(loaded).toEqual([ - { - spec: "acme-plugin@1.0.0", - themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))], - }, - ]) - } finally { - install.mockRestore() - } - }) - - test("rejects oc-themes path traversal", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("rejects oc-themes path traversal", () => + withTmp( + async (dir) => { const mod = path.join(dir, "mod") await fs.mkdir(mod, { recursive: true }) const file = path.join(mod, "package.json") await Bun.write(file, JSON.stringify({ name: "acme", "oc-themes": ["../escape.json"] }, null, 2)) return { mod, file } }, - }) + (tmp) => + Effect.gen(function* () { + const json = yield* Effect.promise(() => Filesystem.readJson>(tmp.extra.file)) + expect(() => + readPackageThemes("acme", { + dir: tmp.extra.mod, + pkg: tmp.extra.file, + json, + }), + ).toThrow("outside plugin directory") + }), + ), + ) - const json = await Filesystem.readJson>(tmp.extra.file) - expect(() => - readPackageThemes("acme", { - dir: tmp.extra.mod, - pkg: tmp.extra.file, - json, - }), - ).toThrow("outside plugin directory") - }) - - test("retries failed file plugins once after wait and keeps order", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("retries failed file plugins once after wait and keeps order", () => + withTmp( + async (dir) => { const a = path.join(dir, "a") const b = path.join(dir, "b") const aSpec = pathToFileURL(a).href @@ -1078,110 +1158,122 @@ export default { await fs.mkdir(b, { recursive: true }) return { a, b, aSpec, bSpec } }, - }) + (tmp) => + Effect.gen(function* () { + let wait = 0 + const calls: Array<[string, boolean]> = [] - let wait = 0 - const calls: Array<[string, boolean]> = [] + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({ + spec, + scope: "local" as const, + source: tmp.path, + })), + kind: "tui", + wait: async () => { + wait += 1 + await Bun.write(path.join(tmp.extra.a, "index.ts"), "export default {}\n") + await Bun.write(path.join(tmp.extra.b, "index.ts"), "export default {}\n") + }, + report: { + start(candidate, retry) { + calls.push([candidate.plan.spec, retry]) + }, + }, + }), + ) - const loaded = await PluginLoader.loadExternal({ - items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({ - spec, - scope: "local" as const, - source: tmp.path, - })), - kind: "tui", - wait: async () => { - wait += 1 - await Bun.write(path.join(tmp.extra.a, "index.ts"), "export default {}\n") - await Bun.write(path.join(tmp.extra.b, "index.ts"), "export default {}\n") - }, - report: { - start(candidate, retry) { - calls.push([candidate.plan.spec, retry]) - }, - }, - }) + expect(wait).toBe(1) + expect(calls).toEqual([ + [tmp.extra.aSpec, false], + [tmp.extra.bSpec, false], + [tmp.extra.aSpec, true], + [tmp.extra.bSpec, true], + ]) + expect(loaded.map((item) => item.spec)).toEqual([tmp.extra.aSpec, tmp.extra.bSpec]) + }), + ), + ) - expect(wait).toBe(1) - expect(calls).toEqual([ - [tmp.extra.aSpec, false], - [tmp.extra.bSpec, false], - [tmp.extra.aSpec, true], - [tmp.extra.bSpec, true], - ]) - expect(loaded.map((item) => item.spec)).toEqual([tmp.extra.aSpec, tmp.extra.bSpec]) - }) - - test("retries file plugins when finish returns undefined", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("retries file plugins when finish returns undefined", () => + withTmp( + async (dir) => { const file = path.join(dir, "plugin.ts") const spec = pathToFileURL(file).href await Bun.write(file, "export default {}\n") return { spec } }, - }) + (tmp) => + Effect.gen(function* () { + let wait = 0 + let count = 0 - let wait = 0 - let count = 0 + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: [ + { + spec: tmp.extra.spec, + scope: "local" as const, + source: tmp.path, + }, + ], + kind: "tui", + wait: async () => { + wait += 1 + }, + finish: async (load, _item, retry) => { + count += 1 + if (!retry) return + return { + retry, + spec: load.spec, + } + }, + }), + ) - const loaded = await PluginLoader.loadExternal({ - items: [ - { - spec: tmp.extra.spec, - scope: "local" as const, - source: tmp.path, - }, - ], - kind: "tui", - wait: async () => { - wait += 1 - }, - finish: async (load, _item, retry) => { - count += 1 - if (!retry) return - return { - retry, - spec: load.spec, - } - }, - }) + expect(wait).toBe(1) + expect(count).toBe(2) + expect(loaded).toEqual([{ retry: true, spec: tmp.extra.spec }]) + }), + ), + ) - expect(wait).toBe(1) - expect(count).toBe(2) - expect(loaded).toEqual([{ retry: true, spec: tmp.extra.spec }]) - }) + it.live("does not wait or retry npm plugin failures", () => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom")) + let wait = 0 + const errors: Array<[string, boolean]> = [] - test("does not wait or retry npm plugin failures", async () => { - const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom")) - let wait = 0 - const errors: Array<[string, boolean]> = [] + try { + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: [ + { + spec: "acme-plugin@1.0.0", + scope: "local" as const, + source: "test", + }, + ], + kind: "tui", + wait: async () => { + wait += 1 + }, + report: { + error(_candidate, retry, stage) { + errors.push([stage, retry]) + }, + }, + }), + ) - try { - const loaded = await PluginLoader.loadExternal({ - items: [ - { - spec: "acme-plugin@1.0.0", - scope: "local" as const, - source: "test", - }, - ], - kind: "tui", - wait: async () => { - wait += 1 - }, - report: { - error(_candidate, retry, stage) { - errors.push([stage, retry]) - }, - }, - }) - - expect(loaded).toEqual([]) - expect(wait).toBe(0) - expect(errors).toEqual([["install", false]]) - } finally { - install.mockRestore() - } - }) + expect(loaded).toEqual([]) + expect(wait).toBe(0) + expect(errors).toEqual([["install", false]]) + } finally { + install.mockRestore() + } + }), + ) }) From 1d4613006aa2dc25ec2a8050c87763b74f27c46d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 15:41:46 -0400 Subject: [PATCH 122/188] test(project): migrate instance tests to Effect runner (#27130) --- .../opencode/test/project/instance.test.ts | 61 +++++++++---------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 99b0f0666..5ec64754d 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -1,13 +1,12 @@ import { afterEach, describe, expect } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Effect, Fiber, Layer } from "effect" +import { Deferred, Effect, Fiber, Layer } from "effect" import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { InstanceStore } from "../../src/project/instance-store" -import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" let bootstrapRun: Effect.Effect = Effect.void @@ -75,18 +74,18 @@ describe("InstanceStore", () => { Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const store = yield* InstanceStore.Service - const started = Promise.withResolvers() - const release = Promise.withResolvers() + const started = yield* Deferred.make() + const release = yield* Deferred.make() let initialized = 0 - bootstrapRun = Effect.promise(async () => { + bootstrapRun = Effect.gen(function* () { initialized++ - started.resolve() - await release.promise + yield* Deferred.succeed(started, undefined) + yield* Deferred.await(release) }) const first = yield* store.load({ directory: dir }).pipe(Effect.forkScoped) - yield* Effect.promise(() => started.promise) + yield* Deferred.await(started) bootstrapRun = Effect.sync(() => { initialized++ @@ -94,7 +93,7 @@ describe("InstanceStore", () => { const second = yield* store.load({ directory: dir }).pipe(Effect.forkScoped) expect(initialized).toBe(1) - release.resolve() + yield* Deferred.succeed(release, undefined) const [firstCtx, secondCtx] = yield* Effect.all([Fiber.join(first), Fiber.join(second)]) expect(secondCtx).toBe(firstCtx) @@ -147,8 +146,8 @@ describe("InstanceStore", () => { Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const store = yield* InstanceStore.Service - const reloading = Promise.withResolvers() - const releaseReload = Promise.withResolvers() + const reloading = yield* Deferred.make() + const releaseReload = yield* Deferred.make() const disposed: Array = [] const off = registerDisposer(async (directory) => { disposed.push(directory) @@ -156,15 +155,15 @@ describe("InstanceStore", () => { yield* Effect.addFinalizer(() => Effect.sync(off)) const first = yield* store.load({ directory: dir }) - bootstrapRun = Effect.promise(async () => { - reloading.resolve() - await releaseReload.promise + bootstrapRun = Effect.gen(function* () { + yield* Deferred.succeed(reloading, undefined) + yield* Deferred.await(releaseReload) }) const reload = yield* store.reload({ directory: dir }).pipe(Effect.forkScoped) - yield* Effect.promise(() => reloading.promise) + yield* Deferred.await(reloading) const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped) - releaseReload.resolve() + yield* Deferred.succeed(releaseReload, undefined) const second = yield* Fiber.join(reload) yield* Fiber.join(staleDispose) @@ -178,23 +177,23 @@ describe("InstanceStore", () => { Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const store = yield* InstanceStore.Service - const disposing = Promise.withResolvers() - const releaseDispose = Promise.withResolvers() + const disposing = yield* Deferred.make() + const releaseDispose = yield* Deferred.make() const disposed: Array = [] const off = registerDisposer(async (directory) => { disposed.push(directory) - disposing.resolve() - await releaseDispose.promise + Deferred.doneUnsafe(disposing, Effect.void) + await Effect.runPromise(Deferred.await(releaseDispose)) }) yield* Effect.addFinalizer(() => Effect.sync(off)) yield* store.load({ directory: dir }) const first = yield* store.disposeAll().pipe(Effect.forkScoped) - yield* Effect.promise(() => disposing.promise) + yield* Deferred.await(disposing) const second = yield* store.disposeAll().pipe(Effect.forkScoped) expect(disposed).toEqual([dir]) - releaseDispose.resolve() + yield* Deferred.succeed(releaseDispose, undefined) yield* Effect.all([Fiber.join(first), Fiber.join(second)]) expect(disposed).toEqual([dir]) }), @@ -221,19 +220,17 @@ describe("InstanceStore", () => { }), ) - it.live("provides legacy Promise callers with instance ALS", () => + it.instance("provides legacy Promise callers with instance ALS", () => Effect.gen(function* () { - const dir = yield* tmpdirScoped({ git: true }) + const test = yield* TestInstance + const ctx = yield* InstanceRef + if (!ctx) throw new Error("InstanceRef not provided") - const directory = yield* Effect.promise(() => - WithInstance.provide({ - directory: dir, - fn: () => Instance.directory, - }), - ) + const directory = yield* Effect.promise(() => Promise.resolve(Instance.restore(ctx, () => Instance.directory))) - expect(directory).toBe(dir) + expect(directory).toBe(test.directory) expect(() => Instance.current).toThrow() }), + { git: true }, ) }) From e46ab34d27ccbe7afe108331d6bfed59dcc6a14e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 19:44:26 +0000 Subject: [PATCH 123/188] chore: generate --- packages/opencode/src/acp/agent.ts | 3 ++- .../test/plugin/loader-shared.test.ts | 4 +++- .../opencode/test/project/instance.test.ts | 20 ++++++++++--------- .../opencode/test/skill/discovery.test.ts | 4 +++- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index a8eacf835..aa123d599 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -1328,7 +1328,8 @@ export class Agent implements ACPAgent { if (!current) { this.sessionManager.setModel(session.id, model) } - const agent = session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name + const agent = + session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name const parts: Array< | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index ffdb3291b..1b6372390 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -845,7 +845,9 @@ describe("plugin.loader.shared", () => { (tmp) => Effect.gen(function* () { yield* load(tmp.path) - expect(yield* Effect.promise(() => Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark))).toEqual({ + expect( + yield* Effect.promise(() => Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark)), + ).toEqual({ source: "tuple", enabled: true, }) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 5ec64754d..dc87fde45 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -220,17 +220,19 @@ describe("InstanceStore", () => { }), ) - it.instance("provides legacy Promise callers with instance ALS", () => - Effect.gen(function* () { - const test = yield* TestInstance - const ctx = yield* InstanceRef - if (!ctx) throw new Error("InstanceRef not provided") + it.instance( + "provides legacy Promise callers with instance ALS", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const ctx = yield* InstanceRef + if (!ctx) throw new Error("InstanceRef not provided") - const directory = yield* Effect.promise(() => Promise.resolve(Instance.restore(ctx, () => Instance.directory))) + const directory = yield* Effect.promise(() => Promise.resolve(Instance.restore(ctx, () => Instance.directory))) - expect(directory).toBe(test.directory) - expect(() => Instance.current).toThrow() - }), + expect(directory).toBe(test.directory) + expect(() => Instance.current).toThrow() + }), { git: true }, ) }) diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index 43018d9a4..0b07d4df0 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -101,7 +101,9 @@ describe("Discovery.pull", () => { const refs = path.join(agentsSdk, "references") expect(yield* Effect.promise(() => Filesystem.exists(path.join(agentsSdk, "SKILL.md")))).toBe(true) // agents-sdk has reference files per the index - const refDir = yield* Effect.promise(() => Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true }))) + const refDir = yield* Effect.promise(() => + Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true })), + ) expect(refDir.length).toBeGreaterThan(0) } }), From c5849e56cc5280427be59c2252429b6c9938951b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 15:47:17 -0400 Subject: [PATCH 124/188] test(project): migrate project tests to Effect runner (#27134) --- .../opencode/test/project/project.test.ts | 802 ++++++++++-------- 1 file changed, 427 insertions(+), 375 deletions(-) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 9906b3164..9ed1e60bb 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -4,26 +4,41 @@ import { Project } from "@/project/project" import * as Log from "@opencode-ai/core/util/log" import { $ } from "bun" import path from "path" -import { tmpdir } from "../fixture/fixture" +import { tmpdirScoped } from "../fixture/fixture" import { GlobalBus } from "../../src/bus/global" import { ProjectID } from "../../src/project/schema" -import { Effect, Layer, Stream } from "effect" +import { Cause, Effect, Exit, Layer, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { testEffect } from "../lib/effect" void Log.init({ print: false }) const encoder = new TextEncoder() -function run(fn: (svc: Project.Interface) => Effect.Effect, layer = Project.defaultLayer) { - return Effect.runPromise( - Effect.gen(function* () { - const svc = yield* Project.Service - return yield* fn(svc) - }).pipe(Effect.provide(layer)), - ) +const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer) +const it = testEffect(layer) + +function run(fn: (svc: Project.Interface) => Effect.Effect) { + return Effect.gen(function* () { + const svc = yield* Project.Service + return yield* fn(svc) + }) +} + +function gitTmpdir() { + return Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* Effect.promise(() => $`git init`.cwd(tmp).quiet()) + yield* Effect.promise(() => $`git config core.fsmonitor false`.cwd(tmp).quiet()) + yield* Effect.promise(() => $`git config commit.gpgsign false`.cwd(tmp).quiet()) + yield* Effect.promise(() => $`git config user.email "test@opencode.test"`.cwd(tmp).quiet()) + yield* Effect.promise(() => $`git config user.name "Test"`.cwd(tmp).quiet()) + yield* Effect.promise(() => $`git commit --allow-empty -m ${`root commit ${tmp}`}`.cwd(tmp).quiet()) + return tmp + }) } /** @@ -70,400 +85,430 @@ function projectLayerWithFailure(failArg: string) { ) } +const failureIt = (failArg: string) => testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer)) + describe("Project.fromDirectory", () => { - test("should handle git repository with no commits", async () => { - await using tmp = await tmpdir() - await $`git init`.cwd(tmp.path).quiet() + it.live("should handle git repository with no commits", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* Effect.promise(() => $`git init`.cwd(tmp).quiet()) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - expect(project).toBeDefined() - expect(project.id).toBe(ProjectID.global) - expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) + expect(project).toBeDefined() + expect(project.id).toBe(ProjectID.global) + expect(project.vcs).toBe("git") + expect(project.worktree).toBe(tmp) - const opencodeFile = path.join(tmp.path, ".git", "opencode") - expect(await Bun.file(opencodeFile).exists()).toBe(false) - }) + const opencodeFile = path.join(tmp, ".git", "opencode") + expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(false) + }), + ) - test("should handle git repository with commits", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("should handle git repository with commits", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - expect(project).toBeDefined() - expect(project.id).not.toBe(ProjectID.global) - expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) + expect(project).toBeDefined() + expect(project.id).not.toBe(ProjectID.global) + expect(project.vcs).toBe("git") + expect(project.worktree).toBe(tmp) - const opencodeFile = path.join(tmp.path, ".git", "opencode") - expect(await Bun.file(opencodeFile).exists()).toBe(true) - }) + const opencodeFile = path.join(tmp, ".git", "opencode") + expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(true) + }), + ) - test("returns global for non-git directory", async () => { - await using tmp = await tmpdir() - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) - expect(project.id).toBe(ProjectID.global) - }) + it.live("returns global for non-git directory", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + expect(project.id).toBe(ProjectID.global) + }), + ) - test("derives stable project ID from root commit", async () => { - await using tmp = await tmpdir({ git: true }) - const { project: a } = await run((svc) => svc.fromDirectory(tmp.path)) - const { project: b } = await run((svc) => svc.fromDirectory(tmp.path)) - expect(b.id).toBe(a.id) - }) + it.live("derives stable project ID from root commit", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project: a } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project: b } = yield* run((svc) => svc.fromDirectory(tmp)) + expect(b.id).toBe(a.id) + }), + ) }) describe("Project.fromDirectory git failure paths", () => { - test("keeps vcs when rev-list exits non-zero (no commits)", async () => { - await using tmp = await tmpdir() - await $`git init`.cwd(tmp.path).quiet() + it.live("keeps vcs when rev-list exits non-zero (no commits)", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* Effect.promise(() => $`git init`.cwd(tmp).quiet()) - // rev-list fails because HEAD doesn't exist yet — this is the natural scenario - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) - expect(project.vcs).toBe("git") - expect(project.id).toBe(ProjectID.global) - expect(project.worktree).toBe(tmp.path) - }) + // rev-list fails because HEAD doesn't exist yet: this is the natural scenario. + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + expect(project.vcs).toBe("git") + expect(project.id).toBe(ProjectID.global) + expect(project.worktree).toBe(tmp) + }), + ) - test("handles show-toplevel failure gracefully", async () => { - await using tmp = await tmpdir({ git: true }) - const layer = projectLayerWithFailure("--show-toplevel") + failureIt("--show-toplevel").live("handles show-toplevel failure gracefully", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() - const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path), layer) - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) - }) + const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) + expect(project.worktree).toBe(tmp) + expect(sandbox).toBe(tmp) + }), + ) - test("handles git-common-dir failure gracefully", async () => { - await using tmp = await tmpdir({ git: true }) - const layer = projectLayerWithFailure("--git-common-dir") + failureIt("--git-common-dir").live("handles git-common-dir failure gracefully", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() - const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path), layer) - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) - }) + const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) + expect(project.worktree).toBe(tmp) + expect(sandbox).toBe(tmp) + }), + ) }) describe("Project.fromDirectory with worktrees", () => { - test("should set worktree to root when called from root", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("should set worktree to root when called from root", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() - const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path)) + const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) - expect(project.sandboxes).not.toContain(tmp.path) - }) + expect(project.worktree).toBe(tmp) + expect(sandbox).toBe(tmp) + expect(project.sandboxes).not.toContain(tmp) + }), + ) - test("should set worktree to root when called from a worktree", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("should set worktree to root when called from a worktree", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() - const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree") - try { - await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet() + const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-worktree") + yield* Effect.addFinalizer(() => Effect.promise(() => $`git worktree remove ${worktreePath}`.cwd(tmp).quiet().catch(() => {}))) + yield* Effect.promise(() => $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp).quiet()) - const { project, sandbox } = await run((svc) => svc.fromDirectory(worktreePath)) + const { project, sandbox } = yield* run((svc) => svc.fromDirectory(worktreePath)) - expect(project.worktree).toBe(tmp.path) + expect(project.worktree).toBe(tmp) expect(sandbox).toBe(worktreePath) expect(project.sandboxes).toContain(worktreePath) - expect(project.sandboxes).not.toContain(tmp.path) - } finally { - await $`git worktree remove ${worktreePath}` - .cwd(tmp.path) - .quiet() - .catch(() => {}) - } - }) + expect(project.sandboxes).not.toContain(tmp) + }), + ) - test("worktree should share project ID with main repo", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("worktree should share project ID with main repo", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() - const { project: main } = await run((svc) => svc.fromDirectory(tmp.path)) + const { project: main } = yield* run((svc) => svc.fromDirectory(tmp)) - const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared") - try { - await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet() + const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-wt-shared") + yield* Effect.addFinalizer(() => Effect.promise(() => $`git worktree remove ${worktreePath}`.cwd(tmp).quiet().catch(() => {}))) + yield* Effect.promise(() => $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp).quiet()) - const { project: wt } = await run((svc) => svc.fromDirectory(worktreePath)) + const { project: wt } = yield* run((svc) => svc.fromDirectory(worktreePath)) expect(wt.id).toBe(main.id) // Cache should live in the common .git dir, not the worktree's .git file - const cache = path.join(tmp.path, ".git", "opencode") - const exists = await Bun.file(cache).exists() + const cache = path.join(tmp, ".git", "opencode") + const exists = yield* Effect.promise(() => Bun.file(cache).exists()) expect(exists).toBe(true) - } finally { - await $`git worktree remove ${worktreePath}` - .cwd(tmp.path) - .quiet() - .catch(() => {}) - } - }) + }), + ) - test("separate clones of the same repo should share project ID", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("separate clones of the same repo should share project ID", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() // Create a bare remote, push, then clone into a second directory - const bare = tmp.path + "-bare" - const clone = tmp.path + "-clone" - try { - await $`git clone --bare ${tmp.path} ${bare}`.quiet() - await $`git clone ${bare} ${clone}`.quiet() + const bare = tmp + "-bare" + const clone = tmp + "-clone" + yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${bare} ${clone}`.quiet().nothrow()).pipe(Effect.ignore)) + yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet()) + yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet()) - const { project: a } = await run((svc) => svc.fromDirectory(tmp.path)) - const { project: b } = await run((svc) => svc.fromDirectory(clone)) + const { project: a } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project: b } = yield* run((svc) => svc.fromDirectory(clone)) expect(b.id).toBe(a.id) - } finally { - await $`rm -rf ${bare} ${clone}`.quiet().nothrow() - } - }) + }), + ) - test("should accumulate multiple worktrees in sandboxes", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("should accumulate multiple worktrees in sandboxes", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() - const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1") - const worktree2 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt2") - try { - await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet() - await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet() + const worktree1 = path.join(tmp, "..", path.basename(tmp) + "-wt1") + const worktree2 = path.join(tmp, "..", path.basename(tmp) + "-wt2") + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + yield* Effect.promise(() => $`git worktree remove ${worktree1}`.cwd(tmp).quiet().catch(() => {})) + yield* Effect.promise(() => $`git worktree remove ${worktree2}`.cwd(tmp).quiet().catch(() => {})) + }), + ) + yield* Effect.promise(() => $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp).quiet()) + yield* Effect.promise(() => $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp).quiet()) - await run((svc) => svc.fromDirectory(worktree1)) - const { project } = await run((svc) => svc.fromDirectory(worktree2)) + yield* run((svc) => svc.fromDirectory(worktree1)) + const { project } = yield* run((svc) => svc.fromDirectory(worktree2)) - expect(project.worktree).toBe(tmp.path) + expect(project.worktree).toBe(tmp) expect(project.sandboxes).toContain(worktree1) expect(project.sandboxes).toContain(worktree2) - expect(project.sandboxes).not.toContain(tmp.path) - } finally { - await $`git worktree remove ${worktree1}` - .cwd(tmp.path) - .quiet() - .catch(() => {}) - await $`git worktree remove ${worktree2}` - .cwd(tmp.path) - .quiet() - .catch(() => {}) - } - }) + expect(project.sandboxes).not.toContain(tmp) + }), + ) }) describe("Project.discover", () => { - test("should discover favicon.png in root", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should discover favicon.png in root", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - await Bun.write(path.join(tmp.path, "favicon.png"), pngData) + const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData)) - await run((svc) => svc.discover(project)) + yield* run((svc) => svc.discover(project)) - const updated = Project.get(project.id) - expect(updated).toBeDefined() - expect(updated!.icon).toBeDefined() - expect(updated!.icon?.url).toStartWith("data:") - expect(updated!.icon?.url).toContain("base64") - expect(updated!.icon?.color).toBeUndefined() - }) + const updated = Project.get(project.id) + expect(updated).toBeDefined() + expect(updated!.icon).toBeDefined() + expect(updated!.icon?.url).toStartWith("data:") + expect(updated!.icon?.url).toContain("base64") + expect(updated!.icon?.color).toBeUndefined() + }), + ) - test("should not discover non-image files", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should not discover non-image files", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image") + yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.txt"), "not an image")) - await run((svc) => svc.discover(project)) + yield* run((svc) => svc.discover(project)) - const updated = Project.get(project.id) - expect(updated).toBeDefined() - expect(updated!.icon).toBeUndefined() - }) + const updated = Project.get(project.id) + expect(updated).toBeDefined() + expect(updated!.icon).toBeUndefined() + }), + ) - test("should not discover favicon when override is set", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should not discover favicon when override is set", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - await run((svc) => - svc.update({ - projectID: project.id, - icon: { override: "data:image/png;base64,override" }, - }), - ) + yield* run((svc) => + svc.update({ + projectID: project.id, + icon: { override: "data:image/png;base64,override" }, + }), + ) - const updatedProject = await run((svc) => svc.get(project.id)) - if (!updatedProject) throw new Error("Project not found") + const updatedProject = yield* run((svc) => svc.get(project.id)) + if (!updatedProject) throw new Error("Project not found") - const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - await Bun.write(path.join(tmp.path, "favicon.png"), pngData) + const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData)) - await run((svc) => svc.discover(updatedProject)) + yield* run((svc) => svc.discover(updatedProject)) - const updated = Project.get(project.id) - expect(updated).toBeDefined() - expect(updated!.icon?.override).toBe("data:image/png;base64,override") - expect(updated!.icon?.url).toBeUndefined() - }) + const updated = Project.get(project.id) + expect(updated).toBeDefined() + expect(updated!.icon?.override).toBe("data:image/png;base64,override") + expect(updated!.icon?.url).toBeUndefined() + }), + ) }) describe("Project.update", () => { - test("should update name", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should update name", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const updated = await run((svc) => - svc.update({ - projectID: project.id, - name: "New Project Name", - }), - ) + const updated = yield* run((svc) => + svc.update({ + projectID: project.id, + name: "New Project Name", + }), + ) - expect(updated.name).toBe("New Project Name") + expect(updated.name).toBe("New Project Name") - const fromDb = Project.get(project.id) - expect(fromDb?.name).toBe("New Project Name") - }) + const fromDb = Project.get(project.id) + expect(fromDb?.name).toBe("New Project Name") + }), + ) - test("should update icon url", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should update icon url", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const updated = await run((svc) => - svc.update({ - projectID: project.id, - icon: { url: "https://example.com/icon.png" }, - }), - ) + const updated = yield* run((svc) => + svc.update({ + projectID: project.id, + icon: { url: "https://example.com/icon.png" }, + }), + ) - expect(updated.icon?.url).toBe("https://example.com/icon.png") + expect(updated.icon?.url).toBe("https://example.com/icon.png") - const fromDb = Project.get(project.id) - expect(fromDb?.icon?.url).toBe("https://example.com/icon.png") - }) + const fromDb = Project.get(project.id) + expect(fromDb?.icon?.url).toBe("https://example.com/icon.png") + }), + ) - test("should update icon color", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should update icon color", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const updated = await run((svc) => - svc.update({ - projectID: project.id, - icon: { color: "#ff0000" }, - }), - ) + const updated = yield* run((svc) => + svc.update({ + projectID: project.id, + icon: { color: "#ff0000" }, + }), + ) - expect(updated.icon?.color).toBe("#ff0000") + expect(updated.icon?.color).toBe("#ff0000") - const fromDb = Project.get(project.id) - expect(fromDb?.icon?.color).toBe("#ff0000") - }) + const fromDb = Project.get(project.id) + expect(fromDb?.icon?.color).toBe("#ff0000") + }), + ) - test("should update icon override", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should update icon override", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const updated = await run((svc) => - svc.update({ - projectID: project.id, - icon: { override: "data:image/png;base64,abc123" }, - }), - ) + const updated = yield* run((svc) => + svc.update({ + projectID: project.id, + icon: { override: "data:image/png;base64,abc123" }, + }), + ) - expect(updated.icon?.override).toBe("data:image/png;base64,abc123") + expect(updated.icon?.override).toBe("data:image/png;base64,abc123") - const fromDb = Project.get(project.id) - expect(fromDb?.icon?.override).toBe("data:image/png;base64,abc123") - }) + const fromDb = Project.get(project.id) + expect(fromDb?.icon?.override).toBe("data:image/png;base64,abc123") + }), + ) - test("should update commands", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should update commands", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const updated = await run((svc) => - svc.update({ - projectID: project.id, - commands: { start: "npm run dev" }, - }), - ) + const updated = yield* run((svc) => + svc.update({ + projectID: project.id, + commands: { start: "npm run dev" }, + }), + ) - expect(updated.commands?.start).toBe("npm run dev") + expect(updated.commands?.start).toBe("npm run dev") - const fromDb = Project.get(project.id) - expect(fromDb?.commands?.start).toBe("npm run dev") - }) + const fromDb = Project.get(project.id) + expect(fromDb?.commands?.start).toBe("npm run dev") + }), + ) - test("should throw error when project not found", async () => { - await expect( - run((svc) => + it.live("should throw error when project not found", () => + Effect.gen(function* () { + const exit = yield* run((svc) => svc.update({ projectID: ProjectID.make("nonexistent-project-id"), name: "Should Fail", }), - ), - ).rejects.toThrow("Project not found: nonexistent-project-id") - }) + ).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error instanceof Error ? error.message : String(error)).toContain("Project not found: nonexistent-project-id") + } + }), + ) - test("should emit GlobalBus event on update", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should emit GlobalBus event on update", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - let eventPayload: any = null - const on = (data: any) => { - eventPayload = data - } - GlobalBus.on("event", on) + let eventPayload: any = null + const on = (data: any) => { + eventPayload = data + } + GlobalBus.on("event", on) + yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on))) - try { - await run((svc) => svc.update({ projectID: project.id, name: "Updated Name" })) + yield* run((svc) => svc.update({ projectID: project.id, name: "Updated Name" })) expect(eventPayload).not.toBeNull() expect(eventPayload.payload.type).toBe("project.updated") expect(eventPayload.payload.properties.name).toBe("Updated Name") - } finally { - GlobalBus.off("event", on) - } - }) + }), + ) - test("should update multiple fields at once", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should update multiple fields at once", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const updated = await run((svc) => - svc.update({ - projectID: project.id, - name: "Multi Update", - icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" }, - commands: { start: "make start" }, - }), - ) + const updated = yield* run((svc) => + svc.update({ + projectID: project.id, + name: "Multi Update", + icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" }, + commands: { start: "make start" }, + }), + ) - expect(updated.name).toBe("Multi Update") - expect(updated.icon?.url).toBe("https://example.com/favicon.ico") - expect(updated.icon?.override).toBe("data:image/png;base64,abc123") - expect(updated.icon?.color).toBe("#00ff00") - expect(updated.commands?.start).toBe("make start") - }) + expect(updated.name).toBe("Multi Update") + expect(updated.icon?.url).toBe("https://example.com/favicon.ico") + expect(updated.icon?.override).toBe("data:image/png;base64,abc123") + expect(updated.icon?.color).toBe("#00ff00") + expect(updated.commands?.start).toBe("make start") + }), + ) }) describe("Project.list and Project.get", () => { - test("list returns all projects", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("list returns all projects", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const all = Project.list() - expect(all.length).toBeGreaterThan(0) - expect(all.find((p) => p.id === project.id)).toBeDefined() - }) + const all = Project.list() + expect(all.length).toBeGreaterThan(0) + expect(all.find((p) => p.id === project.id)).toBeDefined() + }), + ) - test("get returns project by id", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("get returns project by id", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const found = Project.get(project.id) - expect(found).toBeDefined() - expect(found!.id).toBe(project.id) - }) + const found = Project.get(project.id) + expect(found).toBeDefined() + expect(found!.id).toBe(project.id) + }), + ) test("get returns undefined for unknown id", () => { const found = Project.get(ProjectID.make("nonexistent")) @@ -472,65 +517,72 @@ describe("Project.list and Project.get", () => { }) describe("Project.setInitialized", () => { - test("sets time_initialized on project", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("sets time_initialized on project", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - expect(project.time.initialized).toBeUndefined() + expect(project.time.initialized).toBeUndefined() - Project.setInitialized(project.id) + Project.setInitialized(project.id) - const updated = Project.get(project.id) - expect(updated?.time.initialized).toBeDefined() - }) + const updated = Project.get(project.id) + expect(updated?.time.initialized).toBeDefined() + }), + ) }) describe("Project.addSandbox and Project.removeSandbox", () => { - test("addSandbox adds directory and removeSandbox removes it", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) - const sandboxDir = path.join(tmp.path, "sandbox-test") + it.live("addSandbox adds directory and removeSandbox removes it", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const sandboxDir = path.join(tmp, "sandbox-test") - await run((svc) => svc.addSandbox(project.id, sandboxDir)) + yield* run((svc) => svc.addSandbox(project.id, sandboxDir)) - let found = Project.get(project.id) - expect(found?.sandboxes).toContain(sandboxDir) + let found = Project.get(project.id) + expect(found?.sandboxes).toContain(sandboxDir) - await run((svc) => svc.removeSandbox(project.id, sandboxDir)) + yield* run((svc) => svc.removeSandbox(project.id, sandboxDir)) - found = Project.get(project.id) - expect(found?.sandboxes).not.toContain(sandboxDir) - }) + found = Project.get(project.id) + expect(found?.sandboxes).not.toContain(sandboxDir) + }), + ) - test("addSandbox emits GlobalBus event", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) - const sandboxDir = path.join(tmp.path, "sandbox-event") + it.live("addSandbox emits GlobalBus event", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const sandboxDir = path.join(tmp, "sandbox-event") - const events: any[] = [] - const on = (evt: any) => events.push(evt) - GlobalBus.on("event", on) + const events: any[] = [] + const on = (evt: any) => events.push(evt) + GlobalBus.on("event", on) + yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on))) - await run((svc) => svc.addSandbox(project.id, sandboxDir)) + yield* run((svc) => svc.addSandbox(project.id, sandboxDir)) - GlobalBus.off("event", on) - expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true) - }) + expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true) + }), + ) }) describe("Project.fromDirectory with bare repos", () => { - test("worktree from bare repo should cache in bare repo, not parent", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("worktree from bare repo should cache in bare repo, not parent", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() - const parentDir = path.dirname(tmp.path) - const barePath = path.join(parentDir, `bare-${Date.now()}.git`) - const worktreePath = path.join(parentDir, `worktree-${Date.now()}`) + const parentDir = path.dirname(tmp) + const barePath = path.join(parentDir, `bare-${Date.now()}.git`) + const worktreePath = path.join(parentDir, `worktree-${Date.now()}`) + yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()).pipe(Effect.ignore)) - try { - await $`git clone --bare ${tmp.path} ${barePath}`.quiet() - await $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet() + yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet()) + yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()) - const { project } = await run((svc) => svc.fromDirectory(worktreePath)) + const { project } = yield* run((svc) => svc.fromDirectory(worktreePath)) expect(project.id).not.toBe(ProjectID.global) expect(project.worktree).toBe(barePath) @@ -538,31 +590,34 @@ describe("Project.fromDirectory with bare repos", () => { const correctCache = path.join(barePath, "opencode") const wrongCache = path.join(parentDir, ".git", "opencode") - expect(await Bun.file(correctCache).exists()).toBe(true) - expect(await Bun.file(wrongCache).exists()).toBe(false) - } finally { - await $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow() - } - }) + expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true) + expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false) + }), + ) - test("different bare repos under same parent should not share project ID", async () => { - await using tmp1 = await tmpdir({ git: true }) - await using tmp2 = await tmpdir({ git: true }) + it.live("different bare repos under same parent should not share project ID", () => + Effect.gen(function* () { + const tmp1 = yield* gitTmpdir() + const tmp2 = yield* gitTmpdir() - const parentDir = path.dirname(tmp1.path) - const bareA = path.join(parentDir, `bare-a-${Date.now()}.git`) - const bareB = path.join(parentDir, `bare-b-${Date.now()}.git`) - const worktreeA = path.join(parentDir, `wt-a-${Date.now()}`) - const worktreeB = path.join(parentDir, `wt-b-${Date.now()}`) + const parentDir = path.dirname(tmp1) + const bareA = path.join(parentDir, `bare-a-${Date.now()}.git`) + const bareB = path.join(parentDir, `bare-b-${Date.now()}.git`) + const worktreeA = path.join(parentDir, `wt-a-${Date.now()}`) + const worktreeB = path.join(parentDir, `wt-b-${Date.now()}`) + yield* Effect.addFinalizer(() => + Effect.promise(() => $`rm -rf ${bareA} ${bareB} ${worktreeA} ${worktreeB}`.quiet().nothrow()).pipe( + Effect.ignore, + ), + ) - try { - await $`git clone --bare ${tmp1.path} ${bareA}`.quiet() - await $`git clone --bare ${tmp2.path} ${bareB}`.quiet() - await $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet() - await $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet() + yield* Effect.promise(() => $`git clone --bare ${tmp1} ${bareA}`.quiet()) + yield* Effect.promise(() => $`git clone --bare ${tmp2} ${bareB}`.quiet()) + yield* Effect.promise(() => $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet()) + yield* Effect.promise(() => $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet()) - const { project: projA } = await run((svc) => svc.fromDirectory(worktreeA)) - const { project: projB } = await run((svc) => svc.fromDirectory(worktreeB)) + const { project: projA } = yield* run((svc) => svc.fromDirectory(worktreeA)) + const { project: projB } = yield* run((svc) => svc.fromDirectory(worktreeB)) expect(projA.id).not.toBe(projB.id) @@ -570,34 +625,31 @@ describe("Project.fromDirectory with bare repos", () => { const cacheB = path.join(bareB, "opencode") const wrongCache = path.join(parentDir, ".git", "opencode") - expect(await Bun.file(cacheA).exists()).toBe(true) - expect(await Bun.file(cacheB).exists()).toBe(true) - expect(await Bun.file(wrongCache).exists()).toBe(false) - } finally { - await $`rm -rf ${bareA} ${bareB} ${worktreeA} ${worktreeB}`.quiet().nothrow() - } - }) + expect(yield* Effect.promise(() => Bun.file(cacheA).exists())).toBe(true) + expect(yield* Effect.promise(() => Bun.file(cacheB).exists())).toBe(true) + expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false) + }), + ) - test("bare repo without .git suffix is still detected via core.bare", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("bare repo without .git suffix is still detected via core.bare", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() - const parentDir = path.dirname(tmp.path) - const barePath = path.join(parentDir, `bare-no-suffix-${Date.now()}`) - const worktreePath = path.join(parentDir, `worktree-${Date.now()}`) + const parentDir = path.dirname(tmp) + const barePath = path.join(parentDir, `bare-no-suffix-${Date.now()}`) + const worktreePath = path.join(parentDir, `worktree-${Date.now()}`) + yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()).pipe(Effect.ignore)) - try { - await $`git clone --bare ${tmp.path} ${barePath}`.quiet() - await $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet() + yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet()) + yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()) - const { project } = await run((svc) => svc.fromDirectory(worktreePath)) + const { project } = yield* run((svc) => svc.fromDirectory(worktreePath)) expect(project.id).not.toBe(ProjectID.global) expect(project.worktree).toBe(barePath) const correctCache = path.join(barePath, "opencode") - expect(await Bun.file(correctCache).exists()).toBe(true) - } finally { - await $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow() - } - }) + expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true) + }), + ) }) From f7dbb4dac464aaf0c47dc222e314f68777ebdf06 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 19:48:35 +0000 Subject: [PATCH 125/188] chore: generate --- .../opencode/test/project/project.test.ts | 53 +++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 9ed1e60bb..9b599c2d6 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -85,7 +85,8 @@ function projectLayerWithFailure(failArg: string) { ) } -const failureIt = (failArg: string) => testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer)) +const failureIt = (failArg: string) => + testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer)) describe("Project.fromDirectory", () => { it.live("should handle git repository with no commits", () => @@ -192,7 +193,14 @@ describe("Project.fromDirectory with worktrees", () => { const tmp = yield* gitTmpdir() const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-worktree") - yield* Effect.addFinalizer(() => Effect.promise(() => $`git worktree remove ${worktreePath}`.cwd(tmp).quiet().catch(() => {}))) + yield* Effect.addFinalizer(() => + Effect.promise(() => + $`git worktree remove ${worktreePath}` + .cwd(tmp) + .quiet() + .catch(() => {}), + ), + ) yield* Effect.promise(() => $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp).quiet()) const { project, sandbox } = yield* run((svc) => svc.fromDirectory(worktreePath)) @@ -211,7 +219,14 @@ describe("Project.fromDirectory with worktrees", () => { const { project: main } = yield* run((svc) => svc.fromDirectory(tmp)) const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-wt-shared") - yield* Effect.addFinalizer(() => Effect.promise(() => $`git worktree remove ${worktreePath}`.cwd(tmp).quiet().catch(() => {}))) + yield* Effect.addFinalizer(() => + Effect.promise(() => + $`git worktree remove ${worktreePath}` + .cwd(tmp) + .quiet() + .catch(() => {}), + ), + ) yield* Effect.promise(() => $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp).quiet()) const { project: wt } = yield* run((svc) => svc.fromDirectory(worktreePath)) @@ -229,10 +244,12 @@ describe("Project.fromDirectory with worktrees", () => { Effect.gen(function* () { const tmp = yield* gitTmpdir() - // Create a bare remote, push, then clone into a second directory + // Create a bare remote, push, then clone into a second directory const bare = tmp + "-bare" const clone = tmp + "-clone" - yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${bare} ${clone}`.quiet().nothrow()).pipe(Effect.ignore)) + yield* Effect.addFinalizer(() => + Effect.promise(() => $`rm -rf ${bare} ${clone}`.quiet().nothrow()).pipe(Effect.ignore), + ) yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet()) yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet()) @@ -251,8 +268,18 @@ describe("Project.fromDirectory with worktrees", () => { const worktree2 = path.join(tmp, "..", path.basename(tmp) + "-wt2") yield* Effect.addFinalizer(() => Effect.gen(function* () { - yield* Effect.promise(() => $`git worktree remove ${worktree1}`.cwd(tmp).quiet().catch(() => {})) - yield* Effect.promise(() => $`git worktree remove ${worktree2}`.cwd(tmp).quiet().catch(() => {})) + yield* Effect.promise(() => + $`git worktree remove ${worktree1}` + .cwd(tmp) + .quiet() + .catch(() => {}), + ) + yield* Effect.promise(() => + $`git worktree remove ${worktree2}` + .cwd(tmp) + .quiet() + .catch(() => {}), + ) }), ) yield* Effect.promise(() => $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp).quiet()) @@ -439,7 +466,9 @@ describe("Project.update", () => { expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { const error = Cause.squash(exit.cause) - expect(error instanceof Error ? error.message : String(error)).toContain("Project not found: nonexistent-project-id") + expect(error instanceof Error ? error.message : String(error)).toContain( + "Project not found: nonexistent-project-id", + ) } }), ) @@ -577,7 +606,9 @@ describe("Project.fromDirectory with bare repos", () => { const parentDir = path.dirname(tmp) const barePath = path.join(parentDir, `bare-${Date.now()}.git`) const worktreePath = path.join(parentDir, `worktree-${Date.now()}`) - yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()).pipe(Effect.ignore)) + yield* Effect.addFinalizer(() => + Effect.promise(() => $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()).pipe(Effect.ignore), + ) yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet()) yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()) @@ -638,7 +669,9 @@ describe("Project.fromDirectory with bare repos", () => { const parentDir = path.dirname(tmp) const barePath = path.join(parentDir, `bare-no-suffix-${Date.now()}`) const worktreePath = path.join(parentDir, `worktree-${Date.now()}`) - yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()).pipe(Effect.ignore)) + yield* Effect.addFinalizer(() => + Effect.promise(() => $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()).pipe(Effect.ignore), + ) yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet()) yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()) From e0d0fe1ff79b2e68fbf496f3957b63aa5bcd53a8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 15:58:54 -0400 Subject: [PATCH 126/188] test(bus): migrate integration tests to Effect runner (#27132) --- .../opencode/test/bus/bus-integration.test.ts | 124 +++++++++--------- 1 file changed, 62 insertions(+), 62 deletions(-) diff --git a/packages/opencode/test/bus/bus-integration.test.ts b/packages/opencode/test/bus/bus-integration.test.ts index 3e3d7a3e9..645a94fb3 100644 --- a/packages/opencode/test/bus/bus-integration.test.ts +++ b/packages/opencode/test/bus/bus-integration.test.ts @@ -1,88 +1,88 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { Schema } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { afterEach, describe, expect } from "bun:test" +import { Deferred, Effect, Layer, Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number })) - -function withInstance(directory: string, fn: () => Promise) { - return WithInstance.provide({ directory, fn }) -} +const it = testEffect(Layer.mergeAll(Bus.layer, CrossSpawnSpawner.defaultLayer)) describe("Bus integration: acquireRelease subscriber pattern", () => { afterEach(() => disposeAllInstances()) - test("subscriber via callback facade receives events and cleans up on unsub", async () => { - await using tmp = await tmpdir() - const received: number[] = [] + it.instance("subscriber via callback facade receives events and cleans up on unsub", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: number[] = [] + const receivedTwo = yield* Deferred.make() - await withInstance(tmp.path, async () => { - const unsub = Bus.subscribe(TestEvent, (evt) => { + const unsub = yield* bus.subscribeCallback(TestEvent, (evt) => { received.push(evt.properties.value) + if (received.length === 2) Deferred.doneUnsafe(receivedTwo, Effect.void) }) - await Bun.sleep(10) - await Bus.publish(TestEvent, { value: 1 }) - await Bus.publish(TestEvent, { value: 2 }) - await Bun.sleep(10) + yield* bus.publish(TestEvent, { value: 1 }) + yield* bus.publish(TestEvent, { value: 2 }) + yield* Deferred.await(receivedTwo).pipe(Effect.timeout("2 seconds")) expect(received).toEqual([1, 2]) - unsub() - await Bun.sleep(10) - await Bus.publish(TestEvent, { value: 3 }) - await Bun.sleep(10) + yield* Effect.sync(unsub) + yield* bus.publish(TestEvent, { value: 3 }) + yield* Effect.sleep("10 millis") expect(received).toEqual([1, 2]) - }) - }) + }), + ) - test("subscribeAll receives events from multiple types", async () => { - await using tmp = await tmpdir() - const received: Array<{ type: string; value?: number }> = [] + it.instance("subscribeAll receives events from multiple types", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: Array<{ type: string; value?: number }> = [] + const OtherEvent = BusEvent.define("test.other", Schema.Struct({ value: Schema.Number })) + const receivedTwo = yield* Deferred.make() - const OtherEvent = BusEvent.define("test.other", Schema.Struct({ value: Schema.Number })) - - await withInstance(tmp.path, async () => { - Bus.subscribeAll((evt) => { + yield* bus.subscribeAllCallback((evt) => { received.push({ type: evt.type, value: evt.properties.value }) + if (received.length === 2) Deferred.doneUnsafe(receivedTwo, Effect.void) }) - await Bun.sleep(10) - await Bus.publish(TestEvent, { value: 10 }) - await Bus.publish(OtherEvent, { value: 20 }) - await Bun.sleep(10) - }) + yield* bus.publish(TestEvent, { value: 10 }) + yield* bus.publish(OtherEvent, { value: 20 }) + yield* Deferred.await(receivedTwo).pipe(Effect.timeout("2 seconds")) - expect(received).toEqual([ - { type: "test.integration", value: 10 }, - { type: "test.other", value: 20 }, - ]) - }) + expect(received).toEqual([ + { type: "test.integration", value: 10 }, + { type: "test.other", value: 20 }, + ]) + }), + ) - test("subscriber cleanup on instance disposal interrupts the stream", async () => { - await using tmp = await tmpdir() - const received: number[] = [] - let disposed = false + it.live("subscriber cleanup on instance disposal interrupts the stream", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const received: number[] = [] + const seen = yield* Deferred.make() + const disposed = yield* Deferred.make() - await withInstance(tmp.path, async () => { - Bus.subscribeAll((evt) => { - if (evt.type === Bus.InstanceDisposed.type) { - disposed = true - return - } - received.push(evt.properties.value) - }) - await Bun.sleep(10) - await Bus.publish(TestEvent, { value: 1 }) - await Bun.sleep(10) - }) + yield* Effect.gen(function* () { + const bus = yield* Bus.Service + yield* bus.subscribeAllCallback((evt) => { + if (evt.type === Bus.InstanceDisposed.type) { + Deferred.doneUnsafe(disposed, Effect.void) + return + } + received.push(evt.properties.value) + Deferred.doneUnsafe(seen, Effect.void) + }) + yield* bus.publish(TestEvent, { value: 1 }) + yield* Deferred.await(seen).pipe(Effect.timeout("2 seconds")) + }).pipe(provideInstance(dir)) - await disposeAllInstances() - await Bun.sleep(50) + yield* Effect.promise(() => disposeAllInstances()) + yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds")) - expect(received).toEqual([1]) - expect(disposed).toBe(true) - }) + expect(received).toEqual([1]) + }), + ) }) From 3f74abc6cd8a24eaa99d5a8ab00b1c905b9eff20 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 16:00:54 -0400 Subject: [PATCH 127/188] test: simplify Effect migration follow-ups (#27136) --- packages/opencode/test/fixture/fixture.ts | 2 +- .../opencode/test/project/project.test.ts | 69 ++++++++----------- .../opencode/test/question/question.test.ts | 3 +- 3 files changed, 31 insertions(+), 43 deletions(-) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index d47620f62..fedbc246b 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -135,7 +135,7 @@ export function tmpdirScoped(options?: { git?: boolean; config?: Partial(fn: (svc: Project.Interface) => Effect.Effect) { }) } -function gitTmpdir() { - return Effect.gen(function* () { - const tmp = yield* tmpdirScoped() - yield* Effect.promise(() => $`git init`.cwd(tmp).quiet()) - yield* Effect.promise(() => $`git config core.fsmonitor false`.cwd(tmp).quiet()) - yield* Effect.promise(() => $`git config commit.gpgsign false`.cwd(tmp).quiet()) - yield* Effect.promise(() => $`git config user.email "test@opencode.test"`.cwd(tmp).quiet()) - yield* Effect.promise(() => $`git config user.name "Test"`.cwd(tmp).quiet()) - yield* Effect.promise(() => $`git commit --allow-empty -m ${`root commit ${tmp}`}`.cwd(tmp).quiet()) - return tmp - }) -} - /** * Creates a mock ChildProcessSpawner layer that intercepts git subcommands * matching `failArg` and returns exit code 128, while delegating everything @@ -108,7 +95,7 @@ describe("Project.fromDirectory", () => { it.live("should handle git repository with commits", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) @@ -132,7 +119,7 @@ describe("Project.fromDirectory", () => { it.live("derives stable project ID from root commit", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project: a } = yield* run((svc) => svc.fromDirectory(tmp)) const { project: b } = yield* run((svc) => svc.fromDirectory(tmp)) expect(b.id).toBe(a.id) @@ -156,7 +143,7 @@ describe("Project.fromDirectory git failure paths", () => { failureIt("--show-toplevel").live("handles show-toplevel failure gracefully", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) expect(project.worktree).toBe(tmp) @@ -166,7 +153,7 @@ describe("Project.fromDirectory git failure paths", () => { failureIt("--git-common-dir").live("handles git-common-dir failure gracefully", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) expect(project.worktree).toBe(tmp) @@ -178,7 +165,7 @@ describe("Project.fromDirectory git failure paths", () => { describe("Project.fromDirectory with worktrees", () => { it.live("should set worktree to root when called from root", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) @@ -190,7 +177,7 @@ describe("Project.fromDirectory with worktrees", () => { it.live("should set worktree to root when called from a worktree", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-worktree") yield* Effect.addFinalizer(() => @@ -214,7 +201,7 @@ describe("Project.fromDirectory with worktrees", () => { it.live("worktree should share project ID with main repo", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project: main } = yield* run((svc) => svc.fromDirectory(tmp)) @@ -242,7 +229,7 @@ describe("Project.fromDirectory with worktrees", () => { it.live("separate clones of the same repo should share project ID", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) // Create a bare remote, push, then clone into a second directory const bare = tmp + "-bare" @@ -262,7 +249,7 @@ describe("Project.fromDirectory with worktrees", () => { it.live("should accumulate multiple worktrees in sandboxes", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const worktree1 = path.join(tmp, "..", path.basename(tmp) + "-wt1") const worktree2 = path.join(tmp, "..", path.basename(tmp) + "-wt2") @@ -299,7 +286,7 @@ describe("Project.fromDirectory with worktrees", () => { describe("Project.discover", () => { it.live("should discover favicon.png in root", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) @@ -318,7 +305,7 @@ describe("Project.discover", () => { it.live("should not discover non-image files", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.txt"), "not an image")) @@ -333,7 +320,7 @@ describe("Project.discover", () => { it.live("should not discover favicon when override is set", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) yield* run((svc) => @@ -362,7 +349,7 @@ describe("Project.discover", () => { describe("Project.update", () => { it.live("should update name", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const updated = yield* run((svc) => @@ -381,7 +368,7 @@ describe("Project.update", () => { it.live("should update icon url", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const updated = yield* run((svc) => @@ -400,7 +387,7 @@ describe("Project.update", () => { it.live("should update icon color", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const updated = yield* run((svc) => @@ -419,7 +406,7 @@ describe("Project.update", () => { it.live("should update icon override", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const updated = yield* run((svc) => @@ -438,7 +425,7 @@ describe("Project.update", () => { it.live("should update commands", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const updated = yield* run((svc) => @@ -475,7 +462,7 @@ describe("Project.update", () => { it.live("should emit GlobalBus event on update", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) let eventPayload: any = null @@ -495,7 +482,7 @@ describe("Project.update", () => { it.live("should update multiple fields at once", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const updated = yield* run((svc) => @@ -519,7 +506,7 @@ describe("Project.update", () => { describe("Project.list and Project.get", () => { it.live("list returns all projects", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const all = Project.list() @@ -530,7 +517,7 @@ describe("Project.list and Project.get", () => { it.live("get returns project by id", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const found = Project.get(project.id) @@ -548,7 +535,7 @@ describe("Project.list and Project.get", () => { describe("Project.setInitialized", () => { it.live("sets time_initialized on project", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) expect(project.time.initialized).toBeUndefined() @@ -564,7 +551,7 @@ describe("Project.setInitialized", () => { describe("Project.addSandbox and Project.removeSandbox", () => { it.live("addSandbox adds directory and removeSandbox removes it", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const sandboxDir = path.join(tmp, "sandbox-test") @@ -582,7 +569,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => { it.live("addSandbox emits GlobalBus event", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const sandboxDir = path.join(tmp, "sandbox-event") @@ -601,7 +588,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => { describe("Project.fromDirectory with bare repos", () => { it.live("worktree from bare repo should cache in bare repo, not parent", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const parentDir = path.dirname(tmp) const barePath = path.join(parentDir, `bare-${Date.now()}.git`) @@ -628,8 +615,8 @@ describe("Project.fromDirectory with bare repos", () => { it.live("different bare repos under same parent should not share project ID", () => Effect.gen(function* () { - const tmp1 = yield* gitTmpdir() - const tmp2 = yield* gitTmpdir() + const tmp1 = yield* tmpdirScoped({ git: true }) + const tmp2 = yield* tmpdirScoped({ git: true }) const parentDir = path.dirname(tmp1) const bareA = path.join(parentDir, `bare-a-${Date.now()}.git`) @@ -664,7 +651,7 @@ describe("Project.fromDirectory with bare repos", () => { it.live("bare repo without .git suffix is still detected via core.bare", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const parentDir = path.dirname(tmp) const barePath = path.join(parentDir, `bare-no-suffix-${Date.now()}`) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index a87907c57..3e970b63f 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -397,7 +397,8 @@ it.live("pending question rejects on instance dispose", () => }).pipe(provideInstance(dir), Effect.forkScoped) expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) - yield* Effect.promise(() => InstanceRuntime.disposeInstance(Instance.current)).pipe(provideInstance(dir)) + const ctx = yield* Effect.sync(() => Instance.current).pipe(provideInstance(dir)) + yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx)) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) From b9e7cbf13cdbce6434cf3152236b312db75afd95 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 12 May 2026 16:06:16 -0400 Subject: [PATCH 128/188] sync --- packages/console/app/src/routes/zen/util/handler.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 2e46df036..540dfe7e8 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -299,7 +299,6 @@ export async function handler( let buffer = "" let responseLength = 0 let timestampFirstByte = 0 - let timestampLastByte = 0 function pump(): Promise { return ( From dd14413a642759dfdecb2adee42f56b4b314d08f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 16:16:58 -0400 Subject: [PATCH 129/188] Preserve native LLM tool context (#27116) --- packages/llm/example/tutorial.ts | 4 +- packages/llm/src/index.ts | 1 + .../llm/src/protocols/openai-responses.ts | 2 +- packages/llm/src/protocols/utils/lifecycle.ts | 2 +- packages/llm/src/schema/events.ts | 44 +++++---- packages/llm/src/tool-runtime.ts | 96 ++++++++++++++++--- packages/llm/src/tool.ts | 11 ++- packages/llm/test/adapter.test.ts | 6 +- packages/llm/test/llm.test.ts | 2 +- .../test/provider/anthropic-messages.test.ts | 6 +- .../test/provider/bedrock-converse.test.ts | 8 +- packages/llm/test/provider/gemini.test.ts | 14 +-- .../llm/test/provider/openai-chat.test.ts | 4 +- .../provider/openai-compatible-chat.test.ts | 2 +- .../test/provider/openai-responses.test.ts | 4 +- packages/llm/test/recorded-scenarios.ts | 16 ++-- packages/llm/test/schema.test.ts | 5 + packages/llm/test/tool-runtime.test.ts | 92 ++++++++++++++++-- 18 files changed, 244 insertions(+), 75 deletions(-) diff --git a/packages/llm/example/tutorial.ts b/packages/llm/example/tutorial.ts index a9adecf36..429ac4824 100644 --- a/packages/llm/example/tutorial.ts +++ b/packages/llm/example/tutorial.ts @@ -78,7 +78,7 @@ const streamText = LLM.stream(request).pipe( Stream.tap((event) => Effect.sync(() => { if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`) - if (event.type === "request-finish") process.stdout.write(`\nfinish: ${event.reason}\n`) + if (event.type === "finish") process.stdout.write(`\nfinish: ${event.reason}\n`) }), ), Stream.runDrain, @@ -185,7 +185,7 @@ const FakeProtocol = Protocol.make({ event: Schema.String, initial: () => undefined, step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const), - onHalt: () => [{ type: "request-finish", reason: "stop" }], + onHalt: () => [{ type: "finish", reason: "stop" }], }, }) diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index f4adf4859..acf73b360 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -17,6 +17,7 @@ export type { ExecutableTools, Tool as ToolShape, ToolExecute, + ToolExecuteContext, Tools, ToolSchema, } from "./tool" diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index e31a42cd5..7cf734f02 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -380,7 +380,7 @@ type StepResult = readonly [ParserState, ReadonlyArray] const NO_EVENTS: StepResult["1"] = [] // `response.completed` / `response.incomplete` are clean finishes that emit a -// `request-finish` event; `response.failed` is a hard failure that emits a +// `finish` event; `response.failed` is a hard failure that emits a // `provider-error`. All three end the stream — kept in one set so `step` and // the protocol's `terminal` predicate stay in sync. const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"]) diff --git a/packages/llm/src/protocols/utils/lifecycle.ts b/packages/llm/src/protocols/utils/lifecycle.ts index 67039b137..c249d75ce 100644 --- a/packages/llm/src/protocols/utils/lifecycle.ts +++ b/packages/llm/src/protocols/utils/lifecycle.ts @@ -80,7 +80,7 @@ export const finish = ( usage: input.usage, providerMetadata: input.providerMetadata, }), - LLMEvent.requestFinish(input), + LLMEvent.finish(input), ) return { ...stepped, stepStarted: false } } diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index 6e6bb1541..6a088dc87 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -1,5 +1,5 @@ import { Schema } from "effect" -import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID, RouteID, ToolCallID } from "./ids" +import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids" import { ModelRef } from "./options" import { ToolResultValue } from "./messages" @@ -66,14 +66,13 @@ export class Usage extends Schema.Class("LLM.Usage")({ get visibleOutputTokens() { return Math.max(0, (this.outputTokens ?? 0) - (this.reasoningTokens ?? 0)) } + + static from(input: UsageInput) { + return input instanceof Usage ? input : new Usage(input) + } } -export const RequestStart = Schema.Struct({ - type: Schema.tag("request-start"), - id: ResponseID, - model: ModelRef, -}).annotate({ identifier: "LLM.Event.RequestStart" }) -export type RequestStart = Schema.Schema.Type +export type UsageInput = Usage | ConstructorParameters[0] export const StepStart = Schema.Struct({ type: Schema.tag("step-start"), @@ -185,13 +184,13 @@ export const StepFinish = Schema.Struct({ }).annotate({ identifier: "LLM.Event.StepFinish" }) export type StepFinish = Schema.Schema.Type -export const RequestFinish = Schema.Struct({ - type: Schema.tag("request-finish"), +export const Finish = Schema.Struct({ + type: Schema.tag("finish"), reason: FinishReason, usage: Schema.optional(Usage), providerMetadata: Schema.optional(ProviderMetadata), -}).annotate({ identifier: "LLM.Event.RequestFinish" }) -export type RequestFinish = Schema.Schema.Type +}).annotate({ identifier: "LLM.Event.Finish" }) +export type Finish = Schema.Schema.Type export const ProviderErrorEvent = Schema.Struct({ type: Schema.tag("provider-error"), @@ -202,7 +201,6 @@ export const ProviderErrorEvent = Schema.Struct({ export type ProviderErrorEvent = Schema.Schema.Type const llmEventTagged = Schema.Union([ - RequestStart, StepStart, TextStart, TextDelta, @@ -217,13 +215,15 @@ const llmEventTagged = Schema.Union([ ToolResult, ToolError, StepFinish, - RequestFinish, + Finish, ProviderErrorEvent, ]).pipe(Schema.toTaggedUnion("type")) type WithID = Omit & { readonly id: ID | string } +type WithUsage = Omit & { + readonly usage?: UsageInput +} -const responseID = (value: ResponseID | string) => ResponseID.make(value) const contentBlockID = (value: ContentBlockID | string) => ContentBlockID.make(value) const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value) @@ -233,7 +233,6 @@ const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value) * `events.filter(LLMEvent.guards["tool-call"])`. */ export const LLMEvent = Object.assign(llmEventTagged, { - requestStart: (input: WithID) => RequestStart.make({ ...input, id: responseID(input.id) }), stepStart: StepStart.make, textStart: (input: WithID) => TextStart.make({ ...input, id: contentBlockID(input.id) }), textDelta: (input: WithID) => TextDelta.make({ ...input, id: contentBlockID(input.id) }), @@ -252,11 +251,18 @@ export const LLMEvent = Object.assign(llmEventTagged, { toolCall: (input: WithID) => ToolCall.make({ ...input, id: toolCallID(input.id) }), toolResult: (input: WithID) => ToolResult.make({ ...input, id: toolCallID(input.id) }), toolError: (input: WithID) => ToolError.make({ ...input, id: toolCallID(input.id) }), - stepFinish: StepFinish.make, - requestFinish: RequestFinish.make, + stepFinish: (input: WithUsage) => + StepFinish.make({ + ...input, + usage: input.usage === undefined ? undefined : Usage.from(input.usage), + }), + finish: (input: WithUsage) => + Finish.make({ + ...input, + usage: input.usage === undefined ? undefined : Usage.from(input.usage), + }), providerError: ProviderErrorEvent.make, is: { - requestStart: llmEventTagged.guards["request-start"], stepStart: llmEventTagged.guards["step-start"], textStart: llmEventTagged.guards["text-start"], textDelta: llmEventTagged.guards["text-delta"], @@ -271,7 +277,7 @@ export const LLMEvent = Object.assign(llmEventTagged, { toolResult: llmEventTagged.guards["tool-result"], toolError: llmEventTagged.guards["tool-error"], stepFinish: llmEventTagged.guards["step-finish"], - requestFinish: llmEventTagged.guards["request-finish"], + finish: llmEventTagged.guards.finish, providerError: llmEventTagged.guards["provider-error"], }, }) diff --git a/packages/llm/src/tool-runtime.ts b/packages/llm/src/tool-runtime.ts index f46452582..d83dcc67a 100644 --- a/packages/llm/src/tool-runtime.ts +++ b/packages/llm/src/tool-runtime.ts @@ -12,6 +12,7 @@ import { ToolFailure, ToolResultPart, type ToolResultValue, + Usage, } from "./schema" import { type AnyTool, type ExecutableTools, type Tools, toDefinitions } from "./tool" @@ -72,19 +73,42 @@ export const stream = (options: StreamOptions): Stream.Strea tools: [...options.request.tools.filter((tool) => !runtimeToolNames.has(tool.name)), ...runtimeTools], }) - const loop = (request: LLMRequest, step: number): Stream.Stream => + const loop = ( + request: LLMRequest, + step: number, + usage: Usage | undefined, + providerMetadata: ProviderMetadata | undefined, + ): Stream.Stream => Stream.unwrap( Effect.gen(function* () { - const state: StepState = { assistantContent: [], toolCalls: [], finishReason: undefined } + const state: StepState = { + assistantContent: [], + toolCalls: [], + finishReason: undefined, + usage: undefined, + providerMetadata: undefined, + } const modelStream = options .stream(request) + .pipe(Stream.map((event) => indexStep(event, step))) .pipe(Stream.tap((event) => Effect.sync(() => accumulate(state, event)))) + .pipe(Stream.filter((event) => event.type !== "finish")) const continuation = Stream.unwrap( Effect.gen(function* () { - if (state.finishReason !== "tool-calls" || state.toolCalls.length === 0) return Stream.empty - if (options.toolExecution === "none") return Stream.empty + const totalUsage = addUsage(usage, state.usage) + const totalProviderMetadata = mergeProviderMetadata(providerMetadata, state.providerMetadata) + const finishStream = Stream.fromIterable([ + LLMEvent.finish({ + reason: state.finishReason ?? "unknown", + usage: totalUsage, + providerMetadata: totalProviderMetadata, + }), + ]) + + if (state.finishReason !== "tool-calls" || state.toolCalls.length === 0) return finishStream + if (options.toolExecution === "none") return finishStream const dispatched = yield* Effect.forEach( state.toolCalls, @@ -93,10 +117,14 @@ export const stream = (options: StreamOptions): Stream.Strea ) const resultStream = Stream.fromIterable(dispatched.flatMap(([call, result]) => emitEvents(call, result))) - if (!options.stopWhen) return resultStream - if (options.stopWhen({ step, request })) return resultStream + if (!options.stopWhen) return resultStream.pipe(Stream.concat(finishStream)) + if (options.stopWhen({ step, request })) return resultStream.pipe(Stream.concat(finishStream)) - return resultStream.pipe(Stream.concat(loop(followUpRequest(request, state, dispatched), step + 1))) + return resultStream.pipe( + Stream.concat( + loop(followUpRequest(request, state, dispatched), step + 1, totalUsage, totalProviderMetadata), + ), + ) }), ) @@ -104,13 +132,21 @@ export const stream = (options: StreamOptions): Stream.Strea }), ) - return loop(initialRequest, 0) + return loop(initialRequest, 0, undefined, undefined) +} + +const indexStep = (event: LLMEvent, index: number): LLMEvent => { + if (event.type === "step-start") return LLMEvent.stepStart({ index }) + if (event.type === "step-finish") return LLMEvent.stepFinish({ ...event, index }) + return event } interface StepState { assistantContent: ContentPart[] toolCalls: ToolCallPart[] finishReason: FinishReason | undefined + usage: Usage | undefined + providerMetadata: ProviderMetadata | undefined } const accumulate = (state: StepState, event: LLMEvent) => { @@ -154,9 +190,43 @@ const accumulate = (state: StepState, event: LLMEvent) => { ) return } - if (event.type === "step-finish" || event.type === "request-finish") { + if (event.type === "step-finish") { state.finishReason = event.reason === "stop" && state.toolCalls.length > 0 ? "tool-calls" : event.reason + state.usage = addUsage(state.usage, event.usage) + state.providerMetadata = mergeProviderMetadata(state.providerMetadata, event.providerMetadata) + return } + if (event.type === "finish") { + state.finishReason ??= event.reason + state.usage ??= event.usage + state.providerMetadata = mergeProviderMetadata(state.providerMetadata, event.providerMetadata) + } +} + +const addUsage = (left: Usage | undefined, right: Usage | undefined) => { + if (!left) return right + if (!right) return left + type UsageKey = + | "inputTokens" + | "outputTokens" + | "nonCachedInputTokens" + | "cacheReadInputTokens" + | "cacheWriteInputTokens" + | "reasoningTokens" + | "totalTokens" + const sum = (key: UsageKey) => + left[key] === undefined && right[key] === undefined ? undefined : Number(left[key] ?? 0) + Number(right[key] ?? 0) + + return new Usage({ + inputTokens: sum("inputTokens"), + outputTokens: sum("outputTokens"), + nonCachedInputTokens: sum("nonCachedInputTokens"), + cacheReadInputTokens: sum("cacheReadInputTokens"), + cacheWriteInputTokens: sum("cacheWriteInputTokens"), + reasoningTokens: sum("reasoningTokens"), + totalTokens: sum("totalTokens"), + providerMetadata: mergeProviderMetadata(left.providerMetadata, right.providerMetadata), + }) } const sameProviderMetadata = (left: ProviderMetadata | undefined, right: ProviderMetadata | undefined) => @@ -200,17 +270,17 @@ const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect Effect.succeed({ type: "error" as const, value: failure.message } satisfies ToolResultValue), ), ) } -const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect => - tool._decode(input).pipe( +const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect => + tool._decode(call.input).pipe( Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })), - Effect.flatMap((decoded) => tool.execute!(decoded)), + Effect.flatMap((decoded) => tool.execute!(decoded, { id: call.id, name: call.name })), Effect.flatMap((value) => tool._encode(value).pipe( Effect.mapError( diff --git a/packages/llm/src/tool.ts b/packages/llm/src/tool.ts index 311c8798b..df0a1cd3d 100644 --- a/packages/llm/src/tool.ts +++ b/packages/llm/src/tool.ts @@ -1,5 +1,5 @@ import { Effect, JsonSchema, Schema } from "effect" -import type { ToolDefinition as ToolDefinitionClass } from "./schema" +import type { ToolCallPart, ToolDefinition as ToolDefinitionClass } from "./schema" import { ToolDefinition, ToolFailure } from "./schema" /** @@ -8,9 +8,14 @@ import { ToolDefinition, ToolFailure } from "./schema" * beyond pure data conversion belongs in the handler closure. */ export type ToolSchema = Schema.Codec +export interface ToolExecuteContext { + readonly id: ToolCallPart["id"] + readonly name: ToolCallPart["name"] +} export type ToolExecute, Success extends ToolSchema> = ( params: Schema.Schema.Type, + context?: ToolExecuteContext, ) => Effect.Effect, ToolFailure> /** @@ -61,7 +66,7 @@ type TypedToolConfig = { type DynamicToolConfig = { readonly description: string readonly jsonSchema: JsonSchema.JsonSchema - readonly execute?: (params: unknown) => Effect.Effect + readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect } /** @@ -110,7 +115,7 @@ export function make, Success extends ToolSch export function make(config: { readonly description: string readonly jsonSchema: JsonSchema.JsonSchema - readonly execute: (params: unknown) => Effect.Effect + readonly execute: (params: unknown, context?: ToolExecuteContext) => Effect.Effect }): AnyExecutableTool export function make(config: { readonly description: string diff --git a/packages/llm/test/adapter.test.ts b/packages/llm/test/adapter.test.ts index 5ac8b9d81..80349a5ae 100644 --- a/packages/llm/test/adapter.test.ts +++ b/packages/llm/test/adapter.test.ts @@ -51,7 +51,7 @@ const request = LLM.request({ const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent => event.type === "finish" - ? { type: "request-finish", reason: event.reason } + ? { type: "finish", reason: event.reason } : { type: "text-delta", id: "text-0", text: event.text } const fakeProtocol = Protocol.make({ @@ -112,8 +112,8 @@ describe("llm route", () => { const events = Array.from(yield* llm.stream(request).pipe(Stream.runCollect)) const response = yield* llm.generate(request) - expect(events.map((event) => event.type)).toEqual(["text-delta", "request-finish"]) - expect(response.events.map((event) => event.type)).toEqual(["text-delta", "request-finish"]) + expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"]) + expect(response.events.map((event) => event.type)).toEqual(["text-delta", "finish"]) }), ) diff --git a/packages/llm/test/llm.test.ts b/packages/llm/test/llm.test.ts index c01fe33b2..a20c48411 100644 --- a/packages/llm/test/llm.test.ts +++ b/packages/llm/test/llm.test.ts @@ -127,7 +127,7 @@ describe("llm constructors", () => { LLMResponse.text({ events: [ { type: "text-delta", id: "text-0", text: "hi" }, - { type: "request-finish", reason: "stop" }, + { type: "finish", reason: "stop" }, ], }), ).toBe("hi") diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 6417f73c2..71204bcd6 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -124,7 +124,7 @@ describe("Anthropic Messages route", () => { providerMetadata: { anthropic: { signature: "sig_1" } }, }) expect(response.events.at(-1)).toMatchObject({ - type: "request-finish", + type: "finish", reason: "stop", providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } }, }) @@ -182,7 +182,7 @@ describe("Anthropic Messages route", () => { }, { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, { - type: "request-finish", + type: "finish", reason: "tool-calls", providerMetadata: undefined, usage, @@ -275,7 +275,7 @@ describe("Anthropic Messages route", () => { providerMetadata: { anthropic: { blockType: "web_search_tool_result" } }, }) expect(response.text).toBe("Found it.") - expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" }) + expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" }) }), ) diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index 7d1ad3f30..ffdd6e800 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -169,12 +169,12 @@ describe("Bedrock Converse route", () => { const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body))) expect(response.text).toBe("Hello!") - const finishes = response.events.filter((event) => event.type === "request-finish") + const finishes = response.events.filter((event) => event.type === "finish") // Bedrock splits the finish across `messageStop` (carries reason) and // `metadata` (carries usage). We consolidate them into a single - // terminal `request-finish` event with both. + // terminal `finish` event with both. expect(finishes).toHaveLength(1) - expect(finishes[0]).toMatchObject({ type: "request-finish", reason: "stop" }) + expect(finishes[0]).toMatchObject({ type: "finish", reason: "stop" }) expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, @@ -213,7 +213,7 @@ describe("Bedrock Converse route", () => { { type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' }, ]) - expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "tool-calls" }) + expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" }) }), ) diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index 80c32c58b..7e6bbc846 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -232,7 +232,7 @@ describe("Gemini route", () => { { type: "text-end", id: "text-0" }, { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined }, { - type: "request-finish", + type: "finish", reason: "stop", usage, }, @@ -291,7 +291,7 @@ describe("Gemini route", () => { }, { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, { - type: "request-finish", + type: "finish", reason: "tool-calls", usage, }, @@ -325,7 +325,7 @@ describe("Gemini route", () => { { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } }, { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } }, ]) - expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "tool-calls" }) + expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" }) }), ) @@ -344,10 +344,10 @@ describe("Gemini route", () => { ), ) - expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "request-finish"]) - expect(length.events.at(-1)).toMatchObject({ type: "request-finish", reason: "length" }) - expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "request-finish"]) - expect(filtered.events.at(-1)).toMatchObject({ type: "request-finish", reason: "content-filter" }) + expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"]) + expect(length.events.at(-1)).toMatchObject({ type: "finish", reason: "length" }) + expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"]) + expect(filtered.events.at(-1)).toMatchObject({ type: "finish", reason: "content-filter" }) }), ) diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 115c58849..4303a69ff 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -249,7 +249,7 @@ describe("OpenAI Chat route", () => { { type: "text-end", id: "text-0" }, { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined }, { - type: "request-finish", + type: "finish", reason: "stop", usage, }, @@ -288,7 +288,7 @@ describe("OpenAI Chat route", () => { providerMetadata: undefined, }, { type: "step-finish", index: 0, reason: "tool-calls", usage: undefined, providerMetadata: undefined }, - { type: "request-finish", reason: "tool-calls", usage: undefined }, + { type: "finish", reason: "tool-calls", usage: undefined }, ]) }), ) diff --git a/packages/llm/test/provider/openai-compatible-chat.test.ts b/packages/llm/test/provider/openai-compatible-chat.test.ts index 7759ff720..50aac4109 100644 --- a/packages/llm/test/provider/openai-compatible-chat.test.ts +++ b/packages/llm/test/provider/openai-compatible-chat.test.ts @@ -231,7 +231,7 @@ describe("OpenAI-compatible Chat route", () => { expect(response.text).toBe("Hello!") expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 }) - expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" }) + expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" }) }), ) }) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 8b4469f4e..63452f61b 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -366,7 +366,7 @@ describe("OpenAI Responses route", () => { usage, }, { - type: "request-finish", + type: "finish", reason: "stop", providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, usage, @@ -447,7 +447,7 @@ describe("OpenAI Responses route", () => { }, { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, { - type: "request-finish", + type: "finish", reason: "tool-calls", providerMetadata: undefined, usage, diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts index bdba8580f..3af7a7760 100644 --- a/packages/llm/test/recorded-scenarios.ts +++ b/packages/llm/test/recorded-scenarios.ts @@ -120,8 +120,8 @@ export const runWeatherToolLoop = (request: LLMRequest) => export const expectFinish = ( events: ReadonlyArray, - reason: Extract["reason"], -) => expect(events.at(-1)).toMatchObject({ type: "request-finish", reason }) + reason: Extract["reason"], +) => expect(events.at(-1)).toMatchObject({ type: "finish", reason }) export const expectWeatherToolCall = (response: LLMResponse) => expect(response.toolCalls).toMatchObject([ @@ -129,10 +129,12 @@ export const expectWeatherToolCall = (response: LLMResponse) => ]) export const expectWeatherToolLoop = (events: ReadonlyArray) => { - const finishes = events.filter(LLMEvent.is.requestFinish) - expect(finishes).toHaveLength(2) - expect(finishes[0]?.reason).toBe("tool-calls") - expect(finishes.at(-1)?.reason).toBe("stop") + const finishes = events.filter(LLMEvent.is.finish) + expect(finishes).toHaveLength(1) + expect(finishes[0]?.reason).toBe("stop") + + const stepFinishes = events.filter(LLMEvent.is.stepFinish) + expect(stepFinishes.map((event) => event.reason)).toEqual(["tool-calls", "stop"]) const toolCalls = events.filter(LLMEvent.is.toolCall) expect(toolCalls).toHaveLength(1) @@ -272,7 +274,7 @@ export const eventSummary = (events: ReadonlyArray) => { summary.push({ type: "tool-error", name: event.name, message: event.message }) continue } - if (event.type === "request-finish") { + if (event.type === "finish") { summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) }) } } diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts index 23bd9fd9b..01d6fadd9 100644 --- a/packages/llm/test/schema.test.ts +++ b/packages/llm/test/schema.test.ts @@ -44,6 +44,11 @@ describe("llm schema", () => { expect(() => Schema.decodeUnknownSync(LLMEvent)({ type: "bogus" })).toThrow() }) + test("finish constructors accept usage input", () => { + expect(LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 1 } }).usage).toBeInstanceOf(Usage) + expect(LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }).usage).toBeInstanceOf(Usage) + }) + test("content part tagged union exposes guards", () => { expect(ContentPart.guards.text({ type: "text", text: "hi" })).toBe(true) expect(ContentPart.guards.media({ type: "text", text: "hi" })).toBe(false) diff --git a/packages/llm/test/tool-runtime.test.ts b/packages/llm/test/tool-runtime.test.ts index 040a11fb6..573021c4c 100644 --- a/packages/llm/test/tool-runtime.test.ts +++ b/packages/llm/test/tool-runtime.test.ts @@ -4,7 +4,8 @@ import { GenerationOptions, LLM, LLMEvent, LLMRequest, LLMResponse, ToolChoice } import { LLMClient } from "../src/route" import * as AnthropicMessages from "../src/protocols/anthropic-messages" import * as OpenAIChat from "../src/protocols/openai-chat" -import { tool, ToolFailure } from "../src/tool" +import { tool, ToolFailure, type ToolExecuteContext } from "../src/tool" +import { ToolRuntime } from "../src/tool-runtime" import { it } from "./lib/effect" import * as TestToolRuntime from "./lib/tool-runtime" import { dynamicResponse, scriptedResponses } from "./lib/http" @@ -129,7 +130,7 @@ describe("LLMClient tools", () => { name: "get_weather", result: { type: "json", value: { temperature: 22, condition: "sunny" } }, }) - expect(events.at(-1)?.type).toBe("request-finish") + expect(events.at(-1)?.type).toBe("finish") expect(LLMResponse.text({ events })).toBe("It's sunny in Paris.") }), ) @@ -148,11 +149,40 @@ describe("LLMClient tools", () => { ), ) - expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(1) + expect(events.filter(LLMEvent.is.finish)).toHaveLength(1) expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" }) }), ) + it.effect("passes tool call context to execute", () => + Effect.gen(function* () { + let context: ToolExecuteContext | undefined + const contextual = tool({ + description: "Capture tool context.", + parameters: Schema.Struct({ value: Schema.String }), + success: Schema.Struct({ ok: Schema.Boolean }), + execute: (_params, ctx) => + Effect.sync(() => { + context = ctx + return { ok: true } + }), + }) + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { contextual } }).pipe( + Stream.runCollect, + Effect.provide( + scriptedResponses([ + sseEvents(toolCallChunk("call_ctx", "contextual", '{"value":"x"}'), finishChunk("tool_calls")), + ]), + ), + ), + ) + + expect(events.some(LLMEvent.is.toolResult)).toBe(true) + expect(context).toEqual({ id: "call_ctx", name: "contextual" }) + }), + ) + it.effect("can expose tool schemas without executing tool calls", () => Effect.gen(function* () { const layer = scriptedResponses([ @@ -319,7 +349,7 @@ describe("LLMClient tools", () => { "text-delta", "text-end", "step-finish", - "request-finish", + "finish", ]) expect(LLMResponse.text({ events })).toBe("Done.") }), @@ -343,7 +373,57 @@ describe("LLMClient tools", () => { ), ) - expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(2) + expect(events.filter(LLMEvent.is.finish)).toHaveLength(1) + expect(events.filter(LLMEvent.is.stepStart).map((event) => event.index)).toEqual([0, 1]) + expect(events.filter(LLMEvent.is.stepFinish).map((event) => event.index)).toEqual([0, 1]) + }), + ) + + it.effect("emits one final finish with aggregate usage", () => + Effect.gen(function* () { + let calls = 0 + const events = Array.from( + yield* ToolRuntime.stream({ + request: baseRequest, + tools: { get_weather }, + stopWhen: ToolRuntime.stepCountIs(2), + stream: () => + Stream.fromIterable( + calls++ === 0 + ? [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call_1", name: "get_weather", input: { city: "Paris" } }), + LLMEvent.stepFinish({ + index: 0, + reason: "tool-calls", + usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 }, + }), + LLMEvent.finish({ + reason: "tool-calls", + usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 }, + }), + ] + : [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textDelta({ id: "text_1", text: "Done." }), + LLMEvent.stepFinish({ + index: 0, + reason: "stop", + usage: { inputTokens: 4, outputTokens: 5, totalTokens: 9 }, + }), + LLMEvent.finish({ reason: "stop", usage: { inputTokens: 4, outputTokens: 5, totalTokens: 9 } }), + ], + ), + }).pipe(Stream.runCollect), + ) + + expect(events.filter(LLMEvent.is.stepFinish).map((event) => event.index)).toEqual([0, 1]) + expect(events.filter(LLMEvent.is.finish)).toHaveLength(1) + expect(events.find(LLMEvent.is.finish)?.usage).toMatchObject({ + inputTokens: 5, + outputTokens: 7, + totalTokens: 12, + }) }), ) @@ -362,7 +442,7 @@ describe("LLMClient tools", () => { }).pipe(Stream.runCollect, Effect.provide(layer)), ) - expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(1) + expect(events.filter(LLMEvent.is.finish)).toHaveLength(1) expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" }) }), ) From af4ab017cbf603be0778071a152f818d4836ec0b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 16:30:35 -0400 Subject: [PATCH 130/188] test(session): migrate structured output integration test (#27143) --- .../structured-output-integration.test.ts | 359 ++++++++---------- 1 file changed, 164 insertions(+), 195 deletions(-) diff --git a/packages/opencode/test/session/structured-output-integration.test.ts b/packages/opencode/test/session/structured-output-integration.test.ts index da2ffb793..125c63c0f 100644 --- a/packages/opencode/test/session/structured-output-integration.test.ts +++ b/packages/opencode/test/session/structured-output-integration.test.ts @@ -1,250 +1,219 @@ import { describe, expect, test } from "bun:test" -import path from "path" import { Effect, Layer } from "effect" import { Session } from "@/session/session" import { SessionPrompt } from "../../src/session/prompt" import * as Log from "@opencode-ai/core/util/log" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { MessageV2 } from "../../src/session/message-v2" +import { testEffect } from "../lib/effect" -const projectRoot = path.join(__dirname, "../..") void Log.init({ print: false }) // Skip tests if no API key is available const hasApiKey = !!process.env.ANTHROPIC_API_KEY - -// Helper to run test within Instance context -async function withInstance(fn: () => Promise): Promise { - return WithInstance.provide({ - directory: projectRoot, - fn, - }) -} - -function run(fx: Effect.Effect) { - return Effect.runPromise( - fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))), - ) -} +const it = testEffect(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer)) +const live = hasApiKey ? it.instance : it.instance.skip describe("StructuredOutput Integration", () => { - test.skipIf(!hasApiKey)( + live( "produces structured output with simple schema", - async () => { - await withInstance(() => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "Structured Output Test" }) + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Structured Output Test" }) - const result = yield* prompt.prompt({ - sessionID: session.id, - parts: [ - { - type: "text", - text: "What is 2 + 2? Provide a simple answer.", - }, - ], - format: { - type: "json_schema", - schema: { - type: "object", - properties: { - answer: { type: "number", description: "The numerical answer" }, - explanation: { type: "string", description: "Brief explanation" }, - }, - required: ["answer"], - }, - retryCount: 0, + const result = yield* prompt.prompt({ + sessionID: session.id, + parts: [ + { + type: "text", + text: "What is 2 + 2? Provide a simple answer.", + }, + ], + format: { + type: "json_schema", + schema: { + type: "object", + properties: { + answer: { type: "number", description: "The numerical answer" }, + explanation: { type: "string", description: "Brief explanation" }, }, - }) + required: ["answer"], + }, + retryCount: 0, + }, + }) - // Verify structured output was captured (only on assistant messages) - expect(result.info.role).toBe("assistant") - if (result.info.role === "assistant") { - expect(result.info.structured).toBeDefined() - expect(typeof result.info.structured).toBe("object") + // Verify structured output was captured (only on assistant messages) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.structured).toBeDefined() + expect(typeof result.info.structured).toBe("object") - const output = result.info.structured as any - expect(output.answer).toBe(4) + const output = result.info.structured as any + expect(output.answer).toBe(4) - // Verify no error was set - expect(result.info.error).toBeUndefined() - } + // Verify no error was set + expect(result.info.error).toBeUndefined() + } - // Clean up - // Note: Not removing session to avoid race with background SessionSummary.summarize - }), - ), - ) - }, + // Clean up + // Note: Not removing session to avoid race with background SessionSummary.summarize + }), + { git: true }, 60000, ) - test.skipIf(!hasApiKey)( + live( "produces structured output with nested objects", - async () => { - await withInstance(() => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "Nested Schema Test" }) + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Nested Schema Test" }) - const result = yield* prompt.prompt({ - sessionID: session.id, - parts: [ - { - type: "text", - text: "Tell me about Anthropic company in a structured format.", - }, - ], - format: { - type: "json_schema", - schema: { + const result = yield* prompt.prompt({ + sessionID: session.id, + parts: [ + { + type: "text", + text: "Tell me about Anthropic company in a structured format.", + }, + ], + format: { + type: "json_schema", + schema: { + type: "object", + properties: { + company: { type: "object", properties: { - company: { - type: "object", - properties: { - name: { type: "string" }, - founded: { type: "number" }, - }, - required: ["name", "founded"], - }, - products: { - type: "array", - items: { type: "string" }, - }, + name: { type: "string" }, + founded: { type: "number" }, }, - required: ["company"], + required: ["name", "founded"], + }, + products: { + type: "array", + items: { type: "string" }, }, - retryCount: 0, }, - }) + required: ["company"], + }, + retryCount: 0, + }, + }) - // Verify structured output was captured (only on assistant messages) - expect(result.info.role).toBe("assistant") - if (result.info.role === "assistant") { - expect(result.info.structured).toBeDefined() - const output = result.info.structured as any + // Verify structured output was captured (only on assistant messages) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.structured).toBeDefined() + const output = result.info.structured as any - expect(output.company).toBeDefined() - expect(output.company.name).toBe("Anthropic") - expect(typeof output.company.founded).toBe("number") + expect(output.company).toBeDefined() + expect(output.company.name).toBe("Anthropic") + expect(typeof output.company.founded).toBe("number") - if (output.products) { - expect(Array.isArray(output.products)).toBe(true) - } + if (output.products) { + expect(Array.isArray(output.products)).toBe(true) + } - // Verify no error was set - expect(result.info.error).toBeUndefined() - } + // Verify no error was set + expect(result.info.error).toBeUndefined() + } - // Clean up - // Note: Not removing session to avoid race with background SessionSummary.summarize - }), - ), - ) - }, + // Clean up + // Note: Not removing session to avoid race with background SessionSummary.summarize + }), + { git: true }, 60000, ) - test.skipIf(!hasApiKey)( + live( "works with text outputFormat (default)", - async () => { - await withInstance(() => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "Text Output Test" }) + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Text Output Test" }) - const result = yield* prompt.prompt({ - sessionID: session.id, - parts: [ - { - type: "text", - text: "Say hello.", - }, - ], - format: { - type: "text", - }, - }) + const result = yield* prompt.prompt({ + sessionID: session.id, + parts: [ + { + type: "text", + text: "Say hello.", + }, + ], + format: { + type: "text", + }, + }) - // Verify no structured output (text mode) and no error - expect(result.info.role).toBe("assistant") - if (result.info.role === "assistant") { - expect(result.info.structured).toBeUndefined() - expect(result.info.error).toBeUndefined() - } + // Verify no structured output (text mode) and no error + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.structured).toBeUndefined() + expect(result.info.error).toBeUndefined() + } - // Verify we got a response with parts - expect(result.parts.length).toBeGreaterThan(0) + // Verify we got a response with parts + expect(result.parts.length).toBeGreaterThan(0) - // Clean up - // Note: Not removing session to avoid race with background SessionSummary.summarize - }), - ), - ) - }, + // Clean up + // Note: Not removing session to avoid race with background SessionSummary.summarize + }), + { git: true }, 60000, ) - test.skipIf(!hasApiKey)( + live( "stores outputFormat on user message", - async () => { - await withInstance(() => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "OutputFormat Storage Test" }) + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "OutputFormat Storage Test" }) - yield* prompt.prompt({ - sessionID: session.id, - parts: [ - { - type: "text", - text: "What is 1 + 1?", - }, - ], - format: { - type: "json_schema", - schema: { - type: "object", - properties: { - result: { type: "number" }, - }, - required: ["result"], - }, - retryCount: 3, + yield* prompt.prompt({ + sessionID: session.id, + parts: [ + { + type: "text", + text: "What is 1 + 1?", + }, + ], + format: { + type: "json_schema", + schema: { + type: "object", + properties: { + result: { type: "number" }, }, - }) + required: ["result"], + }, + retryCount: 3, + }, + }) - // Get all messages from session - const messages = yield* sessions.messages({ sessionID: session.id }) - const userMessage = messages.find((m) => m.info.role === "user") + // Get all messages from session + const messages = yield* sessions.messages({ sessionID: session.id }) + const userMessage = messages.find((m) => m.info.role === "user") - // Verify outputFormat was stored on user message - expect(userMessage).toBeDefined() - if (userMessage?.info.role === "user") { - expect(userMessage.info.format).toBeDefined() - expect(userMessage.info.format?.type).toBe("json_schema") - if (userMessage.info.format?.type === "json_schema") { - expect(userMessage.info.format.retryCount).toBe(3) - } - } + // Verify outputFormat was stored on user message + expect(userMessage).toBeDefined() + if (userMessage?.info.role === "user") { + expect(userMessage.info.format).toBeDefined() + expect(userMessage.info.format?.type).toBe("json_schema") + if (userMessage.info.format?.type === "json_schema") { + expect(userMessage.info.format.retryCount).toBe(3) + } + } - // Clean up - // Note: Not removing session to avoid race with background SessionSummary.summarize - }), - ), - ) - }, + // Clean up + // Note: Not removing session to avoid race with background SessionSummary.summarize + }), + { git: true }, 60000, ) From dc9d6a08cbeac02ea02197eb85f6aa409bffa0c4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 16:31:38 -0400 Subject: [PATCH 131/188] test: migrate agent color config tests (#27139) --- .../opencode/test/config/agent-color.test.ts | 68 ++++++++----------- 1 file changed, 30 insertions(+), 38 deletions(-) diff --git a/packages/opencode/test/config/agent-color.test.ts b/packages/opencode/test/config/agent-color.test.ts index 49509156a..369b3a1fd 100644 --- a/packages/opencode/test/config/agent-color.test.ts +++ b/packages/opencode/test/config/agent-color.test.ts @@ -1,58 +1,50 @@ import { test, expect } from "bun:test" import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import path from "path" -import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { Config } from "@/config/config" import { Agent as AgentSvc } from "../../src/agent/agent" import { Color } from "@/util/color" -import { AppRuntime } from "../../src/effect/app-runtime" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(Config.defaultLayer, AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer)) -const writeConfig = (dir: string, agent: Config.Info["agent"]) => - Effect.promise(() => - Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - agent, - }), - ), - ) - -it.live("agent color parsed from project config", () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped() - yield* writeConfig(dir, { - build: { color: "#FFA500" }, - plan: { color: "primary" }, - }) - - yield* Effect.gen(function* () { - const cfg = yield* Effect.promise(() => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))) +it.instance( + "agent color parsed from project config", + () => + Effect.gen(function* () { + const cfg = yield* Config.Service.use((svc) => svc.get()) expect(cfg.agent?.["build"]?.color).toBe("#FFA500") expect(cfg.agent?.["plan"]?.color).toBe("primary") - }).pipe(provideInstance(dir)) - }), + }), + { + git: true, + config: { + agent: { + build: { color: "#FFA500" }, + plan: { color: "primary" }, + }, + }, + }, ) -it.live("Agent.get includes color from config", () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped() - yield* writeConfig(dir, { - plan: { color: "#A855F7" }, - build: { color: "accent" }, - }) - - yield* Effect.gen(function* () { +it.instance( + "Agent.get includes color from config", + () => + Effect.gen(function* () { const plan = yield* AgentSvc.Service.use((svc) => svc.get("plan")) expect(plan?.color).toBe("#A855F7") const build = yield* AgentSvc.Service.use((svc) => svc.get("build")) expect(build?.color).toBe("accent") - }).pipe(provideInstance(dir)) - }), + }), + { + git: true, + config: { + agent: { + plan: { color: "#A855F7" }, + build: { color: "accent" }, + }, + }, + }, ) test("Color.hexToAnsiBold converts valid hex to ANSI", () => { From 2017dc165c7c2bf07c3578d1405caf1c82d66598 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 16:32:23 -0400 Subject: [PATCH 132/188] test: migrate negative tokens regression to Effect runner (#27141) --- .../server/negative-tokens-regression.test.ts | 124 ++++++++---------- 1 file changed, 54 insertions(+), 70 deletions(-) diff --git a/packages/opencode/test/server/negative-tokens-regression.test.ts b/packages/opencode/test/server/negative-tokens-regression.test.ts index 77ad1bc27..290023ead 100644 --- a/packages/opencode/test/server/negative-tokens-regression.test.ts +++ b/packages/opencode/test/server/negative-tokens-regression.test.ts @@ -5,11 +5,10 @@ // negative. The pre-fix `safe()` clamp only guarded against non-finite. The // strict `NonNegativeInt` schema then made every load of the message list // fail to encode, killing Desktop boot for every user with such a row. -import { afterEach, describe, expect } from "bun:test" +import { describe, expect } from "bun:test" import { Effect } from "effect" import { eq } from "drizzle-orm" import { ModelID, ProviderID } from "../../src/provider/schema" -import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" @@ -17,81 +16,66 @@ import { MessageID, PartID } from "../../src/session/schema" import * as Database from "@/storage/db" import { PartTable } from "@/session/session.sql" import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" -import { it } from "../lib/effect" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -afterEach(async () => { - await disposeAllInstances() - await resetDatabase() -}) +const it = testEffect(Session.defaultLayer) -function seedNegativeTokenSession(directory: string) { - return Effect.promise(async () => - WithInstance.provide({ - directory, - fn: () => - Effect.runPromise( - Effect.gen(function* () { - const session = yield* Session.Service - const info = yield* session.create({}) - const message = yield* session.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: info.id, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - const partID = PartID.ascending() - yield* session.updatePart({ - id: partID, - sessionID: info.id, - messageID: message.id, - type: "step-finish", - reason: "stop", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - }) +function seedNegativeTokenSession() { + return Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const message = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + const partID = PartID.ascending() + yield* session.updatePart({ + id: partID, + sessionID: info.id, + messageID: message.id, + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) - // Bypass the schema with a direct SQL update to install the - // negative `output` value we want to test loading. - Database.use((db) => - db - .update(PartTable) - .set({ - data: { - type: "step-finish", - reason: "stop", - cost: 0, - tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } }, - } as never, - }) - .where(eq(PartTable.id, partID)) - .run(), - ) + // Bypass the schema with a direct SQL update to install the + // negative `output` value we want to test loading. + Database.use((db) => + db + .update(PartTable) + .set({ + data: { + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never, + }) + .where(eq(PartTable.id, partID)) + .run(), + ) - return info.id - }).pipe(Effect.provide(Session.defaultLayer)), - ), - }), - ) + return info.id + }) } describe("messages endpoint tolerates legacy negative token counts", () => { - it.live( + it.instance( "returns 200 even when a step-finish part has tokens.output < 0", - Effect.acquireRelease( - Effect.promise(() => tmpdir({ config: { formatter: false, lsp: false } })), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - const sessionID = yield* seedNegativeTokenSession(tmp.path) - const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}` - const res = yield* Effect.promise(async () => Server.Default().app.request(url)) - expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400) - }), - ), - ), + Effect.gen(function* () { + yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase())) + const test = yield* TestInstance + const sessionID = yield* seedNegativeTokenSession() + const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}` + const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400) + }), + { git: true, config: { formatter: false, lsp: false } }, ) }) From ca28dd02ec8eb98a7ad8afea83fdbc3105c48453 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 12 May 2026 15:33:16 -0500 Subject: [PATCH 133/188] fix(compaction): restore tail turns after summarization (#27145) --- packages/opencode/src/config/config.ts | 4 +- packages/opencode/src/session/compaction.ts | 60 ++++++++----------- packages/opencode/src/session/message-v2.ts | 44 ++++++++++++-- .../opencode/test/session/compaction.test.ts | 50 +++++++--------- .../test/session/messages-pagination.test.ts | 16 ++--- 5 files changed, 96 insertions(+), 78 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index d00c97f46..545e48e64 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -261,10 +261,10 @@ export const Info = Schema.Struct({ }), tail_turns: Schema.optional(NonNegativeInt).annotate({ description: - "Number of recent user turns, including their following assistant/tool responses, to serialize into the compaction summary (default: 2)", + "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)", }), preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({ - description: "Maximum number of tokens from recent turns to serialize into the compaction summary", + description: "Maximum number of tokens from recent turns to preserve verbatim after compaction", }), reserved: Schema.optional(NonNegativeInt).annotate({ description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.", diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 3ca4f074f..4eafbdf74 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -79,10 +79,12 @@ Rules: type Turn = { start: number end: number + id: MessageID } type Tail = { start: number + id: MessageID } type CompletedCompaction = { @@ -119,41 +121,19 @@ function completedCompactions(messages: MessageV2.WithParts[]) { }) } -function buildPrompt(input: { previousSummary?: string; context: string[]; tail?: string }) { - const source = input.tail - ? "the conversation history above and the serialized recent conversation tail below" - : "the conversation history above" +function buildPrompt(input: { previousSummary?: string; context: string[] }) { const anchor = input.previousSummary ? [ - `Update the anchored summary below using ${source}.`, + "Update the anchored summary below using the conversation history above.", "Preserve still-true details, remove stale details, and merge in the new facts.", "", input.previousSummary, "", ].join("\n") - : `Create a new anchored summary from ${source}.` - const tail = input.tail - ? [ - "Fold this serialized recent conversation tail into the summary; it is not provider message history.", - "", - input.tail, - "", - ].join("\n") - : undefined - return [anchor, ...(tail ? [tail] : []), SUMMARY_TEMPLATE, ...input.context].join("\n\n") + : "Create a new anchored summary from the conversation history above." + return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n") } -const serialize = Effect.fn("SessionCompaction.serialize")(function* (input: { - messages: MessageV2.WithParts[] - model: Provider.Model -}) { - const messages = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, { - stripMedia: true, - toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, - }) - return messages.length ? JSON.stringify(messages, null, 2) : undefined -}) - function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) { return ( input.cfg.compaction?.preserve_recent_tokens ?? @@ -170,6 +150,7 @@ function turns(messages: MessageV2.WithParts[]) { result.push({ start: i, end: messages.length, + id: msg.info.id, }) } for (let i = 0; i < result.length - 1; i++) { @@ -196,6 +177,7 @@ function splitTurn(input: { if (size > input.budget) continue return { start, + id: input.messages[start]!.info.id, } satisfies Tail } return undefined @@ -262,7 +244,8 @@ export const layer: Layer.Layer< messages: MessageV2.WithParts[] model: Provider.Model }) { - return Token.estimate((yield* serialize(input)) ?? "") + const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model) + return Token.estimate(JSON.stringify(msgs)) }) const select = Effect.fn("SessionCompaction.select")(function* (input: { @@ -271,10 +254,10 @@ export const layer: Layer.Layer< model: Provider.Model }) { const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS - if (limit <= 0) return { head: input.messages, tail: [] } + if (limit <= 0) return { head: input.messages, tail_start_id: undefined } const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model }) const all = turns(input.messages) - if (!all.length) return { head: input.messages, tail: [] } + if (!all.length) return { head: input.messages, tail_start_id: undefined } const recent = all.slice(-limit) const sizes = yield* Effect.forEach( recent, @@ -293,7 +276,7 @@ export const layer: Layer.Layer< const size = sizes[i] if (total + size <= budget) { total += size - keep = { start: turn.start } + keep = { start: turn.start, id: turn.id } continue } const remaining = budget - total @@ -309,10 +292,10 @@ export const layer: Layer.Layer< break } - if (!keep) return { head: input.messages, tail: [] } + if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined } return { head: input.messages.slice(0, keep.start), - tail: input.messages.slice(keep.start), + tail_start_id: keep.id, } }) @@ -423,10 +406,7 @@ export const layer: Layer.Layer< { sessionID: input.sessionID }, { context: [], prompt: undefined }, ) - const tailMessages = structuredClone(selected.tail) - yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: tailMessages }) - const tail = yield* serialize({ messages: tailMessages, model }) - const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context, tail }) + const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }) const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { @@ -493,6 +473,13 @@ export const layer: Layer.Layer< return "stop" } + if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) { + yield* session.updatePart({ + ...compactionPart, + tail_start_id: selected.tail_start_id, + }) + } + if (result === "continue" && input.auto) { if (replay) { const original = replay.info @@ -588,6 +575,7 @@ export const layer: Layer.Layer< sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), text: summary ?? "", + include: selected.tail_start_id, }) } yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 626261d0f..e6ee40e95 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -772,13 +772,12 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( return part.metadata?.anthropic?.signature != null }) for (const part of msg.parts) { - if (msg.info.summary && part.type !== "text") continue if (part.type === "text") { const text = part.text === "" && hasSignedReasoning ? " " : part.text assistantMessage.parts.push({ type: "text", text, - ...(differentModel || msg.info.summary ? {} : { providerMetadata: part.metadata }), + ...(differentModel ? {} : { providerMetadata: part.metadata }), }) } if (part.type === "step-start") @@ -1004,16 +1003,53 @@ export function get(input: { sessionID: SessionID; messageID: MessageID }): With export function filterCompacted(msgs: Iterable) { const result = [] as WithParts[] const completed = new Set() + let retain: MessageID | undefined for (const msg of msgs) { result.push(msg) - if (msg.info.role === "user" && completed.has(msg.info.id)) { - if (msg.parts.some((item): item is CompactionPart => item.type === "compaction")) break + if (retain) { + if (msg.info.id === retain) break continue } + if (msg.info.role === "user" && completed.has(msg.info.id)) { + const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction") + if (!part) continue + if (!part.tail_start_id) break + retain = part.tail_start_id + if (msg.info.id === retain) break + continue + } + if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction")) + break if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error) completed.add(msg.info.parentID) } result.reverse() + const compactionIndex = result.findLastIndex( + (msg) => + msg.info.role === "user" && + msg.parts.some((item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined), + ) + const compaction = result[compactionIndex] + const part = compaction?.parts.find( + (item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined, + ) + const summaryIndex = compaction + ? result.findIndex( + (msg, index) => + index > compactionIndex && + msg.info.role === "assistant" && + msg.info.summary && + msg.info.parentID === compaction.info.id, + ) + : -1 + const tailIndex = part?.tail_start_id ? result.findIndex((msg) => msg.info.id === part.tail_start_id) : -1 + if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) { + return [ + ...result.slice(compactionIndex, summaryIndex + 1), + ...result.slice(tailIndex, compactionIndex), + ...result.slice(summaryIndex + 1), + ] + } return result } diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index c7f349d5c..1d329699f 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -926,12 +926,12 @@ describe("session.compaction.process", () => { ) itCompaction.instance( - "does not persist tail_start_id for serialized recent turns", + "persists tail_start_id for retained recent turns", Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) yield* createUserMessage(session.id, "first") - yield* createUserMessage(session.id, "second") + const keep = yield* createUserMessage(session.id, "second") yield* createUserMessage(session.id, "third") yield* createSummaryCompaction(session.id) @@ -947,18 +947,18 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBeUndefined() + expect(part?.tail_start_id).toBe(keep.id) }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })), ) itCompaction.instance( - "does not persist tail_start_id when shrinking serialized tail", + "shrinks retained tail to fit preserve token budget", Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) yield* createUserMessage(session.id, "first") yield* createUserMessage(session.id, "x".repeat(2_000)) - yield* createUserMessage(session.id, "tiny") + const keep = yield* createUserMessage(session.id, "tiny") yield* createSummaryCompaction(session.id) const msgs = yield* ssn.messages({ sessionID: session.id }) @@ -973,7 +973,7 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBeUndefined() + expect(part?.tail_start_id).toBe(keep.id) }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })), ) @@ -1005,7 +1005,7 @@ describe("session.compaction.process", () => { ) itCompaction.instance( - "serializes retained tail media as text in the summary input", + "falls back to full summary when retained tail media exceeds preserve token budget", () => { const stub = llm() let captured = "" @@ -1078,16 +1078,15 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBeUndefined() + expect(part?.tail_start_id).toBe(keep.id) expect(captured).toContain("zzzz") - expect(captured).toContain("keep tail") + expect(captured).not.toContain("keep tail") const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(filtered.map((msg) => msg.info.id)).toEqual([parent!, expect.any(String)]) + expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id]) expect(filtered[1]?.info.role).toBe("assistant") expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) - expect(filtered.map((msg) => msg.info.id)).not.toContain(keep.id) }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) }, { git: true }, @@ -1354,13 +1353,13 @@ describe("session.compaction.process", () => { ) itCompaction.instance( - "summarizes the head while serializing recent tail into summary input", + "summarizes only the head while keeping recent tail out of summary input", () => { const stub = llm() - let captured: LLM.StreamInput["messages"] = [] + let captured = "" stub.push( reply("summary", (input) => { - captured = input.messages + captured = JSON.stringify(input.messages) }), ) return Effect.gen(function* () { @@ -1381,15 +1380,10 @@ describe("session.compaction.process", () => { auto: false, }) - const head = JSON.stringify(captured.slice(0, -1)) - const prompt = JSON.stringify(captured.at(-1)) - expect(head).toContain("older context") - expect(head).not.toContain("keep this turn") - expect(head).not.toContain("and this one too") - expect(prompt).toContain("keep this turn") - expect(prompt).toContain("and this one too") - expect(prompt).toContain("recent-conversation-tail") - expect(prompt).not.toContain("What did we do so far?") + expect(captured).toContain("older context") + expect(captured).not.toContain("keep this turn") + expect(captured).not.toContain("and this one too") + expect(captured).not.toContain("What did we do so far?") }).pipe(withCompaction({ llm: stub.layer })) }, { git: true }, @@ -1437,7 +1431,7 @@ describe("session.compaction.process", () => { { git: true }, ) - itCompaction.instance("does not replay recent pre-compaction turns across repeated compactions", () => { + itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => { const stub = llm() stub.push(reply("summary one")) stub.push(reply("summary two")) @@ -1468,8 +1462,8 @@ describe("session.compaction.process", () => { expect(ids).not.toContain(u1.id) expect(ids).not.toContain(u2.id) - expect(ids).not.toContain(u3.id) - expect(ids).not.toContain(u4.id) + expect(ids).toContain(u3.id) + expect(ids).toContain(u4.id) expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true) expect( filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), @@ -1478,7 +1472,7 @@ describe("session.compaction.process", () => { }) itCompaction.instance( - "ignores previous summaries when sizing the serialized tail", + "ignores previous summaries when sizing the retained tail", Effect.gen(function* () { const ssn = yield* SessionNs.Service const test = yield* TestInstance @@ -1517,7 +1511,7 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBeUndefined() + expect(part?.tail_start_id).toBe(keep.id) }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })), ) }) diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index e1714a901..09e8d7b42 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -650,7 +650,7 @@ describe("MessageV2.filterCompacted", () => { ), ) - it.instance("ignores original tail when compaction stores tail_start_id", () => + it.instance("retains original tail when compaction stores tail_start_id", () => withSession(({ session, sessionID }) => Effect.gen(function* () { const u1 = yield* addUser(sessionID, "first") @@ -696,12 +696,12 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) + expect(result.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3]) }), ), ) - it.instance("fork keeps legacy tail_start_id without replaying the tail", () => + it.instance("fork remaps compaction tail_start_id for filterCompacted", () => Effect.gen(function* () { const session = yield* SessionNs.Service const created = yield* session.create({}) @@ -748,7 +748,7 @@ describe("MessageV2.filterCompacted", () => { }) const parentFiltered = MessageV2.filterCompacted(MessageV2.stream(created.id)) - expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) + expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3]) const forked = yield* session.fork({ sessionID: created.id }) const childFiltered = MessageV2.filterCompacted(MessageV2.stream(forked.id)) @@ -758,14 +758,14 @@ describe("MessageV2.filterCompacted", () => { expect(tailPart?.type).toBe("compaction") if (!tailPart || tailPart.type !== "compaction") throw new Error("Expected forked compaction part") expect(tailPart.tail_start_id).toBeDefined() - expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(false) + expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(true) yield* session.remove(forked.id) yield* session.remove(created.id) }), ) - it.instance("does not replay an assistant tail when compaction starts inside a turn", () => + it.instance("retains an assistant tail when compaction starts inside a turn", () => withSession(({ session, sessionID }) => Effect.gen(function* () { const u1 = yield* addUser(sessionID, "first") @@ -819,7 +819,7 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a4]) + expect(result.map((item) => item.info.id)).toEqual([c1, s1, a3, u3, a4]) }), ), ) @@ -891,7 +891,7 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - expect(result.map((item) => item.info.id)).toEqual([c2, s2, u4, a4]) + expect(result.map((item) => item.info.id)).toEqual([c2, s2, u3, a3, u4, a4]) }), ), ) From 4cf088ae84813bd724352c37803708de854eaf11 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 16:34:12 -0400 Subject: [PATCH 134/188] test: migrate instance bootstrap to Effect runner (#27144) --- .../test/project/instance-bootstrap.test.ts | 115 ++++++++++-------- 1 file changed, 63 insertions(+), 52 deletions(-) diff --git a/packages/opencode/test/project/instance-bootstrap.test.ts b/packages/opencode/test/project/instance-bootstrap.test.ts index 71521a765..4be2a7611 100644 --- a/packages/opencode/test/project/instance-bootstrap.test.ts +++ b/packages/opencode/test/project/instance-bootstrap.test.ts @@ -1,11 +1,16 @@ -import { afterEach, expect, test } from "bun:test" +import { afterEach, expect } from "bun:test" import { existsSync } from "node:fs" import path from "node:path" import { pathToFileURL } from "node:url" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect, Layer } from "effect" import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap" -import { WithInstance } from "../../src/project/with-instance" -import { InstanceRuntime } from "../../src/project/instance-runtime" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { InstanceLayer } from "../../src/project/instance-layer" +import { InstanceStore } from "../../src/project/instance-store" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(InstanceLayer.layer, CrossSpawnSpawner.defaultLayer)) // InstanceBootstrap must run before any code touches the instance — // originally tracked by PRs #25389 and #25449, now a permanent @@ -19,58 +24,64 @@ afterEach(async () => { await disposeAllInstances() }) -async function bootstrapFixture() { - return tmpdir({ - init: async (dir) => { - const marker = path.join(dir, "config-hook-fired") - const pluginFile = path.join(dir, "plugin.ts") - await Bun.write( - pluginFile, - [ - `const MARKER = ${JSON.stringify(marker)}`, - "export default async () => ({", - " config: async () => {", - ' await Bun.write(MARKER, "ran")', - " },", - "})", - "", - ].join("\n"), - ) - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - plugin: [pathToFileURL(pluginFile).href], - }), - ) - return marker - }, - }) -} - -test("WithInstance.provide runs InstanceBootstrap before fn", async () => { - await using tmp = await bootstrapFixture() - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => "ok", - }) - - expect(existsSync(tmp.extra)).toBe(true) +const bootstrapFixture = Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const marker = path.join(dir, "config-hook-fired") + const pluginFile = path.join(dir, "plugin.ts") + yield* Effect.promise(() => + Bun.write( + pluginFile, + [ + `const MARKER = ${JSON.stringify(marker)}`, + "export default async () => ({", + " config: async () => {", + ' await Bun.write(MARKER, "ran")', + " },", + "})", + "", + ].join("\n"), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(pluginFile).href], + }), + ), + ) + return { directory: dir, marker } }) -test("CLI bootstrap runs InstanceBootstrap before callback", async () => { - await using tmp = await bootstrapFixture() +it.live("InstanceStore.provide runs InstanceBootstrap before effect", () => + Effect.gen(function* () { + const tmp = yield* bootstrapFixture + const store = yield* InstanceStore.Service - await cliBootstrap(tmp.path, async () => "ok") + yield* store.provide({ directory: tmp.directory }, Effect.succeed("ok")) - expect(existsSync(tmp.extra)).toBe(true) -}) + expect(existsSync(tmp.marker)).toBe(true) + }), +) -test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => { - await using tmp = await bootstrapFixture() +it.live("CLI bootstrap runs InstanceBootstrap before callback", () => + Effect.gen(function* () { + const tmp = yield* bootstrapFixture - await InstanceRuntime.reloadInstance({ directory: tmp.path }) + yield* Effect.promise(() => cliBootstrap(tmp.directory, async () => "ok")) - expect(existsSync(tmp.extra)).toBe(true) -}) + expect(existsSync(tmp.marker)).toBe(true) + }), +) + +it.live("InstanceStore.reload runs InstanceBootstrap", () => + Effect.gen(function* () { + const tmp = yield* bootstrapFixture + const store = yield* InstanceStore.Service + + yield* store.reload({ directory: tmp.directory }) + + expect(existsSync(tmp.marker)).toBe(true) + }), +) From 3c34f6704b15ae491750fe6b78d244cfc24d4a7a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 16:41:34 -0400 Subject: [PATCH 135/188] test: migrate auth override plugin test (#27140) --- .../test/plugin/auth-override.test.ts | 67 +++++++++---------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index c77c0ca1c..402d755da 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -1,15 +1,19 @@ import { describe, expect, test } from "bun:test" import path from "path" -import fs from "fs/promises" import { pathToFileURL } from "url" import { Effect, Layer } from "effect" -import { provideTestInstance, tmpdir } from "../fixture/fixture" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { ProviderAuth } from "@/provider/auth" import { ProviderID } from "../../src/provider/schema" import { Plugin } from "@/plugin" import { Auth } from "@/auth" import { Bus } from "@/bus" import { TestConfig } from "../fixture/config" +import { testEffect } from "../lib/effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" + +const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer)) function layer(directory: string, plugins: string[]) { return ProviderAuth.layer.pipe( @@ -37,13 +41,15 @@ function layer(directory: string, plugins: string[]) { } describe("plugin.auth-override", () => { - test("user plugin overrides built-in github-copilot auth", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const pluginDir = path.join(dir, ".opencode", "plugin") - await fs.mkdir(pluginDir, { recursive: true }) + it.instance( + "user plugin overrides built-in github-copilot auth", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const fs = yield* AppFileSystem.Service + const pluginDir = path.join(tmp.directory, ".opencode", "plugin") - await Bun.write( + yield* fs.writeWithDirs( path.join(pluginDir, "custom-copilot-auth.ts"), [ "export default {", @@ -61,37 +67,26 @@ describe("plugin.auth-override", () => { "", ].join("\n"), ) - }, - }) - await using plain = await tmpdir() + const plain = yield* tmpdirScoped({ git: true }) + const plugin = pathToFileURL(path.join(pluginDir, "custom-copilot-auth.ts")).href + const methods = yield* ProviderAuth.Service.use((svc) => svc.methods()).pipe( + Effect.provide(layer(tmp.directory, [plugin])), + ) + const plainMethods = yield* ProviderAuth.Service.use((svc) => svc.methods()).pipe( + Effect.provide(layer(plain, [])), + provideInstance(plain), + ) - const plugin = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "custom-copilot-auth.ts")).href - const [methods, plainMethods] = await Promise.all([ - provideTestInstance({ - directory: tmp.path, - fn: async () => { - return Effect.runPromise( - ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(tmp.path, [plugin]))), - ) - }, + const copilot = methods[ProviderID.make("github-copilot")] + expect(copilot).toBeDefined() + expect(copilot.length).toBe(1) + expect(copilot[0].label).toBe("Test Override Auth") + expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth") }), - provideTestInstance({ - directory: plain.path, - fn: async () => { - return Effect.runPromise( - ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(plain.path, []))), - ) - }, - }), - ]) - - const copilot = methods[ProviderID.make("github-copilot")] - expect(copilot).toBeDefined() - expect(copilot.length).toBe(1) - expect(copilot[0].label).toBe("Test Override Auth") - expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth") - }, 30000) + { git: true }, + 30000, + ) }) const file = path.join(import.meta.dir, "../../src/plugin/index.ts") From 0fb55b4f1a0330d531427a517824ca3714ae8307 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 16:45:39 -0400 Subject: [PATCH 136/188] test(project): migrate global project tests to Effect runner (#27142) --- .../test/project/migrate-global.test.ts | 168 +++++++++--------- 1 file changed, 87 insertions(+), 81 deletions(-) diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts index c476c108b..6efd670c5 100644 --- a/packages/opencode/test/project/migrate-global.test.ts +++ b/packages/opencode/test/project/migrate-global.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import { Project } from "@/project/project" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" @@ -8,19 +8,14 @@ import { ProjectID } from "../../src/project/schema" import { SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" import { $ } from "bun" -import { tmpdir } from "../fixture/fixture" -import { Effect } from "effect" +import { tmpdirScoped } from "../fixture/fixture" +import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { testEffect } from "../lib/effect" -Log.init({ print: false }) +void Log.init({ print: false }) -function run(fn: (svc: Project.Interface) => Effect.Effect) { - return Effect.runPromise( - Effect.gen(function* () { - const svc = yield* Project.Service - return yield* fn(svc) - }).pipe(Effect.provide(Project.defaultLayer)), - ) -} +const it = testEffect(Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer)) function legacySessionID() { // Global-session migration covers persisted IDs from before prefixed session IDs. @@ -63,91 +58,102 @@ function ensureGlobal() { } describe("migrateFromGlobal", () => { - test("migrates global sessions on first project creation", async () => { - // 1. Start with git init but no commits — creates "global" project row - await using tmp = await tmpdir() - await $`git init`.cwd(tmp.path).quiet() - await $`git config user.name "Test"`.cwd(tmp.path).quiet() - await $`git config user.email "test@opencode.test"`.cwd(tmp.path).quiet() - await $`git config commit.gpgsign false`.cwd(tmp.path).quiet() - const { project: pre } = await run((svc) => svc.fromDirectory(tmp.path)) - expect(pre.id).toBe(ProjectID.global) + it.live("migrates global sessions on first project creation", () => + Effect.gen(function* () { + // 1. Start with git init but no commits — creates "global" project row + const tmp = yield* tmpdirScoped() + yield* Effect.promise(() => $`git init`.cwd(tmp).quiet()) + yield* Effect.promise(() => $`git config user.name "Test"`.cwd(tmp).quiet()) + yield* Effect.promise(() => $`git config user.email "test@opencode.test"`.cwd(tmp).quiet()) + yield* Effect.promise(() => $`git config commit.gpgsign false`.cwd(tmp).quiet()) + const projects = yield* Project.Service + const { project: pre } = yield* projects.fromDirectory(tmp) + expect(pre.id).toBe(ProjectID.global) - // 2. Seed a session under "global" with matching directory - const id = legacySessionID() - seed({ id, dir: tmp.path, project: ProjectID.global }) + // 2. Seed a session under "global" with matching directory + const id = legacySessionID() + yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectID.global })) - // 3. Make a commit so the project gets a real ID - await $`git commit --allow-empty -m "root"`.cwd(tmp.path).quiet() + // 3. Make a commit so the project gets a real ID + yield* Effect.promise(() => $`git commit --allow-empty -m "root"`.cwd(tmp).quiet()) - const { project: real } = await run((svc) => svc.fromDirectory(tmp.path)) - expect(real.id).not.toBe(ProjectID.global) + const { project: real } = yield* projects.fromDirectory(tmp) + expect(real.id).not.toBe(ProjectID.global) - // 4. The session should have been migrated to the real project ID - const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) - expect(row).toBeDefined() - expect(row!.project_id).toBe(real.id) - }) + // 4. The session should have been migrated to the real project ID + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) + expect(row).toBeDefined() + expect(row!.project_id).toBe(real.id) + }), + ) - test("migrates global sessions even when project row already exists", async () => { - // 1. Create a repo with a commit — real project ID created immediately - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) - expect(project.id).not.toBe(ProjectID.global) + it.live("migrates global sessions even when project row already exists", () => + Effect.gen(function* () { + // 1. Create a repo with a commit — real project ID created immediately + const tmp = yield* tmpdirScoped({ git: true }) + const projects = yield* Project.Service + const { project } = yield* projects.fromDirectory(tmp) + expect(project.id).not.toBe(ProjectID.global) - // 2. Ensure "global" project row exists (as it would from a prior no-git session) - ensureGlobal() + // 2. Ensure "global" project row exists (as it would from a prior no-git session) + yield* Effect.sync(() => ensureGlobal()) - // 3. Seed a session under "global" with matching directory. - // This simulates a session created before git init that wasn't - // present when the real project row was first created. - const id = legacySessionID() - seed({ id, dir: tmp.path, project: ProjectID.global }) + // 3. Seed a session under "global" with matching directory. + // This simulates a session created before git init that wasn't + // present when the real project row was first created. + const id = legacySessionID() + yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectID.global })) - // 4. Call fromDirectory again — project row already exists, - // so the current code skips migration entirely. This is the bug. - await run((svc) => svc.fromDirectory(tmp.path)) + // 4. Call fromDirectory again — project row already exists, + // so the current code skips migration entirely. This is the bug. + yield* projects.fromDirectory(tmp) - const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) - expect(row).toBeDefined() - expect(row!.project_id).toBe(project.id) - }) + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) + expect(row).toBeDefined() + expect(row!.project_id).toBe(project.id) + }), + ) - test("does not claim sessions with empty directory", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) - expect(project.id).not.toBe(ProjectID.global) + it.live("does not claim sessions with empty directory", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped({ git: true }) + const projects = yield* Project.Service + const { project } = yield* projects.fromDirectory(tmp) + expect(project.id).not.toBe(ProjectID.global) - ensureGlobal() + yield* Effect.sync(() => ensureGlobal()) - // Legacy sessions may lack a directory value. - // Without a matching origin directory, they should remain global. - const id = legacySessionID() - seed({ id, dir: "", project: ProjectID.global }) + // Legacy sessions may lack a directory value. + // Without a matching origin directory, they should remain global. + const id = legacySessionID() + yield* Effect.sync(() => seed({ id, dir: "", project: ProjectID.global })) - await run((svc) => svc.fromDirectory(tmp.path)) + yield* projects.fromDirectory(tmp) - const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) - expect(row).toBeDefined() - expect(row!.project_id).toBe(ProjectID.global) - }) + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) + expect(row).toBeDefined() + expect(row!.project_id).toBe(ProjectID.global) + }), + ) - test("does not steal sessions from unrelated directories", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) - expect(project.id).not.toBe(ProjectID.global) + it.live("does not steal sessions from unrelated directories", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped({ git: true }) + const projects = yield* Project.Service + const { project } = yield* projects.fromDirectory(tmp) + expect(project.id).not.toBe(ProjectID.global) - ensureGlobal() + yield* Effect.sync(() => ensureGlobal()) - // Seed a session under "global" but for a DIFFERENT directory - const id = legacySessionID() - seed({ id, dir: "/some/other/dir", project: ProjectID.global }) + // Seed a session under "global" but for a DIFFERENT directory + const id = legacySessionID() + yield* Effect.sync(() => seed({ id, dir: "/some/other/dir", project: ProjectID.global })) - await run((svc) => svc.fromDirectory(tmp.path)) - - const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) - expect(row).toBeDefined() - // Should remain under "global" — not stolen - expect(row!.project_id).toBe(ProjectID.global) - }) + yield* projects.fromDirectory(tmp) + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) + expect(row).toBeDefined() + // Should remain under "global" — not stolen + expect(row!.project_id).toBe(ProjectID.global) + }), + ) }) From d9f9f1553b439bd3c811322484387479b6b26a2d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 16:48:07 -0400 Subject: [PATCH 137/188] test: use Effect file services in migrated tests (#27154) --- .../opencode/test/skill/discovery.test.ts | 38 +++++++++++-------- .../opencode/test/snapshot/snapshot.test.ts | 24 +++++------- 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index 0b07d4df0..074992c56 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -1,5 +1,6 @@ import { describe, expect, beforeAll, afterAll } from "bun:test" -import { Effect } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Effect, Layer } from "effect" import { Discovery } from "../../src/skill/discovery" import { Global } from "@opencode-ai/core/global" import { Filesystem } from "@/util/filesystem" @@ -13,7 +14,7 @@ let downloadCount = 0 const fixturePath = path.join(import.meta.dir, "../fixture/skills") const cacheDir = path.join(Global.Path.cache, "skills") -const it = testEffect(Discovery.defaultLayer) +const it = testEffect(Layer.mergeAll(Discovery.defaultLayer, AppFileSystem.defaultLayer)) beforeAll(async () => { await rm(cacheDir, { recursive: true, force: true }) @@ -49,36 +50,37 @@ afterAll(async () => { }) describe("Discovery.pull", () => { - const pull = Effect.fn("DiscoveryTest.pull")(function* (url: string) { - return yield* Discovery.Service.use((s) => s.pull(url)) - }) - it.live("downloads skills from cloudflare url", () => Effect.gen(function* () { - const dirs = yield* pull(CLOUDFLARE_SKILLS_URL) + const fsys = yield* AppFileSystem.Service + const discovery = yield* Discovery.Service + const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL) expect(dirs.length).toBeGreaterThan(0) for (const dir of dirs) { expect(dir).toStartWith(cacheDir) const md = path.join(dir, "SKILL.md") - expect(yield* Effect.promise(() => Filesystem.exists(md))).toBe(true) + expect(yield* fsys.existsSafe(md)).toBe(true) } }), ) it.live("url without trailing slash works", () => Effect.gen(function* () { - const dirs = yield* pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, "")) + const fsys = yield* AppFileSystem.Service + const discovery = yield* Discovery.Service + const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, "")) expect(dirs.length).toBeGreaterThan(0) for (const dir of dirs) { const md = path.join(dir, "SKILL.md") - expect(yield* Effect.promise(() => Filesystem.exists(md))).toBe(true) + expect(yield* fsys.existsSafe(md)).toBe(true) } }), ) it.live("returns empty array for invalid url", () => Effect.gen(function* () { - const dirs = yield* pull(`http://localhost:${server.port}/invalid-url/`) + const discovery = yield* Discovery.Service + const dirs = yield* discovery.pull(`http://localhost:${server.port}/invalid-url/`) expect(dirs).toEqual([]) }), ) @@ -86,20 +88,23 @@ describe("Discovery.pull", () => { it.live("returns empty array for non-json response", () => Effect.gen(function* () { // any url not explicitly handled in server returns 404 text "Not Found" - const dirs = yield* pull(`http://localhost:${server.port}/some-other-path/`) + const discovery = yield* Discovery.Service + const dirs = yield* discovery.pull(`http://localhost:${server.port}/some-other-path/`) expect(dirs).toEqual([]) }), ) it.live("downloads reference files alongside SKILL.md", () => Effect.gen(function* () { - const dirs = yield* pull(CLOUDFLARE_SKILLS_URL) + const fsys = yield* AppFileSystem.Service + const discovery = yield* Discovery.Service + const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL) // find a skill dir that should have reference files (e.g. agents-sdk) const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk")) expect(agentsSdk).toBeDefined() if (agentsSdk) { const refs = path.join(agentsSdk, "references") - expect(yield* Effect.promise(() => Filesystem.exists(path.join(agentsSdk, "SKILL.md")))).toBe(true) + expect(yield* fsys.existsSafe(path.join(agentsSdk, "SKILL.md"))).toBe(true) // agents-sdk has reference files per the index const refDir = yield* Effect.promise(() => Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true })), @@ -114,15 +119,16 @@ describe("Discovery.pull", () => { // clear dir and downloadCount yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true })) downloadCount = 0 + const discovery = yield* Discovery.Service // first pull to populate cache - const first = yield* pull(CLOUDFLARE_SKILLS_URL) + const first = yield* discovery.pull(CLOUDFLARE_SKILLS_URL) expect(first.length).toBeGreaterThan(0) const firstCount = downloadCount expect(firstCount).toBeGreaterThan(0) // second pull should return same results from cache - const second = yield* pull(CLOUDFLARE_SKILLS_URL) + const second = yield* discovery.pull(CLOUDFLARE_SKILLS_URL) expect(second.length).toBe(first.length) expect(second.sort()).toEqual(first.sort()) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index fa167281b..de60d58b2 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -1,15 +1,15 @@ import { afterEach, expect } from "bun:test" import { $ } from "bun" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import fs from "fs/promises" import path from "path" -import { Effect, Fiber } from "effect" +import { Effect, Fiber, Layer } from "effect" import { Snapshot } from "../../src/snapshot" -import { Filesystem } from "@/util/filesystem" import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Snapshot.defaultLayer) +const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, AppFileSystem.defaultLayer)) // Git always outputs /-separated paths internally. Snapshot.patch() joins them // with path.join (which produces \ on Windows) then normalizes back to /. @@ -27,17 +27,13 @@ const exec = (cwd: string, command: string[]) => if (code !== 0) throw new Error(`${command.join(" ")} failed: ${await new Response(proc.stderr).text()}`) }) -const write = (file: string, content: string | Uint8Array) => Effect.promise(() => Filesystem.write(file, content)) -const readText = (file: string) => Effect.promise(() => fs.readFile(file, "utf-8")) -const exists = (file: string) => - Effect.promise(() => - fs - .access(file) - .then(() => true) - .catch(() => false), - ) -const mkdirp = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true })) -const rm = (file: string) => Effect.promise(() => fs.rm(file, { recursive: true, force: true })) +const write = (file: string, content: string | Uint8Array) => + AppFileSystem.Service.use((fs) => fs.writeWithDirs(file, content)) +const readText = (file: string) => AppFileSystem.Service.use((fs) => fs.readFileString(file)) +const exists = (file: string) => AppFileSystem.Service.use((fs) => fs.existsSafe(file)) +const mkdirp = (dir: string) => AppFileSystem.Service.use((fs) => fs.ensureDir(dir)) +const rm = (file: string) => + AppFileSystem.Service.use((fs) => fs.remove(file, { recursive: true, force: true }).pipe(Effect.ignore)) const initialize = Effect.fn("SnapshotTest.initialize")(function* (dir: string) { const unique = Math.random().toString(36).slice(2) From 159964b1724b4424a914ea49000314ae978bf2d3 Mon Sep 17 00:00:00 2001 From: Musa Date: Tue, 12 May 2026 14:22:40 -0700 Subject: [PATCH 138/188] feat(plugin): add DigitalOcean OAuth + Inference Routers (#26095) --- packages/opencode/src/cli/cmd/providers.ts | 5 +- packages/opencode/src/plugin/digitalocean.ts | 407 ++++++++++++++++++ packages/opencode/src/plugin/index.ts | 2 + packages/opencode/src/provider/auth.ts | 1 + .../test/provider/digitalocean.test.ts | 144 +++++++ .../test/tool/fixtures/models-api.json | 24 ++ packages/plugin/src/index.ts | 8 +- packages/sdk/js/src/gen/types.gen.ts | 3 + .../assets/icons/provider/digitalocean.svg | 6 + .../src/components/provider-icons/sprite.svg | 14 + .../ui/src/components/provider-icons/types.ts | 1 + packages/web/src/content/docs/providers.mdx | 82 ++++ 12 files changed, 692 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/src/plugin/digitalocean.ts create mode 100644 packages/opencode/test/provider/digitalocean.test.ts create mode 100644 packages/ui/src/assets/icons/provider/digitalocean.svg diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 749139e2d..426ea89fc 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -124,6 +124,7 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( yield* put(saveProvider, { type: "api", key: result.key, + ...(result.metadata ? { metadata: result.metadata } : {}), }) } yield* spinner.stop("Login successful") @@ -156,6 +157,7 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( yield* put(saveProvider, { type: "api", key: result.key, + ...(result.metadata ? { metadata: result.metadata } : {}), }) } yield* Prompt.log.success("Login successful") @@ -191,10 +193,11 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( } if (result.type === "success") { const saveProvider = result.provider ?? provider + const merged = { ...(metadata.metadata ?? {}), ...(result.metadata ?? {}) } yield* put(saveProvider, { type: "api", key: result.key ?? apiKey, - ...metadata, + ...(Object.keys(merged).length ? { metadata: merged } : {}), }) yield* Prompt.log.success("Login successful") } diff --git a/packages/opencode/src/plugin/digitalocean.ts b/packages/opencode/src/plugin/digitalocean.ts new file mode 100644 index 000000000..31656656f --- /dev/null +++ b/packages/opencode/src/plugin/digitalocean.ts @@ -0,0 +1,407 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import type { Model } from "@opencode-ai/sdk/v2" +import * as Log from "@opencode-ai/core/util/log" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { createServer } from "http" + +const log = Log.create({ service: "plugin.digitalocean" }) + +const DO_OAUTH_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82" +const DO_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize" +const DO_API_BASE = "https://api.digitalocean.com" +const DO_INFERENCE_BASE = "https://inference.do-ai.run/v1" +const OAUTH_PORT = 1456 +const OAUTH_REDIRECT_PATH = "/auth/callback" +const OAUTH_TOKEN_PATH = "/auth/token" +const ROUTER_REFRESH_INTERVAL_MS = 5 * 60 * 1000 +const MAK_NAME_PREFIX = "opencode-oauth" + +interface ImplicitTokenPayload { + access_token: string + expires_in: number + state: string +} + +interface PendingOAuth { + state: string + resolve: (tokens: ImplicitTokenPayload) => void + reject: (error: Error) => void +} + +interface ApiKeyInfo { + uuid: string + name: string + secret_key: string +} + +interface RouterEntry { + name: string + uuid?: string + description?: string +} + +let oauthServer: ReturnType | undefined +let pendingOAuth: PendingOAuth | undefined + +function generateState(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)) + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +} + +function redirectUri(): string { + return `http://localhost:${OAUTH_PORT}${OAUTH_REDIRECT_PATH}` +} + +function buildAuthorizeUrl(state: string): string { + const params = new URLSearchParams({ + response_type: "token", + client_id: DO_OAUTH_CLIENT_ID, + redirect_uri: redirectUri(), + scope: "read write", + state, + }) + return `${DO_AUTHORIZE_URL}?${params.toString()}` +} + +const HTML_CALLBACK = ` + + + + OpenCode - DigitalOcean Authorization + + + +
+

Finishing sign-in...

+

You can close this window once it says you're signed in.

+
+ + +` + +async function startOAuthServer(): Promise { + if (oauthServer) return + oauthServer = createServer((req, res) => { + const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`) + + if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) { + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(HTML_CALLBACK) + return + } + + if (req.method === "POST" && url.pathname === OAUTH_TOKEN_PATH) { + const chunks: Buffer[] = [] + req.on("data", (chunk: Buffer) => chunks.push(chunk)) + req.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8") + let body: Record = {} + try { + body = raw ? JSON.parse(raw) : {} + } catch { + body = {} + } + if (!pendingOAuth) { + res.writeHead(409, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "no_pending_oauth" })) + return + } + if (body.error) { + const message = body.error_description || body.error || "OAuth error" + pendingOAuth.reject(new Error(String(message))) + pendingOAuth = undefined + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ ok: true })) + return + } + if (!body.access_token) { + pendingOAuth.reject(new Error("Missing access_token in callback")) + pendingOAuth = undefined + res.writeHead(400, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "missing_access_token" })) + return + } + if (body.state !== pendingOAuth.state) { + pendingOAuth.reject(new Error("Invalid state - potential CSRF attack")) + pendingOAuth = undefined + res.writeHead(400, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "invalid_state" })) + return + } + const expires = parseInt(body.expires_in || "0", 10) + pendingOAuth.resolve({ + access_token: body.access_token, + expires_in: Number.isFinite(expires) && expires > 0 ? expires : 60 * 60 * 24 * 30, + state: body.state, + }) + pendingOAuth = undefined + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ ok: true })) + }) + return + } + + res.writeHead(404) + res.end("Not found") + }) + + await new Promise((resolve, reject) => { + oauthServer!.listen(OAUTH_PORT, () => { + log.info("digitalocean oauth server started", { port: OAUTH_PORT }) + resolve() + }) + oauthServer!.on("error", reject) + }) +} + +function stopOAuthServer() { + if (!oauthServer) return + oauthServer.close(() => log.info("digitalocean oauth server stopped")) + oauthServer = undefined +} + +function waitForOAuthCallback(state: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => { + if (pendingOAuth) { + pendingOAuth = undefined + reject(new Error("OAuth callback timeout - authorization took too long")) + } + }, + 5 * 60 * 1000, + ) + pendingOAuth = { + state, + resolve: (tokens) => { + clearTimeout(timeout) + resolve(tokens) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + }, + } + }) +} + +async function createModelAccessKey(bearer: string): Promise { + // Suffix-on-collision strategy keeps re-`/connect` non-destructive. + const name = `${MAK_NAME_PREFIX}-${Math.floor(Date.now() / 1000)}` + const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/api_keys`, { + method: "POST", + headers: { + Authorization: `Bearer ${bearer}`, + "Content-Type": "application/json", + "User-Agent": `opencode/${InstallationVersion}`, + }, + body: JSON.stringify({ name }), + }) + if (!res.ok) { + const body = await res.text().catch(() => "") + throw new Error(`Failed to create Model Access Key (${res.status}): ${body}`) + } + const data = (await res.json()) as { api_key_info?: ApiKeyInfo } + if (!data.api_key_info?.secret_key) throw new Error("Model Access Key response missing secret_key") + return data.api_key_info +} + +async function listRouters(bearer: string): Promise<{ ok: true; routers: RouterEntry[] } | { ok: false; status: number }> { + const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/routers`, { + headers: { + Authorization: `Bearer ${bearer}`, + Accept: "application/json", + "User-Agent": `opencode/${InstallationVersion}`, + }, + signal: AbortSignal.timeout(10_000), + }).catch(() => undefined) + if (!res) return { ok: false, status: 0 } + if (!res.ok) return { ok: false, status: res.status } + const body = (await res.json().catch(() => undefined)) as { model_routers?: RouterEntry[] } | undefined + return { ok: true, routers: body?.model_routers ?? [] } +} + +function routerModel(router: RouterEntry, providerID: string): Model { + const id = `router:${router.name}` + return { + id, + providerID, + name: router.name, + family: "digitalocean-inference-routers", + api: { id, url: DO_INFERENCE_BASE, npm: "@ai-sdk/openai-compatible" }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 128_000, output: 8_192 }, + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "", + variants: {}, + } +} + +function parseRoutersJSON(raw: string | undefined): RouterEntry[] { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.flatMap((r) => (r && typeof r.name === "string" ? [{ name: r.name, uuid: r.uuid, description: r.description }] : [])) + } catch { + return [] + } +} + +export async function DigitalOceanAuthPlugin(input: PluginInput): Promise { + return { + provider: { + id: "digitalocean", + async models(provider, ctx) { + const baseModels = provider.models + if (ctx.auth?.type !== "api") return baseModels + + const metadata = ctx.auth.metadata ?? {} + const oauthAccess = metadata["oauth_access"] + const oauthExpires = parseInt(metadata["oauth_expires"] || "0", 10) + const fetchedAt = parseInt(metadata["routers_fetched_at"] || "0", 10) + const cached = parseRoutersJSON(metadata["routers"]) + + let routers = cached + const stale = Date.now() - fetchedAt > ROUTER_REFRESH_INTERVAL_MS + const bearerValid = oauthAccess && oauthExpires > Date.now() + + if (bearerValid && stale) { + const result = await listRouters(oauthAccess) + if (result.ok) { + routers = result.routers + const updated: Record = { + ...metadata, + routers: JSON.stringify(routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description }))), + routers_fetched_at: String(Date.now()), + } + await input.client.auth + .set({ + path: { id: "digitalocean" }, + body: { type: "api", key: ctx.auth.key, metadata: updated }, + }) + .catch((err) => log.warn("failed to persist refreshed routers", { error: err })) + } else if (result.status === 401 || result.status === 403) { + log.warn("digitalocean oauth bearer rejected; using cached routers", { status: result.status }) + } else if (result.status !== 0) { + log.warn("digitalocean router refresh failed", { status: result.status }) + } + } + + const merged: Record = { ...baseModels } + for (const router of routers) { + const id = `router:${router.name}` + if (merged[id]) continue + merged[id] = routerModel(router, "digitalocean") + } + return merged + }, + }, + auth: { + provider: "digitalocean", + methods: [ + { + type: "oauth", + label: "Login with DigitalOcean", + async authorize() { + await startOAuthServer() + const state = generateState() + const callbackPromise = waitForOAuthCallback(state) + return { + url: buildAuthorizeUrl(state), + instructions: + "Sign in to DigitalOcean in your browser. OpenCode will create a Model Access Key named opencode-oauth-* and load your Inference Routers. Re-run /connect to refresh routers later.", + method: "auto" as const, + async callback() { + try { + const tokens = await callbackPromise + const apiKeyInfo = await createModelAccessKey(tokens.access_token) + const routerResult = await listRouters(tokens.access_token) + const routers = routerResult.ok ? routerResult.routers : [] + if (!routerResult.ok) { + log.warn("digitalocean initial router fetch failed", { status: routerResult.status }) + } + return { + type: "success" as const, + provider: "digitalocean", + key: apiKeyInfo.secret_key, + metadata: { + mak_uuid: apiKeyInfo.uuid, + mak_name: apiKeyInfo.name, + oauth_access: tokens.access_token, + oauth_expires: String(Date.now() + tokens.expires_in * 1000), + routers: JSON.stringify( + routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description })), + ), + routers_fetched_at: String(Date.now()), + }, + } + } catch (err) { + log.error("digitalocean oauth callback failed", { error: err }) + return { type: "failed" as const } + } finally { + stopOAuthServer() + } + }, + } + }, + }, + { + type: "api", + label: "Paste Model Access Key", + }, + ], + }, + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 7a7f260df..68d47916c 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -19,6 +19,7 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { PoeAuthPlugin } from "opencode-poe-auth" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" import { AzureAuthPlugin } from "./azure" +import { DigitalOceanAuthPlugin } from "./digitalocean" import { Effect, Layer, Context, Stream } from "effect" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" @@ -64,6 +65,7 @@ const INTERNAL_PLUGINS: PluginInstance[] = [ CloudflareWorkersAuthPlugin, CloudflareAIGatewayAuthPlugin, AzureAuthPlugin, + DigitalOceanAuthPlugin, ] function isServerPlugin(value: unknown): value is PluginInstance { diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index ba2a8c744..b63e1eaf4 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -197,6 +197,7 @@ export const layer: Layer.Layer = yield* auth.set(input.providerID, { type: "api", key: result.key, + ...(result.metadata ? { metadata: result.metadata } : {}), }) } diff --git a/packages/opencode/test/provider/digitalocean.test.ts b/packages/opencode/test/provider/digitalocean.test.ts new file mode 100644 index 000000000..6515ea970 --- /dev/null +++ b/packages/opencode/test/provider/digitalocean.test.ts @@ -0,0 +1,144 @@ +import { test, expect, afterEach } from "bun:test" +import path from "path" + +import { tmpdir } from "../fixture/fixture" +import { WithInstance } from "../../src/project/with-instance" +import { Provider } from "../../src/provider/provider" +import { ProviderID } from "../../src/provider/schema" +import { Env } from "../../src/env" +import { Effect } from "effect" +import { AppRuntime } from "../../src/effect/app-runtime" +import { makeRuntime } from "../../src/effect/run-service" + +const envRuntime = makeRuntime(Env.Service, Env.defaultLayer) +const set = (k: string, v: string) => envRuntime.runSync((svc) => svc.set(k, v)) + +async function list() { + return AppRuntime.runPromise( + Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* provider.list() + }), + ) +} + +const DIGITALOCEAN = ProviderID.make("digitalocean") + +const originalAuthContent = process.env.OPENCODE_AUTH_CONTENT +afterEach(() => { + if (originalAuthContent === undefined) delete process.env.OPENCODE_AUTH_CONTENT + else process.env.OPENCODE_AUTH_CONTENT = originalAuthContent +}) + +function injectAuth(metadata: Record | undefined) { + process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ + digitalocean: { + type: "api", + key: "sk_do_test", + ...(metadata ? { metadata } : {}), + }, + }) +} + +test("digitalocean provider autoloads from DIGITALOCEAN_ACCESS_TOKEN", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json" }), + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + set("DIGITALOCEAN_ACCESS_TOKEN", "test-token") + const providers = await list() + expect(providers[DIGITALOCEAN]).toBeDefined() + expect(providers[DIGITALOCEAN].source).toBe("env") + const baseModel = Object.values(providers[DIGITALOCEAN].models)[0] + expect(baseModel.api.url).toBe("https://inference.do-ai.run/v1") + expect(baseModel.api.npm).toBe("@ai-sdk/openai-compatible") + const routerEntries = Object.keys(providers[DIGITALOCEAN].models).filter((id) => id.startsWith("router:")) + expect(routerEntries.length).toBe(0) + }, + }) +}) + +test("digitalocean provider.models surfaces cached routers from auth metadata", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json" }), + ) + }, + }) + injectAuth({ + routers: JSON.stringify([ + { name: "my-router", uuid: "11f1499a-aaaa-bbbb-cccc-4e013e2ddde4" }, + { name: "other-router", uuid: "22f1499a-aaaa-bbbb-cccc-4e013e2ddde4" }, + ]), + routers_fetched_at: String(Date.now()), + oauth_access: "doo_v1_test", + oauth_expires: String(Date.now() + 60 * 60 * 1000), + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await list() + const models = providers[DIGITALOCEAN].models + expect(models["router:my-router"]).toBeDefined() + expect(models["router:my-router"].api.id).toBe("router:my-router") + expect(models["router:my-router"].api.url).toBe("https://inference.do-ai.run/v1") + expect(models["router:my-router"].api.npm).toBe("@ai-sdk/openai-compatible") + expect(models["router:other-router"]).toBeDefined() + }, + }) +}) + +test("digitalocean provider.models skips refresh when oauth bearer is expired", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json" }), + ) + }, + }) + injectAuth({ + routers: JSON.stringify([{ name: "stale-router", uuid: "stale" }]), + routers_fetched_at: "0", + oauth_access: "doo_v1_expired", + oauth_expires: "1", + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await list() + const models = providers[DIGITALOCEAN].models + expect(models["router:stale-router"]).toBeDefined() + }, + }) +}) + +test("digitalocean provider.models passes through base models when no auth metadata", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json" }), + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + set("DIGITALOCEAN_ACCESS_TOKEN", "test-token") + const providers = await list() + const models = providers[DIGITALOCEAN].models + expect(Object.keys(models).length).toBeGreaterThan(0) + expect(Object.keys(models).filter((id) => id.startsWith("router:")).length).toBe(0) + }, + }) +}) diff --git a/packages/opencode/test/tool/fixtures/models-api.json b/packages/opencode/test/tool/fixtures/models-api.json index 5a3eb7e80..7ced5ca5d 100644 --- a/packages/opencode/test/tool/fixtures/models-api.json +++ b/packages/opencode/test/tool/fixtures/models-api.json @@ -1,4 +1,28 @@ { + "digitalocean": { + "id": "digitalocean", + "env": ["DIGITALOCEAN_ACCESS_TOKEN"], + "npm": "@ai-sdk/openai-compatible", + "api": "https://inference.do-ai.run/v1", + "name": "DigitalOcean", + "doc": "https://docs.digitalocean.com/products/genai-platform/", + "models": { + "openai-gpt-oss-120b": { + "id": "openai-gpt-oss-120b", + "name": "GPT OSS 120B", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { "input": ["text"], "output": ["text"] }, + "open_weights": false, + "cost": { "input": 0.35, "output": 0.75 }, + "limit": { "context": 128000, "output": 16384 } + } + } + }, "ollama-cloud": { "id": "ollama-cloud", "env": ["OLLAMA_API_KEY"], diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 2e96dd980..6156477be 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -8,10 +8,9 @@ import type { UserMessage, Message, Part, - Auth, Config as SDKConfig, } from "@opencode-ai/sdk" -import type { Provider as ProviderV2, Model as ModelV2 } from "@opencode-ai/sdk/v2" +import type { Provider as ProviderV2, Model as ModelV2, Auth } from "@opencode-ai/sdk/v2" import type { BunShell } from "./shell.js" import { type ToolDefinition } from "./tool.js" @@ -153,6 +152,7 @@ export type AuthHook = { type: "success" key: string provider?: string + metadata?: Record } | { type: "failed" @@ -177,7 +177,7 @@ export type AuthOAuthResult = { url: string; instructions: string } & ( accountId?: string enterpriseUrl?: string } - | { key: string } + | { key: string; metadata?: Record } )) | { type: "failed" @@ -198,7 +198,7 @@ export type AuthOAuthResult = { url: string; instructions: string } & ( accountId?: string enterpriseUrl?: string } - | { key: string } + | { key: string; metadata?: Record } )) | { type: "failed" diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 8fd2a02b9..5e4fd8906 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1666,6 +1666,9 @@ export type OAuth = { export type ApiAuth = { type: "api" key: string + metadata?: { + [key: string]: string + } } export type WellKnownAuth = { diff --git a/packages/ui/src/assets/icons/provider/digitalocean.svg b/packages/ui/src/assets/icons/provider/digitalocean.svg new file mode 100644 index 000000000..5be390b9d --- /dev/null +++ b/packages/ui/src/assets/icons/provider/digitalocean.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/ui/src/components/provider-icons/sprite.svg b/packages/ui/src/components/provider-icons/sprite.svg index a0214b40d..68b99ce56 100644 --- a/packages/ui/src/components/provider-icons/sprite.svg +++ b/packages/ui/src/components/provider-icons/sprite.svg @@ -854,6 +854,20 @@ d="M79.01 5.863c-4.066 0-6.511 2.92-6.511 6.535 0 3.635 2.445 6.555 6.511 6.555 4.046 0 6.512-2.92 6.512-6.555s-2.466-6.535-6.512-6.535Zm0 10.968c-2.633 0-4.172-1.933-4.172-4.433s1.539-4.455 4.172-4.455c2.635 0 4.151 1.933 4.151 4.434 0 2.521-1.516 4.454-4.15 4.454Zm14.393 2.096c3.393 0 5.542-1.808 5.837-4.539h-2.36c-.316 1.555-1.517 2.437-3.477 2.437-2.423 0-3.878-1.68-3.878-4.433 0-2.774 1.476-4.434 3.878-4.434 1.96 0 3.14.862 3.477 2.5h2.36c-.295-2.773-2.444-4.622-5.837-4.622-3.856 0-6.217 2.669-6.217 6.535 0 3.887 2.36 6.556 6.217 6.556Zm-29.543-.311h2.36v-6.01c0-2.752 1.348-4.244 3.772-4.244h2.276V6.177h-2.255c-2.128 0-3.288.735-3.898 2.605l-.443-.063.527-2.542h-2.36v12.439h.02Zm-24.445-7.332c.106-2.101 1.517-3.53 3.793-3.53 2.276 0 3.646 1.345 3.646 3.53h-7.439Zm9.778.4c0-3.426-2.381-5.821-5.943-5.821-3.73 0-6.174 2.563-6.174 6.535 0 4.013 2.423 6.555 6.28 6.555 2.929 0 5.247-1.597 5.669-3.887h-2.36c-.507 1.156-1.666 1.828-3.31 1.828-2.38 0-3.877-1.408-3.94-3.803h9.694c.042-.588.084-.861.084-1.408Zm5.69 6.932h1.939l5.5-12.44h-2.529L56 15.99l-.316.021-3.793-9.833h-2.508l5.5 12.439ZM32.23 12.35c0-.882-.359-1.701-.99-2.437a8.594 8.594 0 0 1-1.497 1.093c.337.42.527.861.527 1.345 0 2.731-5.837 4.811-14.14 4.811-8.281.021-14.118-2.059-14.118-4.811 0-.463.168-.925.505-1.345a8.13 8.13 0 0 1-1.475-1.093c-.632.736-.99 1.555-.99 2.438 0 4.034 7.207 6.534 16.1 6.534 8.87.021 16.078-2.5 16.078-6.535Zm-3.351 1.534c-.906-.462-1.96-.861-3.16-1.197-1.37.378-2.909.672-4.553.861 2.318.294 4.341.778 5.9 1.408.76-.336 1.37-.693 1.813-1.072Zm-17.849-.357a31.902 31.902 0 0 1-4.467-.84c-1.18.336-2.255.735-3.16 1.197.42.379 1.01.715 1.748 1.05 1.539-.63 3.52-1.113 5.88-1.407Zm21.2-6.808c0-4.013-7.207-6.534-16.079-6.534C7.26.185.051 2.706.051 6.719c0 4.035 7.208 6.535 16.1 6.535 8.872.021 16.079-2.5 16.079-6.535Zm-1.94 0c0 2.732-5.836 4.812-14.139 4.812-8.302.021-14.14-2.06-14.14-4.812 0-2.731 5.838-4.811 14.14-4.811 7.86 0 14.14 2.08 14.14 4.811Zm-3.223 2.564c.758-.336 1.37-.694 1.812-1.072-2.95-1.513-7.544-2.353-12.728-2.353s-9.799.84-12.728 2.353c.422.378 1.012.715 1.75 1.05 2.507-1.05 6.363-1.68 10.978-1.68 4.404 0 8.324.651 10.916 1.702ZM1.042 15.628c-.632.736-.99 1.534-.99 2.438 0 4.034 7.207 6.534 16.1 6.534 8.892 0 16.099-2.521 16.099-6.534 0-.883-.359-1.702-.99-2.438-.422.4-.907.757-1.497 1.093.337.42.527.861.527 1.345 0 2.731-5.837 4.811-14.14 4.811-8.302 0-14.14-2.08-14.14-4.811 0-.463.17-.925.506-1.345a10.73 10.73 0 0 1-1.475-1.093Z" > + + + + + + ` in your DigitalOcean account. You can rotate or revoke it from the **Model Access Keys** page in the "Manage" section of the DigitalOcean console under Inference. + ::: + +4. Run the `/models` command. Your Inference Routers appear as the format `router:` in the model selection. + + ```txt + /models + ``` + +5. To pick up newly created Inference Routers, re-run `/connect` and select **DigitalOcean** again. + +#### Using a Model Access Key + +If you'd rather paste a key directly: + +1. Head over to the **Manage** page in the Inference section of the [DigitalOcean console](https://cloud.digitalocean.com/) and create a new key. + +2. Run the `/connect` command and select **DigitalOcean**, then **Paste Model Access Key**. + + ```txt + ┌ Enter your DigitalOcean Model Access Key + │ + │ + └ enter + ``` + + :::note + Inference Routers are not auto-discovered with this method. To surface them in the model picker, sign in via OAuth instead. + ::: + +3. Run the `/models` command to select a model. + + ```txt + /models + ``` + +#### Environment Variable + +Alternatively, set your Model Access Key as an environment variable. + +```bash frame="none" +export DIGITALOCEAN_ACCESS_TOKEN=your-model-access-key +``` + +#### Inference Routers + +Inference Routers let you define a routing policy across multiple models — picking the cheapest, fastest, or most appropriate model per request based on the task. After OAuth, OpenCode surfaces each router as `router:` in the model picker. + +Selecting a router model is a drop-in replacement for any other model — OpenCode forwards your request and DigitalOcean picks the underlying model based on your router's policy. Learn more about [Inference Routers](https://docs.digitalocean.com/products/inference/how-to/use-inference-router/) + +--- + ### FrogBot 1. Head over to the [FrogBot dashboard](https://app.frogbot.ai/signup), create an account, and generate an API key. From cb511f78ffb90899a1b79f7bc130264b52674299 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 12 May 2026 16:23:15 -0500 Subject: [PATCH 139/188] fix(plugin): preserve tool attachments (#27157) --- packages/opencode/src/tool/registry.ts | 4 +- packages/opencode/test/tool/registry.test.ts | 50 ++++++++++++++++++++ packages/plugin/src/tool.ts | 16 ++++++- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index f72f10dd1..7de3c8f4e 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -160,11 +160,13 @@ export const layer: Layer.Layer< const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx)) const output = typeof result === "string" ? result : result.output const metadata = typeof result === "string" ? {} : (result.metadata ?? {}) + const attachments = typeof result === "string" ? undefined : result.attachments const info = yield* agent.get(toolCtx.agent) const out = yield* truncate.output(output, {}, info) return { - title: "", + title: typeof result === "string" ? "" : (result.title ?? ""), output: out.truncated ? out.content : output, + attachments, metadata: { ...metadata, truncated: out.truncated, diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index fb4dd31a5..595fcd808 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -5,6 +5,7 @@ import { pathToFileURL } from "url" import { Effect, Layer, Result, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" +import { Tool } from "@/tool/tool" import { Flag } from "@opencode-ai/core/flag/flag" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -29,6 +30,7 @@ import { InstanceState } from "@/effect/instance-state" import { Reference } from "@/reference/reference" import { ProviderID, ModelID } from "@/provider/schema" import { ToolJsonSchema } from "@/tool/json-schema" +import { MessageID, SessionID } from "@/session/schema" const node = CrossSpawnSpawner.defaultLayer const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT @@ -193,6 +195,54 @@ describe("tool.registry", () => { }), ) + it.instance("preserves attachments from structured custom tool results", () => + Effect.gen(function* () { + const test = yield* TestInstance + const customTools = path.join(test.directory, ".opencode", "tools") + const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href + yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(customTools, "image.ts"), + [ + `import { tool } from ${JSON.stringify(pluginTool)}`, + "export default tool({", + " description: 'image tool',", + " args: {},", + " execute: async () => ({", + " output: 'here is an image',", + " attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],", + " }),", + "})", + "", + ].join("\n"), + ), + ) + + const registry = yield* ToolRegistry.Service + const loaded = (yield* registry.all()).find((tool) => tool.id === "image") + if (!loaded) throw new Error("custom image tool was not loaded") + const agents = yield* Agent.Service + const result = yield* loaded.execute( + {}, + { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + agent: (yield* agents.defaultInfo()).name, + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } satisfies Tool.Context, + ) + + expect(result.output).toBe("here is an image") + expect(result.attachments).toEqual([ + { type: "file", mime: "image/png", filename: "picture.png", url: "data:image/png;base64,AAAA" }, + ]) + }), + ) + it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () => Effect.gen(function* () { const test = yield* TestInstance diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index 3105bf534..b8a634c79 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -27,7 +27,21 @@ type AskInput = { metadata: { [key: string]: any } } -export type ToolResult = string | { output: string; metadata?: { [key: string]: any } } +export type ToolAttachment = { + type: "file" + mime: string + url: string + filename?: string +} + +export type ToolResult = + | string + | { + title?: string + output: string + metadata?: { [key: string]: any } + attachments?: ToolAttachment[] + } export function tool(input: { description: string From 2cb697b72050c1f7a9661be59c2d610e6ae81d89 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 21:24:39 +0000 Subject: [PATCH 140/188] chore: generate --- packages/opencode/src/plugin/digitalocean.ts | 8 +++++-- .../test/provider/digitalocean.test.ts | 20 ++++-------------- packages/opencode/test/tool/registry.test.ts | 21 ++++++++----------- 3 files changed, 19 insertions(+), 30 deletions(-) diff --git a/packages/opencode/src/plugin/digitalocean.ts b/packages/opencode/src/plugin/digitalocean.ts index 31656656f..fa4adf633 100644 --- a/packages/opencode/src/plugin/digitalocean.ts +++ b/packages/opencode/src/plugin/digitalocean.ts @@ -246,7 +246,9 @@ async function createModelAccessKey(bearer: string): Promise { return data.api_key_info } -async function listRouters(bearer: string): Promise<{ ok: true; routers: RouterEntry[] } | { ok: false; status: number }> { +async function listRouters( + bearer: string, +): Promise<{ ok: true; routers: RouterEntry[] } | { ok: false; status: number }> { const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/routers`, { headers: { Authorization: `Bearer ${bearer}`, @@ -293,7 +295,9 @@ function parseRoutersJSON(raw: string | undefined): RouterEntry[] { try { const parsed = JSON.parse(raw) if (!Array.isArray(parsed)) return [] - return parsed.flatMap((r) => (r && typeof r.name === "string" ? [{ name: r.name, uuid: r.uuid, description: r.description }] : [])) + return parsed.flatMap((r) => + r && typeof r.name === "string" ? [{ name: r.name, uuid: r.uuid, description: r.description }] : [], + ) } catch { return [] } diff --git a/packages/opencode/test/provider/digitalocean.test.ts b/packages/opencode/test/provider/digitalocean.test.ts index 6515ea970..6fc49a6ef 100644 --- a/packages/opencode/test/provider/digitalocean.test.ts +++ b/packages/opencode/test/provider/digitalocean.test.ts @@ -43,10 +43,7 @@ function injectAuth(metadata: Record | undefined) { test("digitalocean provider autoloads from DIGITALOCEAN_ACCESS_TOKEN", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ $schema: "https://opencode.ai/config.json" }), - ) + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) }, }) await WithInstance.provide({ @@ -68,10 +65,7 @@ test("digitalocean provider autoloads from DIGITALOCEAN_ACCESS_TOKEN", async () test("digitalocean provider.models surfaces cached routers from auth metadata", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ $schema: "https://opencode.ai/config.json" }), - ) + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) }, }) injectAuth({ @@ -100,10 +94,7 @@ test("digitalocean provider.models surfaces cached routers from auth metadata", test("digitalocean provider.models skips refresh when oauth bearer is expired", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ $schema: "https://opencode.ai/config.json" }), - ) + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) }, }) injectAuth({ @@ -125,10 +116,7 @@ test("digitalocean provider.models skips refresh when oauth bearer is expired", test("digitalocean provider.models passes through base models when no auth metadata", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ $schema: "https://opencode.ai/config.json" }), - ) + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) }, }) await WithInstance.provide({ diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 595fcd808..b2beda70c 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -223,18 +223,15 @@ describe("tool.registry", () => { const loaded = (yield* registry.all()).find((tool) => tool.id === "image") if (!loaded) throw new Error("custom image tool was not loaded") const agents = yield* Agent.Service - const result = yield* loaded.execute( - {}, - { - sessionID: SessionID.make("ses_test"), - messageID: MessageID.make("msg_test"), - agent: (yield* agents.defaultInfo()).name, - abort: new AbortController().signal, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - } satisfies Tool.Context, - ) + const result = yield* loaded.execute({}, { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + agent: (yield* agents.defaultInfo()).name, + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } satisfies Tool.Context) expect(result.output).toBe("here is an image") expect(result.attachments).toEqual([ From 65368f609d851a9e832d8dd176903df9a690a635 Mon Sep 17 00:00:00 2001 From: Andrew Suffield Date: Tue, 12 May 2026 18:09:06 -0400 Subject: [PATCH 141/188] fix: preserve permission ordering by accepting a layered array (#23214) Co-authored-by: Andrew Suffield Co-authored-by: Aiden Cline --- packages/opencode/src/agent/agent.ts | 6 +- packages/opencode/src/config/config.ts | 22 ++- packages/opencode/src/config/permission.ts | 8 + packages/opencode/test/config/config.test.ts | 178 +++++++++++++++++- .../opencode/test/permission-task.test.ts | 25 ++- 5 files changed, 221 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 423a51318..74ca1a402 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,4 +1,5 @@ import { Config } from "@/config/config" +import { ConfigPermission } from "@/config/permission" import { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "../provider/schema" import { generateObject, streamObject, type ModelMessage } from "ai" @@ -117,7 +118,10 @@ export const layer = Layer.effect( }, }) - const user = Permission.fromConfig(cfg.permission ?? {}) + // Convert permission layers to rulesets and merge them + // Each layer's rules come after the previous, so later configs override earlier ones + const layers = ConfigPermission.toLayers(cfg.permission) + const user = Permission.merge(...layers.map((p) => Permission.fromConfig(p))) const agents: Record = { build: { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 545e48e64..91eeab47e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -55,6 +55,13 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info { if (target.instructions && source.instructions) { merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) } + // Accumulate permission layers for later merging as rulesets. + // This preserves the ordering semantics: later rules override earlier rules. + // Each layer keeps the raw shape the user wrote on disk; consumers should use + // ConfigPermission.toLayers to normalise. + if (source.permission) { + merged.permission = [...ConfigPermission.toLayers(target.permission), ...ConfigPermission.toLayers(source.permission)] + } return merged } @@ -228,7 +235,12 @@ export const Info = Schema.Struct({ description: "Additional instruction files or patterns to include", }), layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), - permission: Schema.optional(ConfigPermission.Info), + permission: Schema.optional( + Schema.Union([ConfigPermission.Info, Schema.mutable(Schema.Array(ConfigPermission.Info))]), + ).annotate({ + description: + "Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones.", + }), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), attachment: Schema.optional(ConfigAttachment.Info).annotate({ description: "Attachment processing configuration, including image size limits and resizing behavior", @@ -704,11 +716,12 @@ export const layer = Layer.effect( } if (Flag.OPENCODE_PERMISSION) { - result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION)) + const envPermission = JSON.parse(Flag.OPENCODE_PERMISSION) as ConfigPermission.Info + result.permission = [...ConfigPermission.toLayers(result.permission), envPermission] } if (result.tools) { - const perms: Record = {} + const perms: ConfigPermission.Info = {} for (const [tool, enabled] of Object.entries(result.tools)) { const action: ConfigPermission.Action = enabled ? "allow" : "deny" if (tool === "write" || tool === "edit" || tool === "patch") { @@ -717,7 +730,8 @@ export const layer = Layer.effect( } perms[tool] = action } - result.permission = mergeDeep(perms, result.permission ?? {}) + // Tools permissions come before other permissions (they can be overridden) + result.permission = [perms, ...ConfigPermission.toLayers(result.permission)] } if (!result.username) result.username = os.userInfo().username diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index 1092ae2b7..c780d2443 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -56,3 +56,11 @@ export const Info = InputSchema.pipe( ).annotate({ identifier: "PermissionConfig" }) type _Info = Schema.Schema.Type export type Info = { -readonly [K in keyof _Info]: _Info[K] } + +// Top-level config accepts either a single permission object or an array of +// layered configs. Internal merging produces arrays; this helper normalises +// either shape into the array form expected by consumers. +export function toLayers(value: Info | Info[] | undefined): Info[] { + if (!value) return [] + return Array.isArray(value) ? value : [value] +} diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 90e78efcd..a2e439177 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -3,7 +3,9 @@ import { Effect, Layer, Option } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "@/config/config" import { ConfigManaged } from "@/config/managed" +import { ConfigPermission } from "@/config/permission" import { ConfigParse } from "../../src/config/parse" +import { Permission } from "../../src/permission" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Instance } from "../../src/project/instance" @@ -276,6 +278,40 @@ test("updates global config and omits empty shell key in json", async () => { } }) +test("global config update preserves single-object permission shape on disk", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + shell: "bash", + permission: { bash: "ask" }, + }), + ) + }, + }) + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = tmp.path + await clear(true) + + try { + // Updating an unrelated key must not rewrite `permission` from object to array form. + await saveGlobal({ shell: "zsh" }) + + const written = await Filesystem.readJson<{ permission?: unknown; shell?: string }>( + path.join(tmp.path, "opencode.json"), + ) + expect(written.shell).toBe("zsh") + expect(Array.isArray(written.permission)).toBe(false) + expect(written.permission).toEqual({ bash: "ask" }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear(true) + } +}) + test("updates global config and omits empty shell key in jsonc", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -1713,7 +1749,10 @@ test("permission config preserves user key order", async () => { directory: tmp.path, fn: async () => { const config = await load() - expect(Object.keys(config.permission!)).toEqual([ + // load() goes through the merge pipeline, producing the layered array form + expect(config.permission).toHaveLength(1) + const perm = (config.permission as ConfigPermission.Info[])[0] + expect(Object.keys(perm)).toEqual([ "*", "edit", "write", @@ -1729,6 +1768,129 @@ test("permission config preserves user key order", async () => { }) }) +// Global bash "rm *" deny is inherited, but user's top-level "*" ask comes after and overrides it +test("user top-level catchall overrides inherited bash rules", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + bash: { "rm *": "deny" }, + }, + }), + ) + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + await Filesystem.write( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + "*": "ask", + bash: { "ls *": "allow" }, + }, + }), + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + const layers = ConfigPermission.toLayers(config.permission) + const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p))) + + expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask") + expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow") + expect(Permission.evaluate("bash", "echo hello", ruleset).action).toBe("ask") + }, + }) +}) + +// No top-level catchall, so global bash "rm *" deny is preserved +test("inherited bash rules apply when no user top-level catchall", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + bash: { "rm *": "deny" }, + }, + }), + ) + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + await Filesystem.write( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + bash: { "ls *": "allow" }, + }, + }), + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + const layers = ConfigPermission.toLayers(config.permission) + const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p))) + + expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("deny") + expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow") + }, + }) +}) + +// User's bash "*" catchall overrides global "rm *" deny +test("user bash catchall overrides inherited bash rules", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + bash: { "rm *": "deny" }, + }, + }), + ) + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + await Filesystem.write( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + bash: { "*": "ask", "ls *": "allow" }, + }, + }), + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + const layers = ConfigPermission.toLayers(config.permission) + const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p))) + + expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask") + expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow") + expect(Permission.evaluate("bash", "echo hello", ruleset).action).toBe("ask") + + // Non-bash permissions should use the top-level "*" rule + expect(Permission.evaluate("read", "foo.txt", ruleset).action).toBe("ask") + }, + }) +}) + test("config parser preserves permission order while rejecting unknown top-level keys", () => { const config = ConfigParse.schema( Config.Info, @@ -1742,7 +1904,8 @@ test("config parser preserves permission order while rejecting unknown top-level "test", ) - expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"]) + // ConfigParse.schema preserves the raw shape the user wrote + expect(Object.keys(config.permission as ConfigPermission.Info)).toEqual(["bash", "*", "edit"]) try { ConfigParse.schema(Config.Info, { invalid_field: true }, "test") throw new Error("expected config parse to fail") @@ -2579,11 +2742,12 @@ test("parseManagedPlist parses permission rules", async () => { ), "test:mobileconfig", ) - expect(config.permission?.["*"]).toBe("ask") - expect(config.permission?.grep).toBe("allow") - expect(config.permission?.webfetch).toBe("ask") - expect(config.permission?.["~/.ssh/*"]).toBe("deny") - const bash = config.permission?.bash as Record + const perm = config.permission as ConfigPermission.Info + expect(perm?.["*"]).toBe("ask") + expect(perm?.grep).toBe("allow") + expect(perm?.webfetch).toBe("ask") + expect(perm?.["~/.ssh/*"]).toBe("deny") + const bash = perm?.bash as Record expect(bash?.["rm -rf *"]).toBe("deny") expect(bash?.["curl *"]).toBe("deny") }) diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index 64b93bb8b..77ceda8a4 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, test, expect } from "bun:test" import { Permission } from "../src/permission" import { Config } from "@/config/config" +import { ConfigPermission } from "@/config/permission" import { Instance } from "../src/project/instance" import { WithInstance } from "../src/project/with-instance" import { disposeAllInstances, tmpdir } from "./fixture/fixture" @@ -163,7 +164,9 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) + const ruleset = Permission.merge( + ...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)), + ) // general and orchestrator-fast should be allowed, code-reviewer denied expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") @@ -188,7 +191,9 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) + const ruleset = Permission.merge( + ...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)), + ) // general and code-reviewer should be ask, orchestrator-* denied expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") @@ -213,7 +218,9 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) + const ruleset = Permission.merge( + ...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)), + ) expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") // Unspecified agents default to "ask" @@ -240,7 +247,9 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) + const ruleset = Permission.merge( + ...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)), + ) // Verify task permissions expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") @@ -278,7 +287,9 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) + const ruleset = Permission.merge( + ...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)), + ) // Last matching rule wins - "*" deny is last, so all agents are denied expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny") @@ -309,7 +320,9 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) + const ruleset = Permission.merge( + ...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)), + ) // Evaluate uses findLast - "general" allow comes after "*" deny expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") From baef5cd43ba1abb921918708d56ccbd08ebeaaec Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 22:11:13 +0000 Subject: [PATCH 142/188] chore: generate --- packages/opencode/src/config/config.ts | 5 ++++- packages/sdk/js/src/v2/gen/types.gen.ts | 5 ++++- packages/sdk/openapi.json | 13 ++++++++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 91eeab47e..78bb83ed8 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -60,7 +60,10 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info { // Each layer keeps the raw shape the user wrote on disk; consumers should use // ConfigPermission.toLayers to normalise. if (source.permission) { - merged.permission = [...ConfigPermission.toLayers(target.permission), ...ConfigPermission.toLayers(source.permission)] + merged.permission = [ + ...ConfigPermission.toLayers(target.permission), + ...ConfigPermission.toLayers(source.permission), + ] } return merged } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f062700b7..b42935519 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1263,7 +1263,10 @@ export type Config = { } instructions?: Array layout?: LayoutConfig - permission?: PermissionConfig + /** + * Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones. + */ + permission?: PermissionConfig | Array tools?: { [key: string]: boolean } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 7005382f6..eed0282fc 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -12407,7 +12407,18 @@ "$ref": "#/components/schemas/LayoutConfig" }, "permission": { - "$ref": "#/components/schemas/PermissionConfig" + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionConfig" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionConfig" + } + } + ], + "description": "Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones." }, "tools": { "type": "object", From 81dd46abec087838b21df8b97d0ecd87dd073d8f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 19:45:01 -0400 Subject: [PATCH 143/188] test: migrate websearch tests to effect runner (#27170) --- packages/opencode/test/tool/websearch.test.ts | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/opencode/test/tool/websearch.test.ts b/packages/opencode/test/tool/websearch.test.ts index 591b385fd..b8edc2dc2 100644 --- a/packages/opencode/test/tool/websearch.test.ts +++ b/packages/opencode/test/tool/websearch.test.ts @@ -4,6 +4,7 @@ import { parseResponse } from "../../src/tool/mcp-websearch" import { selectWebSearchProvider, webSearchModelName, webSearchProviderLabel } from "../../src/tool/websearch" import { ProviderID } from "../../src/provider/schema" import { webSearchEnabled } from "../../src/tool/registry" +import { it } from "../lib/effect" const SESSION_ID = "ses_0196aabbccddeeff001122334455" @@ -74,17 +75,24 @@ describe("websearch MCP response parser", () => { }, }) - test("parses plain JSON-RPC responses", async () => { - await expect(Effect.runPromise(parseResponse(payload))).resolves.toBe("search results") - }) + it.effect("parses plain JSON-RPC responses", () => + Effect.gen(function* () { + const result = yield* parseResponse(payload) + expect(result).toBe("search results") + }), + ) - test("parses SSE JSON-RPC responses", async () => { - await expect(Effect.runPromise(parseResponse(`event: message\ndata: ${payload}\n\n`))).resolves.toBe( - "search results", - ) - }) + it.effect("parses SSE JSON-RPC responses", () => + Effect.gen(function* () { + const result = yield* parseResponse(`event: message\ndata: ${payload}\n\n`) + expect(result).toBe("search results") + }), + ) - test("ignores non-JSON SSE data frames", async () => { - await expect(Effect.runPromise(parseResponse(`data: [DONE]\ndata: ${payload}\n\n`))).resolves.toBe("search results") - }) + it.effect("ignores non-JSON SSE data frames", () => + Effect.gen(function* () { + const result = yield* parseResponse(`data: [DONE]\ndata: ${payload}\n\n`) + expect(result).toBe("search results") + }), + ) }) From 2c334d92420da3e7c5760214edd5aa8299e57022 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 19:46:19 -0400 Subject: [PATCH 144/188] Migrate schema error body tests to Effect runner (#27172) --- .../server/httpapi-schema-error-body.test.ts | 136 ++++++++---------- 1 file changed, 63 insertions(+), 73 deletions(-) diff --git a/packages/opencode/test/server/httpapi-schema-error-body.test.ts b/packages/opencode/test/server/httpapi-schema-error-body.test.ts index fe6a1caad..48ed7b6bf 100644 --- a/packages/opencode/test/server/httpapi-schema-error-body.test.ts +++ b/packages/opencode/test/server/httpapi-schema-error-body.test.ts @@ -3,7 +3,6 @@ import { Effect } from "effect" import { eq } from "drizzle-orm" import * as Database from "@/storage/db" import { ModelID, ProviderID } from "../../src/provider/schema" -import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session } from "@/session/session" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" @@ -11,80 +10,68 @@ import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" import { MessageID, PartID } from "../../src/session/schema" import { PartTable } from "@/session/session.sql" import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" -import { it } from "../lib/effect" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Session.defaultLayer) afterEach(async () => { await disposeAllInstances() await resetDatabase() }) -const withTmp = ( - options: Parameters[0], - fn: (tmp: Awaited>) => Effect.Effect, -) => - Effect.acquireRelease( - Effect.promise(() => tmpdir(options)), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe(Effect.flatMap(fn)) - -async function seedCorruptStepFinishPart(directory: string) { - return WithInstance.provide({ - directory, - fn: () => - Effect.runPromise( - Effect.gen(function* () { - const session = yield* Session.Service - const info = yield* session.create({}) - const message = yield* session.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: info.id, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - const partID = PartID.ascending() - yield* session.updatePart({ - id: partID, - sessionID: info.id, - messageID: message.id, +const seedCorruptStepFinishPart = Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const message = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + const partID = PartID.ascending() + yield* session.updatePart({ + id: partID, + sessionID: info.id, + messageID: message.id, + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + // Schema.Finite still rejects NaN at encode: exact mirror of the corrupt row + // that broke the user's session in the OMO/Windows bug. + yield* Effect.sync(() => + Database.use((db) => + db + .update(PartTable) + .set({ + data: { type: "step-finish", reason: "stop", cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - }) - // Schema.Finite still rejects NaN at encode — exact mirror of the - // corrupt row that broke the user's session in the OMO/Windows bug. - Database.use((db) => - db - .update(PartTable) - .set({ - data: { - type: "step-finish", - reason: "stop", - cost: 0, - tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } }, - } as never, // drizzle's .set() can't narrow the discriminated union - }) - .where(eq(PartTable.id, partID)) - .run(), - ) - return info.id - }).pipe(Effect.provide(Session.defaultLayer)), - ), - }) -} + tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never, // drizzle's .set() can't narrow the discriminated union + }) + .where(eq(PartTable.id, partID)) + .run(), + ), + ) + return info.id +}) describe("schema-rejection wire shape", () => { - it.live( + it.instance( "Payload schema rejection returns NamedError-shaped JSON, not empty", - withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + () => Effect.gen(function* () { + const test = yield* TestInstance const res = yield* Effect.promise(async () => Server.Default().app.request(SyncPaths.history, { method: "POST", - headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, + headers: { "x-opencode-directory": test.directory, "content-type": "application/json" }, body: JSON.stringify({ aggregate: -1 }), }), ) @@ -99,36 +86,38 @@ describe("schema-rejection wire shape", () => { expect(parsed.data.message).toEqual(expect.any(String)) expect(parsed.data.message.length).toBeGreaterThan(0) }), - ), + { git: true, config: { formatter: false, lsp: false } }, ) - it.live( + it.instance( "Query schema rejection returns NamedError-shaped JSON", - withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + () => Effect.gen(function* () { + const test = yield* TestInstance // /find/file?limit=999999 violates the limit constraint check. - const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(tmp.path)}` + const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(test.directory)}` const res = yield* Effect.promise(async () => Server.Default().app.request(url)) const body = yield* Effect.promise(async () => res.text()) expect(res.status).toBe(400) const parsed = JSON.parse(body) expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } }) }), - ), + { git: true, config: { formatter: false, lsp: false } }, ) - it.live( + it.instance( "rejected request body never echoes back unbounded — message is capped", // Defense against DoS-amplification + secret-echo: Effect's Issue formatter // dumps the rejected `actual` verbatim. A multi-MB invalid array would // become a multi-MB 400 response and log line. Cap kicks in around 1KB. - withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + () => Effect.gen(function* () { + const test = yield* TestInstance const huge = "X".repeat(50_000) const res = yield* Effect.promise(async () => Server.Default().app.request(SyncPaths.history, { method: "POST", - headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, + headers: { "x-opencode-directory": test.directory, "content-type": "application/json" }, body: JSON.stringify({ aggregate: huge }), }), ) @@ -139,15 +128,16 @@ describe("schema-rejection wire shape", () => { const parsed = JSON.parse(body) expect(parsed.data.message).not.toContain(huge) }), - ), + { git: true, config: { formatter: false, lsp: false } }, ) - it.live( + it.instance( "response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path", - withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + () => Effect.gen(function* () { - const sessionID = yield* Effect.promise(() => seedCorruptStepFinishPart(tmp.path)) - const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}` + const test = yield* TestInstance + const sessionID = yield* seedCorruptStepFinishPart + const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}` const res = yield* Effect.promise(async () => Server.Default().app.request(url)) const body = yield* Effect.promise(async () => res.text()) expect(res.status).toBe(400) @@ -157,6 +147,6 @@ describe("schema-rejection wire shape", () => { // Field path in data.message — what made this PR worth shipping. expect(parsed.data.message).toMatch(/output/) }), - ), + { config: { formatter: false, lsp: false } }, ) }) From ded4da735b03edb4023597acf0868b032a3c1185 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 19:46:23 -0400 Subject: [PATCH 145/188] test: migrate webfetch tool tests to effect runner (#27171) --- packages/opencode/test/tool/webfetch.test.ts | 112 +++++++++---------- 1 file changed, 51 insertions(+), 61 deletions(-) diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index f3890c016..804c6bde2 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -1,15 +1,14 @@ -import { describe, expect, test } from "bun:test" -import path from "path" +import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { Agent } from "../../src/agent/agent" import { Truncate } from "@/tool/truncate" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { WebFetchTool } from "../../src/tool/webfetch" import { SessionID, MessageID } from "../../src/session/schema" +import { Tool } from "@/tool/tool" +import { testEffect } from "../lib/effect" -const projectRoot = path.join(import.meta.dir, "../..") +const it = testEffect(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)) const ctx = { sessionID: SessionID.make("ses_test"), @@ -22,30 +21,31 @@ const ctx = { ask: () => Effect.void, } -async function withFetch(fetch: (req: Request) => Response | Promise, fn: (url: URL) => Promise) { - using server = Bun.serve({ port: 0, fetch }) - await fn(server.url) -} - -function exec(args: { url: string; format: "text" | "markdown" | "html" }) { - return WebFetchTool.pipe( - Effect.flatMap((info) => info.init()), - Effect.flatMap((tool) => tool.execute(args, ctx)), - Effect.provide(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)), - Effect.runPromise, +const withFetch = ( + fetch: (req: Request) => Response | Promise, + fn: (url: URL) => Effect.Effect, +) => + Effect.acquireUseRelease( + Effect.sync(() => Bun.serve({ port: 0, fetch })), + (server) => fn(server.url), + (server) => Effect.sync(() => server.stop(true)), ) -} + +const exec = Effect.fn("WebFetchToolTest.exec")(function* (args: Tool.InferParameters) { + const info = yield* WebFetchTool + const tool = yield* info.init() + return yield* tool.execute(args, ctx) +}) describe("tool.webfetch", () => { - test("returns image responses as file attachments", async () => { - const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]) - await withFetch( - () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }), - async (url) => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const result = await exec({ url: new URL("/image.png", url).toString(), format: "markdown" }) + it.instance("returns image responses as file attachments", () => + Effect.gen(function* () { + const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]) + yield* withFetch( + () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }), + (url) => + Effect.gen(function* () { + const result = yield* exec({ url: new URL("/image.png", url).toString(), format: "markdown" }) expect(result.output).toBe("Image fetched successfully") expect(result.attachments).toBeDefined() expect(result.attachments?.length).toBe(1) @@ -55,50 +55,40 @@ describe("tool.webfetch", () => { expect(result.attachments?.[0]).not.toHaveProperty("id") expect(result.attachments?.[0]).not.toHaveProperty("sessionID") expect(result.attachments?.[0]).not.toHaveProperty("messageID") - }, - }) - }, - ) - }) + }), + ) + }), + ) - test("keeps svg as text output", async () => { - const svg = 'hello' - await withFetch( + it.instance("keeps svg as text output", () => + withFetch( () => - new Response(svg, { + new Response('hello', { status: 200, headers: { "content-type": "image/svg+xml; charset=UTF-8" }, }), - async (url) => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const result = await exec({ url: new URL("/image.svg", url).toString(), format: "html" }) - expect(result.output).toContain(" + Effect.gen(function* () { + const result = yield* exec({ url: new URL("/image.svg", url).toString(), format: "html" }) + expect(result.output).toContain(" { - await withFetch( + it.instance("keeps text responses as text output", () => + withFetch( () => new Response("hello from webfetch", { status: 200, headers: { "content-type": "text/plain; charset=utf-8" }, }), - async (url) => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const result = await exec({ url: new URL("/file.txt", url).toString(), format: "text" }) - expect(result.output).toBe("hello from webfetch") - expect(result.attachments).toBeUndefined() - }, - }) - }, - ) - }) + (url) => + Effect.gen(function* () { + const result = yield* exec({ url: new URL("/file.txt", url).toString(), format: "text" }) + expect(result.output).toBe("hello from webfetch") + expect(result.attachments).toBeUndefined() + }), + ), + ) }) From b0674b473f06877e6f3e00128c133b5c3f334fd9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 19:46:51 -0400 Subject: [PATCH 146/188] test: migrate session action route to effect runner (#27174) --- .../test/server/session-actions.test.ts | 57 ++++++++----------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 1ccc9bc8e..3891e67f5 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,28 +1,14 @@ -import { afterEach, describe, expect, mock, test } from "bun:test" +import { afterEach, describe, expect, mock } from "bun:test" import { Effect } from "effect" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" -import type { SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" void Log.init({ print: false }) -function run(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) -} - -const svc = { - ...SessionNs, - create(input?: SessionNs.CreateInput) { - return run(SessionNs.Service.use((svc) => svc.create(input))) - }, - remove(id: SessionID) { - return run(SessionNs.Service.use((svc) => svc.remove(id))) - }, -} +const it = testEffect(SessionNs.defaultLayer) afterEach(async () => { mock.restore() @@ -30,21 +16,26 @@ afterEach(async () => { }) describe("session action routes", () => { - test("abort route returns success", async () => { - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const app = Server.Default().app + it.instance("abort route returns success", () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* Effect.acquireRelease( + SessionNs.Service.use((svc) => svc.create({})), + (created) => SessionNs.Service.use((svc) => svc.remove(created.id)).pipe(Effect.ignore), + ) - const res = await app.request(`/session/${session.id}/abort`, { method: "POST" }) + const res = yield* Effect.promise(() => + Promise.resolve( + Server.Default().app.request(`/session/${session.id}/abort`, { + method: "POST", + headers: { "x-opencode-directory": test.directory }, + }), + ), + ) - expect(res.status).toBe(200) - expect(await res.json()).toBe(true) - - await svc.remove(session.id) - }, - }) - }) + expect(res.status).toBe(200) + expect(yield* Effect.promise(() => res.json())).toBe(true) + }), + { git: true }, + ) }) From 9c54255aebef9842acd82d90d69d8299ac549ab0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 19:47:14 -0400 Subject: [PATCH 147/188] fix: migrate sync http api test to effect runner (#27175) --- .../opencode/test/server/httpapi-sync.test.ts | 237 ++++++++++-------- 1 file changed, 130 insertions(+), 107 deletions(-) diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index cd626c28f..0b5934553 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -1,29 +1,25 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { afterEach, describe, expect, mock, spyOn } from "bun:test" import { Context, Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" void Log.init({ print: false }) const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const context = Context.empty() as Context.Context +const it = testEffect(Session.defaultLayer) function app() { return Server.Default().app } -function runSession(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer))) -} - afterEach(async () => { mock.restore() Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces @@ -32,111 +28,138 @@ afterEach(async () => { }) describe("sync HttpApi", () => { - test("serves sync routes", async () => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } - const info = spyOn(Log.create({ service: "server.sync" }), "info") + it.instance( + "serves sync routes", + () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const tmp = yield* TestInstance + const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" } + const info = spyOn(Log.create({ service: "server.sync" }), "info") + const session = yield* Session.Service.use((svc) => svc.create({ title: "sync" })) - const session = await WithInstance.provide({ - directory: tmp.path, - fn: async () => runSession(Session.Service.use((svc) => svc.create({ title: "sync" }))), - }) + const started = yield* Effect.promise(() => + Promise.resolve(app().request(SyncPaths.start, { method: "POST", headers })), + ) + expect(started.status).toBe(200) + expect(yield* Effect.promise(() => started.json())).toBe(true) - const started = await app().request(SyncPaths.start, { method: "POST", headers }) - expect(started.status).toBe(200) - expect(await started.json()).toBe(true) + const history = yield* Effect.promise(() => + Promise.resolve( + app().request(SyncPaths.history, { + method: "POST", + headers, + body: JSON.stringify({}), + }), + ), + ) + expect(history.status).toBe(200) + const rows = (yield* Effect.promise(() => history.json())) as Array<{ + id: string + aggregate_id: string + seq: number + type: string + data: Record + }> + expect(rows.map((row) => row.aggregate_id)).toContain(session.id) - const history = await app().request(SyncPaths.history, { - method: "POST", - headers, - body: JSON.stringify({}), - }) - expect(history.status).toBe(200) - const rows = (await history.json()) as Array<{ - id: string - aggregate_id: string - seq: number - type: string - data: Record - }> - expect(rows.map((row) => row.aggregate_id)).toContain(session.id) - - const replayed = await app().request(SyncPaths.replay, { - method: "POST", - headers, - body: JSON.stringify({ - directory: tmp.path, - events: rows - .filter((row) => row.aggregate_id === session.id) - .map((row) => ({ - id: row.id, - aggregateID: row.aggregate_id, - seq: row.seq, - type: row.type, - data: row.data, - })), + const replayed = yield* Effect.promise(() => + Promise.resolve( + app().request(SyncPaths.replay, { + method: "POST", + headers, + body: JSON.stringify({ + directory: tmp.directory, + events: rows + .filter((row) => row.aggregate_id === session.id) + .map((row) => ({ + id: row.id, + aggregateID: row.aggregate_id, + seq: row.seq, + type: row.type, + data: row.data, + })), + }), + }), + ), + ) + expect(replayed.status).toBe(200) + expect(yield* Effect.promise(() => replayed.json())).toEqual({ sessionID: session.id }) + expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true) + expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true) }), - }) - expect(replayed.status).toBe(200) - expect(await replayed.json()).toEqual({ sessionID: session.id }) - expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true) - expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true) - }) + { git: true, config: { formatter: false, lsp: false } }, + ) - test("validates seq values", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } - const cases = [ - { - path: SyncPaths.history, - body: { aggregate: -1 }, - }, - { - path: SyncPaths.history, - body: { aggregate: 1.5 }, - }, - { - path: SyncPaths.replay, - body: { - directory: tmp.path, - events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }], - }, - }, - { - path: SyncPaths.replay, - body: { - directory: tmp.path, - events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }], - }, - }, - ] + it.instance( + "validates seq values", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" } + const cases = [ + { + path: SyncPaths.history, + body: { aggregate: -1 }, + }, + { + path: SyncPaths.history, + body: { aggregate: 1.5 }, + }, + { + path: SyncPaths.replay, + body: { + directory: tmp.directory, + events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }], + }, + }, + { + path: SyncPaths.replay, + body: { + directory: tmp.directory, + events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }], + }, + }, + ] - for (const item of cases) { - const response = await app().request(item.path, { - method: "POST", - headers, - body: JSON.stringify(item.body), - }) - expect(response.status).toBe(400) - } - }) - - test.todo("returns structured validation errors", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const response = await ExperimentalHttpApiServer.webHandler().handler( - new Request(`http://localhost${SyncPaths.history}`, { - method: "POST", - headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, - body: JSON.stringify({ aggregate: -1 }), + for (const item of cases) { + const response = yield* Effect.promise(() => + Promise.resolve( + app().request(item.path, { + method: "POST", + headers, + body: JSON.stringify(item.body), + }), + ), + ) + expect(response.status).toBe(400) + } }), - context, - ) + { git: true, config: { formatter: false, lsp: false } }, + ) - expect(response.status).toBe(400) - expect(response.headers.get("content-type") ?? "").toContain("application/json") - const body = (await response.json()) as Record - expect(body.success).toBe(false) - expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true) - }) + it.instance.skip( + "returns structured validation errors", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const response = yield* Effect.promise(() => + ExperimentalHttpApiServer.webHandler().handler( + new Request(`http://localhost${SyncPaths.history}`, { + method: "POST", + headers: { "x-opencode-directory": tmp.directory, "content-type": "application/json" }, + body: JSON.stringify({ aggregate: -1 }), + }), + context, + ), + ) + + expect(response.status).toBe(400) + expect(response.headers.get("content-type") ?? "").toContain("application/json") + const body = (yield* Effect.promise(() => response.json())) as Record + expect(body.success).toBe(false) + expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) }) From 3301fad8cdfac520ad8e8238df2c4433d4cf24ef Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 19:47:49 -0400 Subject: [PATCH 148/188] test: migrate app runtime logger effect tests (#27176) --- .../test/effect/app-runtime-logger.test.ts | 103 ++++++++++-------- 1 file changed, 55 insertions(+), 48 deletions(-) diff --git a/packages/opencode/test/effect/app-runtime-logger.test.ts b/packages/opencode/test/effect/app-runtime-logger.test.ts index fe9516ef9..e4ce6511d 100644 --- a/packages/opencode/test/effect/app-runtime-logger.test.ts +++ b/packages/opencode/test/effect/app-runtime-logger.test.ts @@ -1,12 +1,13 @@ import { expect } from "bun:test" -import { Context, Effect, Layer, Logger } from "effect" +import { Context, Deferred, Effect, Fiber, Layer, Logger } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { AppRuntime } from "../../src/effect/app-runtime" +import { AppLayer } from "../../src/effect/app-runtime" import { EffectBridge } from "@/effect/bridge" import { InstanceRef } from "../../src/effect/instance-ref" import * as EffectLogger from "@opencode-ai/core/effect/logger" -import { makeRuntime } from "../../src/effect/run-service" -import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import * as Observability from "@opencode-ai/core/effect/observability" +import { attach } from "../../src/effect/run-service" +import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(CrossSpawnSpawner.defaultLayer) @@ -35,17 +36,8 @@ it.live("makeRuntime installs EffectLogger through Observability.layer", () => }), ) - const current = yield* Effect.promise(() => makeRuntime(Dummy, layer).runPromise((svc) => svc.current())) - - expect(current.effectLogger).toBe(true) - expect(current.defaultLogger).toBe(false) - }), -) - -it.live("AppRuntime also installs EffectLogger through Observability.layer", () => - Effect.gen(function* () { - const current = yield* Effect.promise(() => - AppRuntime.runPromise(Effect.map(Effect.service(Logger.CurrentLoggers), check)), + const current = yield* Dummy.use((svc) => svc.current()).pipe( + Effect.provide(Layer.provideMerge(layer, Observability.layer)), ) expect(current.effectLogger).toBe(true) @@ -53,46 +45,61 @@ it.live("AppRuntime also installs EffectLogger through Observability.layer", () }), ) -it.live("AppRuntime attaches InstanceRef from ALS", () => +it.live("AppLayer also installs EffectLogger through Observability.layer", () => Effect.gen(function* () { - const dir = yield* tmpdirScoped({ git: true }) - const current = yield* Effect.promise(() => - AppRuntime.runPromise( + const current = yield* Effect.map(Effect.service(Logger.CurrentLoggers), check).pipe(Effect.provide(AppLayer)) + + expect(current.effectLogger).toBe(true) + expect(current.defaultLogger).toBe(false) + }), +) + +it.instance( + "attach preserves InstanceRef from the current fiber context", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const current = yield* attach( Effect.gen(function* () { return (yield* InstanceRef)?.directory }), - ), - ).pipe(provideInstance(dir)) + ) - expect(current).toBe(dir) - }), + expect(current).toBe(test.directory) + }), + { git: true }, ) -it.live("EffectBridge preserves logger and instance context across async boundaries", () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped({ git: true }) - const result = yield* Effect.promise(() => - AppRuntime.runPromise( - Effect.gen(function* () { - const bridge = yield* EffectBridge.make() - return yield* Effect.promise(() => - Promise.resolve().then(() => - bridge.promise( - Effect.gen(function* () { - return { - directory: (yield* InstanceRef)?.directory, - ...check(yield* Effect.service(Logger.CurrentLoggers)), - } - }), - ), +it.instance( + "EffectBridge preserves logger and instance context across async boundaries", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const bridge = yield* EffectBridge.make() + const started = yield* Deferred.make() + + const fiber = yield* Effect.gen(function* () { + yield* Deferred.succeed(started, undefined) + return yield* Effect.promise(() => + Promise.resolve().then(() => + bridge.promise( + Effect.gen(function* () { + return { + directory: (yield* InstanceRef)?.directory, + ...check(yield* Effect.service(Logger.CurrentLoggers)), + } + }), ), - ) - }), - ), - ).pipe(provideInstance(dir)) + ), + ) + }).pipe(Effect.forkScoped) - expect(result.directory).toBe(dir) - expect(result.effectLogger).toBe(true) - expect(result.defaultLogger).toBe(false) - }), + yield* Deferred.await(started) + const result = yield* Fiber.join(fiber) + + expect(result.directory).toBe(test.directory) + expect(result.effectLogger).toBe(true) + expect(result.defaultLogger).toBe(false) + }).pipe(Effect.provide(Observability.layer)), + { git: true }, ) From 72ce24c2002f8e6139c3edb26ee8b6534799c6bb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 19:48:17 -0400 Subject: [PATCH 149/188] test: migrate effect cmd ALS test (#27173) --- .../test/cli/effect-cmd-instance-als.test.ts | 70 +++++++++++-------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts index de6fed8da..122b87f17 100644 --- a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts +++ b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts @@ -1,8 +1,13 @@ -import { afterEach, expect, test } from "bun:test" +import { afterEach, expect } from "bun:test" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect } from "effect" -import fs from "fs/promises" +import { fileURLToPath } from "url" +import { InstanceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" -import { disposeAllInstances, provideTestInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(AppFileSystem.defaultLayer) afterEach(async () => { await disposeAllInstances() @@ -14,35 +19,40 @@ afterEach(async () => { // has lost the outer InstanceRef. Services that read `InstanceState.context` // then fall back to `Instance.current` ALS, which must be installed at the JS // callback boundary (Node ALS persists across awaits, Effect's fiber context -// does not). `provideTestInstance` mirrors effectCmd's load + ALS-restore wrap. +// does not). `it.instance` provides the loaded InstanceRef; the explicit +// Instance.restore mirrors effectCmd's load + ALS-restore wrap. // Pins effect-cmd.ts directly: the pattern test below exercises the load + -// Instance.restore + dispose triple via the shared `provideTestInstance` fixture, +// Instance.restore boundary via the shared `it.instance` fixture, // so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't // fail it. This grep guards the actual production callsite. -test("effect-cmd.ts wraps the handler body in Instance.restore", async () => { - const source = await fs.readFile(new URL("../../src/cli/effect-cmd.ts", import.meta.url), "utf8") - expect(source).toContain("Instance.restore(ctx") -}) +it.live("effect-cmd.ts wraps the handler body in Instance.restore", () => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const source = yield* fs.readFileString(fileURLToPath(new URL("../../src/cli/effect-cmd.ts", import.meta.url))) + expect(source).toContain("Instance.restore(ctx") + }), +) -test("Instance.current reachable from inner runPromise inside Effect.promise(async)", async () => { - await using dir = await tmpdir({ git: true }) - await provideTestInstance({ - directory: dir.path, - fn: () => - Effect.runPromise( - Effect.promise(async () => { - await new Promise((r) => setTimeout(r, 5)) - const current = await Effect.runPromise( - Effect.sync(() => { - try { - return Instance.current - } catch { - return undefined - } - }), - ) - expect(current?.directory).toBe(dir.path) +it.instance( + "Instance.current reachable after await inside restored Effect.promise(async)", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const ctx = yield* InstanceRef + if (!ctx) throw new Error("InstanceRef not provided") + + const current = yield* Effect.promise(() => + Instance.restore(ctx, async () => { + await Promise.resolve() + try { + return Instance.current + } catch { + return undefined + } }), - ), - }) -}) + ) + + expect(current?.directory).toBe(test.directory) + }), + { git: true }, +) From 937a3c10b3ecaaaa7543cbd9d98fe69e59482743 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 23:49:28 +0000 Subject: [PATCH 150/188] chore: generate --- .../test/server/session-actions.test.ts | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 3891e67f5..44e324b71 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -16,26 +16,28 @@ afterEach(async () => { }) describe("session action routes", () => { - it.instance("abort route returns success", () => - Effect.gen(function* () { - const test = yield* TestInstance - const session = yield* Effect.acquireRelease( - SessionNs.Service.use((svc) => svc.create({})), - (created) => SessionNs.Service.use((svc) => svc.remove(created.id)).pipe(Effect.ignore), - ) + it.instance( + "abort route returns success", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* Effect.acquireRelease( + SessionNs.Service.use((svc) => svc.create({})), + (created) => SessionNs.Service.use((svc) => svc.remove(created.id)).pipe(Effect.ignore), + ) - const res = yield* Effect.promise(() => - Promise.resolve( - Server.Default().app.request(`/session/${session.id}/abort`, { - method: "POST", - headers: { "x-opencode-directory": test.directory }, - }), - ), - ) + const res = yield* Effect.promise(() => + Promise.resolve( + Server.Default().app.request(`/session/${session.id}/abort`, { + method: "POST", + headers: { "x-opencode-directory": test.directory }, + }), + ), + ) - expect(res.status).toBe(200) - expect(yield* Effect.promise(() => res.json())).toBe(true) - }), + expect(res.status).toBe(200) + expect(yield* Effect.promise(() => res.json())).toBe(true) + }), { git: true }, ) }) From 1b6599f411cb67601cc48fc8e348056dcaf2ec3f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 19:53:46 -0400 Subject: [PATCH 151/188] test(plugin): use noop dependency boundaries (#27148) --- packages/opencode/test/plugin/trigger.test.ts | 47 +++++++++++----- .../test/plugin/workspace-adapter.test.ts | 55 ++++++++++++------- 2 files changed, 67 insertions(+), 35 deletions(-) diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts index 5e16af42b..18fe0e82e 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -1,26 +1,43 @@ -import { afterAll, describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { describe, expect } from "bun:test" +import { Effect, Layer, Option } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import path from "path" import { pathToFileURL } from "url" +import { Account } from "../../src/account/account" +import { Auth } from "../../src/auth" +import { Bus } from "../../src/bus" +import { Config } from "../../src/config/config" +import { Env } from "../../src/env" +import { Plugin } from "../../src/plugin/index" import { ModelID, ProviderID } from "../../src/provider/schema" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { NpmTest } from "../fake/npm" -const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS -process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" - -const { Plugin } = await import("../../src/plugin/index") -const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer)) -const systemHook = "experimental.chat.system.transform" - -afterAll(() => { - if (disableDefault === undefined) { - delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS - return - } - process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault +const emptyAccount = Layer.mock(Account.Service)({ + active: () => Effect.succeed(Option.none()), + activeOrg: () => Effect.succeed(Option.none()), }) +const emptyAuth = Layer.mock(Auth.Service)({ + all: () => Effect.succeed({}), +}) +const configLayer = Config.layer.pipe( + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(emptyAuth), + Layer.provide(emptyAccount), + Layer.provide(NpmTest.noop), +) +const it = testEffect( + Layer.mergeAll( + Plugin.layer.pipe(Layer.provide(Bus.layer), Layer.provide(configLayer)), + CrossSpawnSpawner.defaultLayer, + ), +) +const systemHook = "experimental.chat.system.transform" function withProject(source: string, self: Effect.Effect) { return provideTmpdirInstance((dir) => diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 9199a85a6..d4aaae4a9 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -1,25 +1,46 @@ import { afterAll, afterEach, describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect, Layer, Option } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Flag } from "@opencode-ai/core/flag/flag" import path from "path" import { pathToFileURL } from "url" +import { Account } from "../../src/account/account" +import { Auth } from "../../src/auth" +import { Bus } from "../../src/bus" +import { Config } from "../../src/config/config" +import { Env } from "../../src/env" +import { Workspace } from "../../src/control-plane/workspace" +import { Plugin } from "../../src/plugin/index" +import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { NpmTest } from "../fake/npm" -const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS -process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" - -const { Flag } = await import("@opencode-ai/core/flag/flag") -const { Plugin } = await import("../../src/plugin/index") -const { Workspace } = await import("../../src/control-plane/workspace") -const { InstanceBootstrap } = await import("../../src/project/bootstrap") -const { Instance } = await import("../../src/project/instance") -const { InstanceStore } = await import("../../src/project/instance-store") -const workspaceLayer = Workspace.defaultLayer.pipe( - Layer.provide(InstanceStore.defaultLayer), - Layer.provide(InstanceBootstrap.defaultLayer), +const emptyAccount = Layer.mock(Account.Service)({ + active: () => Effect.succeed(Option.none()), + activeOrg: () => Effect.succeed(Option.none()), +}) +const emptyAuth = Layer.mock(Auth.Service)({ + all: () => Effect.succeed({}), +}) +const configLayer = Config.layer.pipe( + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(emptyAuth), + Layer.provide(emptyAccount), + Layer.provide(NpmTest.noop), ) -const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer)) +const pluginLayer = Plugin.layer.pipe(Layer.provide(Bus.layer), Layer.provide(configLayer)) +const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))), +) +const it = testEffect(Layer.mergeAll(pluginLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer)) const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES @@ -30,12 +51,6 @@ afterEach(async () => { }) afterAll(() => { - if (disableDefault === undefined) { - delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS - } else { - process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault - } - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = experimental }) From d8c8322ddf88ae3b27335b7c8f1bf5eb2d5fce01 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 19:55:07 -0400 Subject: [PATCH 152/188] test: migrate worktree tests to effect runner (#27177) --- .../opencode/test/project/worktree.test.ts | 454 +++++++++--------- 1 file changed, 222 insertions(+), 232 deletions(-) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index b1b9d22b7..fe56008b0 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -1,302 +1,292 @@ -import { $ } from "bun" import { afterEach, describe, expect } from "bun:test" -import * as fs from "fs/promises" import path from "path" -import { Cause, Effect, Exit, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" -import { InstanceRuntime } from "../../src/project/instance-runtime" +import { Cause, Deferred, Effect, Exit, Layer } from "effect" +import { GlobalBus, type GlobalEvent } from "../../src/bus/global" +import { Git } from "../../src/git" import { Worktree } from "../../src/worktree" -import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer)) -const wintest = process.platform !== "win32" ? it.live : it.live.skip +const it = testEffect( + Layer.mergeAll(Worktree.defaultLayer, AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer), +) +const wintest = process.platform !== "win32" ? it.instance : it.instance.skip function normalize(input: string) { return input.replace(/\\/g, "/").toLowerCase() } -async function waitReady() { - const { GlobalBus } = await import("../../src/bus/global") +const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () { + const ready = yield* Deferred.make<{ name: string; branch?: string }>() + const on = (evt: GlobalEvent) => { + if (evt.payload.type !== Worktree.Event.Ready.type) return + Deferred.doneUnsafe(ready, Effect.succeed(evt.payload.properties)) + } - return await new Promise<{ name: string; branch?: string }>((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", on) - reject(new Error("timed out waiting for worktree.ready")) - }, 10_000) + GlobalBus.on("event", on) + yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on))) - function on(evt: { directory?: string; payload: { type: string; properties: { name: string; branch?: string } } }) { - if (evt.payload.type !== Worktree.Event.Ready.type) return - clearTimeout(timer) - GlobalBus.off("event", on) - resolve(evt.payload.properties) - } + return Deferred.await(ready).pipe( + Effect.race( + Effect.sleep("10 seconds").pipe( + Effect.flatMap(() => Effect.fail(new Error("timed out waiting for worktree.ready"))), + ), + ), + ) +}) - GlobalBus.on("event", on) - }) -} +const git = Effect.fn("WorktreeTest.git")(function* (cwd: string, args: string[]) { + const service = yield* Git.Service + const result = yield* service.run(args, { cwd }) + if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString("utf8")}`) + return result.text() +}) + +const gitResult = Effect.fn("WorktreeTest.gitResult")(function* (cwd: string, args: string[]) { + const service = yield* Git.Service + return yield* service.run(args, { cwd }) +}) describe("Worktree", () => { afterEach(() => disposeAllInstances()) describe("makeWorktreeInfo", () => { - it.live("returns info with name, branch, and directory", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const svc = yield* Worktree.Service - const info = yield* svc.makeWorktreeInfo() - - expect(info.name).toBeDefined() - expect(typeof info.name).toBe("string") - expect(info.branch).toBe(`opencode/${info.name}`) - expect(info.directory).toContain(info.name) - }), - { git: true }, - ), - ) - - it.live("uses provided name as base", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const svc = yield* Worktree.Service - const info = yield* svc.makeWorktreeInfo({ name: "my-feature" }) - - expect(info.name).toBe("my-feature") - expect(info.branch).toBe("opencode/my-feature") - }), - { git: true }, - ), - ) - - it.live("slugifies the provided name", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const svc = yield* Worktree.Service - const info = yield* svc.makeWorktreeInfo({ name: "My Feature Branch!" }) - - expect(info.name).toBe("my-feature-branch") - }), - { git: true }, - ), - ) - - it.live("omits branch for detached info", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const svc = yield* Worktree.Service - yield* Effect.promise(() => $`git branch opencode/my-feature`.cwd(dir).quiet()) - - const info = yield* svc.makeWorktreeInfo({ name: "my-feature", detached: true }) - - expect(info.name).toBe("my-feature") - expect(info.branch).toBeUndefined() - }), - { git: true }, - ), - ) - - it.live("throws NotGitError for non-git directories", () => - provideTmpdirInstance(() => + it.instance( + "returns info with name, branch, and directory", + () => Effect.gen(function* () { const svc = yield* Worktree.Service - const exit = yield* Effect.exit(svc.makeWorktreeInfo()) + const info = yield* svc.makeWorktreeInfo() - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError) + expect(info.name).toBeDefined() + expect(typeof info.name).toBe("string") + expect(info.branch).toBe(`opencode/${info.name}`) + expect(info.directory).toContain(info.name) }), - ), + { git: true }, ) - wintest("creates detached git worktree when info has no branch", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const svc = yield* Worktree.Service - const info = yield* svc.makeWorktreeInfo({ name: "detached-test", detached: true }) - const ready = waitReady() - yield* svc.createFromInfo(info) + it.instance( + "uses provided name as base", + () => + Effect.gen(function* () { + const svc = yield* Worktree.Service + const info = yield* svc.makeWorktreeInfo({ name: "my-feature" }) - const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text()) - const normalizedList = normalize(list) - const normalizedDir = normalize(info.directory) - expect(normalizedList).toContain(normalizedDir) + expect(info.name).toBe("my-feature") + expect(info.branch).toBe("opencode/my-feature") + }), + { git: true }, + ) - const branch = yield* Effect.promise(() => - $`git symbolic-ref -q --short HEAD`.cwd(info.directory).quiet().nothrow(), - ) - expect(branch.exitCode).not.toBe(0) + it.instance( + "slugifies the provided name", + () => + Effect.gen(function* () { + const svc = yield* Worktree.Service + const info = yield* svc.makeWorktreeInfo({ name: "My Feature Branch!" }) - const props = yield* Effect.promise(() => ready) - expect(props.name).toBe(info.name) - expect(props.branch).toBeUndefined() + expect(info.name).toBe("my-feature-branch") + }), + { git: true }, + ) - yield* svc.remove({ directory: info.directory }) - }), - { git: true }, - ), + it.instance( + "omits branch for detached info", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const svc = yield* Worktree.Service + yield* git(test.directory, ["branch", "opencode/my-feature"]) + + const info = yield* svc.makeWorktreeInfo({ name: "my-feature", detached: true }) + + expect(info.name).toBe("my-feature") + expect(info.branch).toBeUndefined() + }), + { git: true }, + ) + + it.instance("throws NotGitError for non-git directories", () => + Effect.gen(function* () { + const svc = yield* Worktree.Service + const exit = yield* Effect.exit(svc.makeWorktreeInfo()) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError) + }), + ) + + wintest( + "creates detached git worktree when info has no branch", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const svc = yield* Worktree.Service + const info = yield* svc.makeWorktreeInfo({ name: "detached-test", detached: true }) + const ready = yield* waitReady() + yield* svc.createFromInfo(info) + + const list = yield* git(test.directory, ["worktree", "list", "--porcelain"]) + const normalizedList = normalize(list) + const normalizedDir = normalize(info.directory) + expect(normalizedList).toContain(normalizedDir) + + const branch = yield* gitResult(info.directory, ["symbolic-ref", "-q", "--short", "HEAD"]) + expect(branch.exitCode).not.toBe(0) + + const props = yield* ready + expect(props.name).toBe(info.name) + expect(props.branch).toBeUndefined() + + yield* svc.remove({ directory: info.directory }) + }), + { git: true }, ) }) describe("create + remove lifecycle", () => { - it.live("create returns worktree info and remove cleans up", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const svc = yield* Worktree.Service - const info = yield* svc.create() + it.instance( + "create returns worktree info and remove cleans up", + () => + Effect.gen(function* () { + const svc = yield* Worktree.Service + const ready = yield* waitReady() + const info = yield* svc.create() - expect(info.name).toBeDefined() - expect(info.branch ?? "").toStartWith("opencode/") - expect(info.directory).toBeDefined() + expect(info.name).toBeDefined() + expect(info.branch ?? "").toStartWith("opencode/") + expect(info.directory).toBeDefined() - yield* Effect.promise(() => Bun.sleep(1000)) + yield* ready - const ok = yield* svc.remove({ directory: info.directory }) - expect(ok).toBe(true) - }), - { git: true }, - ), + const ok = yield* svc.remove({ directory: info.directory }) + expect(ok).toBe(true) + }), + { git: true }, ) - it.live("create returns after setup and fires Event.Ready after bootstrap", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const svc = yield* Worktree.Service - const ready = waitReady() - const info = yield* svc.create() + it.instance( + "create returns after setup and fires Event.Ready after bootstrap", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const fs = yield* AppFileSystem.Service + const svc = yield* Worktree.Service + const ready = yield* waitReady() + const info = yield* svc.create() - expect(info.name).toBeDefined() - expect(info.branch ?? "").toStartWith("opencode/") + expect(info.name).toBeDefined() + expect(info.branch ?? "").toStartWith("opencode/") - const text = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text()) - const next = yield* Effect.promise(() => fs.realpath(info.directory).catch(() => info.directory)) - expect(normalize(text)).toContain(normalize(next)) + const text = yield* git(test.directory, ["worktree", "list", "--porcelain"]) + const next = yield* fs.realPath(info.directory).pipe(Effect.catch(() => Effect.succeed(info.directory))) + expect(normalize(text)).toContain(normalize(next)) - const props = yield* Effect.promise(() => ready) - expect(props.name).toBe(info.name) - expect(props.branch).toBe(info.branch) + const props = yield* ready + expect(props.name).toBe(info.name) + expect(props.branch).toBe(info.branch) - yield* Effect.promise(() => - WithInstance.provide({ - directory: info.directory, - fn: () => InstanceRuntime.disposeInstance(Instance.current), - }), - ) - yield* Effect.promise(() => Bun.sleep(100)) - yield* svc.remove({ directory: info.directory }) - }), - { git: true }, - ), + yield* svc.remove({ directory: info.directory }) + }), + { git: true }, ) - it.live("create with custom name", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const svc = yield* Worktree.Service - const ready = waitReady() - const info = yield* svc.create({ name: "test-workspace" }) + it.instance( + "create with custom name", + () => + Effect.gen(function* () { + const svc = yield* Worktree.Service + const ready = yield* waitReady() + const info = yield* svc.create({ name: "test-workspace" }) - expect(info.name).toBe("test-workspace") - expect(info.branch).toBe("opencode/test-workspace") + expect(info.name).toBe("test-workspace") + expect(info.branch).toBe("opencode/test-workspace") - yield* Effect.promise(() => ready) - yield* Effect.promise(() => - WithInstance.provide({ - directory: info.directory, - fn: () => InstanceRuntime.disposeInstance(Instance.current), - }), - ) - yield* Effect.promise(() => Bun.sleep(100)) - yield* svc.remove({ directory: info.directory }) - }), - { git: true }, - ), + yield* ready + yield* svc.remove({ directory: info.directory }) + }), + { git: true }, ) }) describe("createFromInfo", () => { - wintest("creates git worktree and boots asynchronously", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const svc = yield* Worktree.Service - const info = yield* svc.makeWorktreeInfo({ name: "from-info-test" }) - const ready = waitReady() - yield* svc.createFromInfo(info) + wintest( + "creates git worktree and boots asynchronously", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const svc = yield* Worktree.Service + const info = yield* svc.makeWorktreeInfo({ name: "from-info-test" }) + const ready = yield* waitReady() + yield* svc.createFromInfo(info) - const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text()) - const normalizedList = list.replace(/\\/g, "/") - const normalizedDir = info.directory.replace(/\\/g, "/") - expect(normalizedList).toContain(normalizedDir) + const list = yield* git(test.directory, ["worktree", "list", "--porcelain"]) + const normalizedList = list.replace(/\\/g, "/") + const normalizedDir = info.directory.replace(/\\/g, "/") + expect(normalizedList).toContain(normalizedDir) - yield* Effect.promise(() => ready) - yield* svc.remove({ directory: info.directory }) - }), - { git: true }, - ), + yield* ready + yield* svc.remove({ directory: info.directory }) + }), + { git: true }, ) }) describe("list", () => { - it.live("uses parent folder name when worktree basename matches the primary worktree", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const svc = yield* Worktree.Service - const parent = path.join(path.dirname(dir), `${path.basename(dir)}-parent`) - const target = path.join(parent, path.basename(dir)) - const branch = `same-basename-list-${Date.now()}` + it.instance( + "uses parent folder name when worktree basename matches the primary worktree", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const fs = yield* AppFileSystem.Service + const svc = yield* Worktree.Service + const parent = path.join(path.dirname(test.directory), `${path.basename(test.directory)}-parent`) + const target = path.join(parent, path.basename(test.directory)) + const branch = `same-basename-list-${Date.now()}` - yield* Effect.promise(() => fs.mkdir(parent, { recursive: true })) - yield* Effect.promise(() => $`git worktree add -b ${branch} ${target}`.cwd(dir).quiet()) + yield* fs.ensureDir(parent) + yield* git(test.directory, ["worktree", "add", "-b", branch, target]) - const list = yield* svc.list() - const directory = yield* Effect.promise(() => fs.realpath(target).catch(() => target)) + const list = yield* svc.list() + const directory = yield* fs.realPath(target).pipe(Effect.catch(() => Effect.succeed(target))) - expect(list.map((item) => ({ ...item, directory: normalize(item.directory) }))).toContainEqual({ - name: path.basename(parent), - branch, - directory: normalize(directory), - }) + expect(list.map((item) => ({ ...item, directory: normalize(item.directory) }))).toContainEqual({ + name: path.basename(parent), + branch, + directory: normalize(directory), + }) - yield* svc.remove({ directory: target }) - }), - { git: true }, - ), + yield* svc.remove({ directory: target }) + }), + { git: true }, ) }) describe("remove edge cases", () => { - it.live("remove non-existent directory succeeds silently", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const svc = yield* Worktree.Service - const ok = yield* svc.remove({ directory: path.join(dir, "does-not-exist") }) - expect(ok).toBe(true) - }), - { git: true }, - ), + it.instance( + "remove non-existent directory succeeds silently", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const svc = yield* Worktree.Service + const ok = yield* svc.remove({ directory: path.join(test.directory, "does-not-exist") }) + expect(ok).toBe(true) + }), + { git: true }, ) - it.live("throws NotGitError for non-git directories", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const svc = yield* Worktree.Service - const exit = yield* Effect.exit(svc.remove({ directory: "/tmp/fake" })) + it.instance("throws NotGitError for non-git directories", () => + Effect.gen(function* () { + const test = yield* TestInstance + const svc = yield* Worktree.Service + const exit = yield* Effect.exit(svc.remove({ directory: path.join(test.directory, "fake") })) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError) - }), - ), + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError) + }), ) }) }) From 1d243ce25a546986d47322e473212ea69da9c6a7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 12 May 2026 20:06:29 -0400 Subject: [PATCH 153/188] harden cache usage --- .github/actions/setup-bun/action.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 35f42462b..5b44517ec 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -33,8 +33,9 @@ runs: shell: bash run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT" - - name: Cache Bun dependencies - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + - name: Restore Bun dependencies + id: bun-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ steps.cache.outputs.dir }} key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} @@ -56,3 +57,10 @@ runs: bun install ${{ inputs.install-flags }} fi shell: bash + + - name: Save Bun dependencies + if: steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ${{ steps.cache.outputs.dir }} + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} From d0844c600be07c2f85b71ca81258a6c11bf178d3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 20:14:05 -0400 Subject: [PATCH 154/188] test(worktree): use timeoutOrElse for ready wait (#27180) --- packages/opencode/test/project/worktree.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index fe56008b0..bc8a2337b 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -29,11 +29,10 @@ const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () { yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on))) return Deferred.await(ready).pipe( - Effect.race( - Effect.sleep("10 seconds").pipe( - Effect.flatMap(() => Effect.fail(new Error("timed out waiting for worktree.ready"))), - ), - ), + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => Effect.fail(new Error("timed out waiting for worktree.ready")), + }), ) }) From 17d4c366e6ae7778c3865421a4448a373e848cd2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 20:23:20 -0400 Subject: [PATCH 155/188] test(worktree): dispose created instances before removal (#27182) --- .../opencode/test/project/worktree.test.ts | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index bc8a2337b..3975db1ae 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -2,11 +2,13 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Cause, Deferred, Effect, Exit, Layer } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Git } from "../../src/git" +import { Instance } from "../../src/project/instance" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Worktree } from "../../src/worktree" -import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect( @@ -28,7 +30,7 @@ const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () { GlobalBus.on("event", on) yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on))) - return Deferred.await(ready).pipe( + return yield* Deferred.await(ready).pipe( Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.fail(new Error("timed out waiting for worktree.ready")), @@ -36,6 +38,12 @@ const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () { ) }) +const disposeWorktreeInstance = (directory: string) => + Effect.gen(function* () { + const ctx = yield* Effect.sync(() => Instance.current).pipe(provideInstance(directory)) + yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx)) + }) + const git = Effect.fn("WorktreeTest.git")(function* (cwd: string, args: string[]) { const service = yield* Git.Service const result = yield* service.run(args, { cwd }) @@ -125,7 +133,7 @@ describe("Worktree", () => { const test = yield* TestInstance const svc = yield* Worktree.Service const info = yield* svc.makeWorktreeInfo({ name: "detached-test", detached: true }) - const ready = yield* waitReady() + const ready = yield* waitReady().pipe(Effect.forkScoped) yield* svc.createFromInfo(info) const list = yield* git(test.directory, ["worktree", "list", "--porcelain"]) @@ -136,7 +144,7 @@ describe("Worktree", () => { const branch = yield* gitResult(info.directory, ["symbolic-ref", "-q", "--short", "HEAD"]) expect(branch.exitCode).not.toBe(0) - const props = yield* ready + const props = yield* Fiber.join(ready) expect(props.name).toBe(info.name) expect(props.branch).toBeUndefined() @@ -152,14 +160,15 @@ describe("Worktree", () => { () => Effect.gen(function* () { const svc = yield* Worktree.Service - const ready = yield* waitReady() + const ready = yield* waitReady().pipe(Effect.forkScoped) const info = yield* svc.create() expect(info.name).toBeDefined() expect(info.branch ?? "").toStartWith("opencode/") expect(info.directory).toBeDefined() - yield* ready + yield* Fiber.join(ready) + yield* disposeWorktreeInstance(info.directory) const ok = yield* svc.remove({ directory: info.directory }) expect(ok).toBe(true) @@ -174,7 +183,7 @@ describe("Worktree", () => { const test = yield* TestInstance const fs = yield* AppFileSystem.Service const svc = yield* Worktree.Service - const ready = yield* waitReady() + const ready = yield* waitReady().pipe(Effect.forkScoped) const info = yield* svc.create() expect(info.name).toBeDefined() @@ -184,10 +193,11 @@ describe("Worktree", () => { const next = yield* fs.realPath(info.directory).pipe(Effect.catch(() => Effect.succeed(info.directory))) expect(normalize(text)).toContain(normalize(next)) - const props = yield* ready + const props = yield* Fiber.join(ready) expect(props.name).toBe(info.name) expect(props.branch).toBe(info.branch) + yield* disposeWorktreeInstance(info.directory) yield* svc.remove({ directory: info.directory }) }), { git: true }, @@ -198,13 +208,14 @@ describe("Worktree", () => { () => Effect.gen(function* () { const svc = yield* Worktree.Service - const ready = yield* waitReady() + const ready = yield* waitReady().pipe(Effect.forkScoped) const info = yield* svc.create({ name: "test-workspace" }) expect(info.name).toBe("test-workspace") expect(info.branch).toBe("opencode/test-workspace") - yield* ready + yield* Fiber.join(ready) + yield* disposeWorktreeInstance(info.directory) yield* svc.remove({ directory: info.directory }) }), { git: true }, @@ -219,7 +230,7 @@ describe("Worktree", () => { const test = yield* TestInstance const svc = yield* Worktree.Service const info = yield* svc.makeWorktreeInfo({ name: "from-info-test" }) - const ready = yield* waitReady() + const ready = yield* waitReady().pipe(Effect.forkScoped) yield* svc.createFromInfo(info) const list = yield* git(test.directory, ["worktree", "list", "--porcelain"]) @@ -227,7 +238,8 @@ describe("Worktree", () => { const normalizedDir = info.directory.replace(/\\/g, "/") expect(normalizedList).toContain(normalizedDir) - yield* ready + yield* Fiber.join(ready) + yield* disposeWorktreeInstance(info.directory) yield* svc.remove({ directory: info.directory }) }), { git: true }, From d1640c075b90d4683fedc8f4fd86088b84277720 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 13 May 2026 10:25:28 +1000 Subject: [PATCH 156/188] fix(app): use session status for busy state (#27166) --- packages/app/src/pages/layout/sidebar-items.tsx | 13 ++----------- packages/app/src/pages/session.tsx | 6 ++---- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index f27a9bb7a..3aac5a613 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -166,18 +166,9 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { }) const isWorking = createMemo(() => { if (hasPermissions()) return false - const pending = (sessionStore.message[props.session.id] ?? []).findLast( - (message) => - message.role === "assistant" && - typeof (message as { time?: { completed?: unknown } }).time?.completed !== "number", - ) + // This matches how the TUI does it const status = sessionStore.session_status[props.session.id] - return ( - pending !== undefined || - status?.type === "busy" || - status?.type === "retry" || - (status !== undefined && status.type !== "idle") - ) + return status?.type === "busy" || status?.type === "retry" }) const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 1345e355e..8bc7e6a5c 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1497,10 +1497,8 @@ export default function Page() { }) const busy = (sessionID: string) => { - if ((sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle") return true - return (sync.data.message[sessionID] ?? []).some( - (item) => item.role === "assistant" && typeof item.time.completed !== "number", - ) + // This matches how the TUI does it + return (sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle" } const queuedFollowups = createMemo(() => { From 59b976b954decc44849fb9b7b185ea1ab3a37c9b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 20:37:16 -0400 Subject: [PATCH 157/188] Remove TUI logo sound effects (#27183) --- bun.lock | 7 - packages/opencode/package.json | 1 - .../opencode/src/cli/cmd/tui/asset/charge.wav | Bin 590984 -> 0 bytes .../src/cli/cmd/tui/asset/pulse-a.wav | Bin 176444 -> 0 bytes .../src/cli/cmd/tui/asset/pulse-b.wav | Bin 176444 -> 0 bytes .../src/cli/cmd/tui/asset/pulse-c.wav | Bin 176444 -> 0 bytes .../src/cli/cmd/tui/component/logo.tsx | 11 -- .../opencode/src/cli/cmd/tui/util/sound.ts | 156 ------------------ 8 files changed, 175 deletions(-) delete mode 100644 packages/opencode/src/cli/cmd/tui/asset/charge.wav delete mode 100644 packages/opencode/src/cli/cmd/tui/asset/pulse-a.wav delete mode 100644 packages/opencode/src/cli/cmd/tui/asset/pulse-b.wav delete mode 100644 packages/opencode/src/cli/cmd/tui/asset/pulse-c.wav delete mode 100644 packages/opencode/src/cli/cmd/tui/util/sound.ts diff --git a/bun.lock b/bun.lock index 5da588910..8e7a63a2e 100644 --- a/bun.lock +++ b/bun.lock @@ -418,7 +418,6 @@ "bonjour-service": "1.3.0", "bun-pty": "0.4.8", "chokidar": "4.0.3", - "cli-sound": "1.1.3", "clipboardy": "4.0.0", "cross-spawn": "catalog:", "decimal.js": "10.5.0", @@ -2811,8 +2810,6 @@ "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], - "cli-sound": ["cli-sound@1.1.3", "", { "dependencies": { "find-exec": "^1.0.3" }, "bin": { "cli-sound": "dist/esm/cli.js" } }, "sha512-dpdF3KS3wjo1fobKG5iU9KyKqzQWAqueymHzZ9epus/dZ40487gAvS6aXFeBul+GiQAQYUTAtUWgQvw6Jftbyg=="], - "cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], "cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="], @@ -3237,8 +3234,6 @@ "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], - "find-exec": ["find-exec@1.0.3", "", { "dependencies": { "shell-quote": "^1.8.1" } }, "sha512-gnG38zW90mS8hm5smNcrBnakPEt+cGJoiMkJwCU0IYnEb0H2NQk0NIljhNW+48oniCriFek/PH6QXbwsJo/qug=="], - "find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="], "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], @@ -4567,8 +4562,6 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], - "shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="], "shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e9b811fc5..121b34c3a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -129,7 +129,6 @@ "bonjour-service": "1.3.0", "bun-pty": "0.4.8", "chokidar": "4.0.3", - "cli-sound": "1.1.3", "clipboardy": "4.0.0", "cross-spawn": "catalog:", "decimal.js": "10.5.0", diff --git a/packages/opencode/src/cli/cmd/tui/asset/charge.wav b/packages/opencode/src/cli/cmd/tui/asset/charge.wav deleted file mode 100644 index d9597899cd987777eccd011a3df4b0e8432ff777..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 590984 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

EQ04HfPulR<+i3d&4@6-w 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 g0Z9k0Zsx9;9dJq7%Ur+7oUQeG;A! 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

s|#)S0wDX(hBu+J)f1(HnTsAJM1L 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 IvuB!Jcn>1cki?&Za3#0(lh5$XxG|%wV9BLY5 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
bHnbYb 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 z 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
`-5<#q-Pq?&9-3bnZ*tLGCNv;BISO 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≷6|I;ndkJ9zgx6swl7vZmK)CF|E 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�#BH7Auy=t#bj6F^dBjf`75Oy&d{+E1j9hpb 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�gW*Gs1IwSz@jG9?PQM%u!Frfd+w@0Z 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 z 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 j|L5xK|H_&6;*|fSh5z3<=AZD=e)@jpJZY zU^qJjIsD&y0bw{1h>V4N7C(`ojKG)Bh%S{LP7b38rB0z?W^e_NFc<==m_a~OmR8^* z3l!AKVaMgiuPYeANyiQ0Cl>^Blym0rSqNZrz2_biOcnMMU=EeCQS;a-ma?cV1 zdd*733S>*BRiKk%Bx6#b-J%^Kl_IOB)Te47SEHE2Bg3D^T*oBG)5mLm?0Ve7z`<0y zSGlLZzP`%2in;p>eeL>nGfSm;!al2Z_eFM?M^js!fu={{Vp4@6>*@C36DeA$~fPMRY}Cj?#W64 zn~aW(=}dmCD{SW64?O++X@W0AZN+KCdBH#_3z=N0H5pd92!(T%G{}2(Z793G#ecBjr(R*v?xrjr)<#cAevLiSVEV8Wh%R1efPG_G{k za^p7{4$Dv9z%{FxW7SZmI6(ADc1!u5PaaZYl4wPEn__XRpqxFA1-- zF5@oMpM0SIWaz~9JmB2^MDJwjsPahUO8tuGlK-;$vgY#BvC8r7N!y9*mF<<@DaonI zjrvX9S;X1f3yTZqYn$u%%aBWvJIypbqh@wl{XEHv5DCh^p~xM zm71fF@1@`rKTJql)K_9$+E{)^xetO3A%%X^*)^aw$TO}s`D(#wsc!e&D#qT zWaOdl$?d=Ca}wC?yA;Iz((=`AkXCqhFy5QOR~+v)!X6_4;g1pj-aJMKzI}|iefJpA z8}S%X_k{oN!}c^I()F08anf~FIXbHCx9UIw?86S+=n9|)XTts(w$rzvo3fin>!|g9R13;!J8ApNTGZO|>dxx>jpmJ>P2iTo`j_?94Xw>DyW)Fa z(E>YaI~O|vJL0=5M?1%~ClaTivlr)hmrhsOx6^mW*TJ{?4_FvztYMrP%xdgYLROMF zQelcDYBst@`dY>mdQC^DU!Wl~7FTD#_f zj<#XC?vWwfkk8b~^xPuX(%p{UX5B%`0qC0K^4l}n?XS0-=QBSy@6RtseU1VM{5XSx z1M*&d2tvHB2;L664;Bok3e|n978>w&HI(#SW7u&-Mc85l$&(HEo*?=Jp(pr0f$hn@ z(@%ZJlf8YPkp6`5u($}UFuDl8u$Tzf(BQWYuYBHMzM^^q2;mP83Jeadd|4c#>Ms-Q z=W8GM&eO#2#;wDf!MWER*WsVbu&s-evc+HPJu^*9AwwgRRh?~vIrVC-NqKu^KbZ}A zSD`S`d;yqnDu)xdI8!xCH0=_7DRCaDI6eVk7>*)d***9%{^tHx^33ggX%D%dvLAnt zvZ=e}vdOSjvL=VZSbejqzL>J;yV$l!vE;H8Fqbh`yLh{JG|#foJ2N;lwQ#$DJBK}o zHUBslzLK*-ic&*SZs>0mZEkPQuV1Zaq8HI~JG8r#2YiPeCnBdGPS;OeE+#K%FJ4}J zxVgEZ!l=Puen@**!}@>?#Gk>J!Rf}aC8Q#I?O0wPg)gwO1OJ8o#vm zv@G?YI-~j^gJmOxVT#F>G469VQ>aDdb3$tki*oBM3q~7y8>GW;YjX!q+iS-W+d3yV zdp8#*M@hE^rvrC+CqMTB$3-_2=S&Y5=NgZ1&Wj$4&eEPfj%99Kj!tfkj*o6Vc4%il zdp#F<8wJN&%VfLI7f@T2dAt>$QGyxcGk;TnuDD^6hL<*zDqQ_oUQc-*ye4fX#t-J@ zOBWF4^5nT@9tVojw=z_auT#k5J>vbwWX00C_PSO$B|n|s7TXrywAp~K7OjxZrcQsG zM2&BZ(T!3MQw$#VmGoeA2DfQ<*0nXXUN;H1+BESsu)vKPl;KVFiFGjeyIPythsxX< zg~}gwa@AdRe$@-LZ>tijffXq=qLuJkyDGlg;YwJIZ6&B~zN!-Txh4_jSAzxLtI2}J z)zrZ2Ynu=#7*>M}+@L<9&ZTJ(A=H$O$ZryAC~DSjJZdR!I%@4`=I<10SMGqeVRrF# zxc1a^74{Hx+aM464Es^Nn*)E4pNFgl87IKwDwF;b4^!Gx`m?Z^s(JbO-nFbX1C%Ig zbMt8Pd{=VM{do1b^VIB2;9}xp{kHSY8zU668utjVi%5hdj+~P+i%N%Pg(j6wo*4`@ zV)bC>;dsWy$M-=XPncKiwdkYxpyX>AGg(>%9EBmJ1;u=oedR{A9mud|mu9WDxfVp% zL0eOgS)bBZ9Lh+`{g%iFw~G7`E#)^CIN>nklw#IqhSO)#r<0431M%(g zrXI&1m@W`!W=E|D>S(_$)G~arY)W(@Xrz76wBN61x}&Les_8qTwwAX_x2mY@wnU;Z zv@jz#B==)>{GT`J+LojU#?+Aq#H z+CBDJ6fCwp>f-z9H@WW$-*{q`qSRuYqr_uvqEuo7qpD($q6%X7qU_@lF{JTsG5tSe zVqgB;jI~T$i~pA>{6qLx=g;_Lkwo3pi=>D&(d3ehptP#2FX>s?Z!%+Z0{?L3G2|TN zZ~l`kRxVgBdo1>;OfFNb38+k{8?L5C*u#bzIS{n%-&>TsI@{NJUv+;S5b0MNJ|37I zOBtD-NFF1a9-MeRr#r*EcrmxXy0M(Qp1<~IBMo)C!;D_vr`iJ?$nSkQsy-+@Sw5;d zzd6OYGP}sQ!MZ-UR=*0rKe!#ZSG>Ex`1p8^8HrJht%tRTlZI`8i-E(AkH$T~tHb3X zc*IjA5+x)hCMVh>IwxEuIw7PbQ6N?!jUcflEhIT2(I7#Snvp&ynvz|{^WSF< zCu7GB2k?DWbl|q*=6@SxtGFxw=Kbd~r}d_YMmSuf-%63nm5js8289g#`>CpSGLeIcbMjXL>ba!!KCFWH|T6aC^Ne;UW$ z#TUhR#$(6u#Qlp(jJ^FP{ypsL*BDqNGG;fjHX8QjHhS-iU$piY?r8Mq*U^GsgN&4L`Qa1MU*XvltZ{l$t-zef$q6&Y^M`I^Qe#c4_kF`jO ziYx!M5ucdc{{x!(C4v37PZBy^HF+mPJLT=4)3l-NXX$TqC^C<8Gyl{Ue8^cZ>d!?K zm*vrvixeqVhLo69mz4dgzA4MA<*$r`E7Y{sf2qxHOok6OuOU3!3Yx;&Cz@NkblV5J zgF1G5$~sT`7P~hF36Lbi`U8Tai9_<^{Ub$Fz7umZ-BTB{57V>@kU8YC$Wp>8!;1ep z8;WXkZT$*;zdgFMhju@x+IKxc9bcS&Ki#~@xiG!rzM{J&z6D^YV+3H9WB$P2#1_EM z#qT7vAo3u>B7Q?EMixviOVLc3L}f}1rZJ;crK6#Hpi7|*d}vU=K ztn_{K_Vjo3rgRf@Q#8`FGL(*#Q{=1U^CYz-Z-}Lchw(J=O0c#tCmxp{aPB5=7%%hB zZ%%ZNt#lLm;f~H0mu5PI2%@Mqsb;V|rs8Mu zQE^KiWqv`n<-d{4_CMsmT{2>lxzhv^36l@vvy(#Od=f;zGymw1g2bV|YQ(NZ=Eul= ziHT#Xq&-k#wkmy9^*yU{bOzd*lQU$7YO&$GdTj=1|zRk(z@qbs!SM2wg_u821 zm^}D~_}W^IZBu+D_(L21DknjIZp4th3C73_$j? zEP<@MtdR6e>ENf+z_1jCw36ft$+zHWFsC?!__64;Xppd~@Rm@y5UoHv|2=OiF9&x% zmkKAG!*#j*h{xe8%P~YMM`l)7DeJnOpV`(n}RKcHT7u!aD8og#q|^^ zULP_aSnv7n7;LF-B&=od$R+-&7#Gw@vh+<&I-G& zcB&SraIRo2T`Kunm{cg72gx7LvHG|0NBVC?CflDM>Dd`LX)ftqDMe{m$*HOPN!=+7 zNu|lx3AM?%iQ~VR5{7=!CwTlS`}yqGkDo-zh6%dK-xH`)EE0dDj3*YQ9wfD-;U*(~ zKP1;@;H4dBLDQ46_cJvA75#aU*Z$Y2uqSuA*sOq~yrQ_c+PFdyc2T|AfPkB}sxm>B&#%QyXMnQQTGeP}IkHwevhVtJ%}nQ<=oP z$|%XHpjFCL{ndORQ1wEMznYI4xKJQeR*PBtxlXe-iSD$vq%Mi>XT2SrK)v_6torA= z#rigS{04z~rUrU?Dh3sLjRrM(QwG}lUkrcfEf~1#^%!L6(HK_isT$7ey)%^3+cEg0 zXJqKDt7aglCuF#-v#S41H{AfFt76cov#UR(jicZEgaWN&-5{+#T|a2I4j7uKL!%j} zt*DW%<*wchb%nHQhO2g~7pNRUY?NYELlm=AH07Twamj)dcBJ}c*TKco<6^=SBZ{)vY* z?>J}?Z2zwryH%=@rkNX&*_a5oYlOn@5rcIU4KsCUL_wWy13s*-ApnNk*a{PB+=gj2 z(<9ni{xndxVK!-Z7`0?~b+xVZuy-c)-*jsX&Gg5O>JK$dQI&2b-y`iTxGNGN&fe$YKI!I@Q971+wTuDHPh zNWlXUq*y38Me2*phMc|vT8T{c0P;fJ7di$7>XztA7=AHOFm^H0Hls7;woowlw;ZtG zv$3{Xd5X*64(4_Y&Z+hRE`g42Zi`Nv?%^(}oAhk1uXjr1-$V2{!-9e#s8gGh~G<3ZC`Ber%LL8W^J8WZKkXrLT|MbGqG}+wP$j$`UVB3&U zcUtcklwZ3J0@Qe;T&J=pcPVcntt_K0elJcfoFzQWE5L`tDarW(6b%w$Bw`Gv6{OuE z4bvm&!Au{ee);RKFw0d}XSY_yP$aU~`@YTTHKoGKTz!jM`po4T76dwQ&u?&t6 zO$}m=a1J|-m5r)Ql#jbl-Azu+#Lb8ea#07HE?auLm%I2!=p)qG?K$p^ z*)7*&F~&z+ZG1!GXQbB@V$^|j%M1u+d(aKrFsCW6qX3Ptu~@Lgved53M+HWuS=9u! zmzr`=SzRf;=g+1LiA`@!v@MpNt6FndCE7jMyms8P_jieMqH#yMJomhH`{teO!Rnjo z#pS=?ZS_*mw=*E#4bp@q9Rb}~hr9&A%g+xg_*|*|rl7%AJ5*dPMku^Scp=$0` zK2}aO9xOHrPGc5L)-lF97Iu0x;4h6ieLkfcO$(VSH6|?ns%X-Udt9vwR^~}1_2bHZLIxK_fgl# zpus@GNXcl<#Mp$~Jk-p>LhU)klJSLt^@JtB7G(XOU8v2SJ+)nfBhY@`iQM7LnZ=Ra zmDkC@t=MVbEy8)(9qdx=G4A5$>EX)m)#w`U_29be73Y@fJ@5A5ed{LW!{#376X?F= z^UQt7=Z8C&udh3`udO?+FWCLYN8FvjcipYWXVR_9=e^r&9|kuDpI+AyZ%J2W?=_cW zFLM`XuSw^1Phw|UPkSd9k4(pOcPz&%w;OwuE0;Z{>zHl&Q;M?2Db-rh(a37qzTySD zosLD0O}hEC6`7fUrHqM$g@6&J`Jq9EiI3jVGYOp-gGMM?mrw&wdtbFq1F2-MhLYD% z=9e*&j|2xwTZoZ@!9s;1wtT+?UU3ER?6Ur44`)7R$!3@b#8SVf5h7n9M-T}SE8_*? z**>$>^+`|ZnZjs3@c(i7s-?F+uEm|L>@Zy5Sm zgm^Rr48$&^9pw5{KD3GqR*Y^e5uiTydycm}hr9)XjY7p@G~!?>9;raNOgVruy;7ap zuFCpopb-8+)FkvQJCjNAWl&bKphGP7NFIJ364SFn0-`{qTu^^4~w zmIh{=77xZ{W~R>;jBEAjpC#%b^{Sxl+Jx%+8YwEqYGn!vO1m=8xl%rRae8>LzAv#u zjQ+f#x?aEXVwr9seQt5uaO%hS!Dz=Yr6#@_S+`j0UH=ts)EL@8*do(x z+#c6f+;!f0(yQ4UGBAyNGjcHeb^>FfYv#oabn$2*W>s@_cLTNo+ez6$9(ElvpYfcj zTsvO_9*iGavBCVjAN65>%3&QmN98GO4oGa=+yv3ek$yidf25N_r|mDr2hls-0@eYK4#%H6e8q zNU!>L2%ZLkdbGwKBv9iT5~=YW0@9#?6svz!1F8en+95#IZZ%q!HdRLDL6u#_9OY$& zEF}YZO2t*#B6(REJUK5Zd6_|Qj#Q_31~^>QRQ#84kSL!ZzVHrTpFla!4?bxwdY)GH zY|b0dG#drWHZYFSh50+Z4d5q@C0z|=F!c#p8AT8AE7DzppF}ITZ}7vh2yhx66)|7m z?cQ@<|97)?E_?|*wL059q&hCxgYUPX8+Nib|J(YBvRPMI*^Ge-HukxUB@6yduc(F_gs<5X>zreR}I1jr(DYq_fAm_i_peMh8W(Vc4{&D+z zocS($Go$X$L;777S9)01m*2~oLuq=MptQ4$vecjqnpE|Sp%l`L4=Jd0NXl}$P|9jL zDCO`83K{k(ZW$3N4H?K3#Z0Z#naq{c_^e-Ps(-Y8^Ja^u6aQt*(9Lnl{QR#dYcZE1 z+a~|d-^Bv)f8#}}d1WQwg2FPLqN9q6r<{dUIYu2`RUiDSCZS;$R@*GsK-k{g{JP7& z9k-9IyKR7`-(%!t@b!e!nAMEcRM-M+u5Lwad1n15$_#C^?XsV_cY7RqjDOL7&Upj8 z33;e|Fv9A^62n8`sSt?}x07y@y`g-%Xr%F{<6_tYh%)iA*Z@DX60zBEOmG-;3vdtf zM)8^mjPW-MZ3~`?va7<3vcs&xQ0wF9o=T3-}5JM0v3I$~XTk9!=uf49GGo}qrP{#_-UCnBZy>dYp5|s zVAOXs|87$5P;JNU73;wpv>mVzSz}phnM(i- z4E?knH2st%6mZf{B-MnT1g~%za1=579#8H(Zs)I5FN@CZPcn~I5A64T?!4H3v-xU0 zW-V&v=ThH7*PP<4=ycg+%DChh-ALO|>Ocz8voE|?tNV4AN=I$`>(PlD3N=lc@ z`jBp$m6jfnRhEv(T1Y?3BFM1)e?-}k+|6v4 zyxYI2`RxBT3!HK_iePyo#nJ^nrTvBSx zmaT?w?PJZKyC&K;dsVsw2Y&Pp4XX_}k8cg@Os|ZK%+E~=Ebq;mpoCW5Y`t7R+Qml8 z9(nA)IU6{hzS6qTyI;Mj#PoW^##_UdCsH9WA&VgKqH3nFpc|uMVr*jQV2NSYV3T4^ z=d9)E=E3FJ=Qrao7gBiI4KPG!#jC_MB{e0VNe4+g%a+Ot%csc?DcCCNDcvjOD~GF` zs%)$7s$xJ;Y5?^n2(^ZedXvV2x{s!j#-nD2h8wg`qZB%-F$|s2ID}SetUzCDG(*`n z-a<#zm7((LO`4?;6-^dMn}&rNvBnowP4zexO9)&!RxL#-L^VzEy^5uRhO(4Am(r^2 zs6v>Gr#!DTyd;+k@tW@ zhkKF@z}X8rX8XmG1M+3EVc`W_Fe2z&86s&Y=!mKOsN=|y6jvm~WX?qT#GClJ1dO;v zxSz1Luo@l@AMx*r?ip`(uPrVwFQU#aPb-dhjv5YH_GfkrcFwndYzb~Au8X6bS3j9_M0O6dsch-yRy66+JifNTD964n$21< z8!ehw>f;+PVReXyTF!dInrK*h6=fYkWoymfa?fh%GPSCQ60OSfVxx-VBG>ZU!kjXZ zLaMT~g3qNy1&<{k^Wh~*`7cXu@+?ZW@-#{o^PEcX@>5E*^Y=X-FzYkxHK!DgG35zkxq8`14mEi+xy z?c=@p-JpT*eIJMU2SMZ5qZ?EBlSgw#vn0!*;xtt$f;BRvZxsE}=egN{3b*P8!E@T>51v7h2`lDbj> zvS+gDif)PvDtRi3>g5n;Xt<`1j<7bJeyVPg;hz4r5#uu{Q*vV|^HCEFi#KMKFYe7H ztt>2lSpRs@XH#OiY@1}&Y8PakY|n3_=Fn?1?jUC?>G;n!*74Z3&ym4y-ci(U(NWN@ z&5_tH(y`B$-|>xYyThXmufr>wkMWLrfS!1ETee&( zL&{cyUE)Y2TI8+Zp};gRD=#ybBBv^w9IFG11amlmmm!LlislpLHu-B(1hE-m5WXp{ z3APj_?W4^7?Tyg&_=WlT#);?g%0cNq>FzRGV9Rtf?kQWovuw2-y1=#|F?&CQJ2g8w zGj>0AIm|u6JQzFpU;jh@Nl$ifa+hYpUw#YT9Zgsvl}TRH@XAS0bwIDkZAX6$e#y6{S^?6%kcl6=7AO74cQs z6&+QZ6|~hlmEqNGl>{~RRhcye)jqZ8YMr|28eJH=HVDoEtEmryb2nVoS2ZRz_%=H> z$+jxB@U<(o>30Tp{O%s>BI`5nO-G*hdp+fsG)LP1FO@V-IZEIcSbN@15^=i`eJ5Rl}O_)suWcF}yMB?Bx0 z%vhv=;cQ;)5nOM%L-{`OD+!qiUy3q{`+(CVr=&?_0rD0KKqV*T3l&v0ID|k0qL~b> z(IU~=(oxkr(9vJCM~2a!(S{| z-oCK5va@WnDzGH7p0<>?Ub2K(&p)BclF8c1a@gwOg^$&z7Z;Yh7WS4>7UeH|p6^(M zm|r{(GkY{QFPX42q$xqOq?@0clkq zRC%u!rX;FzE?=S;A}b<4CzT+>30{;86cd!l6}Azb6Zk2F!B@x6%ss-x$uY?(&AQH} z$8rJmWTa${q~`)BYY$pz|$cR#mU1h!DPhde~iNzxf6VF zxT(HnzH+`spL1U>oRXc79bccc9?l*u>~9|s?BVUp?(pvZ*!D+nY}RbqY%p!kp(58! z*7nyhRwGw4mnl|kml~I77p)f$=0OWhbJKH~vwvp4&jimTPrFTLOc_rVOhP7GCzL04 z#|_6B#_h+RkNr2AHCj5dI5IRWJ3=~~Fswa9I-EBcK6E*7Gx%cQ>tH_;G^mbj8tCc| z8gS?r7`X4dM3(n;AszdwkkWl6NI+j9^0BuBxz{_4yzM0&;O^5IFzQQsibAjkclurr zn)KriP4@p9vO*dTlMJwq%nY22^fCgJ+>w)Lj`?#y=PzV$BM(bay*N%fKXdBEw} zrS3(~jrR4&d(k@!42efgY$GgIJWE_6!eD|c;$OsDWH7QW%3jKHnoXKW`V)F*MrkoG&j}adq|E<6W!T*GNgs(-fL|;fyNMuRkOSVhzN{z`PWcua( z6{&UV$isM0qNVz-k;wc)_c~u)@{;Z^>n9k((%0YYdgAGp)I57zQwk2yji-TzKI>t z(8yok)1U<3K)AqI5Jh#a^%!+MaKBmsII5-;7FwePq0)>9JzGpyZ%ZPmVmr`8G8yTkVCp>QNZtG=zl9)WC(X*h41 zZIo|OZO&@#YvF1SXlv{c>Imy1?=tNn>(S}s>$62VAmg7-46DOJBPL^=V=EIOlX279 zGmdjA^G1s%OI9l%R>M%8>s6b;ZL*z=-JpH0!@Z-HlQ(Dn7fe^eH?6lP_g;^UnDp46 zaHsL?2tO0?kSda0Q!G4rD5^4nQOvsvN%D;oP-+xqN8BG{G&APa-qo z3gStUD6o=@y>z2ooh+r|fxMvh+c{TzMi^4wr-C;o360_D;;mW zeC_{q+qFLFR6(t@Lo{)pxHLtx0D`T7qoxkgQ1Mc=P;yq*Qjk?-kUNm;lzuN`AbBKN zBQ7s-CE_B=E)*oh%kRKX#v{bD&VlC0WQ}HpurRQ61O5YO((Tf=P#I9MlBbcqC*B}h z!w2JoaRRWDFxnq)?&$9PuisrQoiClaomd?c9-i*k?f%;F-`3iCw!yZpvPQaUz6@H5 zSkRpBoQ;|hnZB5;mHlZ8)8Uc-u4b}}h4b_Nh1P_7(kyBq%FH>(^kA`E^XTw|I zp712NHarwA3HN`(E4VuRGyDa-_^Dlf`pQ`U0QakZUf*9|Q!j=9BJvSQ2!RGb1H2)x z!KzWK5vS>(v92k-DYV(D8PdYmLiH5=+_nz2ZnRakt+wa2Z+GN&oOc#>Qg=6ZDfg^( zhxC&7!umA(?)yLX+YHPgn+Jsl#fMXdsz=C1V8;@{{Em}@{eTC=^CV0n=p)7;79x8|`iWwae3nX%ijDRs zjSc-WT_S)70B5pg8fN**G6yOEA=ygUsyK=`-gCursqwh-?DO*Sz2zU_#}@PvbQanY zf(e_59E#+K(ul2zfyD77h$M)>YZ7h8*A}KYQ9jV_kbkZv_ zeA2|StkM8k3~374HmPkHZ>d5VY$;Qj_mW%E3t)3;4sf}Yxx|@dfH<3^ub3d%Oq5N6 zPXtRGCDbeWLGYyrsQ{jE4BvObZJr~3Ft;k759d3cWcEU?a@KZ^Vqg3*!(WHwhNlOMhIj`>hvEldgV#vo!BFJUz;*xcfv^2~1E7AQf%d*( zq+eeqlB+KWx!!ArEbmo7e(V)O+V!#_ReBkbyiYx_mk&wbtBPdnHAnLIh9Xsavyl$H zqsXtlcmuFrrGcH^uLGieQv+}NxCaOOJ`VEr?+yOye=$UW92olW#M#6Hlf!8Pz9Y(m z#G~7T)uWk1u44|v9OEJ*N8{w9Jrg&hg_9>^sZ-bEY11^5|7K*ST4(*IH|DBl7#48m z>=wP|tCp4*7*{-&zN}s^lc91~Q`XH;@|&QI{jKZGax`i?Vs~N3Vt;v0_3-3C?wI~q z;9 z3kpeUd+HwAc3MLQW`<$LSB%eC`k1>w6d)mXL$>dn?>QE^3%NM>dU$LFrun`KjS8fR zbPD|w%MwYD@D>Y_1) zxmMOy?^mJM_@LUO!KP-bS)?|v$pBG=`au4JmO`qbGY}+n1JVVZhvY(G5D(~E2tAYo zlA~FvMytuK=A-dmwLraDr5m!Yj8xlKs#Kj;3{}Zj;7~S~FI7B|WmE8$36$%9@_j7gMr7vZ-e~9qeJc^f+JO<38Q4=#N)3geomZB z@=U#%9+;+`eLq`2r!?=mK(NTOw6Ju%+_EybTDew^Dq2t7DA!M(!#Gp2zmZkkl>qB2jp9q)(G%!6d&9g88&p;xq*zD5mxSR@{*xVA_ zr#x)DbA0&xFoAV}XrXE$Bat8xd@(_>TJbS)Bd|XhCHW}nCLJa{Eps5FEN3j2BL7c* zNnulgMhT!Kr>v}Os$!&KrTSEVqy|-!gYZDe)UP2^>M-?h8V(wKnztHo&Ci;$&|A$s zs2vnjs}u^=+J(N;qSyMXC8SlQC8|}X1=RYbbqn>?YK4ku1wxmgB+x);vgVB@fu@J1 zi$;e=p*o(%JVZ+UNKF@Vu4<^ZtRkaYtxT%ouhg$ZrRc5rSN>j}PA)($KxSB`UW#6N z8>}UXE#WMIFXkb3B5W>#6ciDP=RfCHd#8KuJDod5+ojtEn+2P0>#gfQ*LK!sS4382m%lI9F5)k#FC;FU z&Z*AV&SK0Z%`DD*pB|e|pIVygn#6v}mZ(fRO{7jtj^j-@jK_>)jq{E-jV+Evjpd95 zjC~sO8GAhzG4^pRckIs?YHVsue4Kgw=Xl^a@x_5zz-;Xd_uRi(q4~@?^@YND*Tv?AUrTF?gUck#6su}0wrl^b)}e;hs5e0C;hSL_ zTU)c67HFj>ZDcW8cMq_O-2c93c!+y2b@buT^5o`d{q*&T*ZIZi$wkn4_|@43)=j|G zf495W1o!T@|2-_;-9H*Vyu$2y+{2Q@w8Y86>cGXp;m7yHjU_hB!*0lq>9Xh zWQfeBWQNSCWV6hP)t18qRLNGsaty*?W(R6AYB9fIuwg8ub7SbGaiUwH zGJEoAd8!jKMv4QH1JX63dg3X9{|H;~)bI;&FmdCs8nFT~f-s&x2tLT&p>El4vaZRm z>@M%l+0KtocTVSNav(xVI$sw2>Go8hlx--i}P zM+a3$c?au95(m^r=mu7X3z4zI&yae?xG#G2_Hgi6_1N!}=#=Vw;tY0?bzyzwb%lSUa8rIucxQe; zasT-6_c0&C2U8DA68ixA8Yd1HfhUR|PS8gnLF7P$CO#)lCH+LIK~6@FrbwWOq@tz* z(Zo=f(PGic(fiUN7^dkZ8KnSuOmU24EGtaDKmnF+kROnitq5evKF^xSfy>^-$;WZ< zq%9@ndB(-e`<$DT&y0tiUz?XkK$7o4fR2AoaF@SOs9wNdI82aFL_%mrWK}3!^ph~9 z7?a3vv0M>4aURic;%TCL;#gv85_V!S5@ljN5+`Dp61?K{U?XvUu#dP1I9#0T38Y{z zag>Cic&P-txU0mr7^TE-u|jb*F|hcQXo;A$D4p1dh>xh4NTtX};SJ$-A$;LuL4Xjk zAe|t!0EPe&{~X^QZzgXe&oiD^-1}T?Tw$Db9H&p*W6qY#nh$yeZm>XED4D~Ucp1|G zLJY<99CQV=xHJjWQg%++N9?s`$XA99|#}>BzV)f(b)Fb_ZTY}9uF1|L%036 zg4dGQpD!{m7Ef`{z{g(4F$W_Ds9o7T6?6gm-zH#-cs+6b?JC3C^-|vQ`vsLny1Dha zy6Myzk4c*;!Ew=vhY^~w^&!HMg#ntO-F}e);y(L+wVuDdFh6+g?eoGSbYgR9N|_k z)gXz$ZlrElYs6`sY9ef!YX&wSwm@3w+rnF)wYRsWb})6Eb^h0B+kMkD)f3TU*GJoT z*`BuwJK8Z?IsSM2=VbI`_;mQR?`+Vl=X}V#*JAi$z;fjBo7KeC z7*swgW21hfVry`#8@-O6ebVUd?BgHY9MT-&o&Zls&)Cn%FIX>#uIR7suW@g7Z})G9 z?~(Tfj~S0)m~NO#*qqo`I8!(UcyIA^3Frw{33CWNh}noYND@d)$*xFi$o0wbD3d63 zsfH=vQ(sf%)6mhh(Q?ub(s9%E(=*V+8Sdy)0AmaujHv)2CKJY0rbota%$ZDFEaJ=+ zEH%ucKtYzjz!VnzC-t-)NE=uLiUXoSeLzOm3!pM91IUt96y(Y(5AtAD0NJsMgLGMe zAOY4#;3H@f*aP|lj08CWSyTHOL~nP|Ri#%$JWmTf9%de=zNG}!Q|k*@)_@dx5(11^HU;dA{c zf~x)_q6n^xFn|*v9$}014Y1bw&#=;ZOIUur!c#8*Yp&;nEj;yj2xXW&!WQ-x5e@4{ zbigKRguFJnavAYyG)^j$%(x*NY(4RFtfy5eB9`qfn z8A6SVkJwHkM%Siv$Gv7y6L+(prV{6$HpIpG*^#BV`H&~h0@od?w z&usH567XWhNL z<$VC&7d>`9aA7)Oq+;D-;^BP9_Q0jZ?ZW$o$3Q?z;7Is}Fo$S`XzuA=o{%(_RETVf z3`$N-VL<_17p}|fsSk_7D2XLmR(j`mP}TXC!J^o^D0P( zIRG@vbOE$|;-4ADITk3R97{1EmKo>iBxb@u!Sst>nQ@rT0dPzEih-H-J-s;18#+a5 zZ(0SaXEb7zJk$V+W6BG%3W{D*NAg$_0y1slY?2#75#mgO3PL%2UIHX;BAyY>1TXB#l|+au~8chqr1C9N-?mz z_1a#$y|!1qc6Zlj5fX}^VA2g^th>A8dmo;&cLSc6v7Mds{(tq?ztip0wrQ%>11b){ z8R8WjS*DCHSuWv=PKbDdmjVv26Y%oLI1Cnn&0wT3nY6`p0rk{`g7R_PJ}Me>83`PX z8Cp6#b>QjXo<3Fo>z)%AFbc2zAyOd z*bZy`^X=1@n_vHF-q||m^U{{gPjkP-G%aq9Z~XgnQNz_wYwCYA-Tdg#M6Ii8jIDd$ zaH-a(0a1IS{%lQPy?@Q;k3XtceLPU@`?08+U*}WZQU|YoQ3t7hP=^JMk=0-8=2wgB zZdV6?lvQv2SXI;Y@pnyTeN}B!y{xvP;ZB{bVbRAYjS2M|nn(>LpIjT001sSpbMdFr zFZ(`kZfS3R*&5hl_4QenSkhd>e`wXmPCrN9aYMU5fvI?Z;90oK84d@MfP^J!d0P#k&nVvQF)Q)VmhPT z;!0x9#J`J`CBWmelO`wZO+K9XB;|2Zb821kx3s#Hy7Z^1*E5c#t;w8`9-QTzF_6`q zu{nD|rYT#Hxg%$F7CmP$D_s9fb+s~Sh@I| z+FWt=uH28=(Yf2RhjN0lH|6}uGG@=u+Lg`76lBfKEYJFuaX-^9gP*ZEJuu^a+SGJT z>XtNg>e1Aolz&s=laD6HC2dV2C(cMjB!na|yWkf`T zHtcQK-q5g6ZSeKrRY6vAN8r4`(18E>@AcF9w)v#_ICyXK%JFJ%z++(c;{s~y`hx_2~hWa@~=aNMwE&~HdS;58`k4<4}fW%qmc zt?Vo9z1e%DhuYKG9n%xk{a^Q)E=v~#*i%k+!n?dXA9c2NOa`0*@XpkZ?haJPvkvK> za~+I7$2wSlPIqYj-0N`fXzM8L(082a$OQ24|2p$Jm7Ud{E4xy<_+7ud_H=LT_U;Mn z>FMG1T!u2&!)4%z`@{j=xpd_htm$v5X}fGQi&uu1vwRAXJU6aA9a2}_#fe? zYrm_|UF{AfVMrcc{$6CCD4!6&ct8Ju$N)Uqm#hzh2lt1tLmr2H3|kvK=WfTB%t zosnN-uSOh+nH!!HO$^gS4u(97I2&9Qo)=^eRRul>c^HrvJjH*Utn=L-c-IFRkPq-v z9M5pyw+f=grN{jSVY*uqe!!&{H&F zW2iHTg~&KK6EO-~3qJx?!Qvo?pbQWea@GC_RB21Mqk(#^%e>8U70{|IGqxEM4ciP( zK(2^L%hUE~UTHq7cc|Z~N>#6vfyy@u2StNiA?pNm(*h|=f{_ZvnG%y|o7i1cC(0Cp zL>mONh0pmP1u~w$Adh#Af1m5lM{*zW4s$YiP!5%QnSF*E&z{5`XJNRPSxnA6)>lpx z>kS9Xdd{&j-*C*#uN+4fi$iANxs|Lb+*7RU++LQ98^)f+y8syB;hb{*ISz$S=I#;v z;f4qg^O(Y7{wtA};D{I`Tr1HE7fH>cB{H03t2{}1L9s^Gtb8Tct1OCK%|g``ZKqnN zE7Ptr4C)xh4Td#lg2`xUH(#?IvF3m(>>*SY(f*0M&!~&p1|jwJKn0>dcy{oij70&6-s|t$$Yd^rG1proWsmoerIo zH)Hyo(=)Ej`8lIwj%9{^PDmATZb?=2+(lKXbN>RyhN{T9tE$}Q&a6_;Nw4}b$F=JG z9PW(CbN)BOKKrj3&u4qjD4hL$`p~SE(-+P%OdFYbbXw_5$h0?AyQdcks`Qu&8Tn$m~G-%Cyuou0I`Fuypa zKwhZGyHM~vHzdC*r#e@Y6`ylD^I4W_hI8iSw9VAxG?NZ4A$uqT8I1($wHh%^f{b&_yN5JZ3RC9x7us$KY(g*&@3{G zjLs$$;K)tT?bNN*)N3B9z-obFmNHXbE59lAmBA!eB>P0}z#M!cyu{BD!~>W<4Zyo^ zvKMfcvm)3TOgPJ*p=7wxg>({4L5l*`_DX8_#NqJ;)UT8~<9LdMvSqBCLLaRkTQr(D zCLU=SJvB0KGS3ygx^$v_Vk+$|Z8H5ieJmiFPzfB@->!Y`)$WbN>%_YNXSCOQn)h^HvTuOD!cQ1b8}O3670BfZ3dV%? zguD;i5>_4oj~I=-6uB_kHF_}SOiX^9F7{FUtayFG%Y?inW#azi=;Svki&A=152y0e zuBR!}AEwJQ?q@JEFJ^wv+L?7HyE1!ij#o}-E+uCy_gwCwyrev0eox-B{6+bp1@!#8 z1+xm^fJb3vp-rB3a?;BA=quMR`S!islr(D_UMu4UCsXvx}}4TYEIN!0PD{VKNH>_N(w6qc@XkB$UiuSe2aWD0265U+v~r? zm*v~!UF?JRdhE4=WFu93%p}^}p1YU08eIP)l(-ByU&edj=(q)#aO{2bb|*Hn1r_Fq zLT-Z3LDa*ZIzXU0=rnK{hnW4WuK-V5 zPk+v+qurx_p14cvq&}J8j@MEV~4U)S%_Z|M8ncfN09UsPXkUtce;_dxI4-Zz{#XSza-aW=1Q@5sv-mUNH=mzzE>2~g|?+yjfgyP=T?sdK0-Pd|K z-M@N4JpeP@liyd_bF%MX&tTujp0Ivx&p-Vcy{!I|y`=+`-j4%Gec^*w`yLHi`T_wg z_QepRKWTWuz>i_tz~YfLgO(Bf;Dga~L*-);!^W}h;kT6CBWuUQM#HG|Q7QG#Si{5; z%1K(-_(HmVJeko+bzwfA&@oTaCRiKkzu0pb4V+Tu|F}7<$GkN5J$@?Zjv#}3Pngep zESkc9C0;0~m24KaN{@?rWcS2$`6r1|L6JJB%rcVNR}rHDLRYlwR10($)m!yVnv({O z_O215uQsI`ewddUM=hsKLhA>!!8U4f09mchV1GLik`MBME(QC+_CtIeu0o0M*Dx%i z*#Qjj8*;}X#3*vy@iU5wyp0|~?QrTwmtk4}CioSGfjy6XjN6P`;#}hFiYMXQ@ErVl z!V3c0Wu?ng7m{nL>krp&uIt^3+>q|AZui_1-6K5ixxe**c!U!ddE6$x^Kc-UJXVo1 ziJwW^iAc|<#3`Pg#8aMPVuL4`MEAs!ES`80&dZ5J^s*5>ym&;k*AJr3^A2&?^Dp9S z&nV(9PrgSQkaHy?<$2sD(cH60+uR3V1Gdw70K>#doFrHqT82TO`A$gG2pS4}Hgt%4NEUpD<4=bo z#8Vg=z8~5FtAreblE5htx}62OZ#!w9Zw;|ITUsq&O*74#j2xqfVY8uGr_(Lb?$O%R z2K8;#4pp*Jrs$Hdmamjir6$QV$$#QD5m}TX{3v+Aui$&}=-hLh9UL$l&ECm;%#_lz z8LMeyG$wWT#FB9ml`-~(vT}6Qm}D3~x@V|$7&*9Y=zf3XV0s_Ff3)Xi-+}JUyA?Sb@MrwTso!tD@A`G3eZ$YyZEFCJ$zR{IzW&{w*!oXfe9Mh* zsbA{8PHG-&UHI9db>FAdmWNGSzw|bKXht*|0iS%?=kxUsJ~2Mpn$kb6Xu4I`+X$;G zZroM-wLw)=)Uczbryf$Xtp0kn`D0S`-H+oRCVf2pL0^~u;awg0!{)j_?^EmUyhqgS zd{3*L_rAHd`2CaG^!Jx*Q{MkuoAv&5ZN>ZZwJYA=t3CR@uJ(WL2W#o?&9$B%BI_1^ zSW)--!{a(|wWMxob@s>K)z^XiCDX^cnpO2_wPW?)YiBnssG|Zn^!mmpA0bV%>K`}Z z8>W03Y5;wH-1zzP-ll(=7krxfrTBAbOKLNsHR+48HNA!XwWO8)ZShx5+re+j_GfK~ zAEWIdKk+}N{Hg+QlDofZ|Hyu;J4!k-x?Xgi?#6Y~dJgtv_NjXB0qi7VPzvaQwhf7g zZNsZZ&yBLj0w}A;>&GS38528cG8z=9KQA$(S!8w(yNWHfa)#=QilNTa{H6V(mFiOU>kO|9OrwWsy7{2F$wISu*-~uV>?iGQ zpdJtsf`d$gPJ-@%ZHGN_xZ%(WuZ9mIz9FcNzZ}PqZO9H(HR?V3I{IIyElzVV*%%_$ zj_JhKVYlNJ<8aOv++F8$&c1j&{u2H)-iX)YXAr6g_X$r4G=hkLa|v`wa+%^%;j-Rk zmdgQ`87}|0Rp>{^e^4(SFCm{HZaChAUqKvoI00V{+w71I zt%SiLLC_A65`4=35tI%5r(#RE?Glh=mtZ<%?lkzDmgzqm6xtd3eHykFtlq3SrZlLa ziUZ2SGP~R)IV9UAwn#+6J>q2ojc}B=Sum9=;{9Z=;^wiK>`#m(tTZ~E(J--$o<-$P zv{5!wE5|Gp+Q`|lZNq`1n4!1w5AQ8XpPFI9gT;Z+!~2ZEe%bLTN_q3 zMmG=}RrNy+t@XDXuGg<`*jZoLu%te?VOqU&Lq$ETp|T#-Fs~i~@F(sKXX;}bKGaWb zpx5tdaBq0iFuOt6aIYb@QPyyvv7~XZ@nvID6QSu|)A1(fPxhuWpN@XQea3#i^ZC)| zgyx*)-ey+wt}p+62?cl-MhmF*QEPkahOZaD=6qZIjnr1yW@!s+7qq*6r+#<*G5Q1c zlll|!OYn>E+wwc;k4Hy#M@Hu&0MR(sb+x;?y9+=v(0%@W)BBh8-x+u@ARE*S77tGv zel&7-1U6!hpY5}87=NwG&ISAsR0)hD$wjgx-8?mq)hk2NhZo-q%ZVAc}LUR%9Y zVh^;>1D^(cgGj(3unOpLhbJ%^+~5%JSb{i+Jn#4&)sJ*=!l839#ZFtX+c9@=cd@O` zEjTKk;;bY{@m64NL)@$`HaDZI%3TH|0gk(WCANCpA>Acz^8A~W1|V5RFR0fC?-sA6 zKHI$EzJ5MWe82gm`z`YQ?x*lA1yHXxe~VvIz)t_W0Rn$(!1RDwfz<&|0?~n@z=eTf zq~{Inv|vLc14k@PhOy8%8C?PX{j6| zohA#HER>du*8@pG2gNspS42Mq?}Y|_k06e(=5OSA^WSmHcskC0ZXu_ZbCs=R%UFr* z3f4Z>JLYdDiRsNe#n{2H(Ff^!=;?Gb?FsD|&5cH&ott<*fu1OsI8S8*86Ib-ugBA= zh2yeuCgtV$Ny>WQ>N9biI|doQKQ=;HGFC$g8M{i+j2@)?8Qlb^2v<-}k1nNb1CA?4 zH&Pak9stH=%JR|olr5u!l;fi|%EQsv@%GWxC^B-_Xf<0WBU@q^sFc-i-q68Pk{=x$i02P#aiC4=4B`f5y(v^w= z*?Q#y`A*ed#R>I&sJfhPO#;G zR@rxhKZEKZZV)weD>Tue8@3%D4gUyK$OcE5V7HKpOQR;NA9M5QHVK2L4R$WBem=ty~&J|o2^y)XGlT4^#P zwIL}bH6ZC|%8|t1$>Rx#KBxqyb#)rp#kDDDc7`rceJmzN9c=Y?o z!Kl`V?~%X5-$itUT@L>dx+UyW$fVGh!EPZJgXlrq$dAZV1D6H{2M_~vejR>aefRjD z@(J`Q^Zw}t_gd@OLV^L;*Y`X^Jd)f8-F~_ra-HK6>cYc!;kP+&b+%z$aHlYJ7=qI* zr#mPuDg=22`5uwvm<1n#|AOswm;)uj1mG`_{h(D~f*lL`pY@Zi+_KIpG5K098;49e zhKokIZo1*A)?L3`!_)?=KWJpiQ|cDQO4T)asdA?*L9s|mmQR*=$qL0@(n3+7WU??p zyjV~!+QVNjyvw^H=-__mL%AS+F{cQ?{4R2NECFW{tCaJBSeFLM4Uc;!SJ21T&%b1rKznKJP7V93fiIv1EU=Oo~*+<#iIhh?M5KmSL|u(|5iNWBjq`ZXOunbP!zEPlqz?6J&? zIqg|nbDw9I~@0$}%V%PRpK|KBpViq^863R&5* z3it8@6{+QyDyEk|tXNt8vSLg5>x!M_&nmW;->%qL{%^(N^1my}%4b%@l*d<~%Mlgq zvXS!IvWMjd%T|>al#u~$V7#op^uMydN;Aq_N|~h%B}YoIPIF2()D=ZYp5LyEo@Z7GZ^8Y{R}m{njexS7AWK$+K=Uzmr@zW^lfjO4t^CFLk` zW@jhl{Fk*k`)lTlEO`bslbC_d%uY|uSd_LneNXB?X%~UKo+rtlQfia>lUoycNxu^G ziJkGV#O^q3LT9X7{LdKAxYlT|*pE?OF)t&D(YGTAQU8XcBX@^EBUXkQ!ls5uLeqj7 zApt@C!ANpjkTRf_Jm&v2u+{Hczzg3K{ug|9`R(!k%Xfv>0-s9HO7Bckp;tJ-p_4q~ zNND$PqTMyfL+29ct^k|_5_}M#jSF{?&)%PkOVi>1=)Z+&1rY1LWP))}@H zwrbm`&BH$3{*S%eF0)SqEdvdMMnFrzGr$sXFZcjt1_Tcsg}j6=gBHSMP$ujk>>mfb zLlFGA!*6&ld<$Y6?&-J(@!gT+_&4&iV*qL?vJZto9Yw!GB|FVS3!Pw2cQGHFW?+|K z9C29e7u*-@cIQpF2)w^D1K;O-jc^QK;gUiCxe5riuD4y*xJ_{-x!YYk+&{SO^H>hN zTP_|W#BUx)0oF9y(~mUd*-6^(wcFF*JJ74myTfaR&)?o)U!u=l-_Jg&eoK7207cny z|9gIF|4jcQ0Ym=Sz>NVn0-=Gvd-4-rQrwGq_F z*8@EeE=NVi)JLs~8Hu_RqlxN?!9?3)$k7q8 z$qxXIDDxZG%uxVUJ~I3HjjqTShZcQx&ZAhZKCFfrUX!UFIEMrcPXn>mlP+I@8zEqy)uko&rjJv11KLG@dIYtLMj1ayyD*+i zn>?I&G7^Li)ct|Oj*iIo#Orh|pk(6 OWU^GmxCI`wG{ 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 FvD* znH?p>I5sDQNl>w;3R2Y!L8h85$W?RM=CdtiTg0}6^Otg&r9zTw8Je4AB!Mgw!opG^ zz~&`~%OxxlvZ$f#!hC#>bGhHy-2Y4-$28$F+W3dUc#^tC3s;3-g!3rR{t)^LhlHPn z-Be&(g--B2+u}@a!L=I+GkEOd(WVU-DhU11_;eEr@(gkdP3e@?7PLZTLC(1mUNg5c znb$)D$va7Sq4c8hv@5SG-%{^Af)9F=74jK$_y?6sm0PI>SCSw$hZXY#G@*m35xXgy zP%HkZtg9>oN3?*lkP^GNk{6ZtDyYch{e6eO^KWX6tBP~D#P%z8DmK6eU#OT%1v(xT z+(1PaD$*vLsw#dUL#3F)rbvMtDJa~0l0L}4$p6AaeNld0egF>XdihrQT=a3HF)4V_Zz$5Q72lO zHUAg5lu5L3{A;;47-Hdk}NZ1-Atc1ede#pBY>l9L40L z4|Jyv!Tva@yD;}?5iA|7!^ER<&`Nct3g!()16F$W>fl!xQf~smz~g{}8AvMAkN3FO zU(oUYo67ATT%8*bs4n5xKFc)V4_3rS12b{9Pk}@=fhp^lz|O!(W+lS|+gUSjqt4q3 zgJ&Dg?rnhqoHvk3!N9;S&fCLOVLvm3!}#Hk2S!mDjtiVe4{|jyJ#ZU#&qEXW4%x6vpL_8$c;2kEpj{_x`H&kJ6Qy<<{Yj}IzgWBK_W;zptMPXf4 z#wFGuxEEH?8D4|Cyb7;_vxAwz)%0+72Th?f!9t1RS+=>d&|X*G}4`PPsvd@58X{1@Jq)KD*G5|hF?;U86^LT<$heGo^;UzrAP3+Ikp z2v;Lbq9gDB=!iM8B2tdjr&f`NRM{C!#njRL@SN^Nnnu$jgQ&z8L`z2Z&5Llb!uDj-u^+DcQyM`!9$pK^Vpb*bB6fZk3K>RkK~{K)snF^GR#V^2r9sn#q>2 zPdP2yDtk{)P>}m%<=6vtmN$cyJXXF}z70+NJ$X>>ljl?9WB=A%F#;C(dNjaSPy(ka z{0f6IpR%g5xv~eH$T_UC4=L}XA@-rN&npyQU)_{d^$6B_YlS<)MUD@jgk1DA8{&u` zPHNP8T=7>`+tF#>R$0VMRSnU^s<(=`MEprS#f0M>D_$ji(lY8Sv7_3ko{BGPr@FKH z0pw`Uw=}7%>N-q2`lFj(tSJU#x|!ysW~e5nS)wVZJ)&t$TEtK?9am^g+LPMy+E>~( zWN3`k=F=_L)<>h=U-y@G0Z9`FbXwg*T?w57Uz|zbPgg-dTh~s%4ZiLL-D>?S-5+|N z?y26S_vlOM&4woW@`gTW!l$AQU$0+eIIiDkxUauu_@e)0pz&!)fxBDQP}SJj(9YPC z{_=QOrArMvjJpgMjik;Q9~qp+RD%lUc7BuDSj|+_*v9muaiHmc#_6U$=sL&44-7Ybk!&^KQ`LTpJBxK(8()I|1&3>el{02jWJg?%{4bLtuwb{e$v}?#{3Jq z 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 DCvxi1 diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index e3e8074cd..557b86877 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -2,7 +2,6 @@ import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@o import { useRenderer } from "@opentui/solid" import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" -import * as Sound from "@tui/util/sound" import { go, logo } from "@/cli/logo" export type LogoShape = { @@ -563,7 +562,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = const [now, setNow] = createSignal(0) let box: BoxRenderable | undefined let timer: ReturnType | undefined - let hum = false const stop = () => { if (!timer) return @@ -575,10 +573,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = const t = performance.now() setNow(t) const item = hold() - if (item && !hum && t - item.at >= HOLD) { - hum = true - Sound.start() - } if (item && t - item.at >= CHARGE) { burst(item.x, item.y) } @@ -605,8 +599,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = onCleanup(() => { stop() - hum = false - Sound.dispose() }) onMount(() => { @@ -626,14 +618,12 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = setNow(t) if (!last) setRelease(undefined) setHold({ x, y, at: t, glyph: select(x, y, ctx) }) - hum = false start() } const burst = (x: number, y: number) => { const item = hold() if (!item) return - hum = false const t = performance.now() const age = t - item.at const rise = ramp(age, HOLD, CHARGE) @@ -655,7 +645,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = ]) setNow(t) start() - Sound.pulse(lerp(0.8, 1, level)) } const frame = createMemo(() => { diff --git a/packages/opencode/src/cli/cmd/tui/util/sound.ts b/packages/opencode/src/cli/cmd/tui/util/sound.ts deleted file mode 100644 index df8b4dc2d..000000000 --- a/packages/opencode/src/cli/cmd/tui/util/sound.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Player } from "cli-sound" -import { mkdirSync } from "node:fs" -import { tmpdir } from "node:os" -import { basename, join } from "node:path" -import { Process } from "@/util/process" -import { which } from "@/util/which" -import pulseA from "../asset/pulse-a.wav" with { type: "file" } -import pulseB from "../asset/pulse-b.wav" with { type: "file" } -import pulseC from "../asset/pulse-c.wav" with { type: "file" } -import charge from "../asset/charge.wav" with { type: "file" } - -const FILE = [pulseA, pulseB, pulseC] - -const HUM = charge -const DIR = join(tmpdir(), "opencode-sfx") - -const LIST = [ - "ffplay", - "mpv", - "mpg123", - "mpg321", - "mplayer", - "afplay", - "play", - "omxplayer", - "aplay", - "cmdmp3", - "cvlc", - "powershell.exe", -] as const - -type Kind = (typeof LIST)[number] - -function args(kind: Kind, file: string, volume: number) { - if (kind === "ffplay") return [kind, "-autoexit", "-nodisp", "-af", `volume=${volume}`, file] - if (kind === "mpv") - return [kind, "--no-video", "--audio-display=no", "--volume", String(Math.round(volume * 100)), file] - if (kind === "mpg123" || kind === "mpg321") return [kind, "-g", String(Math.round(volume * 100)), file] - if (kind === "mplayer") return [kind, "-vo", "null", "-volume", String(Math.round(volume * 100)), file] - if (kind === "afplay" || kind === "omxplayer" || kind === "aplay" || kind === "cmdmp3") return [kind, file] - if (kind === "play") return [kind, "-v", String(volume), file] - if (kind === "cvlc") return [kind, `--gain=${volume}`, "--play-and-exit", file] - return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`] -} - -let item: Player | null | undefined -let kind: Kind | null | undefined -let proc: Process.Child | undefined -let tail: ReturnType | undefined -let cache: Promise<{ hum: string; pulse: string[] }> | undefined -let seq = 0 -let shot = 0 - -function load() { - if (item !== undefined) return item - try { - item = new Player({ volume: 0.35 }) - } catch { - item = null - } - return item -} - -async function file(path: string) { - mkdirSync(DIR, { recursive: true }) - const next = join(DIR, basename(path)) - const out = Bun.file(next) - if (await out.exists()) return next - await Bun.write(out, Bun.file(path)) - return next -} - -function asset() { - cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse })) - return cache -} - -function pick() { - if (kind !== undefined) return kind - kind = LIST.find((item) => which(item)) ?? null - return kind -} - -function run(file: string, volume: number) { - const kind = pick() - if (!kind) return - return Process.spawn(args(kind, file, volume), { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }) -} - -function clear() { - if (!tail) return - clearTimeout(tail) - tail = undefined -} - -function play(file: string, volume: number) { - const item = load() - if (!item) return run(file, volume)?.exited - return item.play(file, { volume }).catch(() => run(file, volume)?.exited) -} - -export function start() { - stop() - const id = ++seq - void asset().then(({ hum }) => { - if (id !== seq) return - const next = run(hum, 0.24) - if (!next) return - proc = next - void next.exited.then( - () => { - if (id !== seq) return - if (proc === next) proc = undefined - }, - () => { - if (id !== seq) return - if (proc === next) proc = undefined - }, - ) - }) -} - -export function stop(delay = 0) { - seq++ - clear() - if (!proc) return - const next = proc - if (delay <= 0) { - proc = undefined - void Process.stop(next).catch(() => undefined) - return - } - tail = setTimeout(() => { - tail = undefined - if (proc === next) proc = undefined - void Process.stop(next).catch(() => undefined) - }, delay) -} - -export function pulse(scale = 1) { - stop(140) - const index = shot++ % FILE.length - void asset() - .then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale)) - .catch(() => undefined) -} - -export function dispose() { - stop() -} - -export * as Sound from "./sound" From d0b8ff0f222e37be9c9eed288c61f3b86d6e42c9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 13 May 2026 00:57:32 +0000 Subject: [PATCH 158/188] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index ce8cded23..935e77f06 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-MUHog06sZEi6bXR1m8exdkjSNW9bHEv9bPQXACJ7SFw=", - "aarch64-linux": "sha256-3dwdZ3It++OsdGT8xMOQ10Arz8eeODp/LXOrI4DLEhY=", - "aarch64-darwin": "sha256-TmUPGDCewjsrT13npVH6B55J43NKKut67p/HgPJpQNM=", - "x86_64-darwin": "sha256-j8I7t3MZoUQUMFRWyaFO75TRbAw5TauSZAa4yKOHFMA=" + "x86_64-linux": "sha256-UCX1xw1DHJC/qM7dk2lD4gQbrtzg3LhwtRNQuwDVGH0=", + "aarch64-linux": "sha256-IgQmkCL+R48B15Kc+Fc9HzcKZeQgluEePwGNKL+QLqk=", + "aarch64-darwin": "sha256-7Kr4U/ueWVzSpOae4CsuVq5EwVfvc3bxOVkbdEkW0EU=", + "x86_64-darwin": "sha256-HyLQUa0D1gSSoMLO2gXraU3WTXEBnvHmjqY5B54Eg3w=" } } From b7c6fa611fb40bc12d7176c8bb092a816fdb1e99 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 20:59:02 -0400 Subject: [PATCH 159/188] effect: add RuntimeFlags service (#27181) --- packages/opencode/src/effect/runtime-flags.ts | 27 +++++++++ packages/opencode/src/plugin/index.ts | 15 +++-- .../agent/plugin-agent-regression.test.ts | 7 ++- .../test/effect/runtime-flags.test.ts | 55 +++++++++++++++++++ .../test/plugin/auth-override.test.ts | 2 + .../test/plugin/loader-shared.test.ts | 44 ++++----------- packages/opencode/test/plugin/trigger.test.ts | 7 ++- .../test/plugin/workspace-adapter.test.ts | 7 ++- packages/opencode/test/preload.ts | 1 - 9 files changed, 124 insertions(+), 41 deletions(-) create mode 100644 packages/opencode/src/effect/runtime-flags.ts create mode 100644 packages/opencode/test/effect/runtime-flags.test.ts diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts new file mode 100644 index 000000000..5f07dc6ac --- /dev/null +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -0,0 +1,27 @@ +import { Config, ConfigProvider, Context, Effect, Layer } from "effect" +import { ConfigService } from "@/effect/config-service" + +export class Service extends ConfigService.Service()("@opencode/RuntimeFlags", { + pure: Config.boolean("OPENCODE_PURE").pipe(Config.withDefault(false)), + disableDefaultPlugins: Config.boolean("OPENCODE_DISABLE_DEFAULT_PLUGINS").pipe(Config.withDefault(false)), +}) {} + +export type Info = Context.Service.Shape + +const emptyConfigLayer = Service.defaultLayer.pipe( + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({}))), + Layer.orDie, +) + +export const layer = (overrides: Partial = {}) => + Layer.effect( + Service, + Effect.gen(function* () { + const flags = yield* Service + return Service.of({ ...flags, ...overrides }) + }), + ).pipe(Layer.provide(emptyConfigLayer)) + +export const defaultLayer = Service.defaultLayer.pipe(Layer.orDie) + +export * as RuntimeFlags from "./runtime-flags" diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 68d47916c..e87f6db23 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -9,7 +9,6 @@ import { Config } from "@/config/config" import { Bus } from "../bus" import * as Log from "@opencode-ai/core/util/log" import { createOpencodeClient } from "@opencode-ai/sdk" -import { Flag } from "@opencode-ai/core/flag/flag" import { ServerAuth } from "@/server/auth" import { CodexAuthPlugin } from "./codex" import { Session } from "@/session/session" @@ -28,6 +27,7 @@ import { PluginLoader } from "./loader" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" import { registerAdapter } from "@/control-plane/adapters" import type { WorkspaceAdapter } from "@/control-plane/types" +import { RuntimeFlags } from "@/effect/runtime-flags" const log = Log.create({ service: "plugin" }) @@ -112,6 +112,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const bus = yield* Bus.Service const config = yield* Config.Service + const flags = yield* RuntimeFlags.Service const state = yield* InstanceState.make( Effect.fn("Plugin.state")(function* (ctx) { @@ -148,7 +149,7 @@ export const layer = Layer.effect( $: typeof Bun === "undefined" ? undefined : Bun.$, } - for (const plugin of INTERNAL_PLUGINS) { + for (const plugin of flags.disableDefaultPlugins ? [] : INTERNAL_PLUGINS) { log.info("loading internal plugin", { name: plugin.name }) const init = yield* Effect.tryPromise({ try: () => plugin(input), @@ -159,8 +160,8 @@ export const layer = Layer.effect( if (init._tag === "Some") hooks.push(init.value) } - const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin_origins ?? []) - if (Flag.OPENCODE_PURE && cfg.plugin_origins?.length) { + const plugins = flags.pure ? [] : (cfg.plugin_origins ?? []) + if (flags.pure && cfg.plugin_origins?.length) { log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length }) } if (plugins.length) yield* config.waitForDependencies() @@ -285,6 +286,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(Config.defaultLayer), + Layer.provide(RuntimeFlags.defaultLayer), +) export * as Plugin from "." diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index e2dd8a5f7..60d59ee90 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -7,6 +7,7 @@ import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" import { Env } from "../../src/env" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Plugin } from "../../src/plugin" import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" @@ -29,7 +30,11 @@ const configLayer = Config.layer.pipe( Layer.provide(AccountTest.empty), Layer.provide(NpmTest.noop), ) -const pluginLayer = Plugin.layer.pipe(Layer.provide(Bus.layer), Layer.provide(configLayer)) +const pluginLayer = Plugin.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(configLayer), + Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), +) const agentLayer = Agent.layer.pipe( Layer.provide(configLayer), Layer.provide(AuthTest.empty), diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts new file mode 100644 index 000000000..5c9518a27 --- /dev/null +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -0,0 +1,55 @@ +import { describe, expect } from "bun:test" +import { ConfigProvider, Effect, Layer } from "effect" +import { RuntimeFlags } from "../../src/effect/runtime-flags" +import { it } from "../lib/effect" + +const fromConfig = (input: Record) => + RuntimeFlags.defaultLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input)))) + +const readFlags = RuntimeFlags.Service.useSync((flags) => flags) + +describe("RuntimeFlags", () => { + it.effect("defaultLayer parses plugin flags from the active ConfigProvider", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe( + Effect.provide( + fromConfig({ + OPENCODE_PURE: "true", + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", + }), + ), + ) + + expect(flags.pure).toBe(true) + expect(flags.disableDefaultPlugins).toBe(true) + }), + ) + + it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(RuntimeFlags.layer({ disableDefaultPlugins: true }))) + + expect(flags.pure).toBe(false) + expect(flags.disableDefaultPlugins).toBe(true) + }), + ) + + it.effect("layer ignores the active ConfigProvider for omitted test overrides", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe( + Effect.provide(RuntimeFlags.layer()), + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_PURE: "true", + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", + }), + ), + ), + ) + + expect(flags.pure).toBe(false) + expect(flags.disableDefaultPlugins).toBe(false) + }), + ) +}) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index 402d755da..adc66e48c 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -7,6 +7,7 @@ import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { ProviderAuth } from "@/provider/auth" import { ProviderID } from "../../src/provider/schema" import { Plugin } from "@/plugin" +import { RuntimeFlags } from "@/effect/runtime-flags" import { Auth } from "@/auth" import { Bus } from "@/bus" import { TestConfig } from "../fixture/config" @@ -21,6 +22,7 @@ function layer(directory: string, plugins: string[]) { Layer.provide( Plugin.layer.pipe( Layer.provide(Bus.layer), + Layer.provide(RuntimeFlags.layer()), Layer.provide( TestConfig.layer({ get: () => diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 1b6372390..6b1dd306d 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -1,4 +1,4 @@ -import { afterAll, afterEach, describe, expect, spyOn } from "bun:test" +import { afterEach, describe, expect, spyOn } from "bun:test" import { Effect, Layer } from "effect" import fs from "fs/promises" import path from "path" @@ -8,23 +8,13 @@ import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/f import { testEffect } from "../lib/effect" import { Filesystem } from "@/util/filesystem" -const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS -process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" - const { Plugin } = await import("../../src/plugin/index") const { PluginLoader } = await import("../../src/plugin/loader") const { readPackageThemes } = await import("../../src/plugin/shared") const { Bus } = await import("../../src/bus") const { Npm } = await import("@opencode-ai/core/npm") const { TestConfig } = await import("../fixture/config") - -afterAll(() => { - if (disableDefault === undefined) { - delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS - return - } - process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault -}) +const { RuntimeFlags } = await import("../../src/effect/runtime-flags") afterEach(async () => { await disposeAllInstances() @@ -43,7 +33,7 @@ function withTmp( }) } -function load(dir: string) { +function load(dir: string, flags?: Parameters[0]) { const source = path.join(dir, "opencode.json") return Effect.gen(function* () { const config = yield* Effect.promise( @@ -57,6 +47,7 @@ function load(dir: string) { Effect.provide( Plugin.layer.pipe( Layer.provide(Bus.layer), + Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })), Layer.provide( TestConfig.layer({ get: () => @@ -934,25 +925,14 @@ export default { }, (tmp) => Effect.gen(function* () { - const pure = process.env.OPENCODE_PURE - process.env.OPENCODE_PURE = "1" - - try { - yield* load(tmp.path) - const called = yield* Effect.promise(() => - fs - .readFile(tmp.extra.mark, "utf8") - .then(() => true) - .catch(() => false), - ) - expect(called).toBe(false) - } finally { - if (pure === undefined) { - delete process.env.OPENCODE_PURE - } else { - process.env.OPENCODE_PURE = pure - } - } + yield* load(tmp.path, { pure: true }) + const called = yield* Effect.promise(() => + fs + .readFile(tmp.extra.mark, "utf8") + .then(() => true) + .catch(() => false), + ) + expect(called).toBe(false) }), ), ) diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts index 18fe0e82e..94642fba6 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -10,6 +10,7 @@ import { Auth } from "../../src/auth" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" import { Env } from "../../src/env" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Plugin } from "../../src/plugin/index" import { ModelID, ProviderID } from "../../src/provider/schema" import { provideTmpdirInstance } from "../fixture/fixture" @@ -33,7 +34,11 @@ const configLayer = Config.layer.pipe( ) const it = testEffect( Layer.mergeAll( - Plugin.layer.pipe(Layer.provide(Bus.layer), Layer.provide(configLayer)), + Plugin.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(configLayer), + Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), + ), CrossSpawnSpawner.defaultLayer, ), ) diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index d4aaae4a9..bef860432 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -11,6 +11,7 @@ import { Auth } from "../../src/auth" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" import { Env } from "../../src/env" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Workspace } from "../../src/control-plane/workspace" import { Plugin } from "../../src/plugin/index" import { InstanceBootstrap } from "../../src/project/bootstrap-service" @@ -35,7 +36,11 @@ const configLayer = Config.layer.pipe( Layer.provide(emptyAccount), Layer.provide(NpmTest.noop), ) -const pluginLayer = Plugin.layer.pipe(Layer.provide(Bus.layer), Layer.provide(configLayer)) +const pluginLayer = Plugin.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(configLayer), + Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), +) const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) const workspaceLayer = Workspace.defaultLayer.pipe( Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))), diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index b408f7ef1..6447c2fe9 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -45,7 +45,6 @@ process.env["OPENCODE_TEST_HOME"] = testHome // Set test managed config directory to isolate tests from system managed settings const testManagedConfigDir = path.join(dir, "managed") process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir -process.env["OPENCODE_DISABLE_DEFAULT_PLUGINS"] = "true" // Write the cache version file to prevent global/index.ts from clearing the cache const cacheDir = path.join(dir, "cache", "opencode") From 31e2d72bf7634bb7089cfc8f73300a27b0134100 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 21:09:58 -0400 Subject: [PATCH 160/188] test(worktree): scope created worktree cleanup (#27191) --- .../opencode/test/project/worktree.test.ts | 93 +++++++++---------- 1 file changed, 45 insertions(+), 48 deletions(-) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 3975db1ae..1de760014 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -38,12 +38,31 @@ const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () { ) }) -const disposeWorktreeInstance = (directory: string) => +const removeCreatedWorktree = (directory: string) => Effect.gen(function* () { + const svc = yield* Worktree.Service const ctx = yield* Effect.sync(() => Instance.current).pipe(provideInstance(directory)) yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx)) + const ok = yield* svc.remove({ directory }) + if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${directory}`)) }) +const withCreatedWorktree = ( + input: Parameters[0], + use: (created: { info: Worktree.Info; ready: { name: string; branch?: string } }) => Effect.Effect, +) => + Effect.acquireUseRelease( + Effect.gen(function* () { + const svc = yield* Worktree.Service + const ready = yield* waitReady().pipe(Effect.forkScoped) + const info = yield* svc.create(input) + const props = yield* Fiber.join(ready) + return { info, ready: props } + }), + use, + ({ info }) => removeCreatedWorktree(info.directory), + ) + const git = Effect.fn("WorktreeTest.git")(function* (cwd: string, args: string[]) { const service = yield* Git.Service const result = yield* service.run(args, { cwd }) @@ -158,66 +177,45 @@ describe("Worktree", () => { it.instance( "create returns worktree info and remove cleans up", () => - Effect.gen(function* () { - const svc = yield* Worktree.Service - const ready = yield* waitReady().pipe(Effect.forkScoped) - const info = yield* svc.create() - - expect(info.name).toBeDefined() - expect(info.branch ?? "").toStartWith("opencode/") - expect(info.directory).toBeDefined() - - yield* Fiber.join(ready) - yield* disposeWorktreeInstance(info.directory) - - const ok = yield* svc.remove({ directory: info.directory }) - expect(ok).toBe(true) - }), + withCreatedWorktree(undefined, ({ info }) => + Effect.gen(function* () { + expect(info.name).toBeDefined() + expect(info.branch ?? "").toStartWith("opencode/") + expect(info.directory).toBeDefined() + }), + ), { git: true }, ) it.instance( "create returns after setup and fires Event.Ready after bootstrap", () => - Effect.gen(function* () { - const test = yield* TestInstance - const fs = yield* AppFileSystem.Service - const svc = yield* Worktree.Service - const ready = yield* waitReady().pipe(Effect.forkScoped) - const info = yield* svc.create() + withCreatedWorktree(undefined, ({ info, ready }) => + Effect.gen(function* () { + const svc = yield* Worktree.Service - expect(info.name).toBeDefined() - expect(info.branch ?? "").toStartWith("opencode/") + expect(info.name).toBeDefined() + expect(info.branch ?? "").toStartWith("opencode/") - const text = yield* git(test.directory, ["worktree", "list", "--porcelain"]) - const next = yield* fs.realPath(info.directory).pipe(Effect.catch(() => Effect.succeed(info.directory))) - expect(normalize(text)).toContain(normalize(next)) + expect(ready.name).toBe(info.name) + expect(ready.branch).toBe(info.branch) - const props = yield* Fiber.join(ready) - expect(props.name).toBe(info.name) - expect(props.branch).toBe(info.branch) - - yield* disposeWorktreeInstance(info.directory) - yield* svc.remove({ directory: info.directory }) - }), + const list = yield* svc.list() + expect(list).toContainEqual(expect.objectContaining({ name: info.name, branch: info.branch })) + }), + ), { git: true }, ) it.instance( "create with custom name", () => - Effect.gen(function* () { - const svc = yield* Worktree.Service - const ready = yield* waitReady().pipe(Effect.forkScoped) - const info = yield* svc.create({ name: "test-workspace" }) - - expect(info.name).toBe("test-workspace") - expect(info.branch).toBe("opencode/test-workspace") - - yield* Fiber.join(ready) - yield* disposeWorktreeInstance(info.directory) - yield* svc.remove({ directory: info.directory }) - }), + withCreatedWorktree({ name: "test-workspace" }, ({ info }) => + Effect.gen(function* () { + expect(info.name).toBe("test-workspace") + expect(info.branch).toBe("opencode/test-workspace") + }), + ), { git: true }, ) }) @@ -239,8 +237,7 @@ describe("Worktree", () => { expect(normalizedList).toContain(normalizedDir) yield* Fiber.join(ready) - yield* disposeWorktreeInstance(info.directory) - yield* svc.remove({ directory: info.directory }) + yield* removeCreatedWorktree(info.directory) }), { git: true }, ) From b6d3fa09bc001d9bee624f03702ac573329ba6e9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 21:24:53 -0400 Subject: [PATCH 161/188] effect(core): add AppProcess service (Phase 1) (#27178) --- packages/core/src/process.ts | 207 ++++++++++++++++++++ packages/core/test/process/process.test.ts | 210 +++++++++++++++++++++ 2 files changed, 417 insertions(+) create mode 100644 packages/core/src/process.ts create mode 100644 packages/core/test/process/process.test.ts diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts new file mode 100644 index 000000000..f855459b2 --- /dev/null +++ b/packages/core/src/process.ts @@ -0,0 +1,207 @@ +import { Context, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect" +import type { PlatformError } from "effect/PlatformError" +import { ChildProcess } from "effect/unstable/process" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { CrossSpawnSpawner } from "./cross-spawn-spawner" + +export class AppProcessError extends Schema.TaggedErrorClass()("AppProcessError", { + command: Schema.String, + exitCode: Schema.optional(Schema.Number), + stderr: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect), +}) {} + +export interface RunOptions { + readonly maxOutputBytes?: number + readonly maxErrorBytes?: number + readonly signal?: AbortSignal + readonly timeout?: Duration.Input +} + +export interface RunStreamOptions { + readonly signal?: AbortSignal + readonly includeStderr?: boolean + readonly okExitCodes?: ReadonlyArray + readonly maxErrorBytes?: number +} + +export interface RunResult { + readonly command: string + readonly exitCode: number + readonly stdout: Buffer + readonly stderr: Buffer + readonly truncated: boolean +} + +export type Interface = ChildProcessSpawner["Service"] & { + readonly run: (command: ChildProcess.Command, options?: RunOptions) => Effect.Effect + readonly runStream: ( + command: ChildProcess.Command, + options?: RunStreamOptions, + ) => Stream.Stream +} + +export class Service extends Context.Service()("@opencode/AppProcess") {} + +export const requireSuccess = (result: RunResult): Effect.Effect => + result.exitCode === 0 + ? Effect.succeed(result) + : Effect.fail( + new AppProcessError({ + command: result.command, + exitCode: result.exitCode, + stderr: result.stderr.toString("utf8"), + }), + ) + +export const requireExitIn = + (codes: ReadonlyArray) => + (result: RunResult): Effect.Effect => + codes.includes(result.exitCode) + ? Effect.succeed(result) + : Effect.fail( + new AppProcessError({ + command: result.command, + exitCode: result.exitCode, + stderr: result.stderr.toString("utf8"), + }), + ) + +const describeCommand = (command: ChildProcess.Command): string => { + if (command._tag === "StandardCommand") { + return command.args.length ? `${command.command} ${command.args.join(" ")}` : command.command + } + return `${describeCommand(command.left)} | ${describeCommand(command.right)}` +} + +const wrapError = (description: string, cause: unknown): AppProcessError => + cause instanceof AppProcessError ? cause : new AppProcessError({ command: description, cause }) + +const abortError = (signal: AbortSignal): Error => { + const reason = signal.reason + if (reason instanceof Error) return reason + const err = new Error("Aborted") + err.name = "AbortError" + return err +} + +const waitForAbort = (signal: AbortSignal) => + Effect.callback((resume) => { + if (signal.aborted) { + resume(Effect.fail(abortError(signal))) + return + } + const onabort = () => resume(Effect.fail(abortError(signal))) + signal.addEventListener("abort", onabort, { once: true }) + return Effect.sync(() => signal.removeEventListener("abort", onabort)) + }) + +const collectStream = (stream: Stream.Stream, maxOutputBytes: number | undefined) => + Stream.runFold( + stream, + () => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }), + (acc, chunk) => { + if (maxOutputBytes === undefined) { + acc.chunks.push(chunk) + acc.bytes += chunk.length + return acc + } + const remaining = maxOutputBytes - acc.bytes + if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining)) + acc.bytes += chunk.length + acc.truncated = acc.truncated || acc.bytes > maxOutputBytes + return acc + }, + ).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated }))) + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner + + const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) { + const description = describeCommand(command) + const collect = Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn(command) + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStream(handle.stdout, options?.maxOutputBytes), + collectStream(handle.stderr, options?.maxErrorBytes), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ) + return { + command: description, + exitCode, + stdout: stdout.buffer, + stderr: stderr.buffer, + truncated: stdout.truncated, + } satisfies RunResult + }), + ) + const timed = options?.timeout + ? Effect.timeoutOrElse(collect, { + duration: options.timeout, + orElse: () => + Effect.fail(new AppProcessError({ command: description, cause: new Error("Timed out") })), + }) + : collect + const aborted = options?.signal + ? timed.pipe( + Effect.raceFirst( + waitForAbort(options.signal).pipe(Effect.mapError((cause) => wrapError(description, cause))), + ), + ) + : timed + return yield* aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause)))) + }) + + const runStream = (command: ChildProcess.Command, options?: RunStreamOptions): Stream.Stream => { + const description = describeCommand(command) + const okExitCodes = options?.okExitCodes + const built: Stream.Stream = Stream.unwrap( + Effect.gen(function* () { + const handle = yield* spawner.spawn(command) + const stderrFiber = yield* Effect.forkScoped( + collectStream(handle.stderr, options?.maxErrorBytes).pipe( + Effect.map((x) => x.buffer.toString("utf8")), + ), + ) + const source = options?.includeStderr === true ? handle.all : handle.stdout + const lines = source.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.filter((line) => line.length > 0), + ) + const tail = Stream.unwrap( + Effect.gen(function* () { + const code = yield* handle.exitCode + if (okExitCodes && okExitCodes.length > 0 && !okExitCodes.includes(code)) { + const stderr = yield* Fiber.join(stderrFiber) + return Stream.fail(new AppProcessError({ command: description, exitCode: code, stderr })) + } + return Stream.empty + }), + ) + return Stream.concat(lines, tail) as Stream.Stream + }), + ) + const mapped = built.pipe( + Stream.catch((cause): Stream.Stream => Stream.fail(wrapError(description, cause))), + ) + if (!options?.signal) return mapped + const signal = options.signal + return mapped.pipe( + Stream.interruptWhen(waitForAbort(signal).pipe(Effect.mapError((cause) => wrapError(description, cause)))), + ) + } + + return Service.of({ ...spawner, run, runStream }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) + +export * as AppProcess from "./process" diff --git a/packages/core/test/process/process.test.ts b/packages/core/test/process/process.test.ts new file mode 100644 index 000000000..1b46c1f1e --- /dev/null +++ b/packages/core/test/process/process.test.ts @@ -0,0 +1,210 @@ +import { describe, expect } from "bun:test" +import { Effect, Exit, Stream } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { AppProcess } from "@opencode-ai/core/process" +import { testEffect } from "../lib/effect" + +const it = testEffect(AppProcess.defaultLayer) + +const NODE = process.execPath +const cmd = (...args: string[]) => ChildProcess.make(NODE, args) + +describe("AppProcess", () => { + describe("run", () => { + it.effect( + "captures stdout and exit code zero", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const result = yield* svc.run(cmd("-e", "process.stdout.write('hi\\n')")) + expect(result.exitCode).toBe(0) + expect(result.stdout.toString("utf8")).toBe("hi\n") + expect(result.truncated).toBe(false) + }), + ) + + it.effect( + "non-zero exit returns RunResult; caller can require success", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const result = yield* svc.run(cmd("-e", "process.exit(1)")) + expect(result.exitCode).toBe(1) + }), + ) + + it.effect( + "requireSuccess fails on non-zero exit", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess))) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const reason = exit.cause.reasons[0] + if (reason && reason._tag === "Fail") { + expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError) + expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1) + } else { + throw new Error("expected fail reason") + } + } + }), + ) + + it.effect( + "requireSuccess succeeds on exit 0", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const result = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(AppProcess.requireSuccess)) + expect(result.exitCode).toBe(0) + }), + ) + + it.effect( + "requireExitIn allowlists multiple exit codes", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const requireZeroOrOne = AppProcess.requireExitIn([0, 1]) + const okZero = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(requireZeroOrOne)) + expect(okZero.exitCode).toBe(0) + const okOne = yield* svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(requireZeroOrOne)) + expect(okOne.exitCode).toBe(1) + const exit = yield* Effect.exit( + svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne)), + ) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const reason = exit.cause.reasons[0] + if (reason && reason._tag === "Fail") { + expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError) + expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2) + } + } + }), + ) + + it.effect( + "truncates output when maxOutputBytes is set", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const result = yield* svc.run(cmd("-e", "process.stdout.write('0123456789')"), { maxOutputBytes: 5 }) + expect(result.exitCode).toBe(0) + expect(result.truncated).toBe(true) + expect(result.stdout.length).toBe(5) + expect(result.stdout.toString("utf8")).toBe("01234") + }), + ) + + it.effect( + "result includes command description", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const result = yield* svc.run(cmd("-e", "process.stdout.write('hi')")) + expect(result.command).toBe(`${NODE} -e process.stdout.write('hi')`) + }), + ) + }) + + describe("inherited platform methods", () => { + it.effect( + "string returns stdout as string", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const out = yield* svc.string(cmd("-e", "process.stdout.write('hi\\n')")) + expect(out).toBe("hi\n") + }), + ) + + it.effect( + "lines returns the platform's array of lines", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const out = yield* svc.lines(cmd("-e", "process.stdout.write('a\\nb\\n')")) + expect(Array.from(out)).toEqual(["a", "b"]) + }), + ) + }) + + describe("runStream", () => { + it.live( + "emits lines incrementally and ends cleanly on exit 0", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const result = yield* svc + .runStream(cmd("-e", "console.log('one'); console.log('two'); console.log('three')")) + .pipe(Stream.runCollect) + expect(Array.from(result)).toEqual(["one", "two", "three"]) + }), + ) + + it.live( + "fails with AppProcessError when exit not in okExitCodes", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const exit = yield* Effect.exit( + svc + .runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0] }) + .pipe(Stream.runCollect), + ) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const reason = exit.cause.reasons[0] + if (reason && reason._tag === "Fail") { + expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError) + } + } + }), + ) + + it.live( + "okExitCodes allowlist treats non-zero as success", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const result = yield* svc + .runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] }) + .pipe(Stream.runCollect) + expect(Array.from(result)).toEqual(["only"]) + }), + ) + + it.live( + "without okExitCodes, never fails on exit code", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const result = yield* svc + .runStream(cmd("-e", "console.log('only'); process.exit(7)")) + .pipe(Stream.runCollect) + expect(Array.from(result)).toEqual(["only"]) + }), + ) + + it.live( + "AbortSignal interrupts the stream", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const controller = new AbortController() + setTimeout(() => controller.abort(), 50) + const exit = yield* Effect.exit( + svc + .runStream(cmd("-e", "setInterval(() => console.log('tick'), 100); setTimeout(() => {}, 60_000)"), { + signal: controller.signal, + }) + .pipe(Stream.runCollect), + ) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + }) + + describe("spawn (inherited)", () => { + it.live( + "returns the platform ChildProcessHandle for advanced use", + Effect.scoped( + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const handle = yield* svc.spawn(cmd("-e", "setInterval(() => {}, 1_000)")) + expect(yield* handle.isRunning).toBe(true) + yield* handle.kill() + }), + ), + ) + }) +}) From adccab597068be3aed3f0d661bbbfba74529ccf3 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 13 May 2026 01:26:12 +0000 Subject: [PATCH 162/188] chore: generate --- packages/core/src/process.ts | 12 ++++++------ packages/core/test/process/process.test.ts | 16 ++++++---------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts index f855459b2..2da8eb834 100644 --- a/packages/core/src/process.ts +++ b/packages/core/src/process.ts @@ -144,8 +144,7 @@ export const layer = Layer.effect( const timed = options?.timeout ? Effect.timeoutOrElse(collect, { duration: options.timeout, - orElse: () => - Effect.fail(new AppProcessError({ command: description, cause: new Error("Timed out") })), + orElse: () => Effect.fail(new AppProcessError({ command: description, cause: new Error("Timed out") })), }) : collect const aborted = options?.signal @@ -158,16 +157,17 @@ export const layer = Layer.effect( return yield* aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause)))) }) - const runStream = (command: ChildProcess.Command, options?: RunStreamOptions): Stream.Stream => { + const runStream = ( + command: ChildProcess.Command, + options?: RunStreamOptions, + ): Stream.Stream => { const description = describeCommand(command) const okExitCodes = options?.okExitCodes const built: Stream.Stream = Stream.unwrap( Effect.gen(function* () { const handle = yield* spawner.spawn(command) const stderrFiber = yield* Effect.forkScoped( - collectStream(handle.stderr, options?.maxErrorBytes).pipe( - Effect.map((x) => x.buffer.toString("utf8")), - ), + collectStream(handle.stderr, options?.maxErrorBytes).pipe(Effect.map((x) => x.buffer.toString("utf8"))), ) const source = options?.includeStderr === true ? handle.all : handle.stdout const lines = source.pipe( diff --git a/packages/core/test/process/process.test.ts b/packages/core/test/process/process.test.ts index 1b46c1f1e..726c3c4d8 100644 --- a/packages/core/test/process/process.test.ts +++ b/packages/core/test/process/process.test.ts @@ -35,7 +35,9 @@ describe("AppProcess", () => { "requireSuccess fails on non-zero exit", Effect.gen(function* () { const svc = yield* AppProcess.Service - const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess))) + const exit = yield* Effect.exit( + svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess)), + ) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { const reason = exit.cause.reasons[0] @@ -67,9 +69,7 @@ describe("AppProcess", () => { expect(okZero.exitCode).toBe(0) const okOne = yield* svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(requireZeroOrOne)) expect(okOne.exitCode).toBe(1) - const exit = yield* Effect.exit( - svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne)), - ) + const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne))) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { const reason = exit.cause.reasons[0] @@ -140,9 +140,7 @@ describe("AppProcess", () => { Effect.gen(function* () { const svc = yield* AppProcess.Service const exit = yield* Effect.exit( - svc - .runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0] }) - .pipe(Stream.runCollect), + svc.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0] }).pipe(Stream.runCollect), ) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { @@ -169,9 +167,7 @@ describe("AppProcess", () => { "without okExitCodes, never fails on exit code", Effect.gen(function* () { const svc = yield* AppProcess.Service - const result = yield* svc - .runStream(cmd("-e", "console.log('only'); process.exit(7)")) - .pipe(Stream.runCollect) + const result = yield* svc.runStream(cmd("-e", "console.log('only'); process.exit(7)")).pipe(Stream.runCollect) expect(Array.from(result)).toEqual(["only"]) }), ) From d6367853aef34b5c5627e18014f9f8d0bdedaaac Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 13 May 2026 03:26:25 +0200 Subject: [PATCH 163/188] Add TUI notifications and attention sounds (disabled by default) (#26980) --- bun.lock | 390 +------------- bunfig.toml | 1 + package.json | 9 +- packages/opencode/package.json | 1 + packages/opencode/specs/tui-plugins.md | 34 ++ packages/opencode/specs/v2/notifications.md | 13 + packages/opencode/src/audio.d.ts | 5 + packages/opencode/src/cli/cmd/tui/app.tsx | 12 +- .../opencode/src/cli/cmd/tui/attention.ts | 261 ++++++++++ .../src/cli/cmd/tui/config/tui-schema.ts | 45 ++ .../opencode/src/cli/cmd/tui/config/tui.ts | 33 +- .../tui/feature-plugins/home/tips-view.tsx | 230 +++++++-- .../cli/cmd/tui/feature-plugins/home/tips.tsx | 4 +- .../feature-plugins/system/notifications.ts | 94 ++++ .../opencode/src/cli/cmd/tui/plugin/api.tsx | 2 + .../src/cli/cmd/tui/plugin/internal.ts | 2 + .../src/cli/cmd/tui/plugin/runtime.ts | 55 +- .../opencode/src/cli/cmd/tui/util/audio.ts | 58 +++ packages/opencode/src/util/filesystem.ts | 9 +- .../test/cli/cmd/tui/attention.test.ts | 484 ++++++++++++++++++ .../test/cli/cmd/tui/notifications.test.ts | 267 ++++++++++ .../test/cli/run/runtime.boot.test.ts | 75 ++- .../test/cli/tui/plugin-loader.test.ts | 80 +++ packages/opencode/test/config/tui.test.ts | 54 ++ packages/opencode/test/fixture/tui-plugin.ts | 19 +- packages/opencode/test/fixture/tui-runtime.ts | 12 +- packages/plugin/package.json | 6 +- packages/plugin/src/tui.ts | 81 +++ packages/ui/src/assets/audio/alert-01.mp3 | Bin 0 -> 11436 bytes packages/ui/src/assets/audio/alert-02.mp3 | Bin 0 -> 8667 bytes packages/ui/src/assets/audio/alert-03.mp3 | Bin 0 -> 3197 bytes packages/ui/src/assets/audio/alert-04.mp3 | Bin 0 -> 7369 bytes packages/ui/src/assets/audio/alert-05.mp3 | Bin 0 -> 7603 bytes packages/ui/src/assets/audio/alert-06.mp3 | Bin 0 -> 7516 bytes packages/ui/src/assets/audio/alert-07.mp3 | Bin 0 -> 6741 bytes packages/ui/src/assets/audio/alert-08.mp3 | Bin 0 -> 6171 bytes packages/ui/src/assets/audio/alert-09.mp3 | Bin 0 -> 5752 bytes packages/ui/src/assets/audio/alert-10.mp3 | Bin 0 -> 10780 bytes packages/ui/src/assets/audio/bip-bop-01.mp3 | Bin 0 -> 4574 bytes packages/ui/src/assets/audio/bip-bop-02.mp3 | Bin 0 -> 5697 bytes packages/ui/src/assets/audio/bip-bop-03.mp3 | Bin 0 -> 4186 bytes packages/ui/src/assets/audio/bip-bop-04.mp3 | Bin 0 -> 5436 bytes packages/ui/src/assets/audio/bip-bop-05.mp3 | Bin 0 -> 3534 bytes packages/ui/src/assets/audio/bip-bop-06.mp3 | Bin 0 -> 5796 bytes packages/ui/src/assets/audio/bip-bop-07.mp3 | Bin 0 -> 3925 bytes packages/ui/src/assets/audio/bip-bop-08.mp3 | Bin 0 -> 4390 bytes packages/ui/src/assets/audio/bip-bop-09.mp3 | Bin 0 -> 8654 bytes packages/ui/src/assets/audio/bip-bop-10.mp3 | Bin 0 -> 6760 bytes packages/ui/src/assets/audio/nope-01.mp3 | Bin 0 -> 3195 bytes packages/ui/src/assets/audio/nope-02.mp3 | Bin 0 -> 5753 bytes packages/ui/src/assets/audio/nope-03.mp3 | Bin 0 -> 3820 bytes packages/ui/src/assets/audio/nope-04.mp3 | Bin 0 -> 3562 bytes packages/ui/src/assets/audio/nope-05.mp3 | Bin 0 -> 5858 bytes packages/ui/src/assets/audio/nope-06.mp3 | Bin 0 -> 6066 bytes packages/ui/src/assets/audio/nope-07.mp3 | Bin 0 -> 3459 bytes packages/ui/src/assets/audio/nope-08.mp3 | Bin 0 -> 7450 bytes packages/ui/src/assets/audio/nope-09.mp3 | Bin 0 -> 5960 bytes packages/ui/src/assets/audio/nope-10.mp3 | Bin 0 -> 3981 bytes packages/ui/src/assets/audio/nope-11.mp3 | Bin 0 -> 13681 bytes packages/ui/src/assets/audio/nope-12.mp3 | Bin 0 -> 11595 bytes .../ui/src/assets/audio/staplebops-01.mp3 | Bin 0 -> 3665 bytes .../ui/src/assets/audio/staplebops-02.mp3 | Bin 0 -> 3898 bytes .../ui/src/assets/audio/staplebops-03.mp3 | Bin 0 -> 5905 bytes .../ui/src/assets/audio/staplebops-04.mp3 | Bin 0 -> 4553 bytes .../ui/src/assets/audio/staplebops-05.mp3 | Bin 0 -> 3458 bytes .../ui/src/assets/audio/staplebops-06.mp3 | Bin 0 -> 4971 bytes .../ui/src/assets/audio/staplebops-07.mp3 | Bin 0 -> 10376 bytes packages/ui/src/assets/audio/yup-01.mp3 | Bin 0 -> 4418 bytes packages/ui/src/assets/audio/yup-02.mp3 | Bin 0 -> 6061 bytes packages/ui/src/assets/audio/yup-03.mp3 | Bin 0 -> 7575 bytes packages/ui/src/assets/audio/yup-04.mp3 | Bin 0 -> 8674 bytes packages/ui/src/assets/audio/yup-05.mp3 | Bin 0 -> 10339 bytes packages/ui/src/assets/audio/yup-06.mp3 | Bin 0 -> 5905 bytes 73 files changed, 1856 insertions(+), 480 deletions(-) create mode 100644 packages/opencode/specs/v2/notifications.md create mode 100644 packages/opencode/src/cli/cmd/tui/attention.ts create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/system/notifications.ts create mode 100644 packages/opencode/src/cli/cmd/tui/util/audio.ts create mode 100644 packages/opencode/test/cli/cmd/tui/attention.test.ts create mode 100644 packages/opencode/test/cli/cmd/tui/notifications.test.ts create mode 100644 packages/ui/src/assets/audio/alert-01.mp3 create mode 100644 packages/ui/src/assets/audio/alert-02.mp3 create mode 100644 packages/ui/src/assets/audio/alert-03.mp3 create mode 100644 packages/ui/src/assets/audio/alert-04.mp3 create mode 100644 packages/ui/src/assets/audio/alert-05.mp3 create mode 100644 packages/ui/src/assets/audio/alert-06.mp3 create mode 100644 packages/ui/src/assets/audio/alert-07.mp3 create mode 100644 packages/ui/src/assets/audio/alert-08.mp3 create mode 100644 packages/ui/src/assets/audio/alert-09.mp3 create mode 100644 packages/ui/src/assets/audio/alert-10.mp3 create mode 100644 packages/ui/src/assets/audio/bip-bop-01.mp3 create mode 100644 packages/ui/src/assets/audio/bip-bop-02.mp3 create mode 100644 packages/ui/src/assets/audio/bip-bop-03.mp3 create mode 100644 packages/ui/src/assets/audio/bip-bop-04.mp3 create mode 100644 packages/ui/src/assets/audio/bip-bop-05.mp3 create mode 100644 packages/ui/src/assets/audio/bip-bop-06.mp3 create mode 100644 packages/ui/src/assets/audio/bip-bop-07.mp3 create mode 100644 packages/ui/src/assets/audio/bip-bop-08.mp3 create mode 100644 packages/ui/src/assets/audio/bip-bop-09.mp3 create mode 100644 packages/ui/src/assets/audio/bip-bop-10.mp3 create mode 100644 packages/ui/src/assets/audio/nope-01.mp3 create mode 100644 packages/ui/src/assets/audio/nope-02.mp3 create mode 100644 packages/ui/src/assets/audio/nope-03.mp3 create mode 100644 packages/ui/src/assets/audio/nope-04.mp3 create mode 100644 packages/ui/src/assets/audio/nope-05.mp3 create mode 100644 packages/ui/src/assets/audio/nope-06.mp3 create mode 100644 packages/ui/src/assets/audio/nope-07.mp3 create mode 100644 packages/ui/src/assets/audio/nope-08.mp3 create mode 100644 packages/ui/src/assets/audio/nope-09.mp3 create mode 100644 packages/ui/src/assets/audio/nope-10.mp3 create mode 100644 packages/ui/src/assets/audio/nope-11.mp3 create mode 100644 packages/ui/src/assets/audio/nope-12.mp3 create mode 100644 packages/ui/src/assets/audio/staplebops-01.mp3 create mode 100644 packages/ui/src/assets/audio/staplebops-02.mp3 create mode 100644 packages/ui/src/assets/audio/staplebops-03.mp3 create mode 100644 packages/ui/src/assets/audio/staplebops-04.mp3 create mode 100644 packages/ui/src/assets/audio/staplebops-05.mp3 create mode 100644 packages/ui/src/assets/audio/staplebops-06.mp3 create mode 100644 packages/ui/src/assets/audio/staplebops-07.mp3 create mode 100644 packages/ui/src/assets/audio/yup-01.mp3 create mode 100644 packages/ui/src/assets/audio/yup-02.mp3 create mode 100644 packages/ui/src/assets/audio/yup-03.mp3 create mode 100644 packages/ui/src/assets/audio/yup-04.mp3 create mode 100644 packages/ui/src/assets/audio/yup-05.mp3 create mode 100644 packages/ui/src/assets/audio/yup-06.mp3 diff --git a/bun.lock b/bun.lock index 8e7a63a2e..76602e885 100644 --- a/bun.lock +++ b/bun.lock @@ -397,6 +397,7 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/ui": "workspace:*", "@openrouter/ai-sdk-provider": "2.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", @@ -507,9 +508,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.6", - "@opentui/keymap": ">=0.2.6", - "@opentui/solid": ">=0.2.6", + "@opentui/core": ">=0.2.8", + "@opentui/keymap": ">=0.2.8", + "@opentui/solid": ">=0.2.8", }, "optionalPeers": [ "@opentui/core", @@ -676,6 +677,9 @@ "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", }, "overrides": { + "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", + "@opentui/solid": "catalog:", "@types/bun": "catalog:", "@types/node": "catalog:", }, @@ -689,9 +693,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.6", - "@opentui/keymap": "0.2.6", - "@opentui/solid": "0.2.6", + "@opentui/core": "0.2.8", + "@opentui/keymap": "0.2.8", + "@opentui/solid": "0.2.8", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1070,8 +1074,6 @@ "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], - "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], - "@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], @@ -1288,62 +1290,6 @@ "@isaacs/string-locale-compare": ["@isaacs/string-locale-compare@1.1.0", "", {}, "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ=="], - "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], - - "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], - - "@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="], - - "@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="], - - "@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="], - - "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="], - - "@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="], - - "@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="], - - "@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="], - - "@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="], - - "@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="], - - "@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="], - - "@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="], - - "@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="], - - "@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="], - - "@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="], - - "@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="], - - "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="], - - "@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="], - - "@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="], - - "@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="], - - "@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="], - - "@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="], - - "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="], - - "@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="], - - "@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="], - - "@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="], - - "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], - "@joshwooding/vite-plugin-react-docgen-typescript": ["@joshwooding/vite-plugin-react-docgen-typescript@0.7.0", "", { "dependencies": { "glob": "^13.0.1", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "typescript": ">= 4.3.x", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["typescript"] }, "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -1616,23 +1562,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.6", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.6", "@opentui/core-darwin-x64": "0.2.6", "@opentui/core-linux-arm64": "0.2.6", "@opentui/core-linux-x64": "0.2.6", "@opentui/core-win32-arm64": "0.2.6", "@opentui/core-win32-x64": "0.2.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dBpMaWVM7wtW2/2TlGPrkPjg6gOL3MVU/5XXk+U1LDJB8L4q4NeYWVdzfAVNcEvgmuuCy/cVqdY2D4ei+e7MMg=="], + "@opentui/core": ["@opentui/core@0.2.8", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.8", "@opentui/core-darwin-x64": "0.2.8", "@opentui/core-linux-arm64": "0.2.8", "@opentui/core-linux-x64": "0.2.8", "@opentui/core-win32-arm64": "0.2.8", "@opentui/core-win32-x64": "0.2.8" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-bRRiCXuwjS8/6mN1oA5iVaf55z9APyalm7FnoxkLkEyIU1VDaQeTpYtElBbfo1rxtcO6Rj53XywH9oW8auNO9A=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hR5nsxNj+059utzenTCF0kealUlibON6fLuebFUCGM/5kJnqa+shIh0XbUDFm0+F47vqVUgZufBdUuieQZIbvQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Qh6VCMQgW3hWh/7MR51y+XuQezh8NOLwKS8EQSoKzAr4VOc/W5P0/DvgMKgwaqXw2Mz0AIba/BvZ6by20yc4zA=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-pJ/bH4WC/mbBaakM1YdH6TVo67jhy0KPd61bCz97w0I/PJGr8fmNKvhmMt/AwyFgOQi3FYZiEKLMpGdvUcSsrQ=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-wQjJ38C3IiVx/gwwBYxnCarzgD75FdS7IyUErt3lhn57XriNiCbb7ScphWnRMwwtL8CI+bBGzClroDRA2lCfvg=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Pnd3kOxig8ii+/IqYheOPEgferylsQA0L6tKBnHQ9jRlCJOcu0Rv65Jepueh212vevdV9DzPURJnhejG06J6g=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-fx4ADeWSSSVU1O/MkMnklCRxtWRy6CLeAvktLlNdPb+BhmQIDg1kpZcdv7m/3cgD1/ksFEXIwO6VTvfKYE0umw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-458Mx9tBzEPzfft8cSt5ZaIpEepoxBXBOL6AUVmDTKWaZ3uouraPcEKraGAyvOTDQp2XDI3R8c/2GdaR77FaUQ=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.8", "", { "os": "linux", "cpu": "x64" }, "sha512-4ekUyzopBj2ClsUbneLnUOrmZtvU67FCVFLgmBfKL4IvVl/P0YobGNg71gN1JNiYpY7hK77qOpidVLHcNMIE7w=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-BDUrdrT1RCcVnQoHJmUut4y811jDBAEtc6GJFB4Gs265Be8SrTjVCus6p2fSQ7j9sZQ1OcjO+5+4NkheSZICDQ=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-63K046wpzTzQOLOG9LTsp3+Ld0TNTxeQczexkg0pKSBxZFhws+/9YIGjTctZmJUfE1g1X4tI31dO+KNRpXRHQw=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-SUYAzRJ9TSoD2Qt8kn6FJz6dbTrFEPVig5mScB4zFGgGQO/Bbod2/Q31vLS/IQrX+FDb67WaErD+kuMCnMPPLA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.8", "", { "os": "win32", "cpu": "x64" }, "sha512-+WDiTlTyDpgkis8rPAhW1fS7TwXJih+fk+RYXS2bC3tAKsRD+O3PRSkVABRbjkuXbtfJZf2cjOHZFGN4Vf5qDg=="], - "@opentui/keymap": ["@opentui/keymap@0.2.6", "", { "dependencies": { "@opentui/core": "0.2.6" }, "peerDependencies": { "@opentui/react": "0.2.6", "@opentui/solid": "0.2.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-+6OYuedrFCKVo4ryGFNwws++2VOmPcXU3PwpY0mP47gYQY2nvQ+etWIs2Y7r5eMIqUfxVCldkKsrzcEcA4tb/A=="], + "@opentui/keymap": ["@opentui/keymap@0.2.8", "", { "dependencies": { "@opentui/core": "0.2.8" }, "peerDependencies": { "@opentui/react": "0.2.8", "@opentui/solid": "0.2.8", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-/H9j8fP64cf3/nFDCvVP8+7cwU/oRh4sgfQH2NhcPp8illgBb/e9pG5x3vM0nK4RVyTqUvkPXsOeIX5u7vltlg=="], - "@opentui/solid": ["@opentui/solid@0.2.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.6", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2y225WlOGi/fCaajkxBmLyVW8Cr+OmhowHdvrYcz5w2kBD15sKbJLIYu1G9DxceirT1uIyasGy2TGzRRcVkTDg=="], + "@opentui/solid": ["@opentui/solid@0.2.8", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.8", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-f2g0riBuzk4/ZmcJnp1k13odUmNZcfA3nF7RzdSlEfpkwNDfc4xqnRAwYbNNDwGNrJX0JDCTEZY5ZEhuL155MQ=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -2284,8 +2230,6 @@ "@thisbeyond/solid-dnd": ["@thisbeyond/solid-dnd@0.7.5", "", { "peerDependencies": { "solid-js": "^1.5" } }, "sha512-DfI5ff+yYGpK9M21LhYwIPlbP2msKxN2ARwuu6GF8tT1GgNVDTI8VCQvH4TJFoVApP9d44izmAcTh/iTCH2UUw=="], - "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], - "@tsconfig/bun": ["@tsconfig/bun@1.0.9", "", {}, "sha512-4M0/Ivfwcpz325z6CwSifOBZYji3DFOEpY6zEUt0+Xi2qRhzwvmqQN9XAHJh3OVvRJuAqVTLU2abdCplvp6mwQ=="], "@tsconfig/node22": ["@tsconfig/node22@22.0.2", "", {}, "sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA=="], @@ -2550,8 +2494,6 @@ "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], - "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], - "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], @@ -2620,8 +2562,6 @@ "avvio": ["avvio@9.2.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="], - "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], - "aws-sdk": ["aws-sdk@2.1692.0", "", { "dependencies": { "buffer": "4.9.2", "events": "1.1.1", "ieee754": "1.1.13", "jmespath": "0.16.0", "querystring": "0.2.0", "sax": "1.2.1", "url": "0.10.3", "util": "^0.12.4", "uuid": "8.0.0", "xml2js": "0.6.2" } }, "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw=="], "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], @@ -2686,8 +2626,6 @@ "blob-to-buffer": ["blob-to-buffer@1.2.9", "", {}, "sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA=="], - "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], - "body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="], "bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="], @@ -2730,16 +2668,6 @@ "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], - "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], - - "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="], - - "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="], - - "bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="], - - "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="], - "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -3160,8 +3088,6 @@ "execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], - "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], - "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], @@ -3224,8 +3150,6 @@ "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], - "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], - "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -3316,8 +3240,6 @@ "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361", "sha512-dW0nwaiBBcun9y5WJSvm3HxDLe5o9V0xLCndQvWonRVubU8CS1PHxZpLffyPt1YujPWC13ez03aWxcuKBPYYGQ=="], - "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], - "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], @@ -3470,8 +3392,6 @@ "ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="], - "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], - "immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], "import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="], @@ -3614,16 +3534,12 @@ "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], - "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], - "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jmespath": ["jmespath@0.16.0", "", {}, "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw=="], "jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="], - "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], - "js-base64": ["js-base64@3.7.7", "", {}, "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw=="], "js-beautify": ["js-beautify@1.15.4", "", { "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", "glob": "^10.4.2", "js-cookie": "^3.0.5", "nopt": "^7.2.1" }, "bin": { "css-beautify": "js/bin/css-beautify.js", "html-beautify": "js/bin/html-beautify.js", "js-beautify": "js/bin/js-beautify.js" } }, "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA=="], @@ -4084,8 +4000,6 @@ "oidc-token-hash": ["oidc-token-hash@5.2.0", "", {}, "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw=="], - "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], - "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -4160,12 +4074,6 @@ "param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="], - "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], - - "parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="], - - "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], - "parse-conflict-json": ["parse-conflict-json@5.0.1", "", { "dependencies": { "json-parse-even-better-errors": "^5.0.0", "just-diff": "^6.0.0", "just-diff-apply": "^5.2.0" } }, "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ=="], "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], @@ -4210,8 +4118,6 @@ "peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="], - "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], - "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], @@ -4232,8 +4138,6 @@ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], - "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], - "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], "pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="], @@ -4242,16 +4146,12 @@ "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], - "planck": ["planck@1.5.0", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-dlvqJE+FscZgrGUXJ5ybd0o5bvZ5XXyZNbm08xGsXp9WjXeAyWSFT6n9s/1PQcUBo4546fDXA5RMA4wbDyZw6g=="], - "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], - "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], - "poe-oauth": ["poe-oauth@0.0.6", "", {}, "sha512-dI8xrVl7RSFh0B+cb4GGuCjIfGtDT9VpbpVkP0UKcunpXF0eFw+6GencoJ7k+E02ZYqopBQApMVWGq70/GP69w=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], @@ -4376,8 +4276,6 @@ "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], - "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], - "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], @@ -4584,8 +4482,6 @@ "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], - "simple-xml-to-json": ["simple-xml-to-json@1.2.7", "", {}, "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q=="], - "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "sitemap": ["sitemap@9.0.1", "", { "dependencies": { "@types/node": "^24.9.2", "@types/sax": "^1.2.1", "arg": "^5.0.0", "sax": "^1.4.1" }, "bin": { "sitemap": "dist/esm/cli.js" } }, "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ=="], @@ -4672,8 +4568,6 @@ "stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="], - "stage-js": ["stage-js@1.0.2", "", {}, "sha512-EWTRBYlg7Qv9wGUao99/PfRe3KaiQqWmgSvTOXvaWnu1Jk/q/vV8yJVu6bi/3EqDZeMVnCPAjheba6OFc5k1GQ=="], - "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], @@ -4722,8 +4616,6 @@ "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], - "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], - "stubborn-fs": ["stubborn-fs@2.0.0", "", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="], "stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], @@ -4776,8 +4668,6 @@ "thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="], - "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], - "thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="], "tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], @@ -4790,8 +4680,6 @@ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], - "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], @@ -4812,8 +4700,6 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], - "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], "toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.8", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-b+5ynEFp4Woe5a22hzNQm42lD23t13ZMihVxHbzjA50zdcM9aOSJTIjdJ0PDSd4/50HbBXcpHiQsz6rM4N88ww=="], @@ -4966,8 +4852,6 @@ "utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="], - "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], - "util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -5096,8 +4980,6 @@ "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], - "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], - "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], @@ -5450,46 +5332,10 @@ "@gitlab/opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], - "@happy-dom/global-registrator/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], - - "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-color/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-contain/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-cover/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-crop/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-displace/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-fisheye/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-flip/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-mask/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-print/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-quantize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-resize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-rotate/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-threshold/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@jsx-email/cli/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@jsx-email/cli/esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="], @@ -5628,24 +5474,16 @@ "@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "@slack/logger/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "@slack/oauth/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="], - "@slack/oauth/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "@slack/socket-mode/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="], - "@slack/socket-mode/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "@slack/socket-mode/@types/ws": ["@types/ws@7.4.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww=="], "@slack/socket-mode/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], "@slack/web-api/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="], - "@slack/web-api/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "@slack/web-api/eventemitter3": ["eventemitter3@3.1.2", "", {}, "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q=="], "@slack/web-api/form-data": ["form-data@2.5.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" } }, "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A=="], @@ -5706,62 +5544,8 @@ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], - "@types/body-parser/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/cacache/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/cacheable-request/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/connect/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/cross-spawn/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/express-serve-static-core/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/fontkit/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/fs-extra/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/is-stream/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/jsonwebtoken/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/keyv/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/mssql/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/node-fetch/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/npm-registry-fetch/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/npmcli__arborist/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/npmlog/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/pacote/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/plist/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "@types/readable-stream/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/responselike/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/sax/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/send/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/serve-static/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/ssri/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/tunnel/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/ws/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/yauzl/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], "@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], @@ -5838,18 +5622,12 @@ "builder-util-runtime/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "bun-types/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], - "c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "c12/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], - "cloudflare/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], "condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], @@ -5884,8 +5662,6 @@ "effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "electron/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "electron-builder/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -5940,8 +5716,6 @@ "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], - "happy-dom/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "happy-dom/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], "html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], @@ -5954,8 +5728,6 @@ "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], - "image-q/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], @@ -6028,9 +5800,9 @@ "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], - "opentui-spinner/@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="], + "opentui-spinner/@opentui/core": ["@opentui/core@0.2.7", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.7", "@opentui/core-darwin-x64": "0.2.7", "@opentui/core-linux-arm64": "0.2.7", "@opentui/core-linux-x64": "0.2.7", "@opentui/core-win32-arm64": "0.2.7", "@opentui/core-win32-x64": "0.2.7" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-cnN6JcaGC7SeQzobBy/CHzqUAQFtypazuw1CjQBo7WwoOiLMGubt9W5FXeF0zIrSxH2Ed6NLWhPYRg7SD4629Q=="], - "opentui-spinner/@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="], + "opentui-spinner/@opentui/solid": ["@opentui/solid@0.2.7", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.7", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-nlkx9HvuWaHtc5A8eUEAPNi+5+37LZS3ln73WRmtT5xin8LnQf+yhwopqGgPSnLq1ODLwhkKRdr/9JCDr2j7Bg=="], "ora/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], @@ -6044,14 +5816,10 @@ "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], - "parse-bmfont-xml/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], - "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], - "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], @@ -6076,8 +5844,6 @@ "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "protobufjs/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], "readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], @@ -6108,8 +5874,6 @@ "shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], - "sitemap/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "sitemap/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -6132,20 +5896,14 @@ "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "stripe/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - "tedious/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], - "token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - "tree-sitter-bash/node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="], "tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -6162,8 +5920,6 @@ "uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], - "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], @@ -6478,8 +6234,6 @@ "@gitlab/opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], - "@happy-dom/global-registrator/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "@jsx-email/cli/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], "@jsx-email/cli/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], @@ -6674,14 +6428,6 @@ "@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "@slack/logger/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@slack/oauth/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@slack/socket-mode/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@slack/web-api/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], @@ -6708,60 +6454,6 @@ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - "@types/body-parser/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/cacache/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/cacheable-request/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/connect/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/cross-spawn/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/express-serve-static-core/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/fontkit/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/fs-extra/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/is-stream/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/jsonwebtoken/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/keyv/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/mssql/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/node-fetch/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/npm-registry-fetch/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/npmcli__arborist/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/npmlog/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/pacote/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/plist/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/readable-stream/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/responselike/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/sax/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/send/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/serve-static/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/ssri/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/tunnel/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/ws/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/yauzl/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -6810,12 +6502,8 @@ "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "bun-types/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], - "cloudflare/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "crc/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -6834,8 +6522,6 @@ "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - "electron/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -6848,14 +6534,10 @@ "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "happy-dom/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], "iconv-corefoundation/cli-truncate/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "image-q/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "js-beautify/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "js-beautify/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -6874,8 +6556,6 @@ "motion/framer-motion/motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], - "mssql/tedious/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "mssql/tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -6894,24 +6574,22 @@ "opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="], + "opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CAy6cL3byz2Xf6gFiJHBpcnsp/2ADEWLLOUokVypOyPLcy8GY3sPzlA4pkAjVGQMYQhDj+Y3+SXz4uTLt4AETg=="], - "opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="], + "opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-K06h333rMkC9cyMJr/VvcRK3ik81Admd8ZsES5uf5YXWPdYhXGf75I1T8mKIThhUmoFLb8R5xqfuPmoocsjM7Q=="], - "opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="], + "opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-iYWGTztbdG9yYSB5Alxuo0dWAmkWQR0+/paNWUyPOocjigmKgMmACDtHgYqa7sxkIcWgmXljt/f8rgXDG4wdMg=="], - "opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="], + "opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-tymBCfYbsDRfHQNXsolkFfaTEIDhemD4+1ZovUztQd7i+0Ggnu9WbPN1SNCiRz6PjrlaNeQzZE3Wl8FfVdw/cw=="], - "opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="], + "opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-XLPJWdT8QOukrYDkpIng6+uNUlF66ByXcQlC3qA9JbrUTBetZhgXs8Q2jEjRfc+Ty3uh1iRSA6PgJGbbOK/f4Q=="], - "opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="], + "opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.7", "", { "os": "win32", "cpu": "x64" }, "sha512-CzVGEfqysVk8Hxcj0RDv/DtXIM6iZmbmr23kW7y8CJMPtmV1gmKI4D9abVjynWJnGbaSBnDi43mgZnGMgOdyEg=="], - "opentui-spinner/@opentui/core/bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], + "opentui-spinner/@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], "opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], - "opentui-spinner/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], - "ora/bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "ora/bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -6920,14 +6598,10 @@ "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "parse-bmfont-xml/xml2js/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], - "protobufjs/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "readable-stream/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], @@ -6938,16 +6612,10 @@ "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "sitemap/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "storybook/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "stripe/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "tedious/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "tw-to-css/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "tw-to-css/tailwindcss/glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -7250,8 +6918,6 @@ "js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "mssql/tedious/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "opencontrol/@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "opencontrol/@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], diff --git a/bunfig.toml b/bunfig.toml index 363579bbf..47c4ac539 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,6 +2,7 @@ exact = true # Only install newly resolved package versions published at least 3 days ago. minimumReleaseAge = 259200 +minimumReleaseAgeExcludes = ["@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-x64", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid"] [test] root = "./do-not-run-tests-from-root" diff --git a/package.json b/package.json index 6d82864d6..f1cc7da5c 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.6", - "@opentui/keymap": "0.2.6", - "@opentui/solid": "0.2.6", + "@opentui/core": "0.2.8", + "@opentui/keymap": "0.2.8", + "@opentui/solid": "0.2.8", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", @@ -128,6 +128,9 @@ "electron" ], "overrides": { + "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", + "@opentui/solid": "catalog:", "@types/bun": "catalog:", "@types/node": "catalog:" }, diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 121b34c3a..b0427f231 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -108,6 +108,7 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/ui": "workspace:*", "@openrouter/ai-sdk-provider": "2.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index c1a9b271c..e9ece6c0a 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -29,6 +29,16 @@ Example: "plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]], "plugin_enabled": { "acme.demo": false + }, + "attention": { + "enabled": true, + "notifications": true, + "sound": true, + "volume": 0.4, + "sound_pack": "opencode.default", + "sounds": { + "error": "/Users/me/sounds/error.mp3" + } } } ``` @@ -45,6 +55,11 @@ Example: - Internal plugins can declare `enabled: false` to be registered but inactive by default; `plugin_enabled` and runtime KV can still enable them by id. - `plugin_enabled` is merged across config layers. - Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup. +- `attention.enabled` defaults to `false`; when `false`, it disables all `api.attention.notify(...)` delivery. +- `attention.notifications` and `attention.sound` independently control terminal-mediated desktop notifications and built-in sounds. +- `attention.volume` sets the default built-in sound volume from `0` to `1`. +- `attention.sound_pack` selects the initial semantic sound pack. Persisted runtime selection in KV can override it. +- `attention.sounds` overrides individual semantic sound slots such as `error`, `done`, or `subagent_done`. - `leader_timeout` is a top-level TUI setting. - `keybinds` is a flat object keyed by command id; values are key binding values (`false`, `"none"`, a key string/object, a binding object, or an array of key strings/objects/binding objects). - `keybinds.leader` sets the key used by `` shortcuts. @@ -212,6 +227,7 @@ That is what makes local config-scoped plugins able to import `@opencode-ai/plug Top-level API groups exposed to `tui(api, options, meta)`: - `api.app.version` +- `api.attention.notify(input)` - `api.keys.formatSequence(parts)`, `formatBindings(bindings)` - `api.keymap` - `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current` @@ -246,6 +262,24 @@ Top-level API groups exposed to `tui(api, options, meta)`: - `formatBindings(bindings)` formats binding lists and returns `undefined` when there is nothing to show. - For generic config-to-bindings helpers, import `createBindingLookup` from `@opencode-ai/plugin/tui`. +### Attention + +- `api.attention.notify({ title?, message, notification?, sound? })` requests user attention while keeping terminal focus, notifications, and audio owned by the host. +- `message` is required; `title` defaults to `"opencode"`; `notification` defaults to enabled with `when: "blurred"`; `sound` defaults to enabled with `when: "always"`. +- `when: "always"` requests delivery regardless of terminal focus state. +- `when: "focused"` only requests delivery after the terminal is known focused; `when: "blurred"` only requests delivery after the terminal is known blurred. +- Example: `notification: { when: "blurred" }, sound: { name: "question", when: "always" }` plays sound while focused but only triggers system notifications when blurred. +- Semantic sound names are `"default"`, `"question"`, `"permission"`, `"error"`, `"done"`, and `"subagent_done"`. +- `sound: true` plays the `"default"` sound; `sound: { name: "question" }` plays a named semantic sound. +- `sound: { volume }` overrides volume for that call; `sound: false` disables sound for that call; `notification: false` disables system notification for that call. +- `api.attention.soundboard.registerPack({ id, name?, sounds })` registers a sound pack and returns a disposer. Relative paths resolve from the plugin root and are cleaned up on plugin deactivation. +- `api.attention.soundboard.activate(id, { persist })` selects the active pack. `persist: true` writes the selected pack id to TUI KV state, not `tui.json`. +- `api.attention.soundboard.current()` and `list()` expose the active/registered packs for plugin UX. +- Config `attention.sounds` overrides active-pack sounds by slot. Failed loads fall back to the active pack and then `opencode.default`. +- The host strips ANSI/control characters and collapses newlines before sending text to the terminal notification API. +- Terminal and OS settings decide whether a requested notification is visibly displayed. +- Prefer privacy-safe messages such as `"A question needs your input"`; avoid full commands, paths, prompts, errors, secrets, or file contents unless the plugin intentionally exposes them. + ### Routes - Reserved route names: `home` and `session`. diff --git a/packages/opencode/specs/v2/notifications.md b/packages/opencode/specs/v2/notifications.md new file mode 100644 index 000000000..96018f994 --- /dev/null +++ b/packages/opencode/specs/v2/notifications.md @@ -0,0 +1,13 @@ +# TUI Notifications Default + +Problem: + +- v1 defaults `attention.enabled` to `false` +- users can opt in with `attention.enabled = true` +- v2 should make core TUI notifications a default behavior + +## v2 Target + +Flip `attention.enabled` to `true` by default in v2. + +Keep `attention.enabled = false` as the explicit opt-out. diff --git a/packages/opencode/src/audio.d.ts b/packages/opencode/src/audio.d.ts index c7c947450..7b99d097a 100644 --- a/packages/opencode/src/audio.d.ts +++ b/packages/opencode/src/audio.d.ts @@ -3,6 +3,11 @@ declare module "*.wav" { export default file } +declare module "*.mp3" { + const file: string + export default file +} + declare module "*.wasm" { const file: string export default file diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index d7f2cd14b..29cca133b 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -2,6 +2,7 @@ import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@op import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import * as Clipboard from "@tui/util/clipboard" import * as Selection from "@tui/util/selection" +import * as TuiAudio from "@tui/util/audio" import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" import { @@ -63,6 +64,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { createTuiApi } from "@/cli/cmd/tui/plugin/api" import type { RouteMap } from "@/cli/cmd/tui/plugin/api" +import { createTuiAttention } from "@/cli/cmd/tui/attention" import { FormatError, FormatUnknownError } from "@/cli/error" import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette" import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap" @@ -176,10 +178,10 @@ export function tui(input: { unguard?.() resolve() } - const onBeforeExit = async () => { offKeymap() await TuiPluginRuntime.dispose() + TuiAudio.dispose() } const renderer = await createCliRenderer(rendererConfig(input.config)) @@ -283,6 +285,7 @@ function App(props: { onSnapshot?: () => Promise }) { routeRev() return routes.get(name)?.at(-1)?.render } + const attention = createTuiAttention({ renderer, config: tuiConfig, kv }) const api = createTuiApi({ tuiConfig, @@ -298,11 +301,13 @@ function App(props: { onSnapshot?: () => Promise }) { theme: themeState, toast, renderer, + attention, }) const [ready, setReady] = createSignal(false) TuiPluginRuntime.init({ api, config: tuiConfig, + dispose: () => attention.dispose(), }) .catch((error) => { console.error("Failed to load TUI plugins", error) @@ -320,7 +325,10 @@ function App(props: { onSnapshot?: () => Promise }) { }, { priority: 1 }, ) - onCleanup(offSelectionKeys) + onCleanup(() => { + offSelectionKeys() + attention.dispose() + }) // Wire up console copy-to-clipboard via opentui's onCopySelection callback renderer.console.onCopySelection = async (text: string) => { diff --git a/packages/opencode/src/cli/cmd/tui/attention.ts b/packages/opencode/src/cli/cmd/tui/attention.ts new file mode 100644 index 000000000..60bd97b97 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/attention.ts @@ -0,0 +1,261 @@ +import type { + TuiAttention, + TuiAttentionNotifyInput, + TuiAttentionNotifyResult, + TuiAttentionNotifySkipReason, + TuiAttentionWhen, + TuiKV, + TuiAttentionSoundName, + TuiAttentionSoundPack, + TuiAttentionSoundPackInfo, +} from "@opencode-ai/plugin/tui" +import stripAnsi from "strip-ansi" +import type { TuiConfig } from "./config/tui" +import { isAttentionSoundName } from "./config/tui-schema" +import * as TuiAudio from "@tui/util/audio" +import defaultSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" } +import questionSoundPath from "@opencode-ai/ui/audio/bip-bop-03.mp3" with { type: "file" } +import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with { type: "file" } +import errorSoundPath from "@opencode-ai/ui/audio/nope-03.mp3" with { type: "file" } +import doneSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" } +import subagentDoneSoundPath from "@opencode-ai/ui/audio/yup-01.mp3" with { type: "file" } +import * as Log from "@opencode-ai/core/util/log" + +type FocusState = "unknown" | "focused" | "blurred" + +type AttentionRenderer = { + readonly isDestroyed: boolean + on(event: "focus" | "blur", listener: () => void): unknown + off(event: "focus" | "blur", listener: () => void): unknown + triggerNotification(message: string, title?: string): boolean +} + +type RegisteredSoundPack = TuiAttentionSoundPack & { + builtin: boolean +} + +type TuiAttentionHost = TuiAttention & { + dispose(): void +} + +const log = Log.create({ service: "tui.attention" }) + +const DEFAULT_TITLE = "opencode" +const DEFAULT_PACK_ID = "opencode.default" +const KV_SOUND_PACK = "attention_sound_pack" +const TITLE_LIMIT = 80 +const MESSAGE_LIMIT = 240 +const BUILTIN_PACK: RegisteredSoundPack = { + id: DEFAULT_PACK_ID, + name: "OpenCode Default", + builtin: true, + sounds: { + default: defaultSoundPath, + question: questionSoundPath, + permission: permissionSoundPath, + error: errorSoundPath, + done: doneSoundPath, + subagent_done: subagentDoneSoundPath, + }, +} + +function skipped(reason: TuiAttentionNotifySkipReason): TuiAttentionNotifyResult { + return { + ok: false, + notification: false, + sound: false, + skipped: reason, + } +} + +function normalizeText(input: string | undefined, fallback: string, limit: number) { + const text = stripAnsi(input ?? "") + .replace(/[ \t]*[\r\n]+[ \t]*/g, " ") + .replace(/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, "") + .trim() + const normalized = text.length ? text : fallback + return Array.from(normalized).slice(0, limit).join("") +} + +function clampVolume(volume: number) { + if (!Number.isFinite(volume)) return 0 + return Math.min(1, Math.max(0, volume)) +} + +function soundVolume(input: TuiAttentionNotifyInput, config: Pick) { + if (!config.attention.sound) return + if (input.sound === false) return + if (input.sound === undefined) return clampVolume(config.attention.volume) + if (input.sound === true) return clampVolume(config.attention.volume) + return clampVolume(input.sound.volume ?? config.attention.volume) +} + +function normalizePack(pack: TuiAttentionSoundPack): RegisteredSoundPack | undefined { + const id = pack.id.trim() + if (!id) return + return { + id, + name: pack.name?.trim() || undefined, + builtin: false, + sounds: Object.fromEntries( + Object.entries(pack.sounds).filter( + (item): item is [TuiAttentionSoundName, string] => + isAttentionSoundName(item[0]) && typeof item[1] === "string" && item[1].trim().length > 0, + ), + ), + } +} + +function focusSkip(when: TuiAttentionWhen, focus: FocusState) { + if (when === "always") return + if (focus === "unknown") return "focus_unknown" + if (when === "blurred" && focus === "focused") return "focused" + if (when === "focused" && focus === "blurred") return "blurred" +} + +export function createTuiAttention(input: { + renderer: AttentionRenderer + config: Pick + kv?: TuiKV + audio?: Pick +}): TuiAttentionHost { + let focus: FocusState = "unknown" + let disposed = false + let activePackID: string | undefined + const packs = new Map([[BUILTIN_PACK.id, BUILTIN_PACK]]) + const audio = input.audio ?? TuiAudio + + const onFocus = () => { + focus = "focused" + } + const onBlur = () => { + focus = "blurred" + } + + input.renderer.on("focus", onFocus) + input.renderer.on("blur", onBlur) + + function configuredPackID() { + const stored = input.kv?.get(KV_SOUND_PACK, undefined) + return activePackID ?? stored ?? input.config.attention.sound_pack + } + + function currentPack() { + return packs.get(configuredPackID()) ?? BUILTIN_PACK + } + + function soundCandidates(name: TuiAttentionSoundName) { + return [input.config.attention.sounds[name], currentPack().sounds[name], BUILTIN_PACK.sounds[name]].filter( + (item, index, list): item is string => typeof item === "string" && list.indexOf(item) === index, + ) + } + + async function playSound(name: TuiAttentionSoundName, volume: number) { + try { + for (const file of soundCandidates(name)) { + const current = await audio.loadSoundFile(file).catch((error) => { + log.debug("failed to load attention sound", { file, error }) + return null + }) + if (disposed) return false + if (current == null) continue + if (audio.play(current, { volume }) != null) return true + } + return false + } catch (error) { + log.debug("failed to play attention sound", { error }) + return false + } + } + + return { + async notify(request) { + try { + if (!input.config.attention.enabled) return skipped("attention_disabled") + if (disposed || input.renderer.isDestroyed) return skipped("renderer_destroyed") + + const message = normalizeText(request.message, "", MESSAGE_LIMIT) + if (!message) return skipped("empty_message") + + const requestedNotification = typeof request.notification === "object" ? request.notification : undefined + const notificationSkip = focusSkip(requestedNotification?.when ?? "blurred", focus) + const notificationRequested = input.config.attention.notifications && request.notification !== false + const shouldNotify = notificationRequested && !notificationSkip + const notification = shouldNotify + ? (() => { + try { + return input.renderer.triggerNotification( + message, + normalizeText(request.title, DEFAULT_TITLE, TITLE_LIMIT), + ) + } catch (error) { + log.debug("failed to trigger attention notification", { error }) + return false + } + })() + : false + const volume = soundVolume(request, input.config) + const requestedSound = typeof request.sound === "object" ? request.sound : undefined + const soundSkip = volume === undefined ? undefined : focusSkip(requestedSound?.when ?? "always", focus) + const soundName = requestedSound?.name && isAttentionSoundName(requestedSound.name) ? requestedSound.name : "default" + const sound = volume === undefined || soundSkip ? false : await playSound(soundName, volume) + + if (!notification && !sound) { + if (notificationRequested && notificationSkip) return skipped(notificationSkip) + if (soundSkip) return skipped(soundSkip) + } + + return { + ok: notification || sound, + notification, + sound, + } + } catch (error) { + log.debug("failed to handle attention notification", { error }) + return { + ok: false, + notification: false, + sound: false, + } + } + }, + soundboard: { + registerPack(pack) { + const next = normalizePack(pack) + if (!next) return () => {} + packs.set(next.id, next) + let disposed = false + return () => { + if (disposed) return + disposed = true + if (packs.get(next.id) === next) packs.delete(next.id) + } + }, + activate(id, options) { + const pack = packs.get(id) + if (!pack) return false + activePackID = pack.id + if (options?.persist) input.kv?.set(KV_SOUND_PACK, pack.id) + return true + }, + current() { + return currentPack().id + }, + list(): TuiAttentionSoundPackInfo[] { + const current = currentPack().id + return Array.from(packs.values()).map((pack) => ({ + id: pack.id, + name: pack.name, + active: pack.id === current, + builtin: pack.builtin, + })) + }, + }, + dispose() { + if (disposed) return + disposed = true + input.renderer.off("focus", onFocus) + input.renderer.off("blur", onBlur) + }, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 80765da3c..2c99f2a5e 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -1,12 +1,47 @@ import { ConfigPlugin } from "@/config/plugin" import { TuiKeybind } from "./keybind" import { Schema } from "effect" +import { isRecord } from "@/util/record" +import { Filesystem } from "@/util/filesystem" +import { TuiAttentionSoundNames, type TuiAttentionSoundName } from "@opencode-ai/plugin/tui" + +export type TuiAttentionSoundPaths = Partial> + +export function isAttentionSoundName(value: string): value is TuiAttentionSoundName { + return TuiAttentionSoundNames.includes(value as TuiAttentionSoundName) +} + +export function resolveAttentionSoundPaths( + root: string, + sounds: unknown, + options?: { trim?: boolean }, +): TuiAttentionSoundPaths { + if (!isRecord(sounds)) return {} + return Object.fromEntries( + Object.entries(sounds).flatMap(([name, file]) => { + if (!isAttentionSoundName(name)) return [] + if (typeof file !== "string") return [] + const value = options?.trim ? file.trim() : file + if (!value) return [] + return [[name, Filesystem.resolveFilePath(root, value)]] + }), + ) +} export const KeymapLeaderTimeoutDefault = 2000 const KeymapLeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({ description: "Leader key timeout in milliseconds", }) +const TuiAttentionSounds = Schema.Struct({ + default: Schema.optional(Schema.String), + question: Schema.optional(Schema.String), + permission: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), + done: Schema.optional(Schema.String), + subagent_done: Schema.optional(Schema.String), +}) + export const ScrollSpeed = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001)) export const ScrollAcceleration = Schema.Struct({ @@ -17,6 +52,15 @@ export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({ description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column", }) +export const Attention = Schema.Struct({ + enabled: Schema.optional(Schema.Boolean), + notifications: Schema.optional(Schema.Boolean), + sound: Schema.optional(Schema.Boolean), + volume: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1))), + sound_pack: Schema.optional(Schema.String), + sounds: Schema.optional(TuiAttentionSounds), +}).annotate({ description: "Attention notification and sound settings" }) + export const TuiInfo = Schema.Struct({ $schema: Schema.optional(Schema.String), theme: Schema.optional(Schema.String), @@ -24,6 +68,7 @@ export const TuiInfo = Schema.Struct({ plugin: Schema.optional(Schema.Array(ConfigPlugin.Spec)), plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), leader_timeout: Schema.optional(KeymapLeaderTimeout), + attention: Schema.optional(Attention), scroll_speed: Schema.optional(ScrollSpeed).annotate({ description: "TUI scroll speed", }), diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index e53e20d34..562b369db 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -1,5 +1,6 @@ export * as TuiConfig from "./tui" +import path from "path" import { createBindingLookup } from "@opentui/keymap/extras" import { mergeDeep, unique } from "remeda" import { Context, Effect, Fiber, Layer, Schema } from "effect" @@ -7,7 +8,7 @@ import { ConfigParse } from "@/config/parse" import { InvalidError } from "@/config/error" import * as ConfigPaths from "@/config/paths" import { migrateTuiConfig } from "./tui-migrate" -import { KeymapLeaderTimeoutDefault, TuiInfo } from "./tui-schema" +import { KeymapLeaderTimeoutDefault, resolveAttentionSoundPaths, TuiInfo } from "./tui-schema" import { Flag } from "@opencode-ai/core/flag/flag" import { isRecord } from "@/util/record" import { Global } from "@opencode-ai/core/global" @@ -22,6 +23,7 @@ import * as Log from "@opencode-ai/core/util/log" import { ConfigVariable } from "@/config/variable" import { Npm } from "@opencode-ai/core/npm" import type { DeepMutable } from "@opencode-ai/core/schema" +import type { TuiAttentionSoundName } from "@opencode-ai/plugin/tui" const log = Log.create({ service: "tui.config" }) @@ -33,7 +35,15 @@ type Acc = { plugin_origins: ConfigPlugin.Origin[] } -export type Resolved = Omit & { +export type Resolved = Omit & { + attention: { + enabled: boolean + notifications: boolean + sound: boolean + volume: number + sound_pack: string + sounds: Partial> + } keybinds: TuiKeybind.BindingLookupView leader_timeout: number // Internal resolved plugin list used by runtime loading. @@ -101,7 +111,16 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: }) } } - const validated = ConfigParse.schema(Info, normalized, configFilepath) + const parsed = ConfigParse.schema(Info, normalized, configFilepath) + const validated = parsed.attention?.sounds + ? { + ...parsed, + attention: { + ...parsed.attention, + sounds: resolveAttentionSoundPaths(path.dirname(configFilepath), parsed.attention.sounds), + }, + } + : parsed return yield* resolvePlugins(validated, configFilepath) }).pipe( // catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation @@ -197,6 +216,14 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: const parsedKeybinds = TuiKeybind.parse(keybinds) const result: Resolved = { ...acc.result, + attention: { + enabled: acc.result.attention?.enabled ?? false, + notifications: acc.result.attention?.notifications ?? true, + sound: acc.result.attention?.sound ?? true, + volume: acc.result.attention?.volume ?? 0.4, + sound_pack: acc.result.attention?.sound_pack ?? "opencode.default", + sounds: acc.result.attention?.sounds ?? {}, + }, keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), { commandMap: TuiKeybind.CommandMap, bindingDefaults: TuiKeybind.bindingDefaults(), diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx index 07a2844e9..8c50914df 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx @@ -1,11 +1,52 @@ -import { createMemo, For } from "solid-js" +import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import { createMemo, For, type Accessor } from "solid-js" import { DEFAULT_THEMES, useTheme } from "@tui/context/theme" import { Flag } from "@opencode-ai/core/flag/flag" +import { useCommandShortcut } from "../../keymap" const themeCount = Object.keys(DEFAULT_THEMES).length -const themeTip = `Use {highlight}/themes{/highlight} or {highlight}Ctrl+X T{/highlight} to switch between ${themeCount} built-in themes` type TipPart = { text: string; highlight: boolean } +type TipShortcut = Accessor +type Shortcuts = { + agentCycle: TipShortcut + childFirst: TipShortcut + childNext: TipShortcut + childPrevious: TipShortcut + commandList: TipShortcut + editorOpen: TipShortcut + helpShow: TipShortcut + inputClear: TipShortcut + inputNewline: TipShortcut + inputPaste: TipShortcut + inputUndo: TipShortcut + leader: TipShortcut + messagesCopy: TipShortcut + messagesFirst: TipShortcut + messagesLast: TipShortcut + messagesPageDown: TipShortcut + messagesPageUp: TipShortcut + messagesToggleConceal: TipShortcut + modelCycleRecent: TipShortcut + modelList: TipShortcut + sessionCycleRecent: TipShortcut + sessionCycleRecentReverse: TipShortcut + sessionExport: TipShortcut + sessionInterrupt: TipShortcut + sessionList: TipShortcut + sessionNew: TipShortcut + sessionParent: TipShortcut + sessionPinToggle: TipShortcut + sessionQuickSwitch1: TipShortcut + sessionQuickSwitch9: TipShortcut + sessionSidebarToggle: TipShortcut + sessionTimeline: TipShortcut + sessionToggleRecent: TipShortcut + statusView: TipShortcut + terminalSuspend: TipShortcut + themeList: TipShortcut +} +type Tip = string | ((shortcuts: Shortcuts) => string | undefined) function parse(tip: string): TipPart[] { const parts: TipPart[] = [] @@ -33,17 +74,86 @@ function parse(tip: string): TipPart[] { const NO_MODELS_TIP = "Run {highlight}/connect{/highlight} to add an AI provider and start coding" -export function Tips(props: { connected?: boolean }) { +function shortcutText(value: string) { + return `{highlight}${value}{/highlight}` +} + +function commandText(command: string, shortcut: string) { + if (!shortcut) return shortcutText(command) + return `${shortcutText(command)} or ${shortcutText(shortcut)}` +} + +function press(shortcut: string, text: string) { + if (!shortcut) return undefined + return `Press ${shortcutText(shortcut)} ${text}` +} + +function configShortcut(api: TuiPluginApi, command: string): TipShortcut { + return () => + api.tuiConfig.keybinds + .get(command) + .map((binding) => api.keys.formatSequence(Array.from(api.keymap.parseKeySequence(binding.key)))) + .filter(Boolean) + .join(", ") +} + +export function Tips(props: { api: TuiPluginApi; connected?: boolean }) { const theme = useTheme().theme - const randomTip = TIPS[Math.floor(Math.random() * TIPS.length)] - const parts = createMemo(() => parse(props.connected === false ? NO_MODELS_TIP : randomTip)) + const tipOffset = Math.random() + const shortcuts: Shortcuts = { + agentCycle: useCommandShortcut("agent.cycle"), + childFirst: configShortcut(props.api, "session.child.first"), + childNext: configShortcut(props.api, "session.child.next"), + childPrevious: configShortcut(props.api, "session.child.previous"), + commandList: useCommandShortcut("command.palette.show"), + editorOpen: useCommandShortcut("prompt.editor"), + helpShow: useCommandShortcut("help.show"), + inputClear: useCommandShortcut("prompt.clear"), + inputNewline: useCommandShortcut("input.newline"), + inputPaste: useCommandShortcut("prompt.paste"), + inputUndo: useCommandShortcut("input.undo"), + leader: configShortcut(props.api, "leader"), + messagesCopy: configShortcut(props.api, "messages.copy"), + messagesFirst: configShortcut(props.api, "session.first"), + messagesLast: configShortcut(props.api, "session.last"), + messagesPageDown: configShortcut(props.api, "session.page.down"), + messagesPageUp: configShortcut(props.api, "session.page.up"), + messagesToggleConceal: configShortcut(props.api, "session.toggle.conceal"), + modelCycleRecent: useCommandShortcut("model.cycle_recent"), + modelList: useCommandShortcut("model.list"), + sessionCycleRecent: useCommandShortcut("session.cycle_recent"), + sessionCycleRecentReverse: useCommandShortcut("session.cycle_recent_reverse"), + sessionExport: configShortcut(props.api, "session.export"), + sessionInterrupt: configShortcut(props.api, "session.interrupt"), + sessionList: useCommandShortcut("session.list"), + sessionNew: useCommandShortcut("session.new"), + sessionParent: configShortcut(props.api, "session.parent"), + sessionPinToggle: configShortcut(props.api, "session.pin.toggle"), + sessionQuickSwitch1: useCommandShortcut("session.quick_switch.1"), + sessionQuickSwitch9: useCommandShortcut("session.quick_switch.9"), + sessionSidebarToggle: configShortcut(props.api, "session.sidebar.toggle"), + sessionTimeline: configShortcut(props.api, "session.timeline"), + sessionToggleRecent: configShortcut(props.api, "session.toggle.recent"), + statusView: useCommandShortcut("opencode.status"), + terminalSuspend: useCommandShortcut("terminal.suspend"), + themeList: useCommandShortcut("theme.switch"), + } + const tip = createMemo(() => { + if (props.connected === false) return NO_MODELS_TIP + const tips = TIPS.flatMap((item) => { + const value = typeof item === "string" ? item : item(shortcuts) + return value ? [value] : [] + }) + return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP + }) + const parts = createMemo(() => parse(tip())) return ( ● Tip{" "} - + {(part) => {part.text}} @@ -52,46 +162,66 @@ export function Tips(props: { connected?: boolean }) { ) } -const TIPS = [ +const TIPS: Tip[] = [ "Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files", "Start a message with {highlight}!{/highlight} to run shell commands directly (e.g., {highlight}!ls -la{/highlight})", - "Press {highlight}Tab{/highlight} to cycle between Build and Plan agents", + (shortcuts) => press(shortcuts.agentCycle(), "to cycle between Build and Plan agents"), "Use {highlight}/undo{/highlight} to revert the last message and file changes", "Use {highlight}/redo{/highlight} to restore previously undone messages and file changes", "Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai", "Drag and drop images or PDFs into the terminal to add them as context", - "Press {highlight}Ctrl+V{/highlight} to paste images from your clipboard into the prompt", - "Press {highlight}Ctrl+X E{/highlight} or {highlight}/editor{/highlight} to compose messages in your external editor", + (shortcuts) => press(shortcuts.inputPaste(), "to paste images from your clipboard into the prompt"), + (shortcuts) => `Use ${commandText("/editor", shortcuts.editorOpen())} to compose messages in your external editor`, "Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase", - "Run {highlight}/models{/highlight} or {highlight}Ctrl+X M{/highlight} to see and switch between available AI models", - themeTip, - "Press {highlight}Ctrl+X N{/highlight} or {highlight}/new{/highlight} to start a fresh conversation session", - "Use {highlight}/sessions{/highlight} or {highlight}Ctrl+X L{/highlight} to list and continue previous conversations", + (shortcuts) => `Use ${commandText("/models", shortcuts.modelList())} to see and switch between available AI models`, + (shortcuts) => `Use ${commandText("/themes", shortcuts.themeList())} to switch between ${themeCount} built-in themes`, + (shortcuts) => `Use ${commandText("/new", shortcuts.sessionNew())} to start a fresh conversation session`, + (shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list and continue previous conversations`, ...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING - ? [ - "Press {highlight}Ctrl+F{/highlight} in the session list to pin a session so it stays at the top", - "Pinned and recent sessions are bound to {highlight}Ctrl+X 1{/highlight} through {highlight}Ctrl+X 9{/highlight} for one-press switching", - "Press {highlight}Ctrl+X ]{/highlight} / {highlight}Ctrl+X [{/highlight} to cycle through recently visited sessions", - "Press {highlight}Ctrl+H{/highlight} in the session list to show or hide a session in the Recent group", - ] + ? ([ + (shortcuts) => + press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"), + (shortcuts) => + shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9() + ? `Pinned and recent sessions are bound to ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} for one-press switching` + : undefined, + (shortcuts) => + shortcuts.sessionCycleRecent() && shortcuts.sessionCycleRecentReverse() + ? `Press ${shortcutText(shortcuts.sessionCycleRecent())} / ${shortcutText(shortcuts.sessionCycleRecentReverse())} to cycle through recently visited sessions` + : undefined, + (shortcuts) => + press(shortcuts.sessionToggleRecent(), "in the session list to show or hide a session in the Recent group"), + ] satisfies Tip[]) : []), "Run {highlight}/compact{/highlight} to summarize long sessions near context limits", - "Press {highlight}Ctrl+X X{/highlight} or {highlight}/export{/highlight} to save the conversation as Markdown", - "Press {highlight}Ctrl+X Y{/highlight} to copy the assistant's last message to clipboard", - "Press {highlight}Ctrl+P{/highlight} to see all available actions and commands", + (shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`, + (shortcuts) => press(shortcuts.messagesCopy(), "to copy the assistant's last message to clipboard"), + (shortcuts) => press(shortcuts.commandList(), "to see all available actions and commands"), "Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers", - "The leader key is {highlight}Ctrl+X{/highlight}; combine with other keys for quick actions", - "Press {highlight}F2{/highlight} to quickly switch between recently used models", - "Press {highlight}Ctrl+X B{/highlight} to show/hide the sidebar panel", - "Use {highlight}PageUp{/highlight}/{highlight}PageDown{/highlight} to navigate through conversation history", - "Press {highlight}Ctrl+G{/highlight} or {highlight}Home{/highlight} to jump to the beginning of the conversation", - "Press {highlight}Ctrl+Alt+G{/highlight} or {highlight}End{/highlight} to jump to the most recent message", - "Press {highlight}Shift+Enter{/highlight} or {highlight}Ctrl+J{/highlight} to add newlines in your prompt", - "Press {highlight}Ctrl+C{/highlight} when typing to clear the input field", - "Press {highlight}Escape{/highlight} to stop the AI mid-response", + (shortcuts) => `The leader key is ${shortcutText(shortcuts.leader())}; combine with other keys for quick actions`, + (shortcuts) => press(shortcuts.modelCycleRecent(), "to quickly switch between recently used models"), + (shortcuts) => press(shortcuts.sessionSidebarToggle(), "in a session to show or hide the sidebar panel"), + (shortcuts) => + shortcuts.messagesPageUp() && shortcuts.messagesPageDown() + ? `Use ${shortcutText(shortcuts.messagesPageUp())}/${shortcutText(shortcuts.messagesPageDown())} to navigate through conversation history` + : undefined, + (shortcuts) => press(shortcuts.messagesFirst(), "to jump to the beginning of the conversation"), + (shortcuts) => press(shortcuts.messagesLast(), "to jump to the most recent message"), + (shortcuts) => press(shortcuts.inputNewline(), "to add newlines in your prompt"), + (shortcuts) => press(shortcuts.inputClear(), "when typing to clear the input field"), + (shortcuts) => press(shortcuts.sessionInterrupt(), "to stop the AI mid-response"), "Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes", "Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents", - "Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions", + (shortcuts) => { + const items = [ + shortcuts.sessionParent(), + shortcuts.childFirst(), + shortcuts.childPrevious(), + shortcuts.childNext(), + ].filter(Boolean) + if (!items.length) return undefined + return `Use ${items.map(shortcutText).join(" / ")} to move between parent and child sessions` + }, "Create {highlight}opencode.json{/highlight} for server settings and {highlight}tui.json{/highlight} for TUI settings", "Place TUI settings in {highlight}~/.config/opencode/tui.json{/highlight} for global config", "Add {highlight}$schema{/highlight} to your config for autocomplete in your editor", @@ -99,22 +229,21 @@ const TIPS = [ "Override any keybind in {highlight}tui.json{/highlight} via the {highlight}keybinds{/highlight} section", "Set any keybind to {highlight}none{/highlight} to disable it completely", "Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section", - "OpenCode auto-handles OAuth for remote MCP servers requiring auth", - "Add {highlight}.md{/highlight} files to {highlight}.opencode/command/{/highlight} to define reusable custom prompts", + "Add {highlight}.md{/highlight} files to {highlight}.opencode/commands/{/highlight} to define reusable custom prompts", "Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input", "Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight})", - "Add {highlight}.md{/highlight} files to {highlight}.opencode/agent/{/highlight} for specialized AI personas", + "Add {highlight}.md{/highlight} files to {highlight}.opencode/agents/{/highlight} for specialized AI personas", "Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools", 'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions', 'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands', 'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing', - "OpenCode auto-formats files using prettier, gofmt, ruff, and more", - 'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting', + 'Set {highlight}"formatter": true{/highlight} in config to enable built-in formatters like prettier, gofmt, and ruff', + 'Set {highlight}"formatter": false{/highlight} in config to disable formatters enabled by another config layer', "Define custom formatter commands with file extensions in config", - "OpenCode uses LSP servers for intelligent code analysis", + 'Set {highlight}"lsp": true{/highlight} in config to enable built-in LSP servers for code analysis', "Create {highlight}.ts{/highlight} files in {highlight}.opencode/tools/{/highlight} to define new LLM tools", "Tool definitions can invoke scripts written in Python, Go, etc", - "Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks", + "Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugins/{/highlight} for event hooks", "Use plugins to send OS notifications when sessions complete", "Create a plugin to prevent OpenCode from reading sensitive files", "Use {highlight}opencode run{/highlight} for non-interactive scripting", @@ -133,7 +262,7 @@ const TIPS = [ 'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors', "Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory", "Themes support dark/light variants for both modes", - "Reference ANSI colors 0-255 in custom themes", + "Use numeric xterm color codes 0-255 in custom theme JSON", "Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config", "Use {highlight}{file:path}{/highlight} to include file contents in config values", "Use {highlight}instructions{/highlight} in config to load additional rules files", @@ -149,18 +278,23 @@ const TIPS = [ "Permission {highlight}external_directory{/highlight} protects files outside project", "Run {highlight}opencode debug config{/highlight} to troubleshoot configuration", "Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr", - "Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages", - "Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages", - "Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info", + (shortcuts) => `Use ${commandText("/timeline", shortcuts.sessionTimeline())} to jump to specific messages`, + (shortcuts) => press(shortcuts.messagesToggleConceal(), "to toggle code block visibility in messages"), + (shortcuts) => `Use ${commandText("/status", shortcuts.statusView())} to see system status info`, "Enable {highlight}scroll_acceleration{/highlight} in {highlight}tui.json{/highlight} for smooth macOS-style scrolling", - "Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})", + (shortcuts) => + shortcuts.commandList() + ? `Toggle username display in chat via the command palette (${shortcutText(shortcuts.commandList())})` + : "Toggle username display in chat via the command palette", "Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use", "Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models", "Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing", "Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs", - "Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog", + (shortcuts) => `Use ${commandText("/help", shortcuts.helpShow())} to show the help dialog`, "Use {highlight}/rename{/highlight} to rename the current session", ...(process.platform === "win32" - ? ["Press {highlight}Ctrl+Z{/highlight} to undo changes in your prompt"] - : ["Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell"]), + ? ([(shortcuts) => press(shortcuts.inputUndo(), "to undo changes in your prompt")] satisfies Tip[]) + : ([ + (shortcuts) => press(shortcuts.terminalSuspend(), "to suspend the terminal and return to your shell"), + ] satisfies Tip[])), ] diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx index 69071b1f7..598366c08 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx @@ -24,9 +24,9 @@ function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connec })) return ( - + - + ) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/notifications.ts b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/notifications.ts new file mode 100644 index 000000000..cda815f5f --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/notifications.ts @@ -0,0 +1,94 @@ +import type { Event } from "@opencode-ai/sdk/v2" +import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { InternalTuiPlugin } from "../../plugin/internal" + +const id = "internal:notifications" + +type SessionError = Extract["properties"]["error"] + +function notify(api: TuiPluginApi, sessionID: string | undefined, message: string, sound: TuiAttentionSoundName) { + const session = sessionID ? api.state.session.get(sessionID) : undefined + const isSubagent = session?.parentID !== undefined + void api.attention.notify({ + title: session?.title, + message, + notification: isSubagent ? false : { when: "blurred" }, + sound: { name: sound, when: "always" }, + }) +} + +function sessionErrorMessage(error: SessionError) { + if (error?.name === "MessageAbortedError") return "Session aborted" + const data = error?.data + if (data && typeof data === "object" && "message" in data && data.message === "SSE read timed out") { + return "Model stopped responding" + } + return "Session error" +} + +const tui: TuiPlugin = async (api) => { + const active = new Set() + const errored = new Set() + const questions = new Set() + const permissions = new Set() + + api.event.on("question.asked", (event) => { + if (questions.has(event.properties.id)) return + questions.add(event.properties.id) + notify(api, event.properties.sessionID, "Question needs input", "question") + }) + + api.event.on("question.replied", (event) => { + questions.delete(event.properties.requestID) + }) + + api.event.on("question.rejected", (event) => { + questions.delete(event.properties.requestID) + }) + + api.event.on("permission.asked", (event) => { + if (permissions.has(event.properties.id)) return + permissions.add(event.properties.id) + notify(api, event.properties.sessionID, "Permission needs input", "permission") + }) + + api.event.on("permission.replied", (event) => { + permissions.delete(event.properties.requestID) + }) + + api.event.on("session.status", (event) => { + const sessionID = event.properties.sessionID + if (event.properties.status.type === "busy" || event.properties.status.type === "retry") { + active.add(sessionID) + errored.delete(sessionID) + return + } + + if (event.properties.status.type !== "idle") return + if (!active.has(sessionID)) return + active.delete(sessionID) + + if (errored.has(sessionID)) { + errored.delete(sessionID) + return + } + + const session = api.state.session.get(sessionID) + notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done") + }) + + api.event.on("session.error", (event) => { + const sessionID = event.properties.sessionID + if (!sessionID) return + if (!active.has(sessionID)) return + errored.add(sessionID) + notify(api, sessionID, sessionErrorMessage(event.properties.error), "error") + }) +} + +const plugin: InternalTuiPlugin = { + id, + tui, +} + +export default plugin diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 8958a9285..05bfa31d1 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -40,6 +40,7 @@ type Input = { theme: ReturnType toast: ReturnType renderer: TuiPluginApi["renderer"] + attention: TuiPluginApi["attention"] } function routeRegister(routes: RouteMap, list: TuiRouteDefinition[], bump: () => void) { @@ -206,6 +207,7 @@ export function createTuiApi(input: Input): TuiPluginApi { } return { app: appApi(), + attention: input.attention, // Keep deprecated `api.command` working for v1 plugins; remove in v2. command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds), keys: { diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 664b5c1ac..eaa9dfb32 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -7,6 +7,7 @@ import SidebarTodo from "../feature-plugins/sidebar/todo" import SidebarFiles from "../feature-plugins/sidebar/files" import SidebarFooter from "../feature-plugins/sidebar/footer" import PluginManager from "../feature-plugins/system/plugins" +import Notifications from "../feature-plugins/system/notifications" import SessionV2Debug from "../feature-plugins/system/session-v2" import WhichKey from "../feature-plugins/system/which-key" import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" @@ -27,6 +28,7 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [ SidebarTodo, SidebarFiles, SidebarFooter, + Notifications, PluginManager, WhichKey, ...(Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []), diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index dad4595e7..4af16d2b8 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -17,6 +17,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui" import * as Log from "@opencode-ai/core/util/log" import { errorData, errorMessage } from "@/util/error" import { isRecord } from "@/util/record" +import { resolveAttentionSoundPaths } from "../config/tui-schema" import { readPackageThemes, readPluginId, @@ -51,7 +52,7 @@ type PluginLoad = { id: string module: TuiPluginModule origin: ConfigPlugin.Origin - theme_root: string + plugin_root: string theme_files: string[] } @@ -106,6 +107,7 @@ const ScopedKeymapMethods = new Set([ type RuntimeState = { directory: string api: Api + dispose?: () => void slots: HostSlots plugins: PluginEntry[] plugins_by_id: Map @@ -156,6 +158,37 @@ function createScopedKeymap(keymap: TuiPluginApi["keymap"], scope: PluginScope): }) } +function createScopedAttention( + attention: TuiPluginApi["attention"], + scope: PluginScope, + root: string, +): TuiPluginApi["attention"] { + return { + notify(input) { + return attention.notify(input) + }, + soundboard: { + registerPack(pack) { + return scope.track( + attention.soundboard.registerPack({ + ...pack, + sounds: resolveAttentionSoundPaths(root, pack.sounds, { trim: true }), + }), + ) + }, + activate(id, options) { + return attention.soundboard.activate(id, options) + }, + current() { + return attention.soundboard.current() + }, + list() { + return attention.soundboard.list() + }, + }, + } +} + type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" } function runCleanup(fn: () => unknown, ms: number): Promise { @@ -204,8 +237,7 @@ function createThemeInstaller( plugin: PluginEntry, ): TuiTheme["install"] { return async (file) => { - const raw = file.startsWith("file://") ? fileURLToPath(file) : file - const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw) + const src = Filesystem.resolveFilePath(root, file) const name = path.basename(src, path.extname(src)) const source_dir = path.dirname(meta.source) const local_dir = @@ -330,7 +362,7 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad { scope: "global", source: target, }, - theme_root: process.cwd(), + plugin_root: process.cwd(), theme_files: [], } } @@ -352,7 +384,7 @@ async function readThemeFiles(spec: string, pkg?: PluginPackage) { async function syncPluginThemes(plugin: PluginEntry) { if (!plugin.load.theme_files.length) return if (plugin.meta.state === "same") return - const install = createThemeInstaller(plugin.load.origin, plugin.load.theme_root, plugin.load.spec, plugin) + const install = createThemeInstaller(plugin.load.origin, plugin.load.plugin_root, plugin.load.spec, plugin) for (const file of plugin.load.theme_files) { await install(file).catch((error) => { warn("failed to sync tui plugin oc-themes", { path: plugin.load.spec, id: plugin.id, theme: file, error }) @@ -552,7 +584,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop } const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), { - install: createThemeInstaller(load.origin, load.theme_root, load.spec, plugin), + install: createThemeInstaller(load.origin, load.plugin_root, load.spec, plugin), }) const event: TuiPluginApi["event"] = { @@ -576,6 +608,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop return { app: api.app, + attention: createScopedAttention(api.attention, scope, load.plugin_root), // Keep deprecated `api.command` working for v1 plugins; remove in v2. command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds), keys: api.keys, @@ -682,7 +715,7 @@ async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => P id, module: mod, origin, - theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target), + plugin_root: loaded.pkg?.dir ?? resolveRoot(loaded.target), theme_files, } }, @@ -709,7 +742,7 @@ async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => P id, module: EMPTY_TUI, origin, - theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target), + plugin_root: loaded.pkg?.dir ?? resolveRoot(loaded.target), theme_files, } }, @@ -967,7 +1000,7 @@ let loaded: Promise | undefined let runtime: RuntimeState | undefined export const Slot = View -export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved }) { +export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved; dispose?: () => void }) { const cwd = process.cwd() if (loaded) { if (dir !== cwd) { @@ -1014,15 +1047,17 @@ export async function dispose() { for (const plugin of queue) { await deactivatePluginEntry(state, plugin, false) } + state.dispose?.() } -async function load(input: { api: Api; config: TuiConfig.Resolved }) { +async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: () => void }) { const { api, config } = input const cwd = process.cwd() const slots = setupSlots(api) const next: RuntimeState = { directory: cwd, api, + dispose: input.dispose, slots, plugins: [], plugins_by_id: new Map(), diff --git a/packages/opencode/src/cli/cmd/tui/util/audio.ts b/packages/opencode/src/cli/cmd/tui/util/audio.ts new file mode 100644 index 000000000..7d7c3d5a4 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/audio.ts @@ -0,0 +1,58 @@ +import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core" +import * as Log from "@opencode-ai/core/util/log" + +const log = Log.create({ service: "tui.audio" }) + +let audio: Audio | null | undefined +const sounds = new Map>() + +function getAudio() { + if (audio !== undefined) return audio + try { + const next = Audio.create({ autoStart: false }) + next.on("error", (error: Error, context: AudioErrorContext) => { + log.debug("tui audio error", { error, context }) + }) + audio = next + return next + } catch (error) { + log.debug("failed to create tui audio", { error }) + audio = null + return null + } +} + +export function loadSoundFile(file: string) { + const current = getAudio() + if (!current) return Promise.resolve(null) + const cached = sounds.get(file) + if (cached) return cached + const task = Bun.file(file) + .bytes() + .then((bytes) => current.loadSound(bytes)) + .catch((error) => { + log.debug("failed to load tui sound", { file, error }) + return null + }) + sounds.set(file, task) + return task +} + +export function play(sound: AudioSound, options?: AudioPlayOptions) { + const current = getAudio() + if (!current) return null + if (!current.isStarted() && !current.start()) return null + return current.play(sound, options) +} + +export function stopVoice(voice: AudioVoice) { + return audio?.stopVoice(voice) ?? false +} + +export function dispose() { + audio?.dispose() + audio = undefined + sounds.clear() +} + +export * as TuiAudio from "./audio" diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index ded2b4517..696603adb 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -1,10 +1,11 @@ import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises" import { createWriteStream, existsSync, statSync } from "fs" import { realpathSync } from "fs" -import { dirname, join, relative, resolve as pathResolve, win32 } from "path" +import { dirname, isAbsolute, join, relative, resolve as pathResolve, win32 } from "path" import { Readable } from "stream" import { pipeline } from "stream/promises" import { Glob } from "@opencode-ai/core/util/glob" +import { fileURLToPath } from "url" // Fast sync version for metadata checks export async function exists(p: string): Promise { @@ -142,6 +143,12 @@ export function resolve(p: string): string { } } +export function resolveFilePath(root: string, file: string): string { + const raw = file.startsWith("file://") ? fileURLToPath(file) : file + if (isAbsolute(raw)) return raw + return pathResolve(root, raw) +} + export function windowsPath(p: string): string { if (process.platform !== "win32") return p return ( diff --git a/packages/opencode/test/cli/cmd/tui/attention.test.ts b/packages/opencode/test/cli/cmd/tui/attention.test.ts new file mode 100644 index 000000000..071aabdd7 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/attention.test.ts @@ -0,0 +1,484 @@ +import { describe, expect, test } from "bun:test" +import type { AudioPlayOptions, AudioSound } from "@opentui/core" +import { createTuiAttention } from "@/cli/cmd/tui/attention" +import type { TuiConfig } from "@/cli/cmd/tui/config/tui" + +type FocusEvent = "focus" | "blur" + +type AttentionConfig = Pick + +class FakeRenderer { + isDestroyed = false + notificationResult = true + notificationThrows = false + notifications: { message: string; title: string | undefined }[] = [] + listeners: Record void>> = { + focus: new Set(), + blur: new Set(), + } + + on(event: FocusEvent, listener: () => void) { + this.listeners[event].add(listener) + return this + } + + off(event: FocusEvent, listener: () => void) { + this.listeners[event].delete(listener) + return this + } + + emit(event: FocusEvent) { + for (const listener of this.listeners[event]) listener() + } + + listenerCount(event: FocusEvent) { + return this.listeners[event].size + } + + triggerNotification(message: string, title?: string) { + if (this.notificationThrows) throw new Error("notification failed") + this.notifications.push({ message, title }) + return this.notificationResult + } +} + +class FakeAudioEngine { + loadResult: AudioSound | null = 1 + playResult: number | null = 1 + loadCalls = 0 + playCalls = 0 + volumes: (number | undefined)[] = [] + loadPaths: string[] = [] + rejectLoad = false + rejectPaths = new Set() + + async loadSoundFile(path: string) { + this.loadCalls += 1 + this.loadPaths.push(path) + if (this.rejectLoad || this.rejectPaths.has(path)) throw new Error("decode failed") + return this.loadResult + } + + play(_sound: AudioSound, options?: AudioPlayOptions) { + this.playCalls += 1 + this.volumes.push(options?.volume) + return this.playResult + } +} + +class FakeKV { + store: Record = {} + + get ready() { + return true + } + + get(key: string, fallback?: Value) { + return (this.store[key] ?? fallback) as Value + } + + set(key: string, value: unknown) { + this.store[key] = value + } +} + +function config(attention: Partial = {}): AttentionConfig { + return { + attention: { + enabled: true, + notifications: true, + sound: true, + volume: 0.4, + sound_pack: "opencode.default", + sounds: {}, + ...attention, + }, + } +} + +describe("createTuiAttention", () => { + test("defaults to sound always and notification blurred", async () => { + const renderer = new FakeRenderer() + const audio = new FakeAudioEngine() + const attention = createTuiAttention({ renderer, config: config(), audio }) + + expect(await attention.notify({ message: "hello" })).toEqual({ + ok: true, + notification: false, + sound: true, + }) + expect(renderer.notifications).toHaveLength(0) + expect(audio.playCalls).toBe(1) + }) + + test("supports blurred-only requests", async () => { + const renderer = new FakeRenderer() + const audio = new FakeAudioEngine() + const attention = createTuiAttention({ renderer, config: config(), audio }) + + expect(await attention.notify({ message: "unknown", sound: { when: "blurred" } })).toEqual({ + ok: false, + notification: false, + sound: false, + skipped: "focus_unknown", + }) + renderer.emit("focus") + expect(await attention.notify({ message: "focused", sound: { when: "blurred" } })).toEqual({ + ok: false, + notification: false, + sound: false, + skipped: "focused", + }) + renderer.emit("blur") + expect(await attention.notify({ message: "blurred", sound: { when: "blurred" } })).toEqual({ + ok: true, + notification: true, + sound: true, + }) + expect(audio.playCalls).toBe(1) + }) + + test("supports focused-only requests", async () => { + const renderer = new FakeRenderer() + const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudioEngine() }) + + expect(await attention.notify({ message: "unknown", notification: { when: "focused" }, sound: false })).toEqual({ + ok: false, + notification: false, + sound: false, + skipped: "focus_unknown", + }) + renderer.emit("blur") + expect(await attention.notify({ message: "blurred", notification: { when: "focused" }, sound: false })).toEqual({ + ok: false, + notification: false, + sound: false, + skipped: "blurred", + }) + renderer.emit("focus") + expect(await attention.notify({ message: "focused", notification: { when: "focused" }, sound: false })).toEqual({ + ok: true, + notification: true, + sound: false, + }) + expect(renderer.notifications).toEqual([{ title: "opencode", message: "focused" }]) + }) + + test("notification can deliver while focused when requested", async () => { + const renderer = new FakeRenderer() + const audio = new FakeAudioEngine() + const attention = createTuiAttention({ renderer, config: config(), audio }) + renderer.emit("focus") + + expect(await attention.notify({ message: "hello", notification: { when: "always" } })).toEqual({ + ok: true, + notification: true, + sound: true, + }) + expect(audio.playCalls).toBe(1) + expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello" }]) + }) + + test("notifies while blurred", async () => { + const renderer = new FakeRenderer() + const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudioEngine() }) + renderer.emit("blur") + + expect(await attention.notify({ title: "opencode", message: "hello", sound: false })).toEqual({ + ok: true, + notification: true, + sound: false, + }) + expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello" }]) + }) + + test("when requested, blurred-only calls do not notify or play sound while focused", async () => { + const renderer = new FakeRenderer() + const audio = new FakeAudioEngine() + const attention = createTuiAttention({ renderer, config: config(), audio }) + renderer.emit("focus") + + expect(await attention.notify({ message: "hello", sound: { when: "blurred" } })).toEqual({ + ok: false, + notification: false, + sound: false, + skipped: "focused", + }) + expect(renderer.notifications).toHaveLength(0) + expect(audio.loadCalls).toBe(0) + }) + + test("can play sound always while notification is blurred-only", async () => { + const renderer = new FakeRenderer() + const audio = new FakeAudioEngine() + const attention = createTuiAttention({ renderer, config: config(), audio }) + renderer.emit("focus") + + expect( + await attention.notify({ + message: "hello", + sound: { name: "question" }, + }), + ).toEqual({ + ok: true, + notification: false, + sound: true, + }) + expect(renderer.notifications).toHaveLength(0) + expect(audio.playCalls).toBe(1) + + renderer.emit("blur") + expect( + await attention.notify({ + message: "hello again", + sound: { name: "question" }, + }), + ).toEqual({ + ok: true, + notification: true, + sound: true, + }) + expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello again" }]) + }) + + test("can disable notification per call while still playing sound", async () => { + const renderer = new FakeRenderer() + const audio = new FakeAudioEngine() + const attention = createTuiAttention({ renderer, config: config(), audio }) + + expect(await attention.notify({ message: "hello", notification: false })).toEqual({ + ok: true, + notification: false, + sound: true, + }) + expect(renderer.notifications).toHaveLength(0) + expect(audio.playCalls).toBe(1) + }) + + test("skips empty messages and disabled attention", async () => { + const empty = new FakeRenderer() + empty.emit("blur") + const disabled = new FakeRenderer() + disabled.emit("blur") + + expect(await createTuiAttention({ renderer: empty, config: config() }).notify({ message: " \n " })).toEqual({ + ok: false, + notification: false, + sound: false, + skipped: "empty_message", + }) + expect( + await createTuiAttention({ renderer: disabled, config: config({ enabled: false }) }).notify({ message: "hello" }), + ).toEqual({ + ok: false, + notification: false, + sound: false, + skipped: "attention_disabled", + }) + }) + + test("respects notification and sound config independently", async () => { + const renderer = new FakeRenderer() + const audio = new FakeAudioEngine() + const attention = createTuiAttention({ renderer, config: config({ notifications: false }), audio }) + renderer.emit("blur") + + expect(await attention.notify({ message: "hello", sound: true })).toEqual({ + ok: true, + notification: false, + sound: true, + }) + expect(renderer.notifications).toHaveLength(0) + expect(audio.playCalls).toBe(1) + + const soundDisabledRenderer = new FakeRenderer() + const soundDisabledAudio = new FakeAudioEngine() + const soundDisabled = createTuiAttention({ + renderer: soundDisabledRenderer, + config: config({ sound: false }), + audio: soundDisabledAudio, + }) + soundDisabledRenderer.emit("blur") + + expect(await soundDisabled.notify({ message: "hello", sound: true })).toEqual({ + ok: true, + notification: true, + sound: false, + }) + expect(soundDisabledAudio.loadCalls).toBe(0) + }) + + test("loads audio lazily only for eligible sound requests", async () => { + const renderer = new FakeRenderer() + const audio = new FakeAudioEngine() + const attention = createTuiAttention({ renderer, config: config(), audio }) + + await attention.notify({ message: "unknown", sound: { when: "blurred" } }) + expect(audio.loadCalls).toBe(0) + + renderer.emit("blur") + expect(await attention.notify({ message: "blurred", sound: { volume: 2 } })).toEqual({ + ok: true, + notification: true, + sound: true, + }) + expect(audio.loadCalls).toBe(1) + expect(audio.volumes).toEqual([1]) + }) + + test("handles unavailable playback and delegates sound loading", async () => { + const unavailableRenderer = new FakeRenderer() + const unavailableAudio = new FakeAudioEngine() + unavailableAudio.playResult = null + const unavailable = createTuiAttention({ renderer: unavailableRenderer, config: config(), audio: unavailableAudio }) + unavailableRenderer.emit("blur") + + expect(await unavailable.notify({ message: "hello", sound: true })).toEqual({ + ok: true, + notification: true, + sound: false, + }) + expect(unavailableAudio.loadCalls).toBe(1) + expect(unavailableAudio.playCalls).toBe(1) + + const repeatedRenderer = new FakeRenderer() + const repeatedAudio = new FakeAudioEngine() + const repeated = createTuiAttention({ renderer: repeatedRenderer, config: config(), audio: repeatedAudio }) + repeatedRenderer.emit("blur") + + await repeated.notify({ message: "one", sound: true }) + await repeated.notify({ message: "two", sound: true }) + expect(repeatedAudio.loadCalls).toBe(2) + expect(repeatedAudio.playCalls).toBe(2) + }) + + test("plays named sounds from the active sound pack", async () => { + const renderer = new FakeRenderer() + const audio = new FakeAudioEngine() + const attention = createTuiAttention({ renderer, config: config(), audio }) + renderer.emit("blur") + + const dispose = attention.soundboard.registerPack({ + id: "acme.soft", + name: "Soft Alerts", + sounds: { + question: "/tmp/question.mp3", + }, + }) + + expect(attention.soundboard.activate("acme.soft")).toBe(true) + expect(attention.soundboard.current()).toBe("acme.soft") + expect(attention.soundboard.list()).toContainEqual({ + id: "acme.soft", + name: "Soft Alerts", + active: true, + builtin: false, + }) + + expect(await attention.notify({ message: "question", sound: { name: "question" } })).toEqual({ + ok: true, + notification: true, + sound: true, + }) + expect(audio.loadPaths).toEqual(["/tmp/question.mp3"]) + + dispose() + expect(attention.soundboard.current()).toBe("opencode.default") + }) + + test("uses config sound overrides before active pack sounds and falls back on load failure", async () => { + const renderer = new FakeRenderer() + const audio = new FakeAudioEngine() + audio.rejectPaths.add("/tmp/bad-question.mp3") + const attention = createTuiAttention({ + renderer, + config: config({ sounds: { question: "/tmp/bad-question.mp3" } }), + audio, + }) + renderer.emit("blur") + + attention.soundboard.registerPack({ + id: "acme.soft", + sounds: { + question: "/tmp/good-question.mp3", + }, + }) + attention.soundboard.activate("acme.soft") + + expect(await attention.notify({ message: "question", sound: { name: "question" } })).toEqual({ + ok: true, + notification: true, + sound: true, + }) + expect(audio.loadPaths).toEqual(["/tmp/bad-question.mp3", "/tmp/good-question.mp3"]) + }) + + test("persists activated sound pack in KV", () => { + const kv = new FakeKV() + const renderer = new FakeRenderer() + const attention = createTuiAttention({ renderer, config: config(), kv }) + + attention.soundboard.registerPack({ id: "acme.soft", sounds: { done: "/tmp/done.mp3" } }) + + expect(attention.soundboard.activate("missing", { persist: true })).toBe(false) + expect(kv.store.attention_sound_pack).toBeUndefined() + expect(attention.soundboard.activate("acme.soft", { persist: true })).toBe(true) + expect(kv.store.attention_sound_pack).toBe("acme.soft") + + const next = createTuiAttention({ renderer: new FakeRenderer(), config: config(), kv }) + next.soundboard.registerPack({ id: "acme.soft", sounds: { done: "/tmp/done.mp3" } }) + expect(next.soundboard.current()).toBe("acme.soft") + }) + + test("does not throw for notification or sound failures", async () => { + const renderer = new FakeRenderer() + const audio = new FakeAudioEngine() + renderer.notificationThrows = true + audio.rejectLoad = true + const attention = createTuiAttention({ renderer, config: config(), audio }) + renderer.emit("blur") + + expect(await attention.notify({ message: "hello", sound: true })).toEqual({ + ok: false, + notification: false, + sound: false, + }) + }) + + test("strips unsafe notification text", async () => { + const renderer = new FakeRenderer() + const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudioEngine() }) + renderer.emit("blur") + + await attention.notify({ + title: "\u001b[31m danger\n title\u0007", + message: "\u001b[32m hello\n world\u0000", + }) + + expect(renderer.notifications).toEqual([{ title: "danger title", message: "hello world" }]) + }) + + test("disposes renderer listeners", async () => { + const renderer = new FakeRenderer() + const audio = new FakeAudioEngine() + const attention = createTuiAttention({ renderer, config: config(), audio }) + renderer.emit("blur") + await attention.notify({ message: "hello", sound: true }) + + expect(renderer.listenerCount("focus")).toBe(1) + expect(renderer.listenerCount("blur")).toBe(1) + + attention.dispose() + renderer.isDestroyed = true + + expect(renderer.listenerCount("focus")).toBe(0) + expect(renderer.listenerCount("blur")).toBe(0) + expect(audio.loadCalls).toBe(1) + expect(await attention.notify({ message: "hello" })).toEqual({ + ok: false, + notification: false, + sound: false, + skipped: "renderer_destroyed", + }) + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/notifications.test.ts b/packages/opencode/test/cli/cmd/tui/notifications.test.ts new file mode 100644 index 000000000..17ed54baf --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/notifications.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, test } from "bun:test" +import Notifications from "@/cli/cmd/tui/feature-plugins/system/notifications" +import type { Event, PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2" +import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui" +import { createTuiPluginApi } from "../../../fixture/tui-plugin" + +async function setup() { + const notifications: TuiAttentionNotifyInput[] = [] + const handlers = new Map void)[]>() + const session = (id: string, title: string, parentID?: string): Session => ({ + id, + title, + slug: id, + projectID: "project", + directory: "/workspace", + ...(parentID && { parentID }), + version: "0.0.0-test", + time: { created: 0, updated: 0 }, + }) + const sessions: Record = { + session: session("session", "Demo session"), + subagent: session("subagent", "Subagent session", "session"), + abort: session("abort", "Abort session"), + timeout: session("timeout", "Timeout session"), + } + + await Notifications.tui( + createTuiPluginApi({ + attention: { + async notify(input) { + notifications.push(input) + return { ok: true, notification: true, sound: true } + }, + }, + event: { + on: (type: Type, handler: (event: Extract) => void) => { + const list = handlers.get(type) ?? [] + const wrapped = handler as (event: Event) => void + list.push(wrapped) + handlers.set(type, list) + return () => { + handlers.set( + type, + (handlers.get(type) ?? []).filter((item) => item !== wrapped), + ) + } + }, + }, + state: { + session: { + get: (sessionID: string) => sessions[sessionID], + }, + }, + }), + undefined, + {} as never, + ) + + return { + notifications, + emit(event: Event) { + for (const handler of handlers.get(event.type) ?? []) handler(event) + }, + } +} + +function question(id: string, sessionID = "session"): QuestionRequest { + return { + id, + sessionID, + questions: [], + } +} + +function permission(id: string, sessionID = "session"): PermissionRequest { + return { + id, + sessionID, + permission: "edit", + patterns: [], + metadata: {}, + always: [], + } +} + +const questionNotification: TuiAttentionNotifyInput = { + title: "Demo session", + message: "Question needs input", + notification: { when: "blurred" }, + sound: { name: "question", when: "always" }, +} + +const permissionNotification: TuiAttentionNotifyInput = { + title: "Demo session", + message: "Permission needs input", + notification: { when: "blurred" }, + sound: { name: "permission", when: "always" }, +} + +describe("internal notifications TUI plugin", () => { + test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => { + const harness = await setup() + + harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") }) + harness.emit({ id: "event-2", type: "permission.asked", properties: permission("permission-1") }) + + expect(harness.notifications).toEqual([questionNotification, permissionNotification]) + }) + + test("dedupes pending questions and permissions until they are resolved", async () => { + const harness = await setup() + + harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") }) + harness.emit({ id: "event-2", type: "question.asked", properties: question("question-1") }) + harness.emit({ + id: "event-3", + type: "question.replied", + properties: { sessionID: "session", requestID: "question-1", answers: [] }, + }) + harness.emit({ id: "event-4", type: "question.asked", properties: question("question-1") }) + + harness.emit({ id: "event-5", type: "permission.asked", properties: permission("permission-1") }) + harness.emit({ id: "event-6", type: "permission.asked", properties: permission("permission-1") }) + harness.emit({ + id: "event-7", + type: "permission.replied", + properties: { sessionID: "session", requestID: "permission-1", reply: "once" }, + }) + harness.emit({ id: "event-8", type: "permission.asked", properties: permission("permission-1") }) + + expect(harness.notifications).toEqual([ + questionNotification, + questionNotification, + permissionNotification, + permissionNotification, + ]) + }) + + test("notifies when an active session becomes idle and suppresses no-op idle", async () => { + const harness = await setup() + + harness.emit({ + id: "event-1", + type: "session.status", + properties: { sessionID: "session", status: { type: "idle" } }, + }) + harness.emit({ + id: "event-2", + type: "session.status", + properties: { sessionID: "session", status: { type: "busy" } }, + }) + harness.emit({ + id: "event-3", + type: "session.status", + properties: { sessionID: "session", status: { type: "idle" } }, + }) + + expect(harness.notifications).toEqual([ + { + title: "Demo session", + message: "Session done", + notification: { when: "blurred" }, + sound: { name: "done", when: "always" }, + }, + ]) + }) + + test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => { + const harness = await setup() + + harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1", "subagent") }) + harness.emit({ + id: "event-2", + type: "session.status", + properties: { sessionID: "subagent", status: { type: "busy" } }, + }) + harness.emit({ + id: "event-3", + type: "session.status", + properties: { sessionID: "subagent", status: { type: "idle" } }, + }) + + expect(harness.notifications).toEqual([ + { + title: "Subagent session", + message: "Question needs input", + notification: false, + sound: { name: "question", when: "always" }, + }, + { + title: "Subagent session", + message: "Session done", + notification: false, + sound: { name: "subagent_done", when: "always" }, + }, + ]) + }) + + test("notifies session errors once and suppresses the following idle done notification", async () => { + const harness = await setup() + + harness.emit({ + id: "event-1", + type: "session.status", + properties: { sessionID: "session", status: { type: "busy" } }, + }) + harness.emit({ + id: "event-2", + type: "session.error", + properties: { sessionID: "session", error: { name: "UnknownError", data: { message: "boom" } } }, + }) + harness.emit({ + id: "event-3", + type: "session.status", + properties: { sessionID: "session", status: { type: "idle" } }, + }) + + expect(harness.notifications).toEqual([ + { + title: "Demo session", + message: "Session error", + notification: { when: "blurred" }, + sound: { name: "error", when: "always" }, + }, + ]) + }) + + test("special-cases aborts and model response timeouts", async () => { + const harness = await setup() + + harness.emit({ + id: "event-1", + type: "session.status", + properties: { sessionID: "abort", status: { type: "busy" } }, + }) + harness.emit({ + id: "event-2", + type: "session.error", + properties: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } }, + }) + harness.emit({ + id: "event-3", + type: "session.status", + properties: { sessionID: "timeout", status: { type: "busy" } }, + }) + harness.emit({ + id: "event-4", + type: "session.error", + properties: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } }, + }) + + expect(harness.notifications).toEqual([ + { + title: "Abort session", + message: "Session aborted", + notification: { when: "blurred" }, + sound: { name: "error", when: "always" }, + }, + { + title: "Timeout session", + message: "Model stopped responding", + notification: { when: "blurred" }, + sound: { name: "error", when: "always" }, + }, + ]) + }) +}) diff --git a/packages/opencode/test/cli/run/runtime.boot.test.ts b/packages/opencode/test/cli/run/runtime.boot.test.ts index e2569b0ac..8dd978553 100644 --- a/packages/opencode/test/cli/run/runtime.boot.test.ts +++ b/packages/opencode/test/cli/run/runtime.boot.test.ts @@ -1,14 +1,9 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import type { KeyEvent, Renderable } from "@opentui/core" -import type { Binding } from "@opentui/keymap" -import { createBindingLookup } from "@opentui/keymap/extras" import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2" import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui" import { formatBindings } from "@/cli/cmd/run/keymap.shared" -import { TuiKeybind } from "@/cli/cmd/tui/config/keybind" import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } from "@/cli/cmd/run/runtime.boot" - -type RunBinding = Binding +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" function model(id: string, providerID: string, context: number, variants?: Record>) { return { @@ -61,45 +56,37 @@ function model(id: string, providerID: string, context: number, variants?: Recor } } -function bindings(...keys: string[]) { - return keys.map((key) => ({ key })) -} - function config(input?: { leader?: string leaderTimeout?: number diff_style?: "auto" | "stacked" bindings?: Partial<{ - commandList: RunBinding[] - variantCycle: RunBinding[] - interrupt: RunBinding[] - historyPrevious: RunBinding[] - historyNext: RunBinding[] - inputClear: RunBinding[] - inputSubmit: RunBinding[] - inputNewline: RunBinding[] + commandList: string[] + variantCycle: string[] + interrupt: string[] + historyPrevious: string[] + historyNext: string[] + inputClear: string[] + inputSubmit: string[] + inputNewline: string[] }> }): Resolved { const bind = input?.bindings - const keybinds = TuiKeybind.Keybinds.parse({ - ...(input?.leader && { leader: input.leader }), - ...(bind?.commandList && { command_list: bind.commandList }), - ...(bind?.variantCycle && { variant_cycle: bind.variantCycle }), - ...(bind?.interrupt && { session_interrupt: bind.interrupt }), - ...(bind?.historyPrevious && { history_previous: bind.historyPrevious }), - ...(bind?.historyNext && { history_next: bind.historyNext }), - ...(bind?.inputClear && { input_clear: bind.inputClear }), - ...(bind?.inputSubmit && { input_submit: bind.inputSubmit }), - ...(bind?.inputNewline && { input_newline: bind.inputNewline }), - }) - return { + return createTuiResolvedConfig({ diff_style: input?.diff_style, - keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), { - commandMap: TuiKeybind.CommandMap, - bindingDefaults: TuiKeybind.bindingDefaults(), - }), - leader_timeout: input?.leaderTimeout ?? 2000, - } + leader_timeout: input?.leaderTimeout, + keybinds: { + ...(input?.leader && { leader: input.leader }), + ...(bind?.commandList && { command_list: bind.commandList }), + ...(bind?.variantCycle && { variant_cycle: bind.variantCycle }), + ...(bind?.interrupt && { session_interrupt: bind.interrupt }), + ...(bind?.historyPrevious && { history_previous: bind.historyPrevious }), + ...(bind?.historyNext && { history_next: bind.historyNext }), + ...(bind?.inputClear && { input_clear: bind.inputClear }), + ...(bind?.inputSubmit && { input_submit: bind.inputSubmit }), + ...(bind?.inputNewline && { input_newline: bind.inputNewline }), + }, + }) } describe("run runtime boot", () => { @@ -112,14 +99,14 @@ describe("run runtime boot", () => { config({ leader: "ctrl+g", bindings: { - commandList: bindings("ctrl+p"), - variantCycle: bindings("ctrl+t", "alt+t"), - interrupt: bindings("ctrl+c"), - historyPrevious: bindings("k"), - historyNext: bindings("j"), - inputClear: bindings("ctrl+l"), - inputSubmit: bindings("ctrl+s"), - inputNewline: bindings("alt+return"), + commandList: ["ctrl+p"], + variantCycle: ["ctrl+t", "alt+t"], + interrupt: ["ctrl+c"], + historyPrevious: ["k"], + historyNext: ["j"], + inputClear: ["ctrl+l"], + inputSubmit: ["ctrl+s"], + inputNewline: ["alt+return"], }, }), ) diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 493520fc0..ce62550e1 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -3,6 +3,7 @@ import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" import { createTestKeymap } from "@opentui/keymap/testing" +import type { TuiAttentionSoundPack } from "@opencode-ai/plugin/tui" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiResolvedConfig, mockTuiRuntime } from "../../fixture/tui-runtime" @@ -854,6 +855,85 @@ test("plugin keymap proxy preserves real keymap receiver", async () => { } }) +test("auto-disposes plugin attention sound packs and resolves sound paths", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "attention-soundpack-plugin.ts") + const spec = pathToFileURL(file).href + const absolute = path.join(dir, "sounds", "default.mp3") + const url = pathToFileURL(path.join(dir, "sounds", "error.mp3")).href + + await Bun.write( + file, + `export default { + id: "demo.attention.soundpack", + tui: async (api) => { + api.attention.soundboard.registerPack({ + id: "demo.pack", + sounds: { + default: ${JSON.stringify(absolute)}, + question: "sounds/question.mp3", + done: " sounds/done.mp3 ", + subagent_done: "sounds/subagent-done.mp3", + error: ${JSON.stringify(url)}, + nope: "sounds/nope.mp3", + permission: "", + }, + }) + }, +} +`, + ) + + return { spec } + }, + }) + + const packs: TuiAttentionSoundPack[] = [] + let dropped = 0 + const attention = { + soundboard: { + registerPack(pack: TuiAttentionSoundPack) { + packs.push(pack) + return () => { + dropped += 1 + } + }, + }, + } + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init({ + api: createTuiPluginApi({ attention }), + config: createTuiResolvedConfig({ + plugin: [tmp.extra.spec], + plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }], + }), + }) + + expect(packs).toEqual([ + { + id: "demo.pack", + sounds: { + default: path.join(tmp.path, "sounds", "default.mp3"), + question: path.join(tmp.path, "sounds", "question.mp3"), + done: path.join(tmp.path, "sounds", "done.mp3"), + subagent_done: path.join(tmp.path, "sounds", "subagent-done.mp3"), + error: path.join(tmp.path, "sounds", "error.mp3"), + }, + }, + ]) + expect(dropped).toBe(0) + } finally { + await TuiPluginRuntime.dispose() + expect(dropped).toBe(1) + cwd.mockRestore() + wait.mockRestore() + } +}) + test("auto-disposes plugin keymap transformers", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index db0456857..5c4f09185 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test" import path from "path" import fs from "fs/promises" +import { pathToFileURL } from "url" import { provideTestInstance, tmpdir } from "../fixture/fixture" import { InstanceRuntime } from "@/project/instance-runtime" import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" @@ -142,6 +143,59 @@ test("loads tui config with the same precedence order as server config paths", a expect(config.diff_style).toBe("stacked") }) +test("resolves attention config defaults and overrides", async () => { + await using defaults = await tmpdir() + expect((await getTuiConfig(defaults.path)).attention).toEqual({ + enabled: false, + notifications: true, + sound: true, + volume: 0.4, + sound_pack: "opencode.default", + sounds: {}, + }) + + await using overridden = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify( + { + attention: { + enabled: false, + notifications: false, + sound: false, + volume: 0.7, + sound_pack: "acme.soft", + sounds: { + default: path.join(dir, "default.mp3"), + question: pathToFileURL(path.join(dir, "question.mp3")).href, + error: "./error.mp3", + subagent_done: "./subagent-done.mp3", + }, + }, + }, + null, + 2, + ), + ) + }, + }) + + expect((await getTuiConfig(overridden.path)).attention).toEqual({ + enabled: false, + notifications: false, + sound: false, + volume: 0.7, + sound_pack: "acme.soft", + sounds: { + default: path.join(overridden.path, "default.mp3"), + question: path.join(overridden.path, "question.mp3"), + error: path.join(overridden.path, "error.mp3"), + subagent_done: path.join(overridden.path, "subagent-done.mp3"), + }, + }) +}) + test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 3d894bd0a..f95025494 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -12,6 +12,10 @@ type Count = { command_drop: number } +type AttentionOpts = Partial> & { + soundboard?: Partial +} + function themeCurrent(): HostPluginApi["theme"]["current"] { const a = RGBA.fromInts(0, 120, 240) const b = RGBA.fromInts(120, 120, 120) @@ -83,6 +87,8 @@ function themeCurrent(): HostPluginApi["theme"]["current"] { type Opts = { client?: HostPluginApi["client"] | (() => HostPluginApi["client"]) renderer?: HostPluginApi["renderer"] + attention?: AttentionOpts + event?: HostPluginApi["event"] count?: Count keymap?: HostPluginApi["keymap"] tuiConfig?: Partial @@ -183,6 +189,17 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { return opts.app?.version ?? "0.0.0-test" }, }, + attention: { + async notify(input) { + return opts.attention?.notify?.(input) ?? { ok: false, notification: false, sound: false } + }, + soundboard: { + registerPack: (pack) => opts.attention?.soundboard?.registerPack?.(pack) ?? (() => {}), + activate: (id, options) => opts.attention?.soundboard?.activate?.(id, options) ?? false, + current: () => opts.attention?.soundboard?.current?.() ?? "opencode.default", + list: () => opts.attention?.soundboard?.list?.() ?? [], + }, + }, keys: { formatSequence: () => "", formatBindings: () => undefined, @@ -190,7 +207,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { get client() { return client() }, - event: { + event: opts.event ?? { on: () => { if (count) count.event_add += 1 return () => { diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts index 64537b6c5..75fc2fdc4 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -5,7 +5,8 @@ import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" import { TuiKeybind } from "../../src/cli/cmd/tui/config/keybind" type PluginSpec = string | [string, Record] -type ResolvedInput = Omit & { +type ResolvedInput = Omit & { + attention?: Partial keybinds?: Partial leader_timeout?: number } @@ -22,6 +23,15 @@ export function createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Re const keybinds = TuiKeybind.Keybinds.parse(input.keybinds ?? {}) return { ...input, + attention: { + enabled: false, + notifications: true, + sound: true, + volume: 0.4, + sound_pack: "opencode.default", + sounds: {}, + ...input.attention, + }, keybinds: createTuiResolvedKeybinds(keybinds), leader_timeout: input.leader_timeout ?? 2000, } diff --git a/packages/plugin/package.json b/packages/plugin/package.json index a3ce97368..baff4cddb 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,9 +22,9 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.2.6", - "@opentui/keymap": ">=0.2.6", - "@opentui/solid": ">=0.2.6" + "@opentui/core": ">=0.2.8", + "@opentui/keymap": ">=0.2.8", + "@opentui/solid": ">=0.2.8" }, "peerDependenciesMeta": { "@opentui/core": { diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index d4c2261b2..354e44abc 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -226,6 +226,76 @@ export type TuiToast = { duration?: number } +export type TuiAttentionWhen = "always" | "focused" | "blurred" + +export const TuiAttentionSoundNames = ["default", "question", "permission", "error", "done", "subagent_done"] as const +export type TuiAttentionSoundName = (typeof TuiAttentionSoundNames)[number] + +export type TuiAttentionSound = + | boolean + | { + name?: TuiAttentionSoundName + volume?: number + when?: TuiAttentionWhen + } + +export type TuiAttentionNotification = + | boolean + | { + when?: TuiAttentionWhen + } + +export type TuiAttentionSoundPack = { + id: string + name?: string + sounds: Partial> +} + +export type TuiAttentionSoundPackInfo = { + id: string + name?: string + active: boolean + builtin: boolean +} + +export type TuiAttentionSoundboardActivateOptions = { + persist?: boolean +} + +export type TuiAttentionSoundboard = { + registerPack(pack: TuiAttentionSoundPack): () => void + activate(id: string, options?: TuiAttentionSoundboardActivateOptions): boolean + current(): string + list(): ReadonlyArray +} + +export type TuiAttentionNotifyInput = { + title?: string + message: string + notification?: TuiAttentionNotification + sound?: TuiAttentionSound +} + +export type TuiAttentionNotifySkipReason = + | "attention_disabled" + | "empty_message" + | "blurred" + | "focused" + | "focus_unknown" + | "renderer_destroyed" + +export type TuiAttentionNotifyResult = { + ok: boolean + notification: boolean + sound: boolean + skipped?: TuiAttentionNotifySkipReason +} + +export type TuiAttention = { + notify(input: TuiAttentionNotifyInput): Promise + soundboard: TuiAttentionSoundboard +} + export type TuiThemeCurrent = { readonly primary: RGBA readonly secondary: RGBA @@ -333,9 +403,19 @@ type TuiBindingLookupView = { omit: (name: string, commands: readonly string[]) => Binding[] } +type TuiAttentionConfigView = { + enabled: boolean + notifications: boolean + sound: boolean + volume: number + sound_pack: string + sounds: Partial> +} + type TuiConfigView = Pick & NonNullable & { leader_timeout: number + attention: TuiAttentionConfigView plugin_enabled?: Record keybinds: TuiBindingLookupView } @@ -499,6 +579,7 @@ export type TuiWorkspace = { export type TuiPluginApi = { app: TuiApp + attention: TuiAttention /** * Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2. * diff --git a/packages/ui/src/assets/audio/alert-01.mp3 b/packages/ui/src/assets/audio/alert-01.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..45c3f6afddf3389d845009c78cd29fdb49a7afd5 GIT binary patch literal 11436 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 KJ>!4s`}{Au`!56l literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/alert-02.mp3 b/packages/ui/src/assets/audio/alert-02.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..2aa5cda6acc966be98c16dbf6a88f1f731567b12 GIT binary patch literal 8667 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 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/alert-04.mp3 b/packages/ui/src/assets/audio/alert-04.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..f4ed65209270bb1abf046093f8ac953def16d5f3 GIT binary patch literal 7369 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 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/alert-06.mp3 b/packages/ui/src/assets/audio/alert-06.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..a0ced67c229ca7cc766b9ed3a3ba389b0ab5e295 GIT binary patch literal 7516 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 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/alert-07.mp3 b/packages/ui/src/assets/audio/alert-07.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..312a419c5733ed39c7ace0d5e08798140e043209 GIT binary patch literal 6741 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 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/bip-bop-01.mp3 b/packages/ui/src/assets/audio/bip-bop-01.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..f518059c54c1c9ca4f14ae0d4149fedc61d7d015 GIT binary patch literal 4574 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 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/bip-bop-02.mp3 b/packages/ui/src/assets/audio/bip-bop-02.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..b25f80ada6f8f354be124386cd83e9562cc07871 GIT binary patch literal 5697 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 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/bip-bop-03.mp3 b/packages/ui/src/assets/audio/bip-bop-03.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..adc68c91dc29e0023efbc142aa2f06dc33c6c6ff GIT binary patch literal 4186 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 NMmm2eTmQ#8{{enkOH}{> literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/bip-bop-08.mp3 b/packages/ui/src/assets/audio/bip-bop-08.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..24af5b0a23800318973d625867aa44933dff1736 GIT binary patch literal 4390 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 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/bip-bop-10.mp3 b/packages/ui/src/assets/audio/bip-bop-10.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..056730a1f13f6c7334ee2f51dc3435886da74368 GIT binary patch literal 6760 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 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/nope-02.mp3 b/packages/ui/src/assets/audio/nope-02.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..8cdf890a1825524298d9f0346fd9196919b55844 GIT binary patch literal 5753 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 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/nope-03.mp3 b/packages/ui/src/assets/audio/nope-03.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..42442317c372845d0f0f3dd82f35c567ed0beff5 GIT binary patch literal 3820 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�Se`zAXIp#WBy8fHHNha zhoZsyad0FRI_q&=Jqk)vmqMd5n=~T;&?FD>NLW5et`qctBtI0M6V^L(>WA)kHb>$kawHG6R_jB>_G& zVF1(5yxeiMsAl^Xamep?IF$TIh2&QK$i8;bZ1e z@GFi79Ob3)P)ukG6eKN^asmWX$TT*JEEt-xJrnTAF6(!)blP}M{0*lpHQwz4k_he~ zgM26xycbCPsD=KK0A zzkjhy;k@EfXI^%|C##;hJ<|D#rruX;y|q4~lUyswB<7I}Wg|M@+dmCuUSCcbr%#u( zPsA@gxJTOi_E7s-3FR-TBb*Di9g+}HLEnM%c;2BEXG*_TRZDfJFz8)+^yn~1sEChK zj;qd_h(jEYU`HIh=&?fiFENP zg?0IlgFdqOhii(Z@3M@J#6F*K_u4?;HCuZhdFUSajz zdA{Ix$mjiA=0KL)gVEv5!&Z0Osknf}qyXnQHJ5e8?Yhl(_P^!mQS?UT_&pV4O|Bh? zb%HrGYiau3J-=lXR6Wu9yk+Xnq$wx4N*j9J&EgAneV`foGuihVD`0miH87KJiIjg7 zr)C)=05#zw4qoIi*4B#GRFPIxaVeDuNegmcBde!-8j!2n(e#M&!j=yLEXp^C<4%V}t|+ zT#X6yq%^fLK6Alls9~8lK4K>nRCS`cMFAETkHjcs5*p0}W=xdWS#+aluOy>s?l=~l zAfPa+fKzn4cK#R=kaa?ZNvcMCtW|~qJTHohrP(uuQDWFD>0zpbn6Nek zD5;VAZAvC4kR2TNLUY6z&vjvtC{|K262=J^4BQH0F;}L^`y4icg%3%cKHE+ws!E=% z8ix>kuifq|BpJJ+*)MdnBMSe3n~5wCV6+D(b0D%7w{IupMWB)fU7H{5Gz{`AF&?0a zIXuOd<=*9^!@mcmLs8n2la3KBRv-^FVMIrEF0_ZR-zfIdr)oQVADTJenz6qKV1K>` z5I=xm2`v(ZyQ|TJF>JhXfRhh^A%2ccYz!nkC7@K#TgFv1R6hj*L9wbw_%~itxf*VZ zi;RQm4JLmqPo|cnSyXcErl)7QjI%FkYmU899$KJdKaZYI^D=PwIKjX%M==l_rtyX|O zRx71N%mY={Ar|Em*_&Ot4>QUqZXT@lNqlqd_-m;1FZ@Gt8mY7Ale+Fm#$PS&pO!sk zOLVC1wg@S7|9bih7q#|mKknp<$0jqCkut3>XoZUJZ(p2=8k+`97VEKV{kmcq=H#TQ z>*HsxNsm4{|6aMVw#Db=k>j2?5e5B&8G}~or@)Nbq&!Vy*D#rPz z|M;xz%1yyrDq$RN$Ji^MIyJ|OBA`1t5L^MaHQ8tF+>M_Rc@wJfsvR_)t37KWu! zG16YAU%dH-OcFnRhAYaG)hGR{z|JAvL8-3%j3l!^PqMe_SV4Ky{ob2*C5R{P9(g3o z5H2{OP#c@dWB1E#J{aASI%K`jkpJDgwfW0NZx>zXo`qCY?u@}~6ES4T zq~}gyKy92>y@Xiu&R2Szm4l(lOfcrga&Z6Jn?bz%Km|l88*A}d=w@NfrGVmZUE^kZ z3+7(R?5&)_J(~B7Fx9cnWusgkNMrC%`I?(bEYooknXKruEva$&Y$x|k;Kfi$ZSa+x(e(5A_#I=Ng z3s`=~kF32zu_RYqmjw_iW7YAUj*2>N|P>PPF8tyebltq9L}O=IR9b33nd9f$UQqABR{ zqINCEOqRD2{KS>oW}*gP!A&neO}pzMoee>^z0Gr4SF#n%;Gz_9b^vuHe4V9iML(jD zROT1mPXJ5Xuw7#V9alxJn*{gCBHu8P2*#2-57)YiM<%YYn1oWL>cFfcC!QlZ7-$rW3?yP-9T$*@UeRAu?Y>~TBpURTD zGXf2R1lOCN8HBKfewzB6xR`%aUU-DW_NtI z3dc{M(=x%3hv%#x9*piRofj~`2&`6^ebLUYa0!0(ec*cH?J@|XB9$l2PP(wrDh(`A3+{o&9g0K$Mp=J(lCXJ6f{EgV}^M;@S=z4)4HbRHQrNt}DHsXd~uGgqla&92m8knR0Oq&0?M zXP2F+?6(>`S}(yJrKiVc$D6WEzkDkuh$Lz-Nq_dtp}5VpO}Q$i6V~KlmRXfw)n+!2 zyE_-XtninSY<{~t={%Nzk?MT;BXLQ2NAH;L>P`z54=AF51W*Pn5-kLjV7xF?*`F9h z<(f}cVu2GLs2mvMrzq$zS+cR-LfmG-py&ohXTkx!6JQJ7J5-Pa`FTVPJz2t45+$lG zA23f2@+jqvEXL=VaqAGMK~E_21lNwmA(L;{Bk*?ZGZ#5xmdUS0gB(o-12gSYdrC4) z2+@ZWJULy?o3V->%y<~oGW)>aKltdPcE72nK>8<=C5JsW9j(6UL*R1z_ZBa_^ z@$?&7-C!nLys^8Rt|1nA{Fiso!j43Q;SYVIfks@2qyDEJdRE1p>t5aIrR{Y9h+T6( zWo2^QIF)=LoC`(4u7qKWvHcZvn{a&oe4~GCeH|bx^mW9>`fIuj8MV2%D{~QC�mj z@?mq%;gCh7l$+_fCr$L&`=VFSrhbRa-Hfu7)Ha-UODr6YWXUlp zmoK%3TRW(E=3;uyt#(fRnR8uP?nC(}LVR&heb#Ww&)Ty_!*8WoY_&qs8nt`1Cl5FV zzbcnrFK9&TNyzmD25EQ-)aR6z{6ds0-}bli`yku2((kshxvOb3b$Igei{&xD!^c1M ze{~!FLL9&j4qDl#sc%=5?oW@Jh}$ib!lRN|I+2}mhWCUyj+}v}pzbWzJqj>eCB>TIg&7 z>s#Nvi|(=Y#G(n$frMPa%sgD!l*~vCCkw;F^RS&zMqHeP+R1Ck9;;kEzjr*&Qd#Lb z3v6#*#QOXY9CVwe)9HQLz5^v?LV;-|BeJmrchhQ>wl1#*8gv-uMgSEpV2hvrK+;uqA5NDK?WD<$5{9F@;!e%Qd~j`&?JnEcGCQ?8GbXnDtP`m~5iEzpGUK|K{H?#NW05 zu_2k;l+M=xTOFhQrt0^V!x_$B#1ex49?iex|6`&5OHuv(;{Oxn&wa8<*xo+b?q01B i20)9kw;%WSJN^y+|L7F{rK3#y57GIj=IuX3=f42xL9Lnq literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/nope-06.mp3 b/packages/ui/src/assets/audio/nope-06.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..ed22c1cba2c66c97e76444cc66cd7b06f3bfd687 GIT binary patch literal 6066 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 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/nope-07.mp3 b/packages/ui/src/assets/audio/nope-07.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..4b6dd462c80ca49e6c25015c28c0db2c74fc9e78 GIT binary patch literal 3459 zcmeH}dpuNI8^`yU8EIk+qLN!fZqYC^T|~LxO(x?qN{qXxjxKbOW?V8EMiU}b%gHOuT*G_x+sruXFk9{eJdd`&ny!)_R_`pY>aNJKEr2 zfEctpiDWB!A^`yE;vXHd5xX9@-q^_Kd*?y{&RX34x9WmP-m^bcWXXzd8PVkh;1_UT zBxaGc1+o@tT;$mTeTxh)@_K>EMWz=47Z7_E#TY1x(b!mA`4?I+iv?50>)GC=>fdc} zwxCsPK;}h-MyGT@NFoh@NQ@mv;kB7_7c_h#qhlHf;PTP6CqL{dVqroY&?|2@_o(E9 z`4`+yJQaJKnx%pxQ6W)BqoTuz^sxTNw99u_LRCw$ue~dR0~fjDl=sCf@9sEF70Jx$ zDL@$#86Hl+Z}8z@Bx!7jjh2G_wy>KHL25Xx_9lB0iI5?G;6$`Cbv`mC?a)|p4hH;b z*Da>BjBKGm76d;V+*;CA=m*Z{CLc{cmADBXj~uecOTyI{~i0^?CBeEP&xk74M&qQ5_DxD{P#8gMdiS=QUuU!8M>##?&z zttRPOFXMQ^1f2jK%rUh5qy6^!Hx=>@L_8TiQcN5*xp=z$x#!h)1D8@wd-w;lr*w21 zo%@2fI>JUT3*#m}==sc*xzq*(Hu+|~cz@s7!{jOB9SUZ z=iGigbz&f1Zuu)-GOa}Wz*OYveI46;p{h^===t-ZRB~9VSwV-TdMNxQxpTNcj*fsPL9F-j5GrpokXaB3 zcp>r=T|T6~{M4YJZiFV6wb}+Hv0?>$nZ#*zhYCCz-g(PB388sBsvdQ?(WJ{tR|6qY zPd)2ec%?#@)t7c(+wZy&T6pDv!LHmO+s{UT(T)_`SV6|2s?EVN!Lq)&EgN5`oGvzZ z-9BL=d#?$@bZ2+2iuE5q{?03TyuR+-bnlAwt#7y=qP(+vQ?*xLyI^d%Jjl5pJqB6s`a}}K`-n2jX1kO`O=%#e@#g;EwZ-Zw@B#)Bu?pK?oqyRwDM}S;Y zUlY>SO)S05%{(3y_DfXe9j6>H`0{532TiQQJO9zrsYHH8MKN0DRU;@-mWQOB!lSt55+gQ_|^-M8dRKD>Cax0b!L|J!&z zm>aSC@Vm~axfk!}UQLd>YFAFLuOkVGJE}&-QvF2J@71)NX{1RwmA|20zYY;vhSTZi zZLqp(kRTg2YjnooqK4mH^(#*xoi0mql@rr7-RFYB_r=)i@^5H<)qO5mRi=Z!zZ;`G zPPO_bc$gmB+{`TUazJfO_f3Bs5E)zbT#ZL=`YL2)FsS7tH+Zc?FcIXZN?WCXRKJ$n z>$^f%L3AF|4Yh{AkJ+hV>L9qEqfnA=T1g11t~!(B>Glwka!Gfzop)BU_VaCdn+HSXMluumSj*0$ z^KsAOjAX5)7&2zG?gXYTkvleOrb0oH5jw_JB*apu1iT$i__iG6Mh2&XJ2hA_kZx#j)iFUh~ffTNA@=DJ#g~Y*y z1`vEz9(73UnBLd+Ee?5muWxzl2d__ zMjgDb6z>VnqFTq|Eqx#e(U%MGga@D#DKHhzgTAWhKzEhB1xJ?4o?Yt}X_4=>bEYP* zOFjd(qbIbc=gueRnb1?-#*+C;vEkGB+CBC|(bYf2-tSo#5gQx&;_ckyeJ!`se47UX zo=3%hnV57J%MPSra@9>{I#`I9LS5?hNtja1S^3geitceGiIsA2tQAQbPxSEjEYqQW zRwZOv06Rn>VPaX+xsGj82#6)qP=BX#cY&l4E?6RB-^3H|IBy1$3rNMaoESqt<;+b? zboe#QT23IA90@6ZwxLW>KyKQr21vPYkv(|&fau!e(7Wl5}+ z2DZJrmi*hNp(=6GE~#e;Dej8Mq;vlvII-ydComWbKzzyj1|j|!bM-IdADdDvy9q#E zuLw7xQ4b+2qP6ZJDSv7i|5)gMU6f+k^+TJ@FbV+7N_2vn-vt2JpPK%U2aEmmLhmmA fm;wNv_BZ<}7D-mLp8xy#<9z&g>yP~W&F}4ZwM5#) literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/nope-08.mp3 b/packages/ui/src/assets/audio/nope-08.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..e54577e43576977e1db835f5d61fe6bedd804e7e GIT binary patch literal 7450 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 Kf7$tO>Go$N6t)8Z literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/nope-10.mp3 b/packages/ui/src/assets/audio/nope-10.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..0720993519ca211892aa69fd91d550a01b27b936 GIT binary patch literal 3981 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 literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/staplebops-01.mp3 b/packages/ui/src/assets/audio/staplebops-01.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..b3b34a32fe8684b535ffb0d76255e62ef873da95 GIT binary patch literal 3665 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