From 47d6fc738281db074b0c507cfaabda0458d86fcb Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 9 May 2026 15:28:13 -0400 Subject: [PATCH 1/2] fix: preserve full OpenCode provider cost shape in auth.loader The normalizeProviderModelCosts function was only setting { input: 0, output: 0 } which doesn't match the full OpenCode provider cost shape that includes cache fields. This fix: - Preserves existing cost fields (input, output, cache.read, cache.write) if valid - Adds missing cache: { read: 0, write: 0 } structure when not present - Validates provider and model objects before modification to avoid poisoning invalid provider metadata - Updates ProviderModel type to include optional cache field Fixes provider cost normalization to maintain cache field compatibility. --- denotational-design-tutorial.md | 240 ++++++++++++++++++ packages/opencode/src/cli/cmd/tui/app.tsx | 1 + .../opencode/src/cli/cmd/tui/plugin/api.tsx | 59 ++++- .../src/cli/cmd/tui/plugin/runtime.ts | 13 + .../test/cli/tui/plugin-loader.test.ts | 73 ++++++ packages/opencode/test/fixture/tui-plugin.ts | 21 ++ packages/plugin/src/tui.ts | 23 ++ 7 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 denotational-design-tutorial.md diff --git a/denotational-design-tutorial.md b/denotational-design-tutorial.md new file mode 100644 index 0000000000..a7608c254c --- /dev/null +++ b/denotational-design-tutorial.md @@ -0,0 +1,240 @@ +# Denotational Design: Part 1 + +## The Basic Idea + +Denotational design means designing an abstraction by first asking: + +> What does this thing mean? + +Before choosing data structures, algorithms, callbacks, queues, caches, or runtime behavior, we give each core type a simple meaning. Then we define operations in terms of that meaning. + +The implementation can be clever later. The meaning should be simple now. + +## API vs Implementation vs Meaning + +When designing a library, there are three related but different things: + +| Layer | Question | Example | +| --- | --- | --- | +| API | What can users call? | `map(signal, f)` | +| Implementation | How does it run? | subscriptions, graphs, caching | +| Denotation | What does it mean? | a value changing over time | + +Most designs jump between API and implementation. Denotational design adds the missing middle: a precise meaning. + +## A Tiny FRP Example + +Functional Reactive Programming is a good example because it starts with a very simple denotation. + +A time-varying value, often called a `Behavior`, can be understood as: + +```ts +Behavior = Time -> A +``` + +That says: + +> A `Behavior` means: give me a time, and I can tell you the `A` value at that time. + +This does not mean the implementation literally stores an infinite function. It means this is the specification. The implementation may use events, subscriptions, incremental recomputation, dependency graphs, or caching. + +## Operations Follow From Meaning + +Once we know what `Behavior` means, operations become easier to define. + +For example, `map` transforms the value inside a behavior: + +```ts +map: (A -> B) -> Behavior -> Behavior +``` + +Its meaning is: + +```ts +map(f, behavior)(time) = f(behavior(time)) +``` + +That is the whole specification. + +Similarly, a constant behavior: + +```ts +constant: A -> Behavior +``` + +means: + +```ts +constant(value)(time) = value +``` + +The definitions are simple because the denotation is simple. + +## Why This Helps + +Denotational design is useful because it separates essence from machinery. + +It helps library users because the abstraction has a clear mental model. + +It helps implementers because correctness has a target independent of implementation details. + +It helps API design because awkward operations become easier to spot. If an operation has no clean meaning, it may be exposing implementation machinery instead of domain meaning. + +## The Design Loop + +A practical denotational design loop looks like this: + +1. Name the core type. +2. Write down what values of that type mean. +3. Define each operation by how it transforms meanings. +4. Notice what laws naturally follow. +5. Choose an implementation that preserves the meaning. + +The key discipline is to delay implementation concerns until after the meaning is clear. + +## Denotation Is Not Implementation + +A denotation can look like an implementation because it is concrete enough to write down: + +```ts +Behavior = Time -> A +``` + +But this equation is not saying that a real FRP system must store a function from every possible time to an `A` value. + +It is saying that this is the model we use to understand a behavior. + +The implementation might use callbacks, mutable cells, event queues, dependency graphs, caching, sampling, or incremental recomputation. Those choices are representation. The denotation is the meaning those representations must preserve. + +So there are two different questions: + +| Question | Answer | +| --- | --- | +| What does a behavior mean? | A function from time to value | +| How do we run it efficiently? | Some concrete representation and algorithm | + +A denotation should be simple and precise. An implementation should be executable and efficient. They do not have to be the same thing. + +## Operations At Two Levels + +Conal Elliott describes a useful pattern: + +> The meaning of each method corresponds to the same method for the meaning. + +This sentence is subtle because there are three things in play: + +1. An abstract type, like `Behavior`. +2. A meaning type, like `Time -> A`. +3. A meaning function that translates from the abstract type to the meaning type. + +For behaviors, Conal often calls the meaning function `at`: + +```ts +at: Behavior -> (Time -> A) +``` + +Read this as: + +> `at(behavior)` gives the meaning of `behavior`. + +So `at` is just the specific FRP name for the more general idea: + +```ts +meaning: Abstract -> Model +``` + +Now we can talk about operations. + +There are two versions of "the same" operation. + +One operation belongs to the abstract API: + +```ts +mapBehavior: (A -> B) -> Behavior -> Behavior +``` + +The other operation belongs to the semantic model: + +```ts +mapFunction: (A -> B) -> (Time -> A) -> (Time -> B) +``` + +They are not literally the same function. They live at different levels. But they are the same conceptual operation: mapping a pure function over a value inside some structure. + +The homomorphism law says that translating meanings should not care which order we take these steps. + +Path 1: operate first, then take the meaning. + +```ts +behavior + -> mapBehavior(f, behavior) + -> at(mapBehavior(f, behavior)) +``` + +Path 2: take the meaning first, then operate on the meaning. + +```ts +behavior + -> at(behavior) + -> mapFunction(f, at(behavior)) +``` + +The law says both paths produce the same meaning: + +```ts +at(mapBehavior(f, behavior)) += +mapFunction(f, at(behavior)) +``` + +Using the generic word `meaning`, the same law is: + +```ts +meaning(operationOnAbstraction(x)) += +operationOnMeaning(meaning(x)) +``` + +In pointwise form: + +```ts +at(mapBehavior(f, behavior))(time) += +f(at(behavior)(time)) +``` + +So the operation on the abstraction must mean the corresponding operation on the model. + +That is the homomorphism idea: the meaning function preserves structure. It translates the abstract operation into the corresponding model operation. + +```ts +// Same shape, different levels: + +mapBehavior(f, behavior) // abstract level + +mapFunction(f, at(behavior)) // meaning/model level + +// Connected by the meaning function: + +at(mapBehavior(f, behavior)) += +mapFunction(f, at(behavior)) +``` + +This gives us a correctness rule. If `Behavior` claims to support `map`, its `map` should behave like `map` on its denotation, which is a function of time. + +## The Main Lesson + +Denotational design does not ask, "How do we build this?" first. + +It asks: + +> What are we talking about? + +For FRP, the answer begins with: + +```ts +Behavior = Time -> A +``` + +That small equation gives the abstraction a center of gravity. Everything else can be designed around it. diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index c7a2cd560f..25d4a37df3 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -275,6 +275,7 @@ function App(props: { onSnapshot?: () => Promise }) { } const api = createTuiApi({ + command, tuiConfig, dialog, keymap, diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 7b7ce0bbb5..9f9848cf75 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -1,4 +1,10 @@ -import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui" +import type { + TuiCommand, + TuiDialogSelectOption, + TuiPluginApi, + TuiRouteDefinition, + TuiSlotProps, +} from "@opencode-ai/plugin/tui" import type { useEvent } from "@tui/context/event" import type { useRoute } from "@tui/context/route" import type { useSDK } from "@tui/context/sdk" @@ -17,6 +23,7 @@ import { Slot as HostSlot } from "./slots" import type { useToast } from "../ui/toast" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Keymap from "../keymap" +import type { useCommandPalette } from "../context/command-palette" type RouteEntry = { key: symbol @@ -26,6 +33,7 @@ type RouteEntry = { export type RouteMap = Map type Input = { + command: ReturnType tuiConfig: TuiConfig.Resolved dialog: ReturnType keymap: ReturnType @@ -41,6 +49,54 @@ type Input = { renderer: TuiPluginApi["renderer"] } +let warnedLegacyCommand = false + +function warnLegacyCommandApi() { + if (warnedLegacyCommand) return + warnedLegacyCommand = true + console.warn("[tui.plugin] api.command is deprecated; use api.keymap.registerLayer({ commands, bindings }) instead") +} + +function commandBinding(command: TuiCommand, input: Input) { + if (!command.keybind) return [] + return input.tuiConfig.keybinds.get(command.keybind).map((binding) => ({ ...binding, cmd: command.value })) +} + +function legacyCommandApi(input: Input): TuiPluginApi["command"] { + return { + register(cb) { + warnLegacyCommandApi() + const list = cb() + return input.keymap.registerLayer({ + commands: list.map((command) => ({ + name: command.value, + title: command.title, + desc: command.description, + category: command.category, + namespace: "palette", + suggested: command.suggested, + hidden: command.hidden, + enabled: command.enabled, + slashName: command.slash?.name, + slashAliases: command.slash?.aliases, + run() { + command.onSelect?.() + }, + })), + bindings: list.flatMap((command) => commandBinding(command, input)), + }) + }, + trigger(value) { + warnLegacyCommandApi() + input.keymap.dispatchCommand(value) + }, + show() { + warnLegacyCommandApi() + input.command.show() + }, + } +} + function routeRegister(routes: RouteMap, list: TuiRouteDefinition[], bump: () => void) { const key = Symbol() for (const item of list) { @@ -209,6 +265,7 @@ export function createTuiApi(input: Input): TuiPluginApi { }, }, keymap: input.keymap, + command: legacyCommandApi(input), route: { register(list) { return routeRegister(input.routes, list, input.bump) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 91ccaaaa01..3c5d3eaa1b 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -563,6 +563,18 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop const keymap = createScopedKeymap(api.keymap, scope) + const command: TuiPluginApi["command"] = { + register(cb) { + return scope.track(api.command.register(cb)) + }, + trigger(value) { + api.command.trigger(value) + }, + show() { + api.command.show() + }, + } + let count = 0 const slots: TuiPluginApi["slots"] = { @@ -578,6 +590,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop app: api.app, keys: api.keys, keymap, + command, route, ui: api.ui, tuiConfig: api.tuiConfig, diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index d62bc19bfe..f83e741a85 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -776,6 +776,79 @@ test("auto-disposes plugin keymap layers", async () => { } }) +test("supports legacy plugin command API", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "legacy-command-plugin.ts") + const spec = pathToFileURL(file).href + const marker = path.join(dir, "legacy-command.txt") + + await Bun.write( + file, + `export default { + id: "demo.command.legacy", + tui: async (api) => { + api.command.register(() => [{ + title: "Legacy command", + value: "demo.command.legacy.run", + onSelect() { + Bun.write(${JSON.stringify(marker)}, "called") + }, + }]) + api.command.trigger("demo.command.legacy.run") + api.command.show() + }, +} +`, + ) + + return { spec, marker } + }, + }) + + let add = 0 + let drop = 0 + let show = 0 + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init({ + api: createTuiPluginApi({ + command: { + register(cb) { + add += 1 + const list = cb() + return () => { + drop += list.length + } + }, + trigger(value) { + expect(value).toBe("demo.command.legacy.run") + Bun.write(tmp.extra.marker, "called") + }, + show() { + show += 1 + }, + }, + }), + config: createTuiResolvedConfig({ + plugin: [tmp.extra.spec], + plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }], + }), + }) + + expect(add).toBe(1) + expect(show).toBe(1) + await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called") + } finally { + await TuiPluginRuntime.dispose() + expect(drop).toBe(1) + cwd.mockRestore() + wait.mockRestore() + } +}) + test("plugin keymap proxy preserves real keymap receiver", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 62a3ae6e6b..342ea0b225 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -82,6 +82,7 @@ function themeCurrent(): HostPluginApi["theme"]["current"] { type Opts = { client?: HostPluginApi["client"] | (() => HostPluginApi["client"]) + command?: Partial renderer?: HostPluginApi["renderer"] count?: Count keymap?: HostPluginApi["keymap"] @@ -131,6 +132,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { ? () => opts.client as HostPluginApi["client"] : fallback const client = () => read() + const commands: ReturnType[0]> = [] let depth = 0 let size: "medium" | "large" | "xlarge" = "medium" const has = opts.theme?.has ?? (() => false) @@ -187,6 +189,25 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { formatSequence: () => "", formatBindings: () => undefined, }, + command: { + register(cb) { + if (opts.command?.register) return opts.command.register(cb) + const list = cb() + commands.push(...list) + if (count) count.command_add += 1 + return () => { + if (count) count.command_drop += 1 + commands.splice(0, commands.length, ...commands.filter((command) => !list.includes(command))) + } + }, + trigger(value) { + if (opts.command?.trigger) return opts.command.trigger(value) + commands.find((command) => command.value === value)?.onSelect?.() + }, + show() { + opts.command?.show?.() + }, + }, get client() { return client() }, diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 13bc17f66b..946a8d6736 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -70,6 +70,23 @@ export type TuiRouteDefinition = { render: (input: { params?: Record }) => JSX.Element } +/** @deprecated Use api.keymap.registerLayer({ commands, bindings }) instead. */ +export type TuiCommand = { + title: string + value: string + description?: string + category?: string + keybind?: string + suggested?: boolean + hidden?: boolean + enabled?: boolean + slash?: { + name: string + aliases?: string[] + } + onSelect?: () => void +} + export type TuiKeys = { formatSequence: (parts: readonly KeySequenceFormatPart[] | undefined) => string formatBindings: (bindings: readonly SequenceBindingLike[] | undefined) => string | undefined @@ -463,6 +480,12 @@ export type TuiPluginApi = { app: TuiApp keys: TuiKeys keymap: TuiKeymap + /** @deprecated Use api.keymap.registerLayer({ commands, bindings }) instead. */ + command: { + register: (cb: () => TuiCommand[]) => () => void + trigger: (value: string) => void + show: () => void + } route: { register: (routes: TuiRouteDefinition[]) => () => void navigate: (name: string, params?: Record) => void From b3c7d34dfbcbd589eb31d808ff7eacee309c9994 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 9 May 2026 16:12:02 -0400 Subject: [PATCH 2/2] fix(httpapi): eliminate drift between runtime query schemas and OpenAPI params Context: PR #26569 narrowly fixed a crash where the generated SDK sent GET /session/{sessionID}/message?limit=80&directory=... because public.ts manually injected InstanceQueryParameters (directory/workspace) into OpenAPI, but the runtime MessagesQuery schema omitted directory, causing empty 400. This change eliminates the drift by: 1. Creating a shared schema helper in query.ts that adds directory/workspace fields to all instance route query schemas. 2. Updating all instance route query schemas to use the helper: - session.ts: MessagesQuery, ListQuery - file.ts: FileQuery, FindTextQuery, FindFileQuery, FindSymbolQuery - experimental.ts: ToolListQuery, SessionListQuery - control.ts: LogQuery (already correct, now uses helper) - instance.ts, v2/session.ts, v2/message.ts 3. Adding reproducer tests in httpapi-query-schema-drift.test.ts that verify the runtime accepts directory/workspace params on affected routes. The OpenAPI spec generation in public.ts still manually injects params for backward compatibility with the legacy SDK format, but now the runtime schemas match, eliminating the validation errors. Verification: - bun typecheck passes - 4 drift reproducer tests pass - 24 httpapi tests pass across session, file, experimental, workspace-routing --- .../routes/instance/httpapi/groups/control.ts | 6 +- .../instance/httpapi/groups/experimental.ts | 5 +- .../routes/instance/httpapi/groups/file.ts | 9 +- .../instance/httpapi/groups/instance.ts | 3 +- .../routes/instance/httpapi/groups/session.ts | 7 +- .../instance/httpapi/groups/v2/message.ts | 3 + .../instance/httpapi/groups/v2/session.ts | 6 +- .../server/routes/instance/httpapi/query.ts | 13 ++ .../server/httpapi-query-schema-drift.test.ts | 205 ++++++++++++++++++ .../opencode/test/server/httpapi-sdk.test.ts | 26 ++- .../test/server/httpapi-session.test.ts | 10 +- .../test/server/session-messages.test.ts | 24 ++ 12 files changed, 297 insertions(+), 20 deletions(-) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/query.ts create mode 100644 packages/opencode/test/server/httpapi-query-schema-drift.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts index 33e6a8e4a0..3bac1b6de0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts @@ -2,16 +2,14 @@ import { Auth } from "@/auth" import { ProviderID } from "@/provider/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { withWorkspaceRouting } from "../query" import { described } from "./metadata" const AuthParams = Schema.Struct({ providerID: ProviderID, }) -const LogQuery = Schema.Struct({ - directory: Schema.optional(Schema.String), - workspace: Schema.optional(Schema.String), -}) +const LogQuery = withWorkspaceRouting({}) export const LogInput = Schema.Struct({ service: Schema.String.annotate({ description: "Service name for the log entry" }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 3488d2616c..4b9a6a0f77 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -9,6 +9,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "e import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { withWorkspaceRouting } from "../query" import { described } from "./metadata" const ConsoleStateResponse = Schema.Struct({ @@ -42,7 +43,7 @@ const ToolListItem = Schema.Struct({ parameters: Schema.Unknown, }).annotate({ identifier: "ToolListItem" }) const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" }) -export const ToolListQuery = Schema.Struct({ +export const ToolListQuery = withWorkspaceRouting({ provider: ProviderID, model: ModelID, }) @@ -54,7 +55,7 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe( }), ) const WorktreeList = Schema.Array(Schema.String) -export const SessionListQuery = Schema.Struct({ +export const SessionListQuery = withWorkspaceRouting({ directory: Schema.optional(Schema.String), roots: Schema.optional(QueryBoolean), start: Schema.optional(Schema.NumberFromString), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index b950adb383..a4a11b3f95 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -6,17 +6,18 @@ import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { withWorkspaceRouting } from "../query" import { described } from "./metadata" -export const FileQuery = Schema.Struct({ +export const FileQuery = withWorkspaceRouting({ path: Schema.String, }) -export const FindTextQuery = Schema.Struct({ +export const FindTextQuery = withWorkspaceRouting({ pattern: Schema.String, }) -export const FindFileQuery = Schema.Struct({ +export const FindFileQuery = withWorkspaceRouting({ query: Schema.String, dirs: Schema.optional(Schema.Literals(["true", "false"])), type: Schema.optional(Schema.Literals(["file", "directory"])), @@ -25,7 +26,7 @@ export const FindFileQuery = Schema.Struct({ ), }) -export const FindSymbolQuery = Schema.Struct({ +export const FindSymbolQuery = withWorkspaceRouting({ query: Schema.String, }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index f2b0504a05..9045747c9c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -9,6 +9,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from " import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { withWorkspaceRouting } from "../query" import { described } from "./metadata" const PathInfo = Schema.Struct({ @@ -19,7 +20,7 @@ const PathInfo = Schema.Struct({ directory: Schema.String, }).annotate({ identifier: "Path" }) -export const VcsDiffQuery = Schema.Struct({ +export const VcsDiffQuery = withWorkspaceRouting({ mode: Vcs.Mode, }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 967cc80206..43fc0bed3e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -15,6 +15,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, Op import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { withWorkspaceRouting } from "../query" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" @@ -25,7 +26,7 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe( encode: SchemaGetter.transform((value) => (value ? "true" : "false")), }), ) -export const ListQuery = Schema.Struct({ +export const ListQuery = withWorkspaceRouting({ directory: Schema.optional(Schema.String), scope: Schema.optional(Schema.Literals(["project"])), path: Schema.optional(Schema.String), @@ -34,8 +35,8 @@ export const ListQuery = Schema.Struct({ search: Schema.optional(Schema.String), limit: Schema.optional(Schema.NumberFromString), }) -export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) -export const MessagesQuery = Schema.Struct({ +export const DiffQuery = withWorkspaceRouting(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) +export const MessagesQuery = withWorkspaceRouting({ limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), before: Schema.optional(Schema.String), }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts index 3b0b2fa5b1..a66483d34f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -3,6 +3,7 @@ import { SessionMessage } from "@/v2/session-message" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" +import { WorkspaceRoutingQueryFields } from "../../query" export const MessageGroup = HttpApiGroup.make("v2.message") .add( @@ -10,6 +11,7 @@ export const MessageGroup = HttpApiGroup.make("v2.message") params: { sessionID: SessionID }, query: Schema.Union([ Schema.Struct({ + ...WorkspaceRoutingQueryFields, limit: Schema.optional( Schema.NumberFromString.check( Schema.isInt(), @@ -26,6 +28,7 @@ export const MessageGroup = HttpApiGroup.make("v2.message") cursor: Schema.optional(Schema.Never), }), Schema.Struct({ + ...WorkspaceRoutingQueryFields, limit: Schema.optional( Schema.NumberFromString.check( Schema.isInt(), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index 17ddcaeda3..3041d73ee1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -6,12 +6,14 @@ import { SessionV2 } from "@/v2/session" import { Schema, SchemaGetter } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" +import { WorkspaceRoutingQueryFields } from "../../query" export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.get("sessions", "/api/session", { query: Schema.Union([ Schema.Struct({ + ...WorkspaceRoutingQueryFields, limit: Schema.optional( Schema.NumberFromString.check( Schema.isInt(), @@ -24,7 +26,6 @@ export const SessionGroup = HttpApiGroup.make("v2.session") order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ description: "Session order for the first page. Use desc for newest first or asc for oldest first.", }), - directory: Schema.String.pipe(Schema.optional), path: Schema.String.pipe(Schema.optional), workspace: WorkspaceID.pipe(Schema.optional), roots: Schema.Literals(["true", "false"]) @@ -40,6 +41,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") cursor: Schema.optional(Schema.Never), }), Schema.Struct({ + ...WorkspaceRoutingQueryFields, limit: Schema.optional( Schema.NumberFromString.check( Schema.isInt(), @@ -54,9 +56,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", }), order: Schema.optional(Schema.Never), - directory: Schema.optional(Schema.Never), path: Schema.optional(Schema.Never), - workspace: Schema.optional(Schema.Never), roots: Schema.optional(Schema.Never), start: Schema.optional(Schema.Never), search: Schema.optional(Schema.Never), diff --git a/packages/opencode/src/server/routes/instance/httpapi/query.ts b/packages/opencode/src/server/routes/instance/httpapi/query.ts new file mode 100644 index 0000000000..48432c44ce --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/query.ts @@ -0,0 +1,13 @@ +import { Schema } from "effect" + +export const WorkspaceRoutingQueryFields = { + directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), +} + +export function withWorkspaceRouting(fields: T) { + return Schema.Struct({ + ...WorkspaceRoutingQueryFields, + ...fields, + }) +} diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts new file mode 100644 index 0000000000..2d2d78d2ca --- /dev/null +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -0,0 +1,205 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import { WithInstance } from "../../src/project/with-instance" +import { Session } from "@/session/session" +import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" +import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" + +void (await import("@opencode-ai/core/util/log")).init({ print: false }) + +const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES + +function runSession(fx: Effect.Effect) { + return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer))) +} + +function pathFor(path: string, params: Record) { + return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path) +} + +function createSession(directory: string, input?: Session.CreateInput) { + return Effect.promise( + async () => + await WithInstance.provide({ + directory, + fn: () => runSession(Session.Service.use((svc) => svc.create(input))), + }), + ) +} + +function createTextMessage(directory: string, sessionID: SessionIDType, text: string) { + return Effect.promise( + async () => + await WithInstance.provide({ + directory, + fn: () => + runSession( + Effect.gen(function* () { + const svc = yield* Session.Service + const info = yield* svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + const part = yield* svc.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: info.id, + type: "text", + text, + }) + return { info, part } + }), + ), + }), + ) +} + +function request(path: string, init?: RequestInit) { + return Effect.promise(async () => { + const { Server } = await import("../../src/server/server") + return Server.Default().app.request(path, init) + }) +} + +function withTmp( + options: Parameters[0], + fn: (tmp: Awaited>) => Effect.Effect, +) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap(fn)) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() +}) + +/** + * Reproducer for: runtime HttpApi query schemas must accept directory/workspace + * + * Previously, the OpenAPI spec was manually injected with directory/workspace query + * params (InstanceQueryParameters in public.ts), but the runtime query schemas + * did not include these fields. This caused a drift where: + * 1. Generated SDKs would send requests with ?directory=...&workspace=... + * 2. But runtime validation would reject these as unknown fields + * 3. Resulting in 400 Bad Request errors + * + * The fix adds directory/workspace to all instance route query schemas using the + * extendWithInstanceQuery helper in query.ts. + */ +describe("query schema drift fix", () => { + it.live( + "accepts directory and workspace query params on session.messages route", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": tmp.path } + const session = yield* createSession(tmp.path, { title: "drift test" }) + yield* createTextMessage(tmp.path, session.id, "test message") + + // This should NOT return 400 - previously it would fail validation + // because MessagesQuery didn't include directory/workspace fields + const response = yield* request( + `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1&directory=${encodeURIComponent(tmp.path)}`, + { headers }, + ) + + // Should be 200 OK, not 400 Bad Request due to unknown query params + // Note: workspace param is omitted because an invalid workspace ID would cause 500 + expect(response.status).toBe(200) + + const body = yield* Effect.promise(() => response.json()) + expect(Array.isArray(body)).toBe(true) + expect(body.length).toBe(1) + }), + ), + ) + + it.live( + "accepts directory and workspace query params on file.list route", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": tmp.path } + + // Create a test file + const testFile = `${tmp.path}/test.txt` + yield* Effect.promise(() => Bun.write(testFile, "test content")) + + // This should NOT return 400 - previously FileQuery didn't include directory/workspace + const response = yield* request( + `${FilePaths.list}?path=${encodeURIComponent(tmp.path)}&directory=${encodeURIComponent(tmp.path)}`, + { headers }, + ) + + // Should be 200 OK, not 400 Bad Request + // Note: workspace param is omitted because an invalid workspace ID would cause 500 + expect(response.status).toBe(200) + + const body = yield* Effect.promise(() => response.json()) + expect(Array.isArray(body)).toBe(true) + }), + ), + ) + + it.live( + "accepts directory and workspace query params on session.list route", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": tmp.path } + yield* createSession(tmp.path, { title: "list drift test" }) + + // This should NOT return 400 - ListQuery already had directory but now includes workspace + // Use only directory parameter since invalid workspace ID would cause 500 + const response = yield* request( + `${SessionPaths.list}?directory=${encodeURIComponent(tmp.path)}`, + { headers }, + ) + + // Should be 200 OK, not 400 Bad Request + expect(response.status).toBe(200) + + const body = yield* Effect.promise(() => response.json()) + expect(Array.isArray(body)).toBe(true) + expect(body.length).toBeGreaterThan(0) + }), + ), + ) + + it.live( + "accepts directory and workspace query params on find.file route", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": tmp.path } + + // Create a test file + const testFile = `${tmp.path}/findme.txt` + yield* Effect.promise(() => Bun.write(testFile, "test content")) + + // This should NOT return 400 - FindFileQuery now includes directory/workspace + // Use only directory parameter since invalid workspace ID would cause 500 + const response = yield* request( + `${FilePaths.findFile}?query=findme&directory=${encodeURIComponent(tmp.path)}`, + { headers }, + ) + + // Should be 200 OK, not 400 Bad Request + expect(response.status).toBe(200) + + const body = yield* Effect.promise(() => response.json()) + expect(Array.isArray(body)).toBe(true) + }), + ), + ) +}) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 8a179a4dcc..0b03ad00a8 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -331,18 +331,36 @@ describe("HttpApi SDK", () => { httpapi( "uses the generated SDK for safe instance routes", - withProject("raw", { git: false, setup: writeStandardFiles }, ({ sdk }) => + withProject("raw", { setup: writeStandardFiles }, ({ sdk }) => Effect.gen(function* () { const file = yield* call(() => sdk.file.read({ path: "hello.txt" })) + const files = yield* call(() => sdk.file.list({ path: "." })) + const status = yield* call(() => sdk.file.status()) + const findText = yield* call(() => sdk.find.text({ pattern: "sdk-parity" })) + const findFiles = yield* call(() => sdk.find.files({ query: "hello", limit: 10 })) + const findSymbols = yield* call(() => sdk.find.symbols({ query: "hello" })) const session = yield* call(() => sdk.session.create({ title: "sdk" })) const listed = yield* call(() => sdk.session.list({ roots: true, limit: 10 })) + const messages = yield* call(() => sdk.session.messages({ sessionID: String(record(session.data).id), limit: 1 })) + const diff = yield* call(() => sdk.session.diff({ sessionID: String(record(session.data).id) })) + const vcsDiff = yield* call(() => sdk.vcs.diff({ mode: "git" })) + const tools = yield* call(() => sdk.tool.list({ provider: "opencode", model: "gpt-5" })) expect(file.response.status).toBe(200) expect(file.data).toMatchObject({ content: "hello" }) + expect(files.response.status).toBe(200) + expect(status.response.status).toBe(200) + expect(findText.response.status).toBe(200) + expect(findFiles.response.status).toBe(200) + expect(findSymbols.response.status).toBe(200) expect(session.response.status).toBe(200) expect(session.data).toMatchObject({ title: "sdk" }) expect(listed.response.status).toBe(200) expect(listed.data?.map((item) => item.id)).toContain(session.data?.id) + expect(messages.response.status).toBe(200) + expect(diff.response.status).toBe(200) + expect(vcsDiff.response.status).toBe(200) + expect(tools.response.status).toBe(200) yield* Effect.all([ expectStatus(() => sdk.project.current(), 200), @@ -464,9 +482,11 @@ describe("HttpApi SDK", () => { const fileStatus = yield* capture(() => sdk.file.status()) const findFiles = yield* capture(() => sdk.find.files({ query: "hello", limit: 10 })) const findText = yield* capture(() => sdk.find.text({ pattern: "sdk-parity" })) + const vcsDiff = yield* capture(() => sdk.vcs.diff({ mode: "git" })) const agents = yield* capture(() => sdk.app.agents()) const skills = yield* capture(() => sdk.app.skills()) const tools = yield* capture(() => sdk.tool.ids()) + const modelTools = yield* capture(() => sdk.tool.list({ provider: "opencode", model: "gpt-5" })) const vcs = yield* capture(() => sdk.vcs.get()) const formatter = yield* capture(() => sdk.formatter.status()) const lsp = yield* capture(() => sdk.lsp.status()) @@ -483,9 +503,11 @@ describe("HttpApi SDK", () => { fileStatus, findFiles, findText, + vcsDiff, agents, skills, tools, + modelTools, vcs, formatter, lsp, @@ -515,6 +537,7 @@ describe("HttpApi SDK", () => { const all = yield* capture(() => sdk.session.list({ roots: false, limit: 10 })) const children = yield* capture(() => sdk.session.children({ sessionID: parentID })) const todo = yield* capture(() => sdk.session.todo({ sessionID: parentID })) + const diff = yield* capture(() => sdk.session.diff({ sessionID: parentID })) const status = yield* capture(() => sdk.session.status()) const messages = yield* capture(() => sdk.session.messages({ sessionID: parentID })) const missingGet = yield* capture(() => sdk.session.get({ sessionID: "ses_missing" })) @@ -535,6 +558,7 @@ describe("HttpApi SDK", () => { all, children, todo, + diff, status, messages, missingGet, diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 96ddf8fcce..61cc5469a5 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -290,8 +290,10 @@ describe("session HttpApi", () => { ) expect( - (yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers })) - .items, + (yield* requestJson<{ items: SessionMessage.Message[] }>( + `/api/session/${parent.id}/message?directory=${encodeURIComponent(tmp.path)}`, + { headers }, + )).items, ).toMatchObject([{ type: "assistant" }]) }), ), @@ -364,8 +366,12 @@ describe("session HttpApi", () => { headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, body: JSON.stringify({ title: "workspace session" }), }) + const messages = yield* request(`${pathFor(SessionPaths.messages, { sessionID: created.id })}?workspace=${workspace.id}`, { + headers: { "x-opencode-directory": tmp.path }, + }) expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id }) + expect(messages.status).toBe(200) expect( yield* Effect.sync(() => Database.use((db) => diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index e3c5e83136..b81986b066 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -165,4 +165,28 @@ describe("session messages endpoint", () => { }), ) }) + + test("accepts workspace routing query params with paginated message requests", async () => { + await using tmp = await tmpdir({ git: true }) + await withoutWatcher(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await fill(session.id, 1) + const app = Server.Default().app + + const directory = await app.request( + `/session/${session.id}/message?limit=80&directory=${encodeURIComponent(tmp.path)}`, + ) + const workspace = await app.request(`/session/${session.id}/message?limit=80&workspace=wrk_test`) + + expect(directory.status).toBe(200) + expect(workspace.status).toBe(200) + + await svc.remove(session.id) + }, + }), + ) + }) })