diff --git a/packages/opencode/src/server/routes/instance/httpapi/errors.ts b/packages/opencode/src/server/routes/instance/httpapi/errors.ts index 5e35d6a79a3c..07e4c2b0cfd7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/errors.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/errors.ts @@ -185,6 +185,23 @@ export class ApiNotFoundError extends Schema.ErrorClass("NotFo { httpApiStatus: 404 }, ) {} +export class ApiBadRequestError extends Schema.ErrorClass("BadRequestError")( + { + name: Schema.Literal("BadRequest"), + data: Schema.Struct({ + message: Schema.String, + }), + }, + { httpApiStatus: 400 }, +) {} + +export function badRequest(message: string) { + return new ApiBadRequestError({ + name: "BadRequest", + data: { message }, + }) +} + export function notFound(message: string) { return new ApiNotFoundError({ name: "NotFoundError", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 959a303dc964..0f27c3cea2d4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -20,7 +20,7 @@ import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields, } from "../middleware/workspace-routing" -import { ApiNotFoundError, PermissionNotFoundError, SessionBusyError } from "../errors" +import { ApiBadRequestError, ApiNotFoundError, PermissionNotFoundError, SessionBusyError } from "../errors" import { described } from "./metadata" import { QueryBoolean } from "./query" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -318,7 +318,7 @@ export const SessionApi = HttpApi.make("session") query: WorkspaceRoutingQuery, payload: PromptPayload, success: described(SessionV1.WithParts, "Created message"), - error: [HttpApiError.BadRequest, ApiNotFoundError], + error: [HttpApiError.BadRequest, ApiBadRequestError, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.prompt", @@ -345,7 +345,7 @@ export const SessionApi = HttpApi.make("session") query: WorkspaceRoutingQuery, payload: CommandPayload, success: described(SessionV1.WithParts, "Created message"), - error: [HttpApiError.BadRequest, ApiNotFoundError], + error: [HttpApiError.BadRequest, ApiBadRequestError, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.command", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 662585020a64..1ee3f4b28f48 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -36,9 +36,13 @@ import { SummarizePayload, UpdatePayload, } from "../groups/session" -import { PermissionNotFoundError } from "../errors" +import { badRequest, PermissionNotFoundError } from "../errors" import * as SessionError from "./session-errors" +// Callers need the valid values to fix a bad variant; other prompt failures keep their existing empty body. +const toPromptError = (error: unknown) => + error instanceof SessionPrompt.VariantNotFoundError ? badRequest(error.message) : new HttpApiError.BadRequest({}) + const tryParseJson = (text: string) => Effect.try({ try: () => JSON.parse(text) as unknown, @@ -302,7 +306,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", ...ctx.payload, sessionID: ctx.params.sessionID, }) - .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + .pipe(Effect.mapError(toPromptError)) return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), { contentType: "application/json", }) @@ -335,7 +339,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", yield* requireSession(ctx.params.sessionID) return yield* promptSvc .command({ ...ctx.payload, sessionID: ctx.params.sessionID }) - .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + .pipe(Effect.mapError(toPromptError)) }) const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0f85d44f209b..4dc4fb530fc1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -99,12 +99,31 @@ function isOrphanedInterruptedTool(part: SessionV1.ToolPart) { return part.state.status === "error" && part.state.metadata?.interrupted === true } +export class VariantNotFoundError extends Schema.TaggedErrorClass()( + "SessionVariantNotFoundError", + { + providerID: Schema.String, + modelID: Schema.String, + variant: Schema.String, + available: Schema.Array(Schema.String), + }, +) { + override get message() { + const hint = this.available.length + ? ` Available variants: ${this.available.join(", ")}` + : " This model has no variants." + return `Variant not found: "${this.variant}" for ${this.providerID}/${this.modelID}.${hint}` + } +} + +export type PromptError = Image.Error | VariantNotFoundError + export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect - readonly prompt: (input: PromptInput) => Effect.Effect + readonly prompt: (input: PromptInput) => Effect.Effect readonly loop: (input: LoopInput) => Effect.Effect readonly shell: (input: ShellInput) => Effect.Effect - readonly command: (input: CommandInput) => Effect.Effect + readonly command: (input: CommandInput) => Effect.Effect readonly resolvePromptParts: (template: string) => Effect.Effect } @@ -613,7 +632,7 @@ const layer = Layer.effect( const currentModel = Effect.fnUntraced(function* (sessionID: SessionID) { const current = yield* db - .select({ model: SessionTable.model }) + .select({ model: SessionTable.model, revert: SessionTable.revert }) .from(SessionTable) .where(eq(SessionTable.id, sessionID)) .get() @@ -625,14 +644,27 @@ const layer = Layer.effect( ...(current.model.variant && current.model.variant !== "default" ? { variant: current.model.variant } : {}), } } - const match = yield* sessions - .findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model) - .pipe(Effect.orDie) + // Undone messages are dropped by the next prompt, so they must not decide the model it runs on. + const pending = current?.revert + const match = pending + ? yield* sessions.messages({ sessionID }).pipe( + Effect.orDie, + Effect.map((msgs) => + Option.fromNullishOr( + msgs + .slice(0, SessionRevert.cutoff(msgs, pending)) + .findLast((m) => m.info.role === "user" && !!m.info.model), + ), + ), + ) + : yield* sessions.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model).pipe(Effect.orDie) if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model return yield* provider.defaultModel().pipe(Effect.orDie) }) - const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) { + // Resolves the agent, model and variant a prompt is recorded with. It does not write to the session, so a + // prompt can be checked before anything destructive happens. + const resolveSelection = Effect.fn("SessionPrompt.resolveSelection")(function* (input: PromptInput) { const agentName = input.agent const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo() if (!ag) { @@ -645,13 +677,38 @@ const layer = Layer.effect( 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 explicit = input.variant !== undefined && input.variant !== "default" ? input.variant : undefined const full = - !input.variant && ag.variant && same + explicit !== undefined || (!input.variant && ag.variant && same) ? yield* provider .getModel(model.providerID, model.modelID) .pipe(Effect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined))) : undefined - const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined) + // An unknown variant would be dropped when the request is built while still being recorded on the + // session, so reject it before anything is persisted. + if (explicit !== undefined && full && !Object.hasOwn(full.variants ?? {}, explicit)) { + const error = new VariantNotFoundError({ + providerID: model.providerID, + modelID: model.modelID, + variant: explicit, + available: Object.keys(full.variants ?? {}), + }) + yield* events.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: new NamedError.Unknown({ message: error.message }).toObject(), + }) + return yield* error + } + const variant = + input.variant ?? (ag.variant && Object.hasOwn(full?.variants ?? {}, ag.variant) ? ag.variant : undefined) + return { ag, model, variant } + }) + + const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* ( + input: PromptInput, + selection: Effect.Success>, + ) { + const { ag, model, variant } = selection const info: SessionV1.User = { id: input.messageID ?? MessageID.ascending(), @@ -1049,12 +1106,15 @@ const layer = Layer.effect( return { info, parts } }, Effect.scoped) - const prompt: (input: PromptInput) => Effect.Effect = Effect.fn( + const prompt: (input: PromptInput) => Effect.Effect = Effect.fn( "SessionPrompt.prompt", )(function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) + // Cleanup permanently drops the undone messages, so a prompt that is rejected must fail before it. The + // selection is resolved once and recorded as is, so what was checked is what the message is created with. + const selection = yield* resolveSelection(input) yield* revert.cleanup(session) - const message = yield* createUserMessage(input) + const message = yield* createUserMessage(input, selection) yield* sessions.touch(input.sessionID) const permissions: PermissionV1.Rule[] = [] @@ -1408,17 +1468,21 @@ const layer = Layer.effect( } template = template.trim() - const taskModel = yield* Effect.gen(function* () { + const callerModel = Effect.fnUntraced(function* () { + if (input.model) return Provider.parseModel(input.model) + return yield* currentModel(input.sessionID) + }) + const pinnedModel = yield* Effect.gen(function* () { if (cmd.model) return Provider.parseModel(cmd.model) if (cmd.agent) { const cmdAgent = yield* agents.get(cmd.agent) if (cmdAgent?.model) return cmdAgent.model } - if (input.model) return Provider.parseModel(input.model) - return yield* currentModel(input.sessionID) + return undefined }) + const taskModel = pinnedModel ?? (yield* callerModel()) - yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID) + const resolvedTaskModel = yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID) const agent = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo() if (!agent) { @@ -1451,11 +1515,21 @@ const layer = Layer.effect( : [...uniqueTemplateParts, ...(input.parts ?? [])] const userAgent = isSubtask ? (input.agent ?? (yield* agents.defaultInfo()).name) : agent.name - const userModel = isSubtask - ? input.model - ? Provider.parseModel(input.model) - : yield* currentModel(input.sessionID) - : taskModel + const userModel = isSubtask ? yield* callerModel() : taskModel + // The caller picked its variant for its own model. When the command pins a different model that does + // not offer that variant, run it on that model's default instead of failing the command. A value the + // caller's model does not declare either is left in place so it is rejected like any other prompt. + const inherited = yield* Effect.gen(function* () { + if (isSubtask || !pinnedModel || input.variant === undefined || input.variant === "default") return false + if (Object.hasOwn(resolvedTaskModel.variants ?? {}, input.variant)) return false + const caller = yield* callerModel() + if (caller.providerID === pinnedModel.providerID && caller.modelID === pinnedModel.modelID) return false + const callerInfo = yield* provider + .getModel(caller.providerID, caller.modelID) + .pipe(Effect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined))) + return Object.hasOwn(callerInfo?.variants ?? {}, input.variant) + }) + const variant = inherited ? undefined : input.variant yield* plugin.trigger( "command.execute.before", @@ -1469,7 +1543,7 @@ const layer = Layer.effect( model: userModel, agent: userAgent, parts, - variant: input.variant, + variant, }) yield* events.publish(Command.Event.Executed, { name: input.command, diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 03e5afd085e0..37fd84f2a399 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -17,6 +17,12 @@ export const RevertInput = Schema.Struct({ }) export type RevertInput = Schema.Schema.Type +// Index of the first message that cleanup drops for a pending revert; the messages before it are kept. +export function cutoff(msgs: SessionV1.WithParts[], revert: { messageID: string; partID?: string }) { + const index = msgs.findIndex((msg) => msg.info.id === revert.messageID) + return index < 0 ? msgs.length : index + (revert.partID ? 1 : 0) +} + export interface Interface { readonly revert: (input: RevertInput) => Effect.Effect readonly unrevert: (input: { sessionID: SessionID }) => Effect.Effect @@ -103,9 +109,8 @@ const layer = Layer.effect( const sessionID = session.id const msgs = yield* sessions.messages({ sessionID }).pipe(Effect.orDie) const messageID = session.revert.messageID - const index = msgs.findIndex((msg) => msg.info.id === messageID) - const target = index < 0 ? undefined : msgs[index] - const remove = index < 0 ? [] : msgs.slice(index + (session.revert.partID ? 1 : 0)) + const target = msgs.find((msg) => msg.info.id === messageID) + const remove = msgs.slice(cutoff(msgs, session.revert)) for (const msg of remove) { yield* sessions.removeMessage({ sessionID, messageID: msg.info.id }) } diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index d8ca640cfba9..5d03c9799e76 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -233,7 +233,14 @@ export const TaskTool = Tool.define( .prompt({ sessionID: ctx.sessionID, agent: currentParent.agent ?? ctx.agent, - variant, + // Follow the parent's current selection; the user may have switched model or variant meanwhile. + // The variant only applies to the model it was picked for, so pass both. + ...(currentParent.model + ? { + model: { providerID: currentParent.model.providerID, modelID: currentParent.model.id }, + variant: currentParent.model.variant, + } + : { variant }), parts: [ { type: "text", diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index d2d4bae87921..ff54c8052a16 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -7,6 +7,28 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { reply } from "../../lib/llm-server" import { cliIt } from "../../lib/cli-process" +import { testProviderConfig } from "../../lib/test-provider" + +// test-model declares `high`; plain-model declares no variants and is pinned by the `pinned` command. +function variantEnv(url: string) { + const config = testProviderConfig(url) + const model = config.provider.test.models["test-model"] + return { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + ...config, + command: { pinned: { template: "check the build", model: "test/plain-model" } }, + provider: { + test: { + ...config.provider.test, + models: { + "test-model": { ...model, variants: { high: { reasoningEffort: "high" } } }, + "plain-model": { ...model, id: "plain-model", name: "Plain Model" }, + }, + }, + }, + }), + } +} describe("opencode run (non-interactive subprocess)", () => { // Happy path: prompt completes, output reaches stdout, process exits 0. @@ -164,6 +186,60 @@ describe("opencode run (non-interactive subprocess)", () => { 30_000, ) + cliIt.concurrent( + "rejects a --variant the model does not declare and names the valid ones", + ({ llm, opencode }) => + Effect.gen(function* () { + const env = variantEnv(llm.url) + const result = yield* opencode.run("say hi", { extraArgs: ["--variant", "hihg"], env }) + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain("Variant not found") + expect(result.stderr).toContain("Available variants: high") + + const json = yield* opencode.run("say hi", { format: "json", extraArgs: ["--variant", "hihg"], env }) + expect(json.exitCode).not.toBe(0) + expect(opencode.parseJsonEvents(json.stdout)[0]).toMatchObject({ + type: "error", + error: { + name: "BadRequest", + data: { message: 'Variant not found: "hihg" for test/test-model. Available variants: high' }, + }, + }) + expect(yield* llm.calls).toBe(0) + }), + 60_000, + ) + + cliIt.concurrent( + "--command on a pinned model drops only a variant the caller's model declares", + ({ llm, opencode }) => + Effect.gen(function* () { + const env = variantEnv(llm.url) + const invalid = yield* opencode.run("", { + model: "test/test-model", + command: "pinned", + extraArgs: ["--variant", "totally-invalid-xyz"], + env, + }) + expect(invalid.exitCode).not.toBe(0) + expect(invalid.stderr).toContain("Variant not found") + expect(invalid.stderr).toContain("for test/plain-model. This model has no variants.") + expect(yield* llm.calls).toBe(0) + + yield* llm.text("pinned done") + const inherited = yield* opencode.run("", { + model: "test/test-model", + command: "pinned", + extraArgs: ["--variant", "high"], + env, + }) + opencode.expectExit(inherited, 0) + expect(inherited.stdout).toContain("pinned done") + expect((yield* llm.inputs)[0]?.reasoning_effort).toBeUndefined() + }), + 60_000, + ) + cliIt.concurrent( "--format json preserves reasoning, tool, and continuation ordering", ({ llm, opencode }) => diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index da6e0f8d036f..b95b3be49784 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -47,6 +47,7 @@ import { Shell } from "@opencode-ai/core/shell" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" +import { TaskTool, type TaskPromptOps } from "@/tool/task" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { Format } from "../../src/format" @@ -208,12 +209,21 @@ const promptRoot = LayerNode.group([ RuntimeFlags.node, ]) -function makePrompt(input?: { mcpInstructions?: MCP.ServerInstructions[]; processor?: "blocking" }) { +function makePrompt(input?: { + mcpInstructions?: MCP.ServerInstructions[] + processor?: "blocking" + backgroundSubagents?: boolean +}) { const replacements = [ [SessionSummary.node, summary], [LSP.node, lsp], [MCP.node, makeMcp(input?.mcpInstructions)], - [RuntimeFlags.node, runtimeFlags], + [ + RuntimeFlags.node, + input?.backgroundSubagents + ? RuntimeFlags.layer({ experimentalEventSystem: true, experimentalBackgroundSubagents: true }) + : runtimeFlags, + ], ] as const if (input?.processor === "blocking") { return LayerNode.compile(promptRoot, [...replacements, [SessionProcessor.node, blockingProcessor]]) @@ -242,6 +252,7 @@ function makeHttpNoLLMServer(input?: { mcpInstructions?: MCP.ServerInstructions[ const it = testEffect(makeHttp()) const noLLMServer = testEffect(makeHttpNoLLMServer()) const raceNoLLMServer = testEffect(makeHttpNoLLMServer({ processor: "blocking" })) +const backgroundNoLLMServer = testEffect(makePrompt({ backgroundSubagents: true })) const withMcpInstructions = testEffect( makeHttp({ mcpInstructions: [ @@ -2381,6 +2392,594 @@ noLLMServer.instance( }, ) +// Explicit variants + +function variantCfg(url: string) { + const base = providerCfg(url) + return { + ...base, + provider: { + ...base.provider, + test: { + ...base.provider.test, + models: { + "test-model": { + ...base.provider.test.models["test-model"], + variants: { high: { reasoningEffort: "high" }, xhigh: { reasoningEffort: "xhigh" } }, + }, + "plain-model": { + ...base.provider.test.models["test-model"], + id: "plain-model", + name: "Plain Model", + }, + }, + }, + }, + command: { + pinned: { template: "check the build", model: "test/plain-model" }, + unpinned: { template: "check the build" }, + }, + } +} + +const expectUnknownVariant = (exit: Exit.Exit, variant: string) => { + expect(Exit.isFailure(exit)).toBe(true) + if (!Exit.isFailure(exit)) return "" + const err = Cause.squash(exit.cause) + expect(err).toBeInstanceOf(SessionPrompt.VariantNotFoundError) + if (!(err instanceof SessionPrompt.VariantNotFoundError)) return "" + expect(err.message).toContain(`Variant not found: "${variant}"`) + return err.message +} + +it.instance("rejects an unknown explicit variant before recording or sending it", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(variantCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + const exit = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + variant: "totally-invalid-xyz", + parts: [{ type: "text", text: "hello" }], + }) + .pipe(Effect.exit) + + const message = expectUnknownVariant(exit, "totally-invalid-xyz") + expect(message).toContain("test/test-model") + expect(message).toContain("high, xhigh") + expect((yield* sessions.get(chat.id)).model).toBeUndefined() + expect(yield* sessions.messages({ sessionID: chat.id })).toHaveLength(0) + expect(yield* llm.calls).toBe(0) + }), +) + +noLLMServer.instance( + "rejects explicit variant names the model does not declare as its own", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + for (const variant of ["constructor", "toString", "__proto__", ""]) { + const exit = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + variant, + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + .pipe(Effect.exit) + expect(expectUnknownVariant(exit, variant)).toContain("Available variants: high, xhigh") + } + expect((yield* sessions.get(chat.id)).model).toBeUndefined() + expect(yield* sessions.messages({ sessionID: chat.id })).toHaveLength(0) + }), + { config: variantCfg("http://localhost:1/v1") }, +) + +it.instance("applies a declared explicit variant to the provider request", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(variantCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.text("done") + + const result = yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + variant: "high", + parts: [{ type: "text", text: "hello" }], + }) + + expect(result.info.role).toBe("assistant") + expect((yield* sessions.get(chat.id)).model).toEqual({ + id: ref.modelID, + providerID: ref.providerID, + variant: "high", + }) + const inputs = yield* llm.inputs + expect(inputs).toHaveLength(1) + expect(inputs[0]?.reasoning_effort).toBe("high") + }), +) + +noLLMServer.instance( + "accepts the default variant sentinel and rejects variants on a model without any", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + const plain = { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("plain-model") } + + const accepted = yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: plain, + variant: "default", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + if (accepted.info.role !== "user") throw new Error("expected user message") + expect(accepted.info.model.variant).toBe("default") + + const exit = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + model: plain, + variant: "high", + noReply: true, + parts: [{ type: "text", text: "hello again" }], + }) + .pipe(Effect.exit) + expect(expectUnknownVariant(exit, "high")).toContain("has no variants") + expect((yield* sessions.get(chat.id)).model?.variant).toBe("default") + expect(yield* sessions.messages({ sessionID: chat.id })).toHaveLength(1) + }), + { config: variantCfg("http://localhost:1/v1") }, +) + +it.instance("command with its own model does not inherit the caller's variant", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(variantCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.text("done") + + yield* prompt.command({ + sessionID: chat.id, + command: "pinned", + arguments: "", + model: "test/test-model", + variant: "high", + }) + + const user = (yield* sessions.messages({ sessionID: chat.id })).find((msg) => msg.info.role === "user") + if (user?.info.role !== "user") throw new Error("expected user message") + expect(user.info.model).toEqual({ + providerID: ProviderV2.ID.make("test"), + modelID: ModelV2.ID.make("plain-model"), + variant: undefined, + }) + expect((yield* sessions.get(chat.id)).model?.variant).toBe("default") + expect((yield* llm.inputs)[0]?.reasoning_effort).toBeUndefined() + }), +) + +it.instance("command with its own model rejects a variant neither model declares", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(variantCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.text("done") + + const exit = yield* prompt + .command({ + sessionID: chat.id, + command: "pinned", + arguments: "", + model: "test/test-model", + variant: "totally-invalid-xyz", + }) + .pipe(Effect.exit) + + expect(expectUnknownVariant(exit, "totally-invalid-xyz")).toContain("test/plain-model") + expect(yield* sessions.messages({ sessionID: chat.id })).toHaveLength(0) + expect(yield* llm.calls).toBe(0) + }), +) + +it.instance("command on the caller's model rejects an unknown variant", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(variantCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.text("done") + + const exit = yield* prompt + .command({ + sessionID: chat.id, + command: "unpinned", + arguments: "", + model: "test/test-model", + variant: "totally-invalid-xyz", + }) + .pipe(Effect.exit) + + expectUnknownVariant(exit, "totally-invalid-xyz") + expect(yield* sessions.messages({ sessionID: chat.id })).toHaveLength(0) + expect(yield* llm.calls).toBe(0) + }), +) + +// Two accepted prompts with the second one undone, so the session holds recoverable messages. +const undoSecondPrompt = Effect.fn("test.undoSecondPrompt")(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const revert = yield* SessionRevert.Service + const chat = yield* sessions.create({ title: "Pinned" }) + const send = (text: string) => + prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + variant: "high", + noReply: true, + parts: [{ type: "text", text }], + }) + const first = yield* send("first") + const second = yield* send("second") + yield* revert.revert({ sessionID: chat.id, messageID: second.info.id }) + expect((yield* sessions.get(chat.id)).revert?.messageID).toBe(second.info.id) + return { chat, first, second } +}) + +const texts = (messages: SessionV1.WithParts[]) => + messages.map((msg) => msg.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")) + +noLLMServer.instance( + "rejected variant keeps undone messages and the revert marker", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const revert = yield* SessionRevert.Service + const { chat, first, second } = yield* undoSecondPrompt() + + const exit = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + variant: "hihg", + noReply: true, + parts: [{ type: "text", text: "replacement" }], + }) + .pipe(Effect.exit) + + expectUnknownVariant(exit, "hihg") + const messages = yield* sessions.messages({ sessionID: chat.id }) + expect(messages.map((msg) => msg.info.id)).toEqual([first.info.id, second.info.id]) + expect(texts(messages)).toEqual(["first", "second"]) + expect((yield* sessions.get(chat.id)).revert?.messageID).toBe(second.info.id) + + // The undo is still recoverable. + const restored = yield* revert.unrevert({ sessionID: chat.id }) + expect(restored.revert).toBeUndefined() + expect(texts(yield* sessions.messages({ sessionID: chat.id }))).toEqual(["first", "second"]) + }), + { config: variantCfg("http://localhost:1/v1") }, +) + +noLLMServer.instance( + "accepted variant replaces undone messages", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const { chat, first } = yield* undoSecondPrompt() + + const accepted = yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + variant: "xhigh", + noReply: true, + parts: [{ type: "text", text: "replacement" }], + }) + + const messages = yield* sessions.messages({ sessionID: chat.id }) + expect(messages.map((msg) => msg.info.id)).toEqual([first.info.id, accepted.info.id]) + expect(texts(messages)).toEqual(["first", "replacement"]) + expect((yield* sessions.get(chat.id)).revert).toBeUndefined() + expect((yield* sessions.get(chat.id)).model?.variant).toBe("xhigh") + }), + { config: variantCfg("http://localhost:1/v1") }, +) + +const plain = { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("plain-model") } +type ModelRef = typeof plain + +// A fork keeps the messages but not the session-level model, so its model comes from the message history. +// The two prompts use different models and the second one is undone in the fork. +const undoInFork = Effect.fn("test.undoInFork")(function* (firstModel: ModelRef, secondModel: ModelRef) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const revert = yield* SessionRevert.Service + const chat = yield* sessions.create({ title: "Pinned" }) + const send = (text: string, model: ModelRef) => + prompt.prompt({ sessionID: chat.id, agent: "build", model, noReply: true, parts: [{ type: "text", text }] }) + yield* send("first", firstModel) + yield* send("second", secondModel) + const fork = yield* sessions.fork({ sessionID: chat.id }) + expect(fork.model).toBeUndefined() + const [first, second] = yield* sessions.messages({ sessionID: fork.id }) + if (!first || !second) throw new Error("expected forked messages") + expect(texts([first, second])).toEqual(["first", "second"]) + yield* revert.revert({ sessionID: fork.id, messageID: second.info.id }) + expect((yield* sessions.get(fork.id)).revert?.messageID).toBe(second.info.id) + return { fork, first, second } +}) + +noLLMServer.instance( + "rejected variant keeps undone messages in a session without its own model", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const revert = yield* SessionRevert.Service + // Only the undone message is on a model that declares "high". + const { fork, first, second } = yield* undoInFork(plain, ref) + + const exit = yield* prompt + .prompt({ + sessionID: fork.id, + agent: "build", + variant: "high", + noReply: true, + parts: [{ type: "text", text: "replacement" }], + }) + .pipe(Effect.exit) + + expect(expectUnknownVariant(exit, "high")).toContain("test/plain-model") + const messages = yield* sessions.messages({ sessionID: fork.id }) + expect(messages.map((msg) => msg.info.id)).toEqual([first.info.id, second.info.id]) + expect((yield* sessions.get(fork.id)).revert?.messageID).toBe(second.info.id) + + const restored = yield* revert.unrevert({ sessionID: fork.id }) + expect(restored.revert).toBeUndefined() + expect(texts(yield* sessions.messages({ sessionID: fork.id }))).toEqual(["first", "second"]) + }), + { config: variantCfg("http://localhost:1/v1") }, +) + +noLLMServer.instance( + "prompt without its own model runs on the model left after undone messages", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + // Only the kept message is on a model that declares "high", so the variant is valid. + const { fork, first } = yield* undoInFork(ref, plain) + + const accepted = yield* prompt.prompt({ + sessionID: fork.id, + agent: "build", + variant: "high", + noReply: true, + parts: [{ type: "text", text: "replacement" }], + }) + + if (accepted.info.role !== "user") throw new Error("expected user message") + expect(accepted.info.model).toEqual({ ...ref, variant: "high" }) + const messages = yield* sessions.messages({ sessionID: fork.id }) + expect(messages.map((msg) => msg.info.id)).toEqual([first.info.id, accepted.info.id]) + expect((yield* sessions.get(fork.id)).revert).toBeUndefined() + }), + { config: variantCfg("http://localhost:1/v1") }, +) + +noLLMServer.instance( + "prompt without a variant replaces undone messages in a session without its own model", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const { fork, first } = yield* undoInFork(plain, ref) + + const accepted = yield* prompt.prompt({ + sessionID: fork.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "replacement" }], + }) + + if (accepted.info.role !== "user") throw new Error("expected user message") + expect(accepted.info.model).toEqual(plain) + const messages = yield* sessions.messages({ sessionID: fork.id }) + expect(messages.map((msg) => msg.info.id)).toEqual([first.info.id, accepted.info.id]) + expect(texts(messages)).toEqual(["first", "replacement"]) + expect((yield* sessions.get(fork.id)).revert).toBeUndefined() + }), + { config: variantCfg("http://localhost:1/v1") }, +) + +it.instance("command without its own model rejects a variant the model left after undone messages lacks", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(variantCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const revert = yield* SessionRevert.Service + yield* llm.text("done") + // Only the undone message is on a model that declares "high". + const { fork, first, second } = yield* undoInFork(plain, ref) + + const exit = yield* prompt + .command({ sessionID: fork.id, command: "unpinned", arguments: "", variant: "high" }) + .pipe(Effect.exit) + + expect(expectUnknownVariant(exit, "high")).toContain("test/plain-model") + const messages = yield* sessions.messages({ sessionID: fork.id }) + expect(messages.map((msg) => msg.info.id)).toEqual([first.info.id, second.info.id]) + expect((yield* sessions.get(fork.id)).revert?.messageID).toBe(second.info.id) + expect(yield* llm.calls).toBe(0) + + const restored = yield* revert.unrevert({ sessionID: fork.id }) + expect(restored.revert).toBeUndefined() + expect(texts(yield* sessions.messages({ sessionID: fork.id }))).toEqual(["first", "second"]) + }), +) + +it.instance("command without its own model runs on the model left after undone messages", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(variantCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + yield* llm.text("done") + // Only the kept message is on a model that declares "high", so the variant is valid. + const { fork, first } = yield* undoInFork(ref, plain) + + yield* prompt.command({ sessionID: fork.id, command: "unpinned", arguments: "", variant: "high" }) + + const users = (yield* sessions.messages({ sessionID: fork.id })).filter((msg) => msg.info.role === "user") + expect(users.map((msg) => msg.info.id)[0]).toBe(first.info.id) + expect(texts(users)).toEqual(["first", "check the build"]) + const replacement = users[1]?.info + if (replacement?.role !== "user") throw new Error("expected user message") + expect(replacement.model).toEqual({ ...ref, variant: "high" }) + expect((yield* sessions.get(fork.id)).revert).toBeUndefined() + expect((yield* llm.inputs)[0]?.reasoning_effort).toBe("high") + }), +) + +backgroundNoLLMServer.instance( + "background task result reaches a parent that switched away from its agent's pinned model", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const jobs = yield* BackgroundJob.Service + const chat = yield* sessions.create({ title: "Pinned" }) + const other = { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("other-model") } + const first = yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + variant: "high", + noReply: true, + parts: [{ type: "text", text: "start" }], + }) + const assistant: SessionV1.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: first.info.id, + sessionID: chat.id, + mode: "build", + agent: "build", + 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, + variant: "high", + time: { created: Date.now() }, + } + yield* sessions.updateMessage(assistant) + + // Every prompt goes through real admission; the child waits until the parent has switched models. + const release = yield* Deferred.make() + const injected = yield* Deferred.make>() + const promptOps: TaskPromptOps = { + cancel: prompt.cancel, + resolvePromptParts: prompt.resolvePromptParts, + prompt: (input) => + input.sessionID === chat.id + ? prompt.prompt({ ...input, noReply: true }).pipe( + Effect.orDie, + Effect.onExit((exit) => Deferred.succeed(injected, exit)), + ) + : Deferred.await(release).pipe(Effect.andThen(prompt.prompt({ ...input, noReply: true })), Effect.orDie), + } + const task = yield* TaskTool + const def = yield* task.init() + const result = yield* def.execute( + { description: "inspect bug", prompt: "look into it", subagent_type: "general", background: true }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: other, + variant: "xhigh", + noReply: true, + parts: [{ type: "text", text: "switch" }], + }) + yield* Deferred.succeed(release, undefined) + expect((yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })).info?.status).toBe("completed") + + const exit = yield* awaitWithTimeout(Deferred.await(injected), "no background result was injected", "5 seconds") + expect(Exit.isSuccess(exit)).toBe(true) + if (!Exit.isSuccess(exit) || exit.value.info.role !== "user") throw new Error("expected injected user message") + expect(exit.value.info.model).toEqual({ ...other, variant: "xhigh" }) + expect( + exit.value.parts.some( + (part) => part.type === "text" && part.synthetic && part.text.includes("Background task completed"), + ), + ).toBe(true) + expect((yield* sessions.get(chat.id)).model).toEqual({ + id: other.modelID, + providerID: other.providerID, + variant: "xhigh", + }) + }), + { + config: { + ...cfg, + agent: { build: { model: "test/test-model" } }, + provider: { + ...cfg.provider, + test: { + ...cfg.provider.test, + models: { + "test-model": { + ...cfg.provider.test.models["test-model"], + variants: { high: { reasoningEffort: "high" } }, + }, + "other-model": { + ...cfg.provider.test.models["test-model"], + id: "other-model", + variants: { xhigh: { reasoningEffort: "xhigh" } }, + }, + }, + }, + }, + }, + }, +) + // 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 42f46fd35d7e..579703a6ad92 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -861,6 +861,62 @@ describe("tool.task", () => { }), ) + background.instance("background task result follows the parent's current model and variant", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const release = defer() + const injected = defer() + const promptOps: TaskPromptOps = { + ...stubOps(), + prompt: (input) => { + if (input.sessionID === chat.id) { + injected.resolve(input) + return Effect.succeed(reply(input, "done")) + } + return Effect.promise(() => release.promise).pipe(Effect.as(reply(input, "background done"))) + }, + } + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + background: true, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + // The user switches the parent session to another model while the task runs. + yield* sessions.setAgentModel({ + sessionID: chat.id, + agent: "build", + model: { id: ModelV2.ID.make("other-model"), providerID: ref.providerID, variant: "default" }, + time: Date.now(), + }) + release.resolve() + + const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 }) + expect(waited.info?.status).toBe("completed") + const notification = yield* Effect.promise(() => injected.promise) + expect(notification.model).toEqual({ providerID: ref.providerID, modelID: ModelV2.ID.make("other-model") }) + expect(notification.variant).toBe("default") + }), + ) + background.instance("background tasks complete through the background job service", () => Effect.gen(function* () { const jobs = yield* BackgroundJob.Service