From 46c828c341e38c976f8cf0172049a6cb30ebc391 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 11:57:36 -0500 Subject: [PATCH 01/10] feat(core): let subagents pick a model and expose model_list --- packages/core/src/tool/plugin/opencode.ts | 28 +++++++++ packages/core/src/tool/plugin/subagent.ts | 48 ++++++++++++---- packages/core/test/tool-opencode.test.ts | 54 +++++++++++++++++ packages/core/test/tool-subagent.test.ts | 70 ++++++++++++++++++++++- 4 files changed, 187 insertions(+), 13 deletions(-) create mode 100644 packages/core/test/tool-opencode.test.ts diff --git a/packages/core/src/tool/plugin/opencode.ts b/packages/core/src/tool/plugin/opencode.ts index 639cd778f1a6..80b042cd1e39 100644 --- a/packages/core/src/tool/plugin/opencode.ts +++ b/packages/core/src/tool/plugin/opencode.ts @@ -3,6 +3,7 @@ export * as OpenCodeTools from "./opencode.js" import { SystemPart, ToolFailure } from "@opencode/ai" import type { Context } from "@opencode/plugin/effect/plugin" import type { SessionHooks } from "@opencode/plugin/effect/session" +import { Model } from "@opencode/schema/model" import { AbsolutePath } from "@opencode/schema/schema" import { Session } from "@opencode/schema/session" import { Effect, Schema } from "effect" @@ -23,6 +24,12 @@ export const MoveInput = Schema.Struct({ const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePath }) +export const ModelListInput = Schema.Struct({ + providerID: Schema.optionalKey(Schema.String).annotate({ description: "Only list models from this provider." }), +}) + +const ModelListOutput = Schema.Struct({ models: Schema.Array(Model.Info) }) + export const Plugin = { id: "opencode.tools", effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) { @@ -85,6 +92,27 @@ export const Plugin = { ), ), }) + draft.add({ + name: "model_list", + description: + 'List the models available in this OpenCode instance. Reference a model as "providerID/id" or "providerID/id#variant" wherever a model is accepted, such as the subagent tool.', + input: ModelListInput, + output: ModelListOutput, + options: { namespace: "opencode", codemode: true }, + execute: (input) => + ctx.model.list().pipe( + Effect.map((list) => { + const models = list.data.filter( + (model) => input.providerID === undefined || model.providerID === input.providerID, + ) + return { + output: { models }, + content: models.map((model) => `${model.providerID}/${model.id}: ${model.name}`).join("\n"), + } + }), + Effect.mapError((error) => new ToolFailure({ message: "Unable to list models", error })), + ), + }) }) .pipe(Effect.orDie) }), diff --git a/packages/core/src/tool/plugin/subagent.ts b/packages/core/src/tool/plugin/subagent.ts index 7885e3b4e422..ff349072c331 100644 --- a/packages/core/src/tool/plugin/subagent.ts +++ b/packages/core/src/tool/plugin/subagent.ts @@ -7,6 +7,7 @@ import { Effect, Schema } from "effect" import { Agent } from "../../agent.js" import { Config } from "../../config.js" import { Job } from "../../job.js" +import { Model } from "../../model.js" import { Permission } from "../../permission.js" import { Session } from "../../session.js" import { SessionSchema } from "../../session/schema.js" @@ -29,6 +30,10 @@ export const Input = Schema.Struct({ agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }), description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }), prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }), + model: Schema.optionalKey(Schema.String).annotate({ + description: + 'Run the subagent on a specific model, as "providerID/id" or "providerID/id#variant". Omit to use the agent\'s configured model, then the current session\'s model. Discover models with the opencode model_list tool.', + }), sessionID: Schema.optionalKey(SessionSchema.ID).annotate({ description: "Continue a specific previous subagent conversation by passing its sessionID. Calls without a sessionID start a new conversation.", @@ -61,8 +66,28 @@ export const Plugin = { const agents = yield* Agent.Service const config = yield* Config.Service const permission = yield* Permission.Service + const models = yield* Model.Service const subagents = yield* SubagentJob.make + const resolveModel = Effect.fn("SubagentTool.resolveModel")(function* (input: string) { + const ref = yield* Effect.try({ + try: () => Model.Ref.parse(input), + catch: () => new ToolFailure({ message: `Invalid model reference: ${input}. Use "providerID/id#variant".` }), + }) + const model = (yield* models.available()).find( + (model) => model.providerID === ref.providerID && model.id === ref.id, + ) + if (model === undefined) + return yield* new ToolFailure({ + message: `Unknown model: ${ref.providerID}/${ref.id}. Use the opencode model_list tool to see available models.`, + }) + if (ref.variant !== undefined && !model.variants.some((variant) => variant.id === ref.variant)) + return yield* new ToolFailure({ + message: `Unknown variant "${ref.variant}" for ${ref.providerID}/${ref.id}. Available: ${model.variants.map((variant) => variant.id).join(", ") || "none"}.`, + }) + return ref + }) + yield* ctx.tool .transform((editor) => editor.add({ @@ -131,24 +156,23 @@ export const Plugin = { return yield* new ToolFailure({ message: `Session ${existing.id} is not a child of the current session`, }) + const override = input.model === undefined ? undefined : yield* resolveModel(input.model) // Continuing with a different agent switches the child, mirroring create semantics - // where the agent's configured model wins over the inherited one. - if (existing !== undefined && existing.agent !== agent.id) { - yield* sessions.switchAgent({ sessionID: existing.id, agent: agent.id }).pipe( - Effect.andThen( - agent.model === undefined - ? Effect.void - : sessions.switchModel({ sessionID: existing.id, model: agent.model }), - ), + // where an explicit model wins over the agent's configured model, which wins over the inherited one. + if (existing !== undefined) { + const switched = existing.agent !== agent.id + const model = override ?? (switched ? agent.model : undefined) + yield* Effect.all([ + switched ? sessions.switchAgent({ sessionID: existing.id, agent: agent.id }) : Effect.void, + model === undefined ? Effect.void : sessions.switchModel({ sessionID: existing.id, model }), + ]).pipe( Effect.mapError( - (error) => - new ToolFailure({ message: `Failed to switch subagent session agent: ${existing.id}`, error }), + (error) => new ToolFailure({ message: `Failed to switch subagent session: ${existing.id}`, error }), ), ) } - // Model selection is policy/config/session state, not an LLM-facing tool argument. - const model = agent.model ?? parent.model + const model = override ?? agent.model ?? parent.model const child = existing ?? (yield* sessions diff --git a/packages/core/test/tool-opencode.test.ts b/packages/core/test/tool-opencode.test.ts new file mode 100644 index 000000000000..f9d1bfe40486 --- /dev/null +++ b/packages/core/test/tool-opencode.test.ts @@ -0,0 +1,54 @@ +import { expect } from "bun:test" +import { Plugin } from "@opencode/core/plugin" +import { PluginHost } from "@opencode/core/plugin/host" +import { Provider } from "@opencode/core/provider" +import { Session } from "@opencode/core/session" +import { Tool } from "@opencode/core/tool" +import { OpenCodeTools } from "@opencode/core/tool/plugin/opencode" +import { Model } from "@opencode/schema/model" +import { Effect } from "effect" +import { testEffect } from "./lib/effect" +import { executeTool, toolIdentity } from "./lib/tool" +import { PluginTestLayer } from "./plugin/fixture" + +const it = testEffect(PluginTestLayer) + +it.effect("lists available models through the opencode namespace", () => + Effect.gen(function* () { + const catalog = yield* Provider.Service + const plugins = yield* Plugin.Service + const pluginHost = yield* PluginHost.make(plugins) + yield* catalog.transform((editor) => { + editor.models.update(Provider.ID.make("test"), Model.ID.make("alpha"), (model) => { + model.name = "Alpha" + model.variants = [{ id: Model.VariantID.make("fast") }] + }) + editor.models.update(Provider.ID.make("other"), Model.ID.make("beta"), (model) => { + model.name = "Beta" + }) + editor.models.update(Provider.ID.make("other"), Model.ID.make("disabled"), (model) => { + model.enabled = false + }) + }) + yield* OpenCodeTools.Plugin.effect(pluginHost) + const registry = yield* Tool.Service + const run = (code: string) => + executeTool(registry, { + sessionID: Session.ID.make("ses_tool_opencode"), + ...toolIdentity, + call: { type: "tool-call", id: `call-${code.length}`, name: "execute", input: { code } }, + }) + + const all = yield* run( + "const list = await tools.opencode.model_list({}); return list.models.map((model) => `${model.providerID}/${model.id}: ${model.name} [${model.variants.map((variant) => variant.id)}]`).sort()", + ) + expect(all.content).toEqual([ + { type: "text", text: JSON.stringify(["other/beta: Beta []", "test/alpha: Alpha [fast]"], null, 2) }, + ]) + + const filtered = yield* run( + 'const list = await tools.opencode.model_list({ providerID: "other" }); return list.models.map((model) => model.id)', + ) + expect(filtered.content).toEqual([{ type: "text", text: JSON.stringify(["beta"], null, 2) }]) + }), +) diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 4981bba667bd..5bf153a8309e 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -45,6 +45,11 @@ const completedOutput = (sessionID: Session.ID) => `\n${childText}\n` const childModel = Model.Ref.make({ id: Model.ID.make("child"), providerID: Provider.ID.make("test") }) const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") }) +const overrideModel = Model.Ref.make({ + id: Model.ID.make("override"), + providerID: Provider.ID.make("test"), + variant: Model.VariantID.make("fast"), +}) const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } const outputSessionID = (value: unknown) => @@ -107,7 +112,7 @@ const executionNode = makeGlobalNode({ const subagentPluginSupervisor = makeLocationNode({ name: "test/subagent-plugins", layer: Layer.effectDiscard(registerToolPlugin(SubagentTool.Plugin)), - deps: [Agent.node, Config.node, Permission.node, Session.node, Job.node, Tool.node], + deps: [Agent.node, Config.node, Model.node, Permission.node, Session.node, Job.node, Tool.node], }) const nodes = LayerNode.group([ @@ -155,6 +160,13 @@ const withSubagent = (location: Location.Ref) => Effect.gen(function* () { const locations = yield* LocationServiceMap.Service yield* Plugin.Service.use((plugins) => plugins.awaitActivation).pipe(Effect.provide(locations.get(location))) + yield* Provider.Service.use((providers) => + providers.transform((editor) => { + editor.models.update(overrideModel.providerID, overrideModel.id, (model) => { + model.variants = [{ id: Model.VariantID.make("fast") }] + }) + }), + ).pipe(Effect.provide(locations.get(location))) yield* Agent.Service.use((agents) => agents.transform((editor) => { // The caller identity used by executeTool; subagent permission asserts against it. @@ -615,6 +627,62 @@ describe("SubagentTool", () => { ), ) + it.live("runs the child on an explicitly requested model", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + const sessions = yield* Session.Service + const parent = yield* sessions.create({ location, model: parentModel }) + yield* withSubagent(parent.location) + const locations = yield* LocationServiceMap.Service + const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location))) + const call = (id: string, input: Record) => + executeTool(registry, { + sessionID: parent.id, + ...toolIdentity, + call: { + type: "tool-call" as const, + id, + name: SubagentTool.name, + input: { agent: "reviewer", description: "review", prompt: "review this", ...input }, + }, + }) + + // The requested model beats the agent's configured model. + const spawned = yield* call("call-override", { model: "test/override#fast" }) + expect(spawned).toMatchObject({ status: "completed", metadata: { status: "completed" } }) + const child = yield* sessions.get(outputSessionID(spawned.metadata)) + expect(child).toMatchObject({ agent: "reviewer", model: overrideModel }) + + // Continuing with a model switches the existing child even when the agent is unchanged. + const continued = yield* call("call-override-continue", { sessionID: child.id, model: "test/override" }) + expect(continued).toMatchObject({ status: "completed", metadata: { sessionID: child.id } }) + expect((yield* sessions.get(child.id)).model).toEqual({ + id: overrideModel.id, + providerID: overrideModel.providerID, + variant: Model.VariantID.make("default"), + }) + + const failures = [ + ["not-a-ref", 'Invalid model reference: not-a-ref. Use "providerID/id#variant".'], + ["test/missing", "Unknown model: test/missing. Use the opencode model_list tool to see available models."], + ["test/override#slow", 'Unknown variant "slow" for test/override. Available: fast.'], + ] as const + for (const [model, message] of failures) { + expect(yield* call(`call-${model}`, { model })).toEqual({ + status: "error", + error: { type: "tool.execution", message }, + }) + } + }), + ), + ), + ) + it.live("returns child runner failures as tool errors", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), From 1f0667b696887f215b24c40f4fdd33dea4d63380 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 12:01:02 -0500 Subject: [PATCH 02/10] fix(core): rename model_list to models and clarify unavailable model error --- packages/core/src/tool/plugin/opencode.ts | 10 +++++----- packages/core/src/tool/plugin/subagent.ts | 4 ++-- packages/core/test/tool-opencode.test.ts | 4 ++-- packages/core/test/tool-subagent.test.ts | 5 ++++- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/core/src/tool/plugin/opencode.ts b/packages/core/src/tool/plugin/opencode.ts index 80b042cd1e39..11b2ad201289 100644 --- a/packages/core/src/tool/plugin/opencode.ts +++ b/packages/core/src/tool/plugin/opencode.ts @@ -24,11 +24,11 @@ export const MoveInput = Schema.Struct({ const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePath }) -export const ModelListInput = Schema.Struct({ +export const ModelsInput = Schema.Struct({ providerID: Schema.optionalKey(Schema.String).annotate({ description: "Only list models from this provider." }), }) -const ModelListOutput = Schema.Struct({ models: Schema.Array(Model.Info) }) +const ModelsOutput = Schema.Struct({ models: Schema.Array(Model.Info) }) export const Plugin = { id: "opencode.tools", @@ -93,11 +93,11 @@ export const Plugin = { ), }) draft.add({ - name: "model_list", + name: "models", description: 'List the models available in this OpenCode instance. Reference a model as "providerID/id" or "providerID/id#variant" wherever a model is accepted, such as the subagent tool.', - input: ModelListInput, - output: ModelListOutput, + input: ModelsInput, + output: ModelsOutput, options: { namespace: "opencode", codemode: true }, execute: (input) => ctx.model.list().pipe( diff --git a/packages/core/src/tool/plugin/subagent.ts b/packages/core/src/tool/plugin/subagent.ts index ff349072c331..18df1f3699b6 100644 --- a/packages/core/src/tool/plugin/subagent.ts +++ b/packages/core/src/tool/plugin/subagent.ts @@ -32,7 +32,7 @@ export const Input = Schema.Struct({ prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }), model: Schema.optionalKey(Schema.String).annotate({ description: - 'Run the subagent on a specific model, as "providerID/id" or "providerID/id#variant". Omit to use the agent\'s configured model, then the current session\'s model. Discover models with the opencode model_list tool.', + 'Run the subagent on a specific model, as "providerID/id" or "providerID/id#variant". Omit to use the agent\'s configured model, then the current session\'s model. List available models with `tools.opencode.models()` in the execute tool.', }), sessionID: Schema.optionalKey(SessionSchema.ID).annotate({ description: @@ -79,7 +79,7 @@ export const Plugin = { ) if (model === undefined) return yield* new ToolFailure({ - message: `Unknown model: ${ref.providerID}/${ref.id}. Use the opencode model_list tool to see available models.`, + message: `Model ${ref.providerID}/${ref.id} is not available. List available models with tools.opencode.models() in the execute tool.`, }) if (ref.variant !== undefined && !model.variants.some((variant) => variant.id === ref.variant)) return yield* new ToolFailure({ diff --git a/packages/core/test/tool-opencode.test.ts b/packages/core/test/tool-opencode.test.ts index f9d1bfe40486..d39d417388d3 100644 --- a/packages/core/test/tool-opencode.test.ts +++ b/packages/core/test/tool-opencode.test.ts @@ -40,14 +40,14 @@ it.effect("lists available models through the opencode namespace", () => }) const all = yield* run( - "const list = await tools.opencode.model_list({}); return list.models.map((model) => `${model.providerID}/${model.id}: ${model.name} [${model.variants.map((variant) => variant.id)}]`).sort()", + "const list = await tools.opencode.models({}); return list.models.map((model) => `${model.providerID}/${model.id}: ${model.name} [${model.variants.map((variant) => variant.id)}]`).sort()", ) expect(all.content).toEqual([ { type: "text", text: JSON.stringify(["other/beta: Beta []", "test/alpha: Alpha [fast]"], null, 2) }, ]) const filtered = yield* run( - 'const list = await tools.opencode.model_list({ providerID: "other" }); return list.models.map((model) => model.id)', + 'const list = await tools.opencode.models({ providerID: "other" }); return list.models.map((model) => model.id)', ) expect(filtered.content).toEqual([{ type: "text", text: JSON.stringify(["beta"], null, 2) }]) }), diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 5bf153a8309e..22b8e8ef2576 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -669,7 +669,10 @@ describe("SubagentTool", () => { const failures = [ ["not-a-ref", 'Invalid model reference: not-a-ref. Use "providerID/id#variant".'], - ["test/missing", "Unknown model: test/missing. Use the opencode model_list tool to see available models."], + [ + "test/missing", + "Model test/missing is not available. List available models with tools.opencode.models() in the execute tool.", + ], ["test/override#slow", 'Unknown variant "slow" for test/override. Available: fast.'], ] as const for (const [model, message] of failures) { From 5fafd6f3ecd4e49113db7ad5c9a46d3e1364b492 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 15:38:07 -0500 Subject: [PATCH 03/10] refactor(core): reword model selection prompts and errors --- packages/core/src/tool/plugin/opencode.ts | 8 +++++--- packages/core/src/tool/plugin/subagent.ts | 9 +++++---- packages/core/test/tool-opencode.test.ts | 2 +- packages/core/test/tool-subagent.test.ts | 9 +++------ 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/core/src/tool/plugin/opencode.ts b/packages/core/src/tool/plugin/opencode.ts index 11b2ad201289..b94cd0960c51 100644 --- a/packages/core/src/tool/plugin/opencode.ts +++ b/packages/core/src/tool/plugin/opencode.ts @@ -25,7 +25,9 @@ export const MoveInput = Schema.Struct({ const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePath }) export const ModelsInput = Schema.Struct({ - providerID: Schema.optionalKey(Schema.String).annotate({ description: "Only list models from this provider." }), + provider: Schema.optionalKey(Schema.String).annotate({ + description: 'Only list models from this provider, for example "anthropic".', + }), }) const ModelsOutput = Schema.Struct({ models: Schema.Array(Model.Info) }) @@ -95,7 +97,7 @@ export const Plugin = { draft.add({ name: "models", description: - 'List the models available in this OpenCode instance. Reference a model as "providerID/id" or "providerID/id#variant" wherever a model is accepted, such as the subagent tool.', + 'List the models available to you. Reference one as "provider/model", adding "#variant" from its variants when needed, for example "anthropic/claude-sonnet-4-5" or "openai/gpt-5#high". Pass the reference anywhere a model is accepted, such as the subagent tool.', input: ModelsInput, output: ModelsOutput, options: { namespace: "opencode", codemode: true }, @@ -103,7 +105,7 @@ export const Plugin = { ctx.model.list().pipe( Effect.map((list) => { const models = list.data.filter( - (model) => input.providerID === undefined || model.providerID === input.providerID, + (model) => input.provider === undefined || model.providerID === input.provider, ) return { output: { models }, diff --git a/packages/core/src/tool/plugin/subagent.ts b/packages/core/src/tool/plugin/subagent.ts index 18df1f3699b6..00627620b733 100644 --- a/packages/core/src/tool/plugin/subagent.ts +++ b/packages/core/src/tool/plugin/subagent.ts @@ -32,7 +32,7 @@ export const Input = Schema.Struct({ prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }), model: Schema.optionalKey(Schema.String).annotate({ description: - 'Run the subagent on a specific model, as "providerID/id" or "providerID/id#variant". Omit to use the agent\'s configured model, then the current session\'s model. List available models with `tools.opencode.models()` in the execute tool.', + 'Only pass this when the user explicitly asks for a specific model. Format "provider/model" or "provider/model#variant", for example "anthropic/claude-sonnet-4-5" or "openai/gpt-5#high". Otherwise omit it and the subagent uses the agent\'s configured model, or your own. Use the models tool to find the reference for a requested model.', }), sessionID: Schema.optionalKey(SessionSchema.ID).annotate({ description: @@ -72,18 +72,19 @@ export const Plugin = { const resolveModel = Effect.fn("SubagentTool.resolveModel")(function* (input: string) { const ref = yield* Effect.try({ try: () => Model.Ref.parse(input), - catch: () => new ToolFailure({ message: `Invalid model reference: ${input}. Use "providerID/id#variant".` }), + catch: () => + new ToolFailure({ message: `Invalid model "${input}". Use "provider/model" or "provider/model#variant".` }), }) const model = (yield* models.available()).find( (model) => model.providerID === ref.providerID && model.id === ref.id, ) if (model === undefined) return yield* new ToolFailure({ - message: `Model ${ref.providerID}/${ref.id} is not available. List available models with tools.opencode.models() in the execute tool.`, + message: `Model "${ref.providerID}/${ref.id}" is not available. Use the models tool to see what is available.`, }) if (ref.variant !== undefined && !model.variants.some((variant) => variant.id === ref.variant)) return yield* new ToolFailure({ - message: `Unknown variant "${ref.variant}" for ${ref.providerID}/${ref.id}. Available: ${model.variants.map((variant) => variant.id).join(", ") || "none"}.`, + message: `Variant "${ref.variant}" is not available for "${ref.providerID}/${ref.id}". Available: ${model.variants.map((variant) => variant.id).join(", ") || "none"}.`, }) return ref }) diff --git a/packages/core/test/tool-opencode.test.ts b/packages/core/test/tool-opencode.test.ts index d39d417388d3..3cd354078811 100644 --- a/packages/core/test/tool-opencode.test.ts +++ b/packages/core/test/tool-opencode.test.ts @@ -47,7 +47,7 @@ it.effect("lists available models through the opencode namespace", () => ]) const filtered = yield* run( - 'const list = await tools.opencode.models({ providerID: "other" }); return list.models.map((model) => model.id)', + 'const list = await tools.opencode.models({ provider: "other" }); return list.models.map((model) => model.id)', ) expect(filtered.content).toEqual([{ type: "text", text: JSON.stringify(["beta"], null, 2) }]) }), diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 22b8e8ef2576..e1dbff3f6b97 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -668,12 +668,9 @@ describe("SubagentTool", () => { }) const failures = [ - ["not-a-ref", 'Invalid model reference: not-a-ref. Use "providerID/id#variant".'], - [ - "test/missing", - "Model test/missing is not available. List available models with tools.opencode.models() in the execute tool.", - ], - ["test/override#slow", 'Unknown variant "slow" for test/override. Available: fast.'], + ["not-a-ref", 'Invalid model "not-a-ref". Use "provider/model" or "provider/model#variant".'], + ["test/missing", 'Model "test/missing" is not available. Use the models tool to see what is available.'], + ["test/override#slow", 'Variant "slow" is not available for "test/override". Available: fast.'], ] as const for (const [model, message] of failures) { expect(yield* call(`call-${model}`, { model })).toEqual({ From 03cce408115dacf43f42bb425ac62462d03065dd Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 15:46:50 -0500 Subject: [PATCH 04/10] refactor(core): drop examples from model prompts and add variant guidance --- packages/core/src/tool/plugin/opencode.ts | 2 +- packages/core/src/tool/plugin/subagent.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/tool/plugin/opencode.ts b/packages/core/src/tool/plugin/opencode.ts index b94cd0960c51..f9f9b29a2291 100644 --- a/packages/core/src/tool/plugin/opencode.ts +++ b/packages/core/src/tool/plugin/opencode.ts @@ -97,7 +97,7 @@ export const Plugin = { draft.add({ name: "models", description: - 'List the models available to you. Reference one as "provider/model", adding "#variant" from its variants when needed, for example "anthropic/claude-sonnet-4-5" or "openai/gpt-5#high". Pass the reference anywhere a model is accepted, such as the subagent tool.', + 'List the models available to you. Reference one as "provider/model", or "provider/model#variant" using an entry from its variants. Pass the reference anywhere a model is accepted, such as the subagent tool.', input: ModelsInput, output: ModelsOutput, options: { namespace: "opencode", codemode: true }, diff --git a/packages/core/src/tool/plugin/subagent.ts b/packages/core/src/tool/plugin/subagent.ts index 00627620b733..a121d19458dc 100644 --- a/packages/core/src/tool/plugin/subagent.ts +++ b/packages/core/src/tool/plugin/subagent.ts @@ -32,7 +32,7 @@ export const Input = Schema.Struct({ prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }), model: Schema.optionalKey(Schema.String).annotate({ description: - 'Only pass this when the user explicitly asks for a specific model. Format "provider/model" or "provider/model#variant", for example "anthropic/claude-sonnet-4-5" or "openai/gpt-5#high". Otherwise omit it and the subagent uses the agent\'s configured model, or your own. Use the models tool to find the reference for a requested model.', + 'Only pass this when the user explicitly asks for a specific model. Format "provider/model" or "provider/model#variant". Include a variant only when the user asks for one; otherwise the model\'s default applies. Omit entirely to use the agent\'s configured model, or your own. Use the models tool to find the reference for a requested model.', }), sessionID: Schema.optionalKey(SessionSchema.ID).annotate({ description: From c9b9c595c0bb21f8bf278c65ba3eb9e5d43c57fd Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 15:51:55 -0500 Subject: [PATCH 05/10] refactor(core): tighten subagent model input wording --- packages/core/src/tool/plugin/subagent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/tool/plugin/subagent.ts b/packages/core/src/tool/plugin/subagent.ts index a121d19458dc..9bb68ba84ad0 100644 --- a/packages/core/src/tool/plugin/subagent.ts +++ b/packages/core/src/tool/plugin/subagent.ts @@ -32,7 +32,7 @@ export const Input = Schema.Struct({ prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }), model: Schema.optionalKey(Schema.String).annotate({ description: - 'Only pass this when the user explicitly asks for a specific model. Format "provider/model" or "provider/model#variant". Include a variant only when the user asks for one; otherwise the model\'s default applies. Omit entirely to use the agent\'s configured model, or your own. Use the models tool to find the reference for a requested model.', + 'Set only when the user explicitly requests a model, and add "#variant" only when they request a variant too. Format "provider/model" or "provider/model#variant". Omitted, the subagent uses the agent\'s configured model, or your own. Use the models tool to find the reference for a requested model.', }), sessionID: Schema.optionalKey(SessionSchema.ID).annotate({ description: From a94a562da50b44ff89aa1b5c14b4181bc4023604 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 16:46:20 -0500 Subject: [PATCH 06/10] feat(core): paginate the models tool with an agent-facing shape --- packages/core/src/tool/plugin/opencode.ts | 51 +++++++++++++--- packages/core/src/tool/plugin/subagent.ts | 2 +- packages/core/test/tool-opencode.test.ts | 72 ++++++++++++++++++----- 3 files changed, 101 insertions(+), 24 deletions(-) diff --git a/packages/core/src/tool/plugin/opencode.ts b/packages/core/src/tool/plugin/opencode.ts index f9f9b29a2291..e87ef3eaa73d 100644 --- a/packages/core/src/tool/plugin/opencode.ts +++ b/packages/core/src/tool/plugin/opencode.ts @@ -26,11 +26,32 @@ const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePat export const ModelsInput = Schema.Struct({ provider: Schema.optionalKey(Schema.String).annotate({ - description: 'Only list models from this provider, for example "anthropic".', + description: "Limit results to models from a particular provider.", + }), + limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))).annotate({ + description: "Maximum number of models to return. Defaults to 20.", + }), + offset: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))).annotate({ + description: "Number of models to skip, for paging through results.", }), }) -const ModelsOutput = Schema.Struct({ models: Schema.Array(Model.Info) }) +const ModelsOutput = Schema.Struct({ + models: Schema.Array( + Schema.Struct({ + id: Schema.String.annotate({ description: 'Model reference in "provider/model" form.' }), + name: Schema.String, + released: Model.Info.fields.time.fields.released.annotate({ + description: "Release date as a Unix timestamp in milliseconds, or 0 when unknown.", + }), + variants: Schema.Array(Model.VariantID), + cost: Model.Info.fields.cost.annotate({ description: "Pricing in USD per million tokens." }), + status: Model.Info.fields.status, + }), + ), + total: Schema.Int, + next: Schema.NullOr(Schema.Int).annotate({ description: "Offset of the next page, or null on the last page." }), +}) export const Plugin = { id: "opencode.tools", @@ -96,20 +117,32 @@ export const Plugin = { }) draft.add({ name: "models", - description: - 'List the models available to you. Reference one as "provider/model", or "provider/model#variant" using an entry from its variants. Pass the reference anywhere a model is accepted, such as the subagent tool.', + description: "List the models available to use.", input: ModelsInput, output: ModelsOutput, options: { namespace: "opencode", codemode: true }, execute: (input) => ctx.model.list().pipe( Effect.map((list) => { - const models = list.data.filter( - (model) => input.provider === undefined || model.providerID === input.provider, - ) + const offset = input.offset ?? 0 + const limit = input.limit ?? 20 + const matching = list.data + .filter((model) => input.provider === undefined || model.providerID === input.provider) + .toSorted((left, right) => right.time.released - left.time.released) + const models = matching.slice(offset, offset + limit).map((model) => ({ + id: `${model.providerID}/${model.id}`, + name: model.name, + released: model.time.released, + variants: model.variants.map((variant) => variant.id), + cost: model.cost, + status: model.status, + })) return { - output: { models }, - content: models.map((model) => `${model.providerID}/${model.id}: ${model.name}`).join("\n"), + output: { + models, + total: matching.length, + next: offset + limit < matching.length ? offset + limit : null, + }, } }), Effect.mapError((error) => new ToolFailure({ message: "Unable to list models", error })), diff --git a/packages/core/src/tool/plugin/subagent.ts b/packages/core/src/tool/plugin/subagent.ts index 9bb68ba84ad0..21231787705d 100644 --- a/packages/core/src/tool/plugin/subagent.ts +++ b/packages/core/src/tool/plugin/subagent.ts @@ -32,7 +32,7 @@ export const Input = Schema.Struct({ prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }), model: Schema.optionalKey(Schema.String).annotate({ description: - 'Set only when the user explicitly requests a model, and add "#variant" only when they request a variant too. Format "provider/model" or "provider/model#variant". Omitted, the subagent uses the agent\'s configured model, or your own. Use the models tool to find the reference for a requested model.', + 'Only use this parameter if the user explicitly asks you to run the subagent on a particular model or variant. The value is written as "provider/model", or "provider/model#variant" to include a variant. Use the models tool to list the available models and their variants. If several models match, choose one from your own provider when possible. Assume the user wants the latest version unless they say otherwise.', }), sessionID: Schema.optionalKey(SessionSchema.ID).annotate({ description: diff --git a/packages/core/test/tool-opencode.test.ts b/packages/core/test/tool-opencode.test.ts index 3cd354078811..b9b213acf74b 100644 --- a/packages/core/test/tool-opencode.test.ts +++ b/packages/core/test/tool-opencode.test.ts @@ -13,7 +13,7 @@ import { PluginTestLayer } from "./plugin/fixture" const it = testEffect(PluginTestLayer) -it.effect("lists available models through the opencode namespace", () => +it.effect("lists available models newest first with paging", () => Effect.gen(function* () { const catalog = yield* Provider.Service const plugins = yield* Plugin.Service @@ -21,34 +21,78 @@ it.effect("lists available models through the opencode namespace", () => yield* catalog.transform((editor) => { editor.models.update(Provider.ID.make("test"), Model.ID.make("alpha"), (model) => { model.name = "Alpha" + model.time.released = 300 model.variants = [{ id: Model.VariantID.make("fast") }] + model.status = "beta" }) editor.models.update(Provider.ID.make("other"), Model.ID.make("beta"), (model) => { model.name = "Beta" + model.time.released = 200 + }) + editor.models.update(Provider.ID.make("other"), Model.ID.make("gamma"), (model) => { + model.name = "Gamma" + model.time.released = 100 }) editor.models.update(Provider.ID.make("other"), Model.ID.make("disabled"), (model) => { + model.time.released = 400 model.enabled = false }) }) yield* OpenCodeTools.Plugin.effect(pluginHost) const registry = yield* Tool.Service - const run = (code: string) => + const run = (input: Record) => executeTool(registry, { sessionID: Session.ID.make("ses_tool_opencode"), ...toolIdentity, - call: { type: "tool-call", id: `call-${code.length}`, name: "execute", input: { code } }, - }) + call: { + type: "tool-call", + id: `call-${JSON.stringify(input)}`, + name: "execute", + input: { code: `return await tools.opencode.models(${JSON.stringify(input)})` }, + }, + }).pipe(Effect.map((result) => JSON.parse(result.content?.[0]?.type === "text" ? result.content[0].text : ""))) + + // Newest first, disabled models excluded, and the full agent-facing shape. + expect(yield* run({})).toEqual({ + models: [ + { + id: "test/alpha", + name: "Alpha", + released: 300, + variants: ["fast"], + cost: [], + status: "beta", + }, + { + id: "other/beta", + name: "Beta", + released: 200, + variants: [], + cost: [], + status: "active", + }, + { + id: "other/gamma", + name: "Gamma", + released: 100, + variants: [], + cost: [], + status: "active", + }, + ], + total: 3, + next: null, + }) - const all = yield* run( - "const list = await tools.opencode.models({}); return list.models.map((model) => `${model.providerID}/${model.id}: ${model.name} [${model.variants.map((variant) => variant.id)}]`).sort()", - ) - expect(all.content).toEqual([ - { type: "text", text: JSON.stringify(["other/beta: Beta []", "test/alpha: Alpha [fast]"], null, 2) }, - ]) + const first = yield* run({ limit: 2 }) + expect(first.models.map((model: { id: string }) => model.id)).toEqual(["test/alpha", "other/beta"]) + expect(first).toMatchObject({ total: 3, next: 2 }) + const second = yield* run({ limit: 2, offset: 2 }) + expect(second.models.map((model: { id: string }) => model.id)).toEqual(["other/gamma"]) + expect(second).toMatchObject({ total: 3, next: null }) - const filtered = yield* run( - 'const list = await tools.opencode.models({ provider: "other" }); return list.models.map((model) => model.id)', - ) - expect(filtered.content).toEqual([{ type: "text", text: JSON.stringify(["beta"], null, 2) }]) + const filtered = yield* run({ provider: "other" }) + expect(filtered.models.map((model: { id: string }) => model.id)).toEqual(["other/beta", "other/gamma"]) + expect(filtered).toMatchObject({ total: 2, next: null }) }), ) From 8d07ecc42803e60ca7e341463ff87b979c35cd9f Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 21:45:44 -0500 Subject: [PATCH 07/10] fix(core): label provider and model ids in the identity block --- packages/core/src/plugin/identity.ts | 15 +++------ .../openai-chat-streams-text.json | 2 +- packages/core/test/plugin/identity.test.ts | 14 +++----- packages/core/test/session-runner.test.ts | 33 ++++++++++++------- 4 files changed, 32 insertions(+), 32 deletions(-) diff --git a/packages/core/src/plugin/identity.ts b/packages/core/src/plugin/identity.ts index 0881db870475..3ad57050e97d 100644 --- a/packages/core/src/plugin/identity.ts +++ b/packages/core/src/plugin/identity.ts @@ -6,12 +6,12 @@ import type { SessionHooks } from "@opencode/plugin/effect/session" import { Model } from "@opencode/schema/model" import { Effect } from "effect" -export function identity(model: { readonly provider: string; readonly name: string; readonly ref: Model.Ref }) { +export function identity(model: { readonly name: string; readonly ref: Model.Ref }) { return [ "# Your Model", - `- Provider: ${model.provider}`, `- Name: ${model.name}`, - `- ID: ${model.ref.providerID}/${model.ref.id}`, + `- Provider ID: ${model.ref.providerID}`, + `- Model ID: ${model.ref.id}`, ].join("\n") } @@ -24,14 +24,7 @@ export const Plugin = define({ (yield* ctx.model.list()).data.find( (model) => model.providerID === event.model.providerID && model.id === event.model.id, ) ?? Model.Info.default(event.model.providerID, event.model.id) - const provider = (yield* ctx.provider.list()).data.find((provider) => provider.id === event.model.providerID) - event.system.splice( - 1, - 0, - SystemPart.make( - identity({ provider: provider?.name ?? event.model.providerID, name: model.name, ref: event.model }), - ), - ) + event.system.splice(1, 0, SystemPart.make(identity({ name: model.name, ref: event.model }))) }).pipe(Effect.catch(() => Effect.void)) yield* ctx.session.hook("context", hook) yield* ctx.session.hook("compaction", hook) diff --git a/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json index 53f8805931d3..37b7cab486cf 100644 --- a/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json +++ b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json @@ -13,7 +13,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent running in OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\\n# Your Model\\n- Provider: openai\\n- Name: gpt-4o-mini\\n- ID: openai/gpt-4o-mini\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}" + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent running in OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\\n# Your Model\\n- Name: gpt-4o-mini\\n- Provider ID: openai\\n- Model ID: gpt-4o-mini\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}" }, "response": { "status": 200, diff --git a/packages/core/test/plugin/identity.test.ts b/packages/core/test/plugin/identity.test.ts index 406cf1e837b2..fe5aab2e4389 100644 --- a/packages/core/test/plugin/identity.test.ts +++ b/packages/core/test/plugin/identity.test.ts @@ -18,15 +18,14 @@ const it = testEffect(PluginTestLayer) test("formats the model identity part", () => { expect( IdentityPlugin.identity({ - provider: "OpenAI", name: "GPT-4o mini", ref: Model.Ref.make({ providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-4o-mini") }), }), - ).toBe(["# Your Model", "- Provider: OpenAI", "- Name: GPT-4o mini", "- ID: openai/gpt-4o-mini"].join("\n")) + ).toBe(["# Your Model", "- Name: GPT-4o mini", "- Provider ID: openai", "- Model ID: gpt-4o-mini"].join("\n")) }) -const identity = (provider: string, name: string, id: string) => - ["# Your Model", `- Provider: ${provider}`, `- Name: ${name}`, `- ID: test/${id}`].join("\n") +const identity = (name: string, id: string) => + ["# Your Model", `- Name: ${name}`, "- Provider ID: test", `- Model ID: ${id}`].join("\n") const context = (id: string): SessionHooks["context"] => ({ sessionID: Session.ID.make("ses_model_identity"), @@ -45,9 +44,6 @@ it.effect("inserts the structured model block after the agent prompt", () => const plugins = yield* Plugin.Service const pluginHost = yield* PluginHost.make(plugins) yield* catalog.transform((editor) => { - editor.update(Provider.ID.make("test"), (provider) => { - provider.name = "Test Provider" - }) editor.models.update(Provider.ID.make("test"), Model.ID.make("meta/muse-spark-1.1"), (model) => { model.name = "Muse Spark" }) @@ -58,7 +54,7 @@ it.effect("inserts the structured model block after the agent prompt", () => yield* hooks.trigger("session", "context", named) expect(named.system.map((part) => part.text)).toEqual([ "Agent prompt", - identity("Test Provider", "Muse Spark", "meta/muse-spark-1.1"), + identity("Muse Spark", "meta/muse-spark-1.1"), "Initial context", ]) @@ -66,7 +62,7 @@ it.effect("inserts the structured model block after the agent prompt", () => yield* hooks.trigger("session", "context", fallback) expect(fallback.system.map((part) => part.text)).toEqual([ "Agent prompt", - identity("Test Provider", "unknown-model", "unknown-model"), + identity("unknown-model", "unknown-model"), "Initial context", ]) }), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 196cb37286e6..9f58161ce1f9 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -63,12 +63,7 @@ import { Document, Info } from "@opencode/schema/config" import { ConfigCompaction } from "@opencode/schema/config/compaction" import { Tool } from "@opencode/core/tool" import type { Info as ToolInfo } from "@opencode/schema/tool" -import { - InstructionStateTable, - SessionInboxTable, - SessionMessageTable, - SessionTable, -} from "@opencode/core/session/sql" +import { InstructionStateTable, SessionInboxTable, SessionMessageTable, SessionTable } from "@opencode/core/session/sql" import { InstructionEntry } from "@opencode/core/session/instruction-entry" import { SessionStore } from "@opencode/core/session/store" import { Instructions } from "@opencode/core/instructions/index" @@ -121,7 +116,7 @@ const testModel = (id: string, limit: ModelLimit = defaultModelLimit) => { const model = testModel("fake-model") const defaultSystem = SessionSystemPrompt.make([]) const identity = (providerID: string, id: string) => - ["# Your Model", `- Provider: ${providerID}`, `- Name: ${id}`, `- ID: ${providerID}/${id}`].join("\n") + ["# Your Model", `- Name: ${id}`, `- Provider ID: ${providerID}`, `- Model ID: ${id}`].join("\n") const fakeIdentity = identity("fake", "fake-model") const replacementIdentity = identity("fake", "replacement") const gptIdentity = identity("openai", "gpt-5") @@ -1740,7 +1735,11 @@ describe("SessionRunnerLLM", () => { yield* s.llm.push(TestLLM.text("Done", "text-build")) yield* s.resume - expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", fakeIdentity, "Initial context"]) + expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([ + "Build agent instructions", + fakeIdentity, + "Initial context", + ]) }) scenario("uses the configured default agent system for omitted-agent sessions", function* (s) { @@ -1761,7 +1760,11 @@ describe("SessionRunnerLLM", () => { yield* s.llm.push(TestLLM.text("Done", "text-reviewer")) yield* s.resume - expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", fakeIdentity, "Initial context"]) + expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([ + "Reviewer instructions", + fakeIdentity, + "Initial context", + ]) expect((yield* s.messages)[0]).toMatchObject({ type: "assistant", agent: "reviewer" }) }) @@ -1784,7 +1787,11 @@ describe("SessionRunnerLLM", () => { yield* s.llm.push(TestLLM.text("Done", "text-selected")) yield* s.resume - expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", fakeIdentity, "Initial context"]) + expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([ + "Reviewer instructions", + fakeIdentity, + "Initial context", + ]) expect((yield* s.messages)[0]).toMatchObject({ type: "assistant", agent: "reviewer" }) }) @@ -3026,7 +3033,11 @@ describe("SessionRunnerLLM", () => { expect(resolutions).toBe(2) expect(s.requests).toHaveLength(3) expect(s.requests[2]?.model).toBe(replacementModel) - expect(s.requests[2]?.system.map((part) => part.text)).toEqual([defaultSystem, replacementIdentity, "Initial context"]) + expect(s.requests[2]?.system.map((part) => part.text)).toEqual([ + defaultSystem, + replacementIdentity, + "Initial context", + ]) expect(systemTexts(s.requests[2])).toContain("Changed during compaction") expect(userTexts(s.requests[2])[0]).toContain("\n## Objective\n- Overflow summary\n") expect(userTexts(s.requests[2]).join("\n")).not.toContain("Queued during compaction") From a46c1460d1067eb942dd969d198dea5e25fe5fa8 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 16 Sep 2026 00:30:50 -0500 Subject: [PATCH 08/10] feat(core): make the models tool searchable and guide subagent model selection --- packages/core/src/tool/plugin/opencode.ts | 114 +++++++++++++++------- packages/core/src/tool/plugin/subagent.ts | 11 ++- packages/core/test/tool-opencode.test.ts | 99 ++++++++++++------- packages/core/test/tool-subagent.test.ts | 2 +- 4 files changed, 148 insertions(+), 78 deletions(-) diff --git a/packages/core/src/tool/plugin/opencode.ts b/packages/core/src/tool/plugin/opencode.ts index e87ef3eaa73d..7009dafcfa02 100644 --- a/packages/core/src/tool/plugin/opencode.ts +++ b/packages/core/src/tool/plugin/opencode.ts @@ -25,8 +25,14 @@ export const MoveInput = Schema.Struct({ const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePath }) export const ModelsInput = Schema.Struct({ + query: Schema.optionalKey(Schema.String).annotate({ + description: "Text to search for in model names and IDs.", + }), provider: Schema.optionalKey(Schema.String).annotate({ - description: "Limit results to models from a particular provider.", + description: "Provider ID or name to filter by. Try your own provider first.", + }), + all: Schema.optionalKey(Schema.Boolean).annotate({ + description: "Include older versions of each model family. By default only the newest version is listed.", }), limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))).annotate({ description: "Maximum number of models to return. Defaults to 20.", @@ -36,20 +42,26 @@ export const ModelsInput = Schema.Struct({ }), }) +const ModelEntry = Schema.Struct({ + id: Schema.String.annotate({ description: "providerID/modelID" }), + name: Schema.String, + released: Model.Info.fields.time.fields.released.annotate({ + description: "Release date as a Unix timestamp in milliseconds, or 0 when unknown.", + }), + variants: Schema.Array(Model.VariantID), + cost: Model.Info.fields.cost.annotate({ description: "Pricing in USD per million tokens." }), + status: Model.Info.fields.status, +}) + const ModelsOutput = Schema.Struct({ - models: Schema.Array( + providers: Schema.Array( Schema.Struct({ - id: Schema.String.annotate({ description: 'Model reference in "provider/model" form.' }), + id: Schema.String, name: Schema.String, - released: Model.Info.fields.time.fields.released.annotate({ - description: "Release date as a Unix timestamp in milliseconds, or 0 when unknown.", - }), - variants: Schema.Array(Model.VariantID), - cost: Model.Info.fields.cost.annotate({ description: "Pricing in USD per million tokens." }), - status: Model.Info.fields.status, + models: Schema.Array(ModelEntry).annotate({ description: "Newest first." }), }), - ), - total: Schema.Int, + ).annotate({ description: "Matching models grouped by provider." }), + total: Schema.Int.annotate({ description: "Number of matching models across all pages." }), next: Schema.NullOr(Schema.Int).annotate({ description: "Offset of the next page, or null on the last page." }), }) @@ -117,36 +129,64 @@ export const Plugin = { }) draft.add({ name: "models", - description: "List the models available to use.", + description: + "Search the models available to use. Use this to turn a model name the user mentions into an exact reference before running a subagent on it. Check your own provider first.", input: ModelsInput, output: ModelsOutput, options: { namespace: "opencode", codemode: true }, execute: (input) => - ctx.model.list().pipe( - Effect.map((list) => { - const offset = input.offset ?? 0 - const limit = input.limit ?? 20 - const matching = list.data - .filter((model) => input.provider === undefined || model.providerID === input.provider) - .toSorted((left, right) => right.time.released - left.time.released) - const models = matching.slice(offset, offset + limit).map((model) => ({ - id: `${model.providerID}/${model.id}`, - name: model.name, - released: model.time.released, - variants: model.variants.map((variant) => variant.id), - cost: model.cost, - status: model.status, - })) - return { - output: { - models, - total: matching.length, - next: offset + limit < matching.length ? offset + limit : null, - }, - } - }), - Effect.mapError((error) => new ToolFailure({ message: "Unable to list models", error })), - ), + Effect.gen(function* () { + const offset = input.offset ?? 0 + const limit = input.limit ?? 20 + const terms = input.query?.toLowerCase().split(/\s+/).filter(Boolean) ?? [] + const names = new Map((yield* ctx.provider.list()).data.map((provider) => [provider.id, provider.name])) + const provider = input.provider?.toLowerCase() + const matching = (yield* ctx.model.list()).data + .filter( + (model) => + provider === undefined || + model.providerID.toLowerCase() === provider || + names.get(model.providerID)?.toLowerCase() === provider, + ) + .filter((model) => { + const text = `${model.providerID}/${model.id} ${model.name}`.toLowerCase() + return terms.every((term) => text.includes(term)) + }) + .toSorted( + (left, right) => + left.providerID.localeCompare(right.providerID) || right.time.released - left.time.released, + ) + .filter((model, index, sorted) => { + if (input.all || model.family === undefined) return true + return ( + sorted.findIndex( + (other) => other.providerID === model.providerID && other.family === model.family, + ) === index + ) + }) + const page = matching.slice(offset, offset + limit) + const providers = Array.from(new Set(page.map((model) => model.providerID))).map((id) => ({ + id, + name: names.get(id) ?? id, + models: page + .filter((model) => model.providerID === id) + .map((model) => ({ + id: `${model.providerID}/${model.id}`, + name: model.name, + released: model.time.released, + variants: model.variants.map((variant) => variant.id), + cost: model.cost, + status: model.status, + })), + })) + return { + output: { + providers, + total: matching.length, + next: offset + limit < matching.length ? offset + limit : null, + }, + } + }).pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to list models", error }))), }) }) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/plugin/subagent.ts b/packages/core/src/tool/plugin/subagent.ts index 21231787705d..1c958c24c0f8 100644 --- a/packages/core/src/tool/plugin/subagent.ts +++ b/packages/core/src/tool/plugin/subagent.ts @@ -27,12 +27,15 @@ const backgroundResult = (sessionID: SessionSchema.ID) => ({ }) export const Input = Schema.Struct({ - agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }), + agent: Schema.String.annotate({ + description: + "The type of specialized agent to use for this task. If the user asks for a subagent by a name that is not one of the available subagents, they most likely mean a model: pick a suitable agent and pass the name through the model parameter instead.", + }), description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }), prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }), model: Schema.optionalKey(Schema.String).annotate({ description: - 'Only use this parameter if the user explicitly asks you to run the subagent on a particular model or variant. The value is written as "provider/model", or "provider/model#variant" to include a variant. Use the models tool to list the available models and their variants. If several models match, choose one from your own provider when possible. Assume the user wants the latest version unless they say otherwise.', + 'NEVER set this unless the user explicitly asks for a particular model or variant. The value is written as "providerID/modelID", or "providerID/modelID#variant" to include a variant. Do not guess the ID: look the model up with the models tool, filtering to your own provider first.', }), sessionID: Schema.optionalKey(SessionSchema.ID).annotate({ description: @@ -73,7 +76,9 @@ export const Plugin = { const ref = yield* Effect.try({ try: () => Model.Ref.parse(input), catch: () => - new ToolFailure({ message: `Invalid model "${input}". Use "provider/model" or "provider/model#variant".` }), + new ToolFailure({ + message: `Invalid model "${input}". Use "providerID/modelID" or "providerID/modelID#variant".`, + }), }) const model = (yield* models.available()).find( (model) => model.providerID === ref.providerID && model.id === ref.id, diff --git a/packages/core/test/tool-opencode.test.ts b/packages/core/test/tool-opencode.test.ts index b9b213acf74b..71d98952ec0b 100644 --- a/packages/core/test/tool-opencode.test.ts +++ b/packages/core/test/tool-opencode.test.ts @@ -13,12 +13,27 @@ import { PluginTestLayer } from "./plugin/fixture" const it = testEffect(PluginTestLayer) -it.effect("lists available models newest first with paging", () => +const alpha = { id: "test/alpha", name: "Alpha", released: 300, variants: ["fast"], cost: [], status: "beta" } +const beta = { id: "other/beta", name: "Beta", released: 200, variants: [], cost: [], status: "active" } +const gamma = { id: "other/gamma", name: "Gamma Flash", released: 100, variants: [], cost: [], status: "active" } +const gammaOld = { + id: "other/gamma-old", + name: "Gamma Flash Old", + released: 50, + variants: [], + cost: [], + status: "active", +} + +it.effect("groups available models by provider with paging", () => Effect.gen(function* () { const catalog = yield* Provider.Service const plugins = yield* Plugin.Service const pluginHost = yield* PluginHost.make(plugins) yield* catalog.transform((editor) => { + editor.update(Provider.ID.make("other"), (provider) => { + provider.name = "Other Provider" + }) editor.models.update(Provider.ID.make("test"), Model.ID.make("alpha"), (model) => { model.name = "Alpha" model.time.released = 300 @@ -30,8 +45,14 @@ it.effect("lists available models newest first with paging", () => model.time.released = 200 }) editor.models.update(Provider.ID.make("other"), Model.ID.make("gamma"), (model) => { - model.name = "Gamma" + model.name = "Gamma Flash" model.time.released = 100 + model.family = Model.Family.make("gamma") + }) + editor.models.update(Provider.ID.make("other"), Model.ID.make("gamma-old"), (model) => { + model.name = "Gamma Flash Old" + model.time.released = 50 + model.family = Model.Family.make("gamma") }) editor.models.update(Provider.ID.make("other"), Model.ID.make("disabled"), (model) => { model.time.released = 400 @@ -52,47 +73,51 @@ it.effect("lists available models newest first with paging", () => }, }).pipe(Effect.map((result) => JSON.parse(result.content?.[0]?.type === "text" ? result.content[0].text : ""))) - // Newest first, disabled models excluded, and the full agent-facing shape. + // Grouped by provider, newest first within each, disabled models excluded. expect(yield* run({})).toEqual({ - models: [ - { - id: "test/alpha", - name: "Alpha", - released: 300, - variants: ["fast"], - cost: [], - status: "beta", - }, - { - id: "other/beta", - name: "Beta", - released: 200, - variants: [], - cost: [], - status: "active", - }, - { - id: "other/gamma", - name: "Gamma", - released: 100, - variants: [], - cost: [], - status: "active", - }, + providers: [ + { id: "other", name: "Other Provider", models: [beta, gamma] }, + { id: "test", name: "test", models: [alpha] }, ], total: 3, next: null, }) - const first = yield* run({ limit: 2 }) - expect(first.models.map((model: { id: string }) => model.id)).toEqual(["test/alpha", "other/beta"]) - expect(first).toMatchObject({ total: 3, next: 2 }) - const second = yield* run({ limit: 2, offset: 2 }) - expect(second.models.map((model: { id: string }) => model.id)).toEqual(["other/gamma"]) - expect(second).toMatchObject({ total: 3, next: null }) + // Paging slices the ordered list, so a page can end inside a provider group. + expect(yield* run({ limit: 2 })).toEqual({ + providers: [{ id: "other", name: "Other Provider", models: [beta, gamma] }], + total: 3, + next: 2, + }) + expect(yield* run({ limit: 2, offset: 2 })).toEqual({ + providers: [{ id: "test", name: "test", models: [alpha] }], + total: 3, + next: null, + }) + + expect(yield* run({ provider: "other provider" })).toMatchObject({ total: 2, providers: [{ id: "other" }] }) + expect(yield* run({ provider: "test" })).toEqual({ + providers: [{ id: "test", name: "test", models: [alpha] }], + total: 1, + next: null, + }) - const filtered = yield* run({ provider: "other" }) - expect(filtered.models.map((model: { id: string }) => model.id)).toEqual(["other/beta", "other/gamma"]) - expect(filtered).toMatchObject({ total: 2, next: null }) + // Every word of the query must appear somewhere in the reference or display name, ignoring case. + expect(yield* run({ query: "GAMMA" })).toEqual({ + providers: [{ id: "other", name: "Other Provider", models: [gamma] }], + total: 1, + next: null, + }) + expect(yield* run({ query: "test/" })).toMatchObject({ total: 1, providers: [{ id: "test" }] }) + expect(yield* run({ query: "other flash" })).toMatchObject({ total: 1, providers: [{ models: [gamma] }] }) + expect(yield* run({ query: "gamma beta" })).toEqual({ providers: [], total: 0, next: null }) + + // Only the newest model of each family is listed unless `all` is set; the query is applied first. + expect(yield* run({ all: true })).toMatchObject({ + total: 4, + providers: [{ id: "other", models: [beta, gamma, gammaOld] }, { id: "test" }], + }) + expect(yield* run({ query: "old" })).toMatchObject({ total: 1, providers: [{ models: [gammaOld] }] }) + expect(yield* run({ provider: "other", query: "alpha" })).toEqual({ providers: [], total: 0, next: null }) }), ) diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index e1dbff3f6b97..bd28964403d3 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -668,7 +668,7 @@ describe("SubagentTool", () => { }) const failures = [ - ["not-a-ref", 'Invalid model "not-a-ref". Use "provider/model" or "provider/model#variant".'], + ["not-a-ref", 'Invalid model "not-a-ref". Use "providerID/modelID" or "providerID/modelID#variant".'], ["test/missing", 'Model "test/missing" is not available. Use the models tool to see what is available.'], ["test/override#slow", 'Variant "slow" is not available for "test/override". Available: fast.'], ] as const From 1f6afd75220e4823fa670869d26161ebb9b1cc95 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 16 Sep 2026 00:37:44 -0500 Subject: [PATCH 09/10] fix(core): say when a model has no variants instead of listing none --- packages/core/src/tool/plugin/subagent.ts | 5 ++++- packages/core/test/tool-subagent.test.ts | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/core/src/tool/plugin/subagent.ts b/packages/core/src/tool/plugin/subagent.ts index 1c958c24c0f8..fe3d07dd7e16 100644 --- a/packages/core/src/tool/plugin/subagent.ts +++ b/packages/core/src/tool/plugin/subagent.ts @@ -89,7 +89,10 @@ export const Plugin = { }) if (ref.variant !== undefined && !model.variants.some((variant) => variant.id === ref.variant)) return yield* new ToolFailure({ - message: `Variant "${ref.variant}" is not available for "${ref.providerID}/${ref.id}". Available: ${model.variants.map((variant) => variant.id).join(", ") || "none"}.`, + message: + model.variants.length === 0 + ? `Model "${ref.providerID}/${ref.id}" has no variants. Omit the variant.` + : `Variant "${ref.variant}" is not available for "${ref.providerID}/${ref.id}". Available: ${model.variants.map((variant) => variant.id).join(", ")}.`, }) return ref }) diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index bd28964403d3..c0e82c789394 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -165,6 +165,7 @@ const withSubagent = (location: Location.Ref) => editor.models.update(overrideModel.providerID, overrideModel.id, (model) => { model.variants = [{ id: Model.VariantID.make("fast") }] }) + editor.models.update(Provider.ID.make("test"), Model.ID.make("plain"), () => {}) }), ).pipe(Effect.provide(locations.get(location))) yield* Agent.Service.use((agents) => @@ -671,6 +672,7 @@ describe("SubagentTool", () => { ["not-a-ref", 'Invalid model "not-a-ref". Use "providerID/modelID" or "providerID/modelID#variant".'], ["test/missing", 'Model "test/missing" is not available. Use the models tool to see what is available.'], ["test/override#slow", 'Variant "slow" is not available for "test/override". Available: fast.'], + ["test/plain#high", 'Model "test/plain" has no variants. Omit the variant.'], ] as const for (const [model, message] of failures) { expect(yield* call(`call-${model}`, { model })).toEqual({ From 2db163add4f36b26c1f4299d792d11a7db9c27aa Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 16 Sep 2026 01:03:04 -0500 Subject: [PATCH 10/10] feat(core): list the caller's provider first in the models tool --- packages/core/src/tool/plugin/opencode.ts | 15 +++++++++++---- packages/core/test/tool-opencode.test.ts | 21 ++++++++++++++++----- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/core/src/tool/plugin/opencode.ts b/packages/core/src/tool/plugin/opencode.ts index 7009dafcfa02..bd583c85d130 100644 --- a/packages/core/src/tool/plugin/opencode.ts +++ b/packages/core/src/tool/plugin/opencode.ts @@ -60,7 +60,7 @@ const ModelsOutput = Schema.Struct({ name: Schema.String, models: Schema.Array(ModelEntry).annotate({ description: "Newest first." }), }), - ).annotate({ description: "Matching models grouped by provider." }), + ).annotate({ description: "Matching models grouped by provider. Your own provider comes first." }), total: Schema.Int.annotate({ description: "Number of matching models across all pages." }), next: Schema.NullOr(Schema.Int).annotate({ description: "Offset of the next page, or null on the last page." }), }) @@ -81,7 +81,11 @@ export const Plugin = { yield* ctx.session.hook("generate", hook) yield* ctx.tool .transform((draft) => { - draft.namespace({ name: "opencode", description: "OpenCode session and runtime tools." }) + draft.namespace({ + name: "opencode", + description: + "Tools for managing OpenCode itself, such as working with sessions and searching the available models.", + }) draft.add({ name: "session_rename", description: @@ -134,10 +138,11 @@ export const Plugin = { input: ModelsInput, output: ModelsOutput, options: { namespace: "opencode", codemode: true }, - execute: (input) => + execute: (input, context) => Effect.gen(function* () { const offset = input.offset ?? 0 const limit = input.limit ?? 20 + const own = (yield* ctx.session.get({ sessionID: context.sessionID })).model?.providerID const terms = input.query?.toLowerCase().split(/\s+/).filter(Boolean) ?? [] const names = new Map((yield* ctx.provider.list()).data.map((provider) => [provider.id, provider.name])) const provider = input.provider?.toLowerCase() @@ -154,7 +159,9 @@ export const Plugin = { }) .toSorted( (left, right) => - left.providerID.localeCompare(right.providerID) || right.time.released - left.time.released, + Number(right.providerID === own) - Number(left.providerID === own) || + left.providerID.localeCompare(right.providerID) || + right.time.released - left.time.released, ) .filter((model, index, sorted) => { if (input.all || model.family === undefined) return true diff --git a/packages/core/test/tool-opencode.test.ts b/packages/core/test/tool-opencode.test.ts index 71d98952ec0b..e956f6794b75 100644 --- a/packages/core/test/tool-opencode.test.ts +++ b/packages/core/test/tool-opencode.test.ts @@ -1,4 +1,5 @@ import { expect } from "bun:test" +import { Location } from "@opencode/core/location" import { Plugin } from "@opencode/core/plugin" import { PluginHost } from "@opencode/core/plugin/host" import { Provider } from "@opencode/core/provider" @@ -29,6 +30,8 @@ it.effect("groups available models by provider with paging", () => Effect.gen(function* () { const catalog = yield* Provider.Service const plugins = yield* Plugin.Service + const sessions = yield* Session.Service + const location = yield* Location.Service const pluginHost = yield* PluginHost.make(plugins) yield* catalog.transform((editor) => { editor.update(Provider.ID.make("other"), (provider) => { @@ -60,10 +63,15 @@ it.effect("groups available models by provider with paging", () => }) }) yield* OpenCodeTools.Plugin.effect(pluginHost) + // The caller runs on `test`, which sorts first despite `other` coming earlier alphabetically. + const session = yield* sessions.create({ + location: Location.Ref.make({ directory: location.directory }), + model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("alpha") }), + }) const registry = yield* Tool.Service const run = (input: Record) => executeTool(registry, { - sessionID: Session.ID.make("ses_tool_opencode"), + sessionID: session.id, ...toolIdentity, call: { type: "tool-call", @@ -76,8 +84,8 @@ it.effect("groups available models by provider with paging", () => // Grouped by provider, newest first within each, disabled models excluded. expect(yield* run({})).toEqual({ providers: [ - { id: "other", name: "Other Provider", models: [beta, gamma] }, { id: "test", name: "test", models: [alpha] }, + { id: "other", name: "Other Provider", models: [beta, gamma] }, ], total: 3, next: null, @@ -85,12 +93,15 @@ it.effect("groups available models by provider with paging", () => // Paging slices the ordered list, so a page can end inside a provider group. expect(yield* run({ limit: 2 })).toEqual({ - providers: [{ id: "other", name: "Other Provider", models: [beta, gamma] }], + providers: [ + { id: "test", name: "test", models: [alpha] }, + { id: "other", name: "Other Provider", models: [beta] }, + ], total: 3, next: 2, }) expect(yield* run({ limit: 2, offset: 2 })).toEqual({ - providers: [{ id: "test", name: "test", models: [alpha] }], + providers: [{ id: "other", name: "Other Provider", models: [gamma] }], total: 3, next: null, }) @@ -115,7 +126,7 @@ it.effect("groups available models by provider with paging", () => // Only the newest model of each family is listed unless `all` is set; the query is applied first. expect(yield* run({ all: true })).toMatchObject({ total: 4, - providers: [{ id: "other", models: [beta, gamma, gammaOld] }, { id: "test" }], + providers: [{ id: "test" }, { id: "other", models: [beta, gamma, gammaOld] }], }) expect(yield* run({ query: "old" })).toMatchObject({ total: 1, providers: [{ models: [gammaOld] }] }) expect(yield* run({ provider: "other", query: "alpha" })).toEqual({ providers: [], total: 0, next: null })