diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2fc93c482521..1362053b7eb4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -315,7 +315,10 @@ export const layer = Layer.effect( sessionID, mode: task.agent, agent: task.agent, - variant: lastUser.model.variant, + variant: + taskModel.providerID === lastUser.model.providerID && taskModel.id === lastUser.model.modelID + ? lastUser.model.variant + : undefined, path: { cwd: ctx.directory, root: ctx.worktree }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, @@ -704,7 +707,7 @@ export const layer = Layer.effect( .get(), ) const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID)) - const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID + const same = !ag.model || (model.providerID === ag.model.providerID && model.modelID === ag.model.modelID) const full = !input.variant && ag.variant && same ? yield* provider diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index fece68800b06..06b282a3b857 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -170,10 +170,11 @@ export const TaskTool = Tool.define( const msg = yield* MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }).pipe(Effect.orDie) if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) + const parentAssistant = msg.info const model = next.model ?? { - modelID: msg.info.modelID, - providerID: msg.info.providerID, + modelID: parentAssistant.modelID, + providerID: parentAssistant.providerID, } const metadata = { parentSessionId: ctx.sessionID, @@ -201,6 +202,10 @@ export const TaskTool = Tool.define( providerID: model.providerID, }, agent: next.name, + variant: + !next.variant && model.providerID === parentAssistant.providerID && model.modelID === parentAssistant.modelID + ? parentAssistant.variant + : undefined, tools: { ...(next.permission.some((rule) => rule.permission === "todowrite") ? {} : { todowrite: false }), ...(next.permission.some((rule) => rule.permission === id) ? {} : { task: false }), diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index ff9ded4d1927..e93d3c036d89 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -41,6 +41,7 @@ import { SystemPrompt } from "../../src/session/system" import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "@/tool/registry" +import type { Tool } from "@/tool/tool" import { Truncate } from "@/tool/truncate" import * as Log from "@opencode-ai/core/util/log" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -985,7 +986,7 @@ noLLMServer.instance( const registry = yield* ToolRegistry.Service const { task } = yield* registry.named() const original = task.execute - task.execute = (_args, ctx) => + task.execute = (_args: unknown, ctx: Tool.Context) => Effect.callback((_resume) => { ctx.abort.addEventListener("abort", () => succeedVoid(aborted), { once: true }) if (ctx.abort.aborted) succeedVoid(aborted) @@ -2227,6 +2228,135 @@ noLLMServer.instance( }, ) +noLLMServer.instance( + "applies agent variant to inherited model when supported", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + + const result = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + + if (result.info.role !== "user") throw new Error("expected user message") + expect(result.info.model).toEqual({ + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), + variant: "xhigh", + }) + }), + { + config: { + ...cfg, + provider: { + ...cfg.provider, + test: { + ...cfg.provider.test, + models: { + "test-model": { + ...cfg.provider.test.models["test-model"], + variants: { xhigh: {}, high: {} }, + }, + }, + }, + }, + agent: { + build: { + variant: "xhigh", + }, + }, + model: "test/test-model", + }, + }, +) + +it.instance( + "subtask explicit different model does not copy parent variant", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig((url) => ({ + ...providerCfg(url), + provider: { + ...providerCfg(url).provider, + other: { + ...cfg.provider.test, + id: "other", + name: "Other", + models: { + "other-model": { + ...cfg.provider.test.models["test-model"], + id: "other-model", + name: "Other Model", + }, + }, + }, + }, + })) + const registry = yield* ToolRegistry.Service + const sessions = yield* Session.Service + const { task } = yield* registry.named() + const original = task.execute + task.execute = (_args: unknown, ctx: Tool.Context) => + Effect.gen(function* () { + const message = yield* MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }).pipe(Effect.orDie) + expect(message.info.role).toBe("assistant") + if (message.info.role !== "assistant") return yield* Effect.die("expected assistant message") + expect(message.info.modelID).toBe(ModelID.make("other-model")) + expect(message.info.providerID).toBe(ProviderID.make("other")) + expect(message.info.variant).toBeUndefined() + return { + title: "inspect bug", + output: "done", + metadata: { + parentSessionId: ctx.sessionID, + sessionId: ctx.sessionID, + model: { + providerID: message.info.providerID, + modelID: message.info.modelID, + }, + }, + } + }) + yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original))) + + const { prompt, chat } = yield* boot() + const parent = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: chat.id, + agent: "build", + model: { ...ref, variant: "xhigh" }, + time: { created: Date.now() }, + }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: parent.id, + sessionID: chat.id, + type: "subtask", + prompt: "look into the cache key path", + description: "inspect bug", + agent: "general", + model: { providerID: ProviderID.make("other"), modelID: ModelID.make("other-model") }, + }) + + yield* llm.text("done") + const result = yield* prompt.loop({ sessionID: chat.id }) + expect(result.info.role).toBe("assistant") + + const messages = yield* MessageV2.filterCompactedEffect(chat.id) + const taskMessage = messages.find( + (message) => message.info.role === "assistant" && message.info.agent === "general", + ) + expect(taskMessage?.info.role).toBe("assistant") + if (taskMessage?.info.role === "assistant") expect(taskMessage.info.variant).toBeUndefined() + }), +) + // Agent / command resolution errors noLLMServer.instance( diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 2b7d001572a0..675f1cfeb6b8 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -54,7 +54,10 @@ function defer() { return { promise, resolve } } -const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { +const seed = Effect.fn("TaskToolTest.seed")(function* ( + title = "Pinned", + opts?: { assistantVariant?: string; assistantModel?: typeof ref }, +) { const session = yield* Session.Service const chat = yield* session.create({ title }) const user = yield* session.updateMessage({ @@ -75,8 +78,9 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { cost: 0, path: { cwd: "/tmp", root: "/tmp" }, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - modelID: ref.modelID, - providerID: ref.providerID, + modelID: opts?.assistantModel?.modelID ?? ref.modelID, + providerID: opts?.assistantModel?.providerID ?? ref.providerID, + variant: opts?.assistantVariant, time: { created: Date.now() }, } yield* session.updateMessage(assistant) @@ -242,6 +246,177 @@ describe("tool.task", () => { }), ) + it.instance("inherits parent variant for same-model subagent without configured variant", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed("Pinned", { assistantVariant: "xhigh" }) + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + + yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: stubOps({ onPrompt: (input) => (seen = input) }) }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(seen?.variant).toBe("xhigh") + }), + ) + + it.instance( + "does not override configured subagent variant with parent variant", + () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed("Pinned", { assistantVariant: "xhigh" }) + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + + yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "reviewer", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: stubOps({ onPrompt: (input) => (seen = input) }) }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(seen?.variant).toBeUndefined() + }), + { + config: { + agent: { + reviewer: { + mode: "subagent", + variant: "high", + }, + }, + }, + }, + ) + + it.instance( + "does not inherit parent variant for explicit different child model", + () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed("Pinned", { assistantVariant: "xhigh" }) + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + + yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "reviewer", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: stubOps({ onPrompt: (input) => (seen = input) }) }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(seen?.model).toEqual({ providerID: ProviderID.make("other"), modelID: ModelID.make("other-model") }) + expect(seen?.variant).toBeUndefined() + }), + { + config: { + agent: { + reviewer: { + mode: "subagent", + model: "other/other-model", + }, + }, + }, + }, + ) + + it.instance("persists inherited parent variant in child session history", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed("Pinned", { assistantVariant: "xhigh" }) + const tool = yield* TaskTool + const def = yield* tool.init() + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { + promptOps: { + ...stubOps(), + prompt: (input) => + Effect.gen(function* () { + const user = yield* sessions.updateMessage({ + id: input.messageID ?? MessageID.ascending(), + role: "user", + sessionID: input.sessionID, + agent: input.agent ?? "general", + model: { + providerID: input.model?.providerID ?? ref.providerID, + modelID: input.model?.modelID ?? ref.modelID, + variant: input.variant, + }, + time: { created: Date.now() }, + }) + const parts = input.parts.map((part) => ({ + ...part, + id: part.id ?? PartID.ascending(), + messageID: user.id, + sessionID: input.sessionID, + })) + yield* Effect.forEach(parts, (part) => sessions.updatePart(part), { discard: true }) + return reply(input, "done") + }), + } satisfies TaskPromptOps, + }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const messages = yield* sessions.messages({ sessionID: result.metadata.sessionId }) + const childUser = messages.find((message) => message.info.role === "user") + expect(childUser?.info.role).toBe("user") + if (childUser?.info.role === "user") expect(childUser.info.model.variant).toBe("xhigh") + }), + ) + it.instance("execute asks by default and skips checks when bypassed", () => Effect.gen(function* () { const { chat, assistant } = yield* seed()