diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0f85d44f209b..3f2963c6ef04 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -633,7 +633,18 @@ const layer = Layer.effect( }) const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) { - const agentName = input.agent + // The durable session row survives compaction and records the current + // agent/model, so synthetic prompts that omit either field do not fall + // back to configured defaults. + const current = yield* sessions.get(input.sessionID).pipe(Effect.orDie) + const prev = + input.agent || current.agent + ? undefined + : (yield* MessageV2.filterCompactedEffect(input.sessionID).pipe(Effect.provideService(Database.Service, database))).findLast( + (m): m is SessionV1.WithParts & { info: SessionV1.User } => + m.info.role === "user" && !!m.info.agent, + ) + const agentName = input.agent ?? current.agent ?? prev?.info.agent const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo() if (!ag) { const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) @@ -643,7 +654,18 @@ const layer = Layer.effect( throw error } - const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID)) + // A genuine agent switch gets the new agent's configured model; an + // injected prompt for the current agent keeps the session model. + const switched = !!current.agent && ag.name !== current.agent + const sessionModel = + !switched && current.model + ? { + providerID: ProviderV2.ID.make(current.model.providerID), + modelID: ModelV2.ID.make(current.model.id), + variant: current.model.variant === "default" ? undefined : current.model.variant, + } + : undefined + const model = input.model ?? sessionModel ?? prev?.info.model ?? ag.model ?? (yield* currentModel(input.sessionID)) const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID const full = !input.variant && ag.variant && same @@ -651,7 +673,8 @@ const layer = Layer.effect( .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) + const modelVariant = model === sessionModel ? sessionModel.variant : model === prev?.info.model ? prev.info.model.variant : undefined + const variant = input.variant ?? modelVariant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined) const info: SessionV1.User = { id: input.messageID ?? MessageID.ascending(), @@ -669,7 +692,6 @@ const layer = Layer.effect( format: input.format, } - const current = yield* sessions.get(input.sessionID).pipe(Effect.orDie) if ( current.agent !== info.agent || current.model?.providerID !== info.model.providerID || diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index da6e0f8d036f..fa25191649da 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -46,6 +46,7 @@ import { SystemPrompt } from "../../src/session/system" import { Shell } from "@opencode-ai/core/shell" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "@/tool/registry" +import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { Truncate } from "@/tool/truncate" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Ripgrep } from "@opencode-ai/core/ripgrep" @@ -165,6 +166,7 @@ const blockingProcessor = Layer.succeed( ) const runtimeFlags = RuntimeFlags.layer({ experimentalEventSystem: true }) +const backgroundRuntimeFlags = RuntimeFlags.layer({ experimentalEventSystem: true, experimentalBackgroundSubagents: true }) const testLLMServerNode = LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }) @@ -208,12 +210,18 @@ const promptRoot = LayerNode.group([ RuntimeFlags.node, ]) -function makePrompt(input?: { mcpInstructions?: MCP.ServerInstructions[]; processor?: "blocking" }) { +type PromptTestInput = { + mcpInstructions?: MCP.ServerInstructions[] + processor?: "blocking" + runtimeFlags?: Layer.Layer +} + +function makePrompt(input?: PromptTestInput) { const replacements = [ [SessionSummary.node, summary], [LSP.node, lsp], [MCP.node, makeMcp(input?.mcpInstructions)], - [RuntimeFlags.node, runtimeFlags], + [RuntimeFlags.node, input?.runtimeFlags ?? runtimeFlags], ] as const if (input?.processor === "blocking") { return LayerNode.compile(promptRoot, [...replacements, [SessionProcessor.node, blockingProcessor]]) @@ -241,6 +249,9 @@ function makeHttpNoLLMServer(input?: { mcpInstructions?: MCP.ServerInstructions[ const it = testEffect(makeHttp()) const noLLMServer = testEffect(makeHttpNoLLMServer()) +const backgroundNoLLMServer = testEffect( + makePrompt({ runtimeFlags: backgroundRuntimeFlags }) as unknown as Layer.Layer, +) const raceNoLLMServer = testEffect(makeHttpNoLLMServer({ processor: "blocking" })) const withMcpInstructions = testEffect( makeHttp({ @@ -2333,6 +2344,7 @@ noLLMServer.instance( const match = yield* prompt.prompt({ sessionID: session.id, agent: "build", + model: ref, noReply: true, parts: [{ type: "text", text: "hello again" }], }) @@ -2468,3 +2480,242 @@ noLLMServer.instance( }), 30_000, ) + +// Agent/model preservation + +noLLMServer.instance( + "prompt without agent and model preserves current session agent and model", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + model: ref, + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + + const next = yield* prompt.prompt({ + sessionID: session.id, + noReply: true, + parts: [{ type: "text", text: "hello again" }], + }) + if (next.info.role !== "user") throw new Error("expected user message") + expect(next.info.agent).toBe("build") + expect(next.info.model).toEqual(ref) + + yield* sessions.remove(session.id) + }), + { + config: { + ...cfg, + default_agent: "plan", + }, + }, +) + +noLLMServer.instance( + "explicit agent without model keeps the session's current model", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + model: { providerID: ProviderV2.ID.make("opencode"), modelID: ModelV2.ID.make("kimi-k2.5-free") }, + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + + const next = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "notification" }], + }) + if (next.info.role !== "user") throw new Error("expected user message") + expect(next.info.agent).toBe("build") + expect(next.info.model.providerID).toBe(ProviderV2.ID.make("opencode")) + expect(next.info.model.modelID).toBe(ModelV2.ID.make("kimi-k2.5-free")) + + yield* sessions.remove(session.id) + }), + { + config: { + ...cfg, + agent: { + build: { + model: "test/test-model", + }, + }, + }, + }, +) + +noLLMServer.instance( + "explicit agent switch without model uses the new agent's model", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + model: { providerID: ProviderV2.ID.make("opencode"), modelID: ModelV2.ID.make("kimi-k2.5-free") }, + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + + const next = yield* prompt.prompt({ + sessionID: session.id, + agent: "plan", + noReply: true, + parts: [{ type: "text", text: "switch" }], + }) + if (next.info.role !== "user") throw new Error("expected user message") + expect(next.info.agent).toBe("plan") + expect(next.info.model.providerID).toBe(ProviderV2.ID.make("test")) + expect(next.info.model.modelID).toBe(ModelV2.ID.make("test-model")) + + yield* sessions.remove(session.id) + }), + { + config: { + ...cfg, + agent: { + plan: { + model: "test/test-model", + }, + }, + }, + }, +) + +noLLMServer.instance( + "prompt without agent, model, and variant preserves the current variant", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + model: ref, + variant: "xhigh", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + + const next = yield* prompt.prompt({ + sessionID: session.id, + noReply: true, + parts: [{ type: "text", text: "hello again" }], + }) + if (next.info.role !== "user") throw new Error("expected user message") + expect(next.info.agent).toBe("build") + expect(next.info.model).toEqual({ ...ref, variant: "xhigh" }) + + yield* sessions.remove(session.id) + }), + { + config: { + ...cfg, + provider: { + ...cfg.provider, + test: { + ...cfg.provider.test, + models: { + "test-model": { + ...cfg.provider.test.models["test-model"], + variants: { xhigh: {}, high: {} }, + }, + }, + }, + }, + default_agent: "plan", + }, + }, +) + +backgroundNoLLMServer.instance( + "background completion injection preserves the parent session model", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const parent = yield* sessions.create({ permission: [{ permission: "*", pattern: "*", action: "allow" }] }) + const seeded = yield* seed(parent.id) + yield* sessions.setAgentModel({ + sessionID: parent.id, + agent: "build", + model: { id: ref.modelID, providerID: ref.providerID, variant: "default" }, + time: Date.now(), + }) + + const taskInfo = yield* TaskTool + const task = yield* taskInfo.init() + const promptOps: TaskPromptOps = { + cancel: () => Effect.void, + resolvePromptParts: () => Effect.succeed([{ type: "text", text: "child prompt" }]), + prompt: (input) => prompt.prompt({ ...input, noReply: true }).pipe(Effect.orDie), + } + + yield* task.execute( + { + description: "background completion", + prompt: "child prompt", + subagent_type: "build", + background: true, + }, + { + sessionID: parent.id, + messageID: seeded.assistant.id, + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + extra: { bypassAgentCheck: true, promptOps }, + }, + ) + + const injected = yield* pollWithTimeout( + Effect.gen(function* () { + const messages = yield* sessions.messages({ sessionID: parent.id }) + const match = messages.findLast( + (message) => + message.info.role === "user" && + message.parts.some((part) => part.type === "text" && part.synthetic === true), + ) + if (match?.info.role === "user") return match + }), + "timed out waiting for background completion injection", + ) + + if (injected.info.role !== "user") throw new Error("expected user message") + expect(injected.info.agent).toBe("build") + expect(injected.info.model).toEqual(ref) + }), + { + config: { + ...cfg, + agent: { + build: { + model: "test/other-model", + }, + }, + }, + }, + 10_000, +)