From bcee24798852ed40166bf6905ab0b8a77be4f531 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 11:05:31 -0400 Subject: [PATCH 01/75] 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 25feb657c1..91d272ea63 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 02/75] 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 f6aaed4358..5920c9670a 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 e6c3ccbc15..61193a013c 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 9af85a2f3a..ab647ab37a 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 7613563fd0..503f87ac50 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 b20847da3b..8d51b5cfef 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 03/75] 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 7f390f0eb6..3242de94d6 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 114a388036..c05d562c9d 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 3b919e2f0a..6033b0944c 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 f5c1674658..1043465305 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 72d9658d16..b9103c702f 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 04/75] 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 6033b0944c..4950be084c 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 1043465305..3821954945 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 05/75] 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 cb144b1a5d..68b7e0a4ae 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 16c44099ce..2771046f80 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 c3b3e55b36..b86980c43d 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 5a38898c0f..2b67a0a4f2 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 06/75] 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 8d51b5cfef..16a58fd866 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 bca78c888a..6f6728216b 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 e742ca5e69..bb65a56360 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 e9ef58afa8..c01fe33b29 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 aa5b258d3d..5fefae51d4 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 a867d16591..0df3541d58 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 208b565272..7d1ad3f309 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 e0b3864a26..ea4eadc498 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 2c692dcd7d..9c81422639 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 627e6ef4a0..7759ff7202 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 2319857ed1..da9dbd82c2 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 127a444a16..bdba8580fd 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 7251dee8af..8f4221784d 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 07/75] 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 bb65a56360..ac700b58fc 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 08/75] 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 3fe7655759..175c723c5f 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 01bffdb02a..e532efa3d8 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 6158aae085..744690b15a 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 1ba0554d3e..b408f7ef11 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 d73750b083..969014e6b3 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 09/75] 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 175c723c5f..73ab3b055e 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 cc2afd1cdf..d7f2cd14b0 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 35c966937c..1dd33106de 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 46a48e18e9..b20c87f30b 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 2958b573dd..fc22263151 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 c7a7b211f2..07a2844e93 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 10/75] 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 73ab3b055e..f76d1aaf9d 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 11/75] 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 c3758e2326..37df44b0b5 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 41487f845a..5c1d1ba223 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 746e658693..e19199ef49 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 3bf7e1b556..8066bda346 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 e20605c3bc..20b3e70e7b 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 06e89c18de..036472337e 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 7027e666ca..b9d5b5af15 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 369ca91d02..f4d6adb935 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 9230a6fe57..99645f3da3 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 9dc9bc01ec..9187bfea34 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 460a2be7a5..12d3791ecc 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 baad8df592..71521a765a 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 509a067d08..26c0fe03e6 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 0b8d8051bc..383442e00e 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 946de2835c..5ec4acb877 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 77c650e55f..ca38931f8f 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 12/75] 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 fac5f699c3..b306401ccd 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 2b3f720c84..bc246dbeda 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 a0466d7b70..e1fe93ba7e 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 13/75] 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 f7c6e2c5b7..58ef0a8a76 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 c6cff94fde..e511a75ffa 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 fadb0457e7..c86ae8c738 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 1856c94bc7..2574594a23 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 dde2e53d17..4ff9d2fc33 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 67c438a386..a65137a2f2 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 14/75] 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 4244e0c0e7..9127bb6d32 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 15/75] 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 777f6e6d17..d96e508c9d 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 52f2b8486d..b951a4d7a5 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 8459dd9ac1..aae794c1a1 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 c9ab433f11..b6eb9dfd0e 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 85e0840cb7..6f18856fde 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 e532efa3d8..a0cc383d0f 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 848a067c3d..51fd267d54 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 16/75] 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 accfbee3b2..ea7328a809 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 1d24fe124d..ff0048a190 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 17/75] 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 e3539021b0..25ca27460f 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 c734a182ae..806c574834 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 18/75] 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 12c81180eb..ef9089c949 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 1dd36ec53a..089559e2cd 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 9b7daf7f0c..005b3b7c4e 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 a65137a2f2..4422c5b719 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 19/75] 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 a08372649f..45fcc6ee27 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 605d114ace..1c85d125a2 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 e78d728e04..e55ae2194e 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 a6599debdf..439f36e0a9 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 4f0ead54e4..b1b9d22b73 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 da80645ad7..c7a479f5ac 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 df0427f455..3d452cc9c0 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 20/75] 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 4950be084c..2de4bbd308 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 f50f8750b3..92b4329e6f 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 9368089511..04348e5c0d 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 4422c5b719..c0201a475f 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 21/75] 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 b1a587075e..eaa678524d 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 c05d562c9d..2e282c2329 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 bbe585237b..5f49a3f3a9 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 13400d79c8..c7f349d5ce 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 22/75] 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 37df44b0b5..4268e5fb7d 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 5faf8be920..6d82864d6d 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 23/75] 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 f7c6319357..9d30ea142e 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 14d9918160..d7409cd2db 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 e26c4068b1..54cfe4fcc5 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 94c8d8fe00..a6719e8674 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 7af429afde..a5fc599738 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 2e282c2329..4b10665aca 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 485e334167..d52a148409 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 222a750057..7539fe4a77 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 a5299ea955..3ac63576dd 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 bb4fd88f04..cf170b95fc 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 26fa2e0b34..6cba3ecd2a 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 f964ed4e15..d4048cf17e 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 8c5f854996..a04b404e86 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 b1e3ec6f42..c70442427c 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 af9aac6964..5635512ced 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 36a8faff7e..b3dec491ac 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 159ba0ce5a..642adbe51d 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 f707e922ee..38c0017d0f 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 a647dc099f..12ce5f5811 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 ed74c648ad..db43412f73 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 f4bd2e2cc1..2f0813affa 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 91d272ea63..643685539d 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 092444c444..5a477e02b3 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 135df6fecf..42b94ffcc5 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 c27b69b6a2..236f14de75 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 757b70f3ff..db05b47843 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 c041462ad4..94182f1a27 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