Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions packages/core/test/session-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>()
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
Expand Down
46 changes: 32 additions & 14 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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
Expand Down
125 changes: 124 additions & 1 deletion packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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,
)
Loading