mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-16 05:46:23 +00:00
Compare commits
9
Commits
v2
...
subagent-model
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f6afd7522 | ||
|
|
a46c1460d1 | ||
|
|
8d07ecc428 | ||
|
|
a94a562da5 | ||
|
|
c9b9c595c0 | ||
|
|
03cce40811 | ||
|
|
5fafd6f3ec | ||
|
|
1f0667b696 | ||
|
|
46c828c341 |
@@ -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)
|
||||
|
||||
@@ -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,47 @@ 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: "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.",
|
||||
}),
|
||||
offset: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))).annotate({
|
||||
description: "Number of models to skip, for paging through results.",
|
||||
}),
|
||||
})
|
||||
|
||||
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({
|
||||
providers: Schema.Array(
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
models: Schema.Array(ModelEntry).annotate({ description: "Newest first." }),
|
||||
}),
|
||||
).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." }),
|
||||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tools",
|
||||
effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) {
|
||||
@@ -85,6 +127,67 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
})
|
||||
draft.add({
|
||||
name: "models",
|
||||
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) =>
|
||||
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)
|
||||
}),
|
||||
|
||||
@@ -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"
|
||||
@@ -26,9 +27,16 @@ 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:
|
||||
'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:
|
||||
"Continue a specific previous subagent conversation by passing its sessionID. Calls without a sessionID start a new conversation.",
|
||||
@@ -61,8 +69,34 @@ 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 "${input}". Use "providerID/modelID" or "providerID/modelID#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. 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:
|
||||
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
|
||||
})
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((editor) =>
|
||||
editor.add({
|
||||
@@ -131,24 +165,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
|
||||
|
||||
+1
-1
@@ -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- `<system-reminder>` 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- `<system-reminder>` 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,
|
||||
|
||||
@@ -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",
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -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("<summary>\n## Objective\n- Overflow summary\n</summary>")
|
||||
expect(userTexts(s.requests[2]).join("\n")).not.toContain("Queued during compaction")
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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)
|
||||
|
||||
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
|
||||
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 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
|
||||
model.enabled = false
|
||||
})
|
||||
})
|
||||
yield* OpenCodeTools.Plugin.effect(pluginHost)
|
||||
const registry = yield* Tool.Service
|
||||
const run = (input: Record<string, unknown>) =>
|
||||
executeTool(registry, {
|
||||
sessionID: Session.ID.make("ses_tool_opencode"),
|
||||
...toolIdentity,
|
||||
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 : "")))
|
||||
|
||||
// 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] },
|
||||
],
|
||||
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,
|
||||
})
|
||||
|
||||
// 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 })
|
||||
}),
|
||||
)
|
||||
@@ -45,6 +45,11 @@ const completedOutput = (sessionID: Session.ID) =>
|
||||
`<subagent sessionID="${sessionID}" state="completed">\n${childText}\n</subagent>`
|
||||
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,14 @@ 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") }]
|
||||
})
|
||||
editor.models.update(Provider.ID.make("test"), Model.ID.make("plain"), () => {})
|
||||
}),
|
||||
).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 +628,63 @@ 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<string, unknown>) =>
|
||||
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 "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({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message },
|
||||
})
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("returns child runner failures as tool errors", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
Reference in New Issue
Block a user