diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 0515d55cf5be..8c3d4eda4839 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -856,6 +856,101 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("first prompt after a durable switchAgent runs with the new agent system", () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + const agent = yield* AgentV2.Service + yield* agent.transform((editor) => { + editor.update(AgentV2.ID.make("build"), (agent) => { + agent.system = "Build agent instructions" + agent.mode = "primary" + }) + editor.update(AgentV2.ID.make("reviewer"), (agent) => { + agent.system = "Reviewer instructions" + agent.mode = "primary" + }) + }) + yield* db + .update(SessionTable) + .set({ agent: "build" }) + .where(eq(SessionTable.id, sessionID)) + .run() + .pipe(Effect.orDie) + const session = yield* SessionV2.Service + // Establish a build turn so the SessionContextEpoch is initialized under build. + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + response = fragmentFixture("text", "text-build", ["Done"]).completeEvents + yield* session.resume(sessionID) + expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"]) + + // TUI Tab-equivalent: durable switch to reviewer (AgentSwitched -> projector). + yield* session.switchAgent({ sessionID, agent: AgentV2.ID.make("reviewer") }) + expect(yield* session.get(sessionID)).toMatchObject({ agent: "reviewer" }) + + requests.length = 0 + response = fragmentFixture("text", "text-reviewer-first", ["Reviewing"]).completeEvents + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First message after switch" }) }) + yield* session.resume(sessionID) + + expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([ + "Reviewer instructions", + "Initial context", + ]) + }), + ) + + it.effect("first message after a durable switchAgent while the drain is mid-turn uses the new agent", () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + const agent = yield* AgentV2.Service + yield* agent.transform((editor) => { + editor.update(AgentV2.ID.make("build"), (agent) => { + agent.system = "Build agent instructions" + agent.mode = "primary" + }) + editor.update(AgentV2.ID.make("reviewer"), (agent) => { + agent.system = "Reviewer instructions" + agent.mode = "primary" + }) + }) + yield* db + .update(SessionTable) + .set({ agent: "build" }) + .where(eq(SessionTable.id, sessionID)) + .run() + .pipe(Effect.orDie) + const session = yield* SessionV2.Service + // Mid-turn gate: the build drain is streaming when the switch lands. + const gate = yield* Deferred.make() + streamGate = gate + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + response = fragmentFixture("text", "text-build", ["Build answer"]).completeEvents + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + while (requests.length < 1) yield* Effect.yieldNow + + // TUI Tab-equivalent, happening while the drain is mid-stream. + yield* session.switchAgent({ sessionID, agent: AgentV2.ID.make("reviewer") }) + expect(yield* session.get(sessionID)).toMatchObject({ agent: "reviewer" }) + + streamGate = undefined + yield* Deferred.succeed(gate, undefined) + yield* Fiber.await(run) + + // First message after the switch, processed by the same drain. + requests.length = 0 + response = fragmentFixture("text", "text-reviewer-first", ["Reviewing"]).completeEvents + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First message after switch" }) }) + yield* session.resume(sessionID) + + expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([ + "Reviewer instructions", + "Initial context", + ]) + }), + ) + it.effect("updates selected-agent skill guidance after an agent switch", () => Effect.gen(function* () { yield* setup diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 22b1d7d99a2a..99c4b583c2f2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -633,14 +633,19 @@ const layer = Layer.effect( }) const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) { - const agentName = input.agent - const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo() + const current = yield* sessions.get(input.sessionID).pipe(Effect.orDie) + const agentName = input.agent ?? current.agent + let ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo() if (!ag) { - const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) - const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` }) - yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) - throw error + if (input.agent) { + const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + throw error + } + // session.agent refers to a removed/nonexistent agent — degrade to the default instead of failing. + ag = yield* agents.defaultInfo() } const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID)) @@ -669,7 +674,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 || @@ -1167,13 +1171,27 @@ const layer = Layer.effect( continue } - const agent = yield* agents.get(lastUser.agent) + // The turn's agent comes from the live session selection (durable + // switchAgent / Tab), not the stamp on the last user message, which + // may predate the switch. Re-read the session so a switch landing + // mid-loop is observed; fall back to the message's agent only when + // the session has none set. + const current = yield* sessions.get(sessionID).pipe(Effect.orDie) + let agent = current.agent ? yield* agents.get(current.agent) : undefined + if (!agent && lastUser.agent !== current.agent) { + agent = yield* agents.get(lastUser.agent) + } if (!agent) { - const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) - const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` }) - yield* events.publish(Session.Event.Error, { sessionID, error: error.toObject() }) - throw error + if (current.agent) { + // current.agent refers to a removed/nonexistent agent — degrade to the default instead of failing. + agent = yield* agents.defaultInfo() + } else { + const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` }) + yield* events.publish(Session.Event.Error, { sessionID, error: error.toObject() }) + throw error + } } const maxSteps = agent.steps ?? Infinity const isLastStep = step >= maxSteps diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 5a0176abc9b0..151454e1b5d2 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -6,7 +6,8 @@ import { SessionProjector } from "@opencode-ai/core/session/projector" import { eq } from "drizzle-orm" import { EventV2Bridge } from "@/event-v2-bridge" import { expect } from "bun:test" -import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" +import { Cause, DateTime, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" +import { SessionEvent } from "@opencode-ai/core/session/event" import path from "path" import { fileURLToPath } from "url" import { NamedError } from "@opencode-ai/core/util/error" @@ -27,6 +28,7 @@ import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" import { Session } from "@/session/session" import { SessionMessageTable } from "@opencode-ai/core/session/sql" +import { SessionMessage } from "@opencode-ai/core/session/message" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -2440,3 +2442,124 @@ noLLMServer.instance( }), 30_000, ) + +// Regressions for issue anomalyco/opencode#41387: after switching the session's +// selected agent, the FIRST user message must be processed with the NEW agent's +// system prompt, not the previously-selected one. +// +// The TUI switch (Tab) updates the session's selected agent (session.agent), +// then the next typed message is processed. MessageV2's user message carries the +// agent that was in effect when the message was created, and the loop builds the +// system prompt from that user message's agent. If createUserMessage ignores the +// switched session.agent (falling back to the default agent), the first message +// after the switch is sent to the model with the OLD/default agent's system +// prompt even though the session is now on the new agent. +const switchPromptConfig = (url: string) => ({ + ...providerCfg(url), + default_agent: "alpha", + agent: { + alpha: { + model: "test/test-model", + prompt: "ALPHA-ONLY-SYSTEM-PROMPT", + }, + beta: { + model: "test/test-model", + prompt: "BETA-ONLY-SYSTEM-PROMPT", + }, + }, +}) + +it.instance( + "first user message after switching session agent uses the new agent's system prompt", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(switchPromptConfig) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Agent switch" }) + + // Tab-equivalent: switch the session's selected agent to "beta". + yield* sessions.setAgentModel({ + sessionID: chat.id, + agent: "beta", + model: { providerID: ref.providerID, id: ref.modelID }, + time: Date.now(), + }) + expect(yield* sessions.get(chat.id)).toMatchObject({ agent: "beta" }) + + // First user message after the switch. No explicit agent is provided, so + // the message should inherit the session's selected agent ("beta"). + yield* prompt.prompt({ + sessionID: chat.id, + noReply: true, + parts: [{ type: "text", text: "hello from beta" }], + }) + yield* llm.text("received") + + yield* prompt.loop({ sessionID: chat.id }) + + const hits = yield* llm.hits + const messages = hits[0]?.body.messages + expect(Array.isArray(messages)).toBe(true) + if (!Array.isArray(messages)) return + const system = messages.filter((m: { role?: string }) => m.role === "system") + const body = JSON.stringify(system) + expect(body).toContain("BETA-ONLY-SYSTEM-PROMPT") + expect(body).not.toContain("ALPHA-ONLY-SYSTEM-PROMPT") + }), + 30_000, +) + +it.instance( + "loop builds the first turn's system prompt from the session's selected agent after a durable switchAgent", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(switchPromptConfig) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const events = yield* EventV2Bridge.Service + const chat = yield* sessions.create({ title: "Stale loop agent" }) + yield* sessions.setAgentModel({ + sessionID: chat.id, + agent: "alpha", + model: { providerID: ref.providerID, id: ref.modelID }, + time: Date.now(), + }) + expect(yield* sessions.get(chat.id)).toMatchObject({ agent: "alpha" }) + + // Author a user message with no explicit agent under the selected alpha. + // createUserMessage inherits session.agent = alpha, so the message carries + // the alpha agent. + yield* prompt.prompt({ + sessionID: chat.id, + noReply: true, + parts: [{ type: "text", text: "first (alpha)" }], + }) + + // Tab-equivalent durable switch (AgentSwitched -> projector updates + // SessionTable.agent), same event the V2 switchAgent endpoint publishes. + yield* events.publish(SessionEvent.AgentSwitched, { + sessionID: chat.id, + messageID: SessionMessage.ID.create(), + timestamp: yield* DateTime.now, + agent: "beta", + }) + expect(yield* sessions.get(chat.id)).toMatchObject({ agent: "beta" }) + + // The first (and only) turn after the switch must run with beta's system + // prompt, but the loop derives the agent from the previously-authored + // message (alpha) rather than the session's current agent. + yield* llm.text("received") + yield* prompt.loop({ sessionID: chat.id }) + + const hits = yield* llm.hits + const messages = hits[0]?.body.messages + expect(Array.isArray(messages)).toBe(true) + if (!Array.isArray(messages)) return + const system = messages.filter((m: { role?: string }) => m.role === "system") + const body = JSON.stringify(system) + expect(body).toContain("BETA-ONLY-SYSTEM-PROMPT") + expect(body).not.toContain("ALPHA-ONLY-SYSTEM-PROMPT") + }), + 30_000, +)