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
30 changes: 26 additions & 4 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -643,15 +654,27 @@ 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
? 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)
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(),
Expand All @@ -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 ||
Expand Down
255 changes: 253 additions & 2 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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: [] })

Expand Down Expand Up @@ -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<RuntimeFlags.Service>
}

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]])
Expand Down Expand Up @@ -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<any, any, never>,
)
const raceNoLLMServer = testEffect(makeHttpNoLLMServer({ processor: "blocking" }))
const withMcpInstructions = testEffect(
makeHttp({
Expand Down Expand Up @@ -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" }],
})
Expand Down Expand Up @@ -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,
)
Loading