You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Switching the composer agent while a turn is running reconfigures the running turn (plan turn gains write access) #59289
Submitting a prompt while a turn is running reconfigures the running turn when the composer's agent differs from the turn's agent. A plan-mode turn that is mid-execution silently gains the new agent's tools, permissions, and system prompt, and the plan plugin tells the model it is no longer restricted.
Repro
Set the composer agent to plan and submit a prompt (turn starts, runs for a while).
While that turn is still running, switch the composer to build and submit the next prompt.
Watch the running plan turn's next steps.
Actual
The still-running plan turn switches to build mid-flight:
build's permission rules and tool snapshot apply to the remaining steps (read-only plan work can write),
the system prompt is re-resolved for build,
the plan plugin injects a "You are NO LONGER in Plan mode" reminder into the running turn.
Expected
The running turn should keep the agent it started with, end to end. The composer's selection should apply to the next prompt's turn — never to a turn that is already executing.
Root cause
Prompt admission does not carry an agent selection. Clients instead call POST /api/session/:sessionID/agent (session.switchAgent) eagerly on submit. Session.switchAgent publishes session.agent.selected unconditionally (no busy check), the projector persists session.agent immediately, and the runner re-reads the persisted agent at every step boundary (prepareContext → SessionContext.select), so the remainder of the in-flight turn runs under the new agent — including its permissions.
PR feat(app): queue and steer follow-up prompts #44683 — app-side queue/steer; its description notes that honoring a different selection per queued prompt "needs per-prompt selection in the admission API (server change, out of scope here)"
Proposed direction
Carry the agent on the admitted prompt and apply it at the delivery/promotion boundary: an admission whose agent differs from the session's is queued instead of steered, the promotion projects agent.selected at the turn boundary, and the runner re-takes its context selection when the persisted agent changed.
Version
Reproduced and diagnosed on opencode v2.1.4. The bug is in the current 2.x "opencode2" (V2) architecture on dev (ed74ec82ce): agent selection is out-of-band session state applied via session.switchAgent and re-read by the runner at every step boundary.
Thanks for the detailed report. I can reproduce this on v2.1.4 and on the latest dev (5bf3e82), using the TUI with a simulated model.
Steps:
Switch the composer to Plan (Shift+Tab) and submit a prompt. The model's first step takes a few seconds and then calls read.
While that turn is still running, switch to Build and submit a second prompt.
The model's next step, still in the same turn, calls patch to create a file.
Result: the second prompt is steered into the running turn and the TUI shows "Switched agent from Plan to Build" in the middle of it. The next model request contains the plan plugin's "You are NO LONGER in Plan mode" reminder, and the patch call succeeds (created.txt is written), even though Plan denies edits. The recording below shows the whole sequence, and the script below reproduces it.
The TUI prompt (prepareAgent) calls session.switchAgent before submitting, just like the app composer you described, so both clients are affected.
Related: #59290 (your proposed fix), #54779 (TUI-side deferral of queued agent/model selection) and #48356 (per-prompt agent/model on queued prompts).
recording.mp4
Reproduction script (drive.ts)
// Repro for anomalyco/opencode#59289: switching the composer agent (plan -> build)// and submitting while a plan turn is running reconfigures the running turn.import{Effect,Stream}from"effect"import{writeFileSync}from"node:fs"import{Llm,OpenCodeDriver}from"opencode-drive"constbodies: unknown[]=[]constout=process.env.REPRO_OUT??"/workspace/repro/requests.json"exportdefaultOpenCodeDriver.use({
...(process.env.OC_DEV ? {opencode: {dev: process.env.OC_DEV}} : {}),project: {git: true,files: {"notes.md": "hello\n"}},tui: {recording: true,keypressOverlay: true},},({ ui, llm })=>Effect.gen(function*(){letstep=0yield*llm.serve((request)=>{constbody=request.bodyas{tools?: unknown[]}// Title generation requests carry no tools; answer them without counting.if(!body.tools)returnStream.make(Llm.text("Title"))constindex=step++bodies.push(request.body)writeFileSync(out,JSON.stringify(bodies,null,2))if(index===0)// First step of the plan turn: think for a while, then call a tool so the turn has a second step.returnStream.make(Llm.text("Planning: reading notes first."),Llm.pause(8000),Llm.toolCall({index: 0,id: "call_read",name: "read",input: {path: "notes.md"}}),Llm.finish("tool-calls"),)if(index===1)// Second step of the same (plan) turn: try to write a file.returnStream.make(Llm.toolCall({index: 0,id: "call_patch",name: "patch",input: {patchText: "*** Begin Patch\n*** Add File: created.txt\n+written during plan turn\n*** End Patch"},}),Llm.finish("tool-calls"),)returnStream.make(Llm.text(`Step ${index+1} done.`))})yield*Effect.sleep(4000)yield*ui.screenshot("start")// Switch the composer to plan.yield*ui.press("tab",{shift: true})yield*ui.waitFor("Plan")yield*ui.submit("Plan the change, do not edit anything")yield*Effect.sleep(3000)yield*ui.screenshot("plan-running")// While the plan turn is still running, switch the composer to build and submit.yield*ui.press("tab",{shift: true})yield*ui.waitFor("Build")yield*ui.submit("Next: implement it")yield*ui.screenshot("switched-to-build")yield*Effect.sleep(12000)yield*Effect.sleep(3000)yield*ui.screenshot("after")}),)
Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.
Thanks for the repro — one correction to the scenario and one important caveat about what the recording actually shows.
Corrected scenario
In my case the second message is submitted while the composer is still on Plan, and the composer is only switched to Build afterwards:
yield* ui.waitFor("Plan")
yield* ui.submit("Plan the change, do not edit anything")
yield* Effect.sleep(3000)
yield* ui.submit("Next: wait 5 seconds with a sleep and then implement it")
yield* ui.press("tab", { shift: true })
yield* ui.waitFor("Build")
yield* Effect.sleep(15000)
Running that: nothing changes server-side. Switching the composer (Shift+Tab) is client-local — it does not call session.switchAgent; the switch only fires when the next thing is submitted (a prompt, a command, or Enter on an empty composer). With nothing submitted after the flip, the turn stays Plan: no banner, no leave reminder.
The switch in your script fires at the submit that follows the flip — same bug, just a simpler path to it. PR #59290 defers that to the delivery boundary.
The recording's write is confounded
patch succeeding in the recording is not evidence of the permission widening. opencode-drive's default config injects
and the core config-agent plugin pushes project-config permissions onto every agent (including plan) after the plan plugin's edit: deny rules. Permission evaluation is last-match-wins, so in any drive recording patch succeeds regardless of the agent — I ran controls on v2.1.4 and current dev where the plan turn calls patch with no agent switch at all and the write still succeeds.
The reliable signals in your repro are the mid-turn "Switched agent from Plan to Build" banner and the "You are NO LONGER in Plan mode" reminder landing in the running turn's next request — those are the bug, and they're what #59290 fixes.
Separately: a project config with an allow-all permissions array defeats the plan agent's deny rules in normal (non-drive) usage too, since config rules are appended after the plugin's deny and last-match wins. That may deserve its own issue.
Extending the trigger surface reported here: the switch does not require the user to touch the composer while a turn runs.
In our client the submit path writes the session agent whenever its local mirror disagrees with the record, and the server accepts that unconditionally — the same unconditional publish described above. The mirror is often stale, so a plain "send" during a running turn can reconfigure it.
Measured over one day of usage (470 agent-switch records): 100 (21%) were no-ops where agent === previous; 55 of those landed within 3 s of a user message, i.e. emitted by submit rather than by an explicit pick. Interval to the previous real switch: 46 under 1 s, but 51 over 10 s — staleness is not a one-round-trip problem.
Each no-op still publishes session.agent.selected and is re-read at the next step boundary, which is what #59290 addresses. If useful I can share the SQL and the timestamps.
Correction first, then the data I should have posted with the first comment.
Correction. I wrote "51 over 10 s" for the gap to the previous real switch. That was an absolute gap; measured as a per-session lag over real switches the figure is 24 (6 in 10–60 s, 10 in 1–10 min, 8 over 10 min). The claim it supported — that mirror staleness is not a one-round-trip problem — still holds, but the number was wrong and this is the right one.
Data. One day of local usage, ~/.local/share/opencode/opencode.db, table session_message. Agent switches are recorded as {agent, previous} in a JSON column; a no-op is agent == previous.
-- 1. how many switches are no-opsselectcount(*) as agent_rows,
sum(case when json_extract(data,'$.agent') = json_extract(data,'$.previous')
then 1 else 0 end) as noop_rows
from session_message
where json_extract(data,'$.previous') is not null;
-- 471 | 101-- 2. how many no-ops land on the same commit as a user message
with noops as (
select session_id, time_created from session_message
where json_extract(data,'$.previous') is not nulland json_extract(data,'$.agent') = json_extract(data,'$.previous'))
selectcount(*),
sum(case when exists (
select1from session_message m2
wherem2.session_id=noops.session_idand json_extract(m2.data,'$.text') is not nulland json_extract(m2.data,'$.text') not like'<system-reminder>%'andm2.time_created between noops.time_created-200andnoops.time_created+3000
) then 1 else 0 end)
from noops;
-- 101 | 56-- 3. lag from the previous real switch in the same session
with rows as (
select session_id, time_created,
lag(case when json_extract(data,'$.agent') = json_extract(data,'$.previous')
then null else time_created end)
over (partition by session_id order by time_created) as last_real,
case when json_extract(data,'$.agent') = json_extract(data,'$.previous')
then 1 else 0 end as noop
from session_message
where json_extract(data,'$.previous') is not null)
select case
when time_created - last_real <1000 then '<1s'
when time_created - last_real <10000 then '1-10s'
when time_created - last_real <60000 then '10-60s'
when time_created - last_real <600000 then '1-10min'
else '>10min' end as bucket,
count(*)
from rows where noop =1and last_real is not nullgroup by bucket order by2desc;
-- <1s 46 | 1-10s 1 | 10-60s 6 | 1-10min 10 | >10min 8-- (71 of the 101 no-ops follow a real switch; the other 30 are the first switch in their session)
What it looks like on the wire — three no-ops, and the user message that was committed right after each (UTC):
no-op written
switch
user message written
delta
03:06:12.223
build → build
03:06:12.281 "开始吧"
+58 ms
03:03:50.974
build → build
03:03:51.034
+60 ms
03:02:50.684
build → build
03:02:50.733
+49 ms
The switch precedes the prompt by ~50–60 ms, which is the client writing the selection on its way to submitting. The record already said build; the mirror did not. Total: 101 of 471 (21.4%) switches change nothing, and each one still publishes session.agent.selected, which the runner re-reads at the next step boundary.
The client-side half — confirm against the record before emitting a switch, and hold picks back while a turn is busy — is fixed here and pending review, but it does not change the server-side property in the title: a switch arriving mid-turn still reconfigures that turn, and with this trigger it can arrive on any submit.
Activity
opencode-agent commentedon Oct 26, 2026
Thanks for the detailed report. I can reproduce this on v2.1.4 and on the latest
dev(5bf3e82), using the TUI with a simulated model.Steps:
read.patchto create a file.Result: the second prompt is steered into the running turn and the TUI shows "Switched agent from Plan to Build" in the middle of it. The next model request contains the plan plugin's "You are NO LONGER in Plan mode" reminder, and the
patchcall succeeds (created.txtis written), even though Plan denies edits. The recording below shows the whole sequence, and the script below reproduces it.The TUI prompt (
prepareAgent) callssession.switchAgentbefore submitting, just like the app composer you described, so both clients are affected.Related: #59290 (your proposed fix), #54779 (TUI-side deferral of queued agent/model selection) and #48356 (per-prompt agent/model on queued prompts).
recording.mp4
Reproduction script (drive.ts)
Run it with opencode-drive:
opencode-drive run ./drive.ts. It drivesopencodefrom yourPATH; setOC_DEVto the path of an OpenCode checkout to run that from source instead.houman-moallemi commentedon Oct 26, 2026
Thanks for the repro — one correction to the scenario and one important caveat about what the recording actually shows.
Corrected scenario
In my case the second message is submitted while the composer is still on Plan, and the composer is only switched to Build afterwards:
Running that: nothing changes server-side. Switching the composer (Shift+Tab) is client-local — it does not call
session.switchAgent; the switch only fires when the next thing is submitted (a prompt, a command, or Enter on an empty composer). With nothing submitted after the flip, the turn stays Plan: no banner, no leave reminder.The switch in your script fires at the submit that follows the flip — same bug, just a simpler path to it. PR #59290 defers that to the delivery boundary.
The recording's write is confounded
patchsucceeding in the recording is not evidence of the permission widening. opencode-drive's default config injectsand the core config-agent plugin pushes project-config permissions onto every agent (including plan) after the plan plugin's
edit: denyrules. Permission evaluation is last-match-wins, so in any drive recordingpatchsucceeds regardless of the agent — I ran controls on v2.1.4 and current dev where the plan turn callspatchwith no agent switch at all and the write still succeeds.The reliable signals in your repro are the mid-turn "Switched agent from Plan to Build" banner and the "You are NO LONGER in Plan mode" reminder landing in the running turn's next request — those are the bug, and they're what #59290 fixes.
Separately: a project config with an allow-all
permissionsarray defeats the plan agent's deny rules in normal (non-drive) usage too, since config rules are appended after the plugin's deny and last-match wins. That may deserve its own issue.MorningStar0709 commentedon Oct 30, 2026
Extending the trigger surface reported here: the switch does not require the user to touch the composer while a turn runs.
In our client the submit path writes the session agent whenever its local mirror disagrees with the record, and the server accepts that unconditionally — the same unconditional publish described above. The mirror is often stale, so a plain "send" during a running turn can reconfigure it.
Measured over one day of usage (470 agent-switch records): 100 (21%) were no-ops where
agent === previous; 55 of those landed within 3 s of a user message, i.e. emitted by submit rather than by an explicit pick. Interval to the previous real switch: 46 under 1 s, but 51 over 10 s — staleness is not a one-round-trip problem.Each no-op still publishes
session.agent.selectedand is re-read at the next step boundary, which is what #59290 addresses. If useful I can share the SQL and the timestamps.MorningStar0709 commentedon Oct 30, 2026
Correction first, then the data I should have posted with the first comment.
Correction. I wrote "51 over 10 s" for the gap to the previous real switch. That was an absolute gap; measured as a per-session lag over real switches the figure is 24 (6 in 10–60 s, 10 in 1–10 min, 8 over 10 min). The claim it supported — that mirror staleness is not a one-round-trip problem — still holds, but the number was wrong and this is the right one.
Data. One day of local usage,
~/.local/share/opencode/opencode.db, tablesession_message. Agent switches are recorded as{agent, previous}in a JSON column; a no-op isagent == previous.What it looks like on the wire — three no-ops, and the user message that was committed right after each (UTC):
The switch precedes the prompt by ~50–60 ms, which is the client writing the selection on its way to submitting. The record already said
build; the mirror did not. Total: 101 of 471 (21.4%) switches change nothing, and each one still publishessession.agent.selected, which the runner re-reads at the next step boundary.The client-side half — confirm against the record before emitting a switch, and hold picks back while a turn is busy — is fixed here and pending review, but it does not change the server-side property in the title: a switch arriving mid-turn still reconfigures that turn, and with this trigger it can arrive on any submit.