Summary
SessionReminders.apply's legacy (non-experimentalPlanMode) branch decides whether to inject the "operational mode has changed from plan to build" reminder using:
https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/reminders.ts#L37
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
if (wasPlan && input.agent.name === "build") {
// ...push BUILD_SWITCH synthetic reminder
}
.some() here checks "has this session ever contained a plan-agent message", not "was the immediately preceding turn plan". input.messages comes from MessageV2.filterCompactedEffect(sessionID) (see prompt.ts around the main agent loop), which is the full uncompacted session history, not a per-turn window.
Why this matters for custom agents
opencode explicitly supports custom primary agents (https://opencode.ai/docs/agents), which can be switched to via Tab like build/plan. With only the two built-in agents, "session ever had a plan message" and "previous turn was plan" are almost always equivalent in practice, so this has presumably gone unnoticed.
Once a third primary agent is in rotation — e.g. a custom read-only advisor agent (config agent: { ask: { mode: "primary", permission: { edit: "deny" } } }) — the assumption breaks:
- User is in
plan, does some read-only exploration.
- User switches to a custom agent (e.g.
ask) for a few turns of Q&A, unrelated to planning.
- User switches to
build.
At step 3, wasPlan is still true (the plan message from step 1 is still in the uncompacted history), so BUILD_SWITCH fires — even though the actual previous turn's agent was ask, not plan. The model receives:
<system-reminder>
Your operational mode has changed from plan to build.
...
</system-reminder>
which is factually wrong for that turn (it changed from the custom agent to build, not from plan). Worse: because the check scans the entire uncompacted history rather than being edge-triggered, this reminder can re-fire on every subsequent build turn for the rest of the session (until compaction eventually drops the old plan-agent message out of the window), regardless of how many other agents were used in between. A reminder that's supposed to be a rare, high-signal "your mode just changed" notice instead becomes noisy and occasionally false, which risks the model learning to discount it.
Reproduction
- Define a custom primary agent, e.g.:
---
name: ask
mode: primary
permission:
edit: deny
---
You are a read-only advisor.
- In a session: switch to
plan, send one message. Switch to ask, send one or more messages. Switch to build, send a message.
- Inspect the synthetic parts on that last user message (or just observe the model's response) — the
BUILD_SWITCH reminder appears and claims the transition was "from plan to build", despite the immediately preceding turn being ask.
Confirmed against a live 1.18.3 session with exactly this custom-agent setup (transcript inspected via opencode db / sqlite).
Suggested fix
Mirror the pattern already used a few lines below in the same file for the experimentalPlanMode branch (assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant"), then check assistantMessage?.info.agent === "plan"), which is edge-triggered and doesn't have this issue. I've opened a PR with this fix: (linked below).
Environment
- opencode 1.18.3 (macOS, Homebrew install)
- Reproduced conceptually against
dev branch source (same logic present, reminders.ts unchanged between the two as of this writing)
Summary
SessionReminders.apply's legacy (non-experimentalPlanMode) branch decides whether to inject the "operational mode has changed from plan to build" reminder using:https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/reminders.ts#L37
.some()here checks "has this session ever contained a plan-agent message", not "was the immediately preceding turn plan".input.messagescomes fromMessageV2.filterCompactedEffect(sessionID)(seeprompt.tsaround the main agent loop), which is the full uncompacted session history, not a per-turn window.Why this matters for custom agents
opencode explicitly supports custom primary agents (https://opencode.ai/docs/agents), which can be switched to via Tab like
build/plan. With only the two built-in agents, "session ever had a plan message" and "previous turn was plan" are almost always equivalent in practice, so this has presumably gone unnoticed.Once a third primary agent is in rotation — e.g. a custom read-only advisor agent (config
agent: { ask: { mode: "primary", permission: { edit: "deny" } } }) — the assumption breaks:plan, does some read-only exploration.ask) for a few turns of Q&A, unrelated to planning.build.At step 3,
wasPlanis stilltrue(theplanmessage from step 1 is still in the uncompacted history), soBUILD_SWITCHfires — even though the actual previous turn's agent wasask, notplan. The model receives:which is factually wrong for that turn (it changed from the custom agent to build, not from plan). Worse: because the check scans the entire uncompacted history rather than being edge-triggered, this reminder can re-fire on every subsequent build turn for the rest of the session (until compaction eventually drops the old plan-agent message out of the window), regardless of how many other agents were used in between. A reminder that's supposed to be a rare, high-signal "your mode just changed" notice instead becomes noisy and occasionally false, which risks the model learning to discount it.
Reproduction
plan, send one message. Switch toask, send one or more messages. Switch tobuild, send a message.BUILD_SWITCHreminder appears and claims the transition was "from plan to build", despite the immediately preceding turn beingask.Confirmed against a live 1.18.3 session with exactly this custom-agent setup (transcript inspected via
opencode db/ sqlite).Suggested fix
Mirror the pattern already used a few lines below in the same file for the
experimentalPlanModebranch (assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant"), then checkassistantMessage?.info.agent === "plan"), which is edge-triggered and doesn't have this issue. I've opened a PR with this fix: (linked below).Environment
devbranch source (same logic present,reminders.tsunchanged between the two as of this writing)