Skip to content

Switching the composer agent while a turn is running reconfigures the running turn (plan turn gains write access) #59289

Open

Description

@houman-moallemi

Description

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

  1. Set the composer agent to plan and submit a prompt (turn starts, runs for a while).
  2. While that turn is still running, switch the composer to build and submit the next prompt.
  3. 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.

Related

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.

Activity

  1. opencode-agent commented on Oct 26, 2026

    @opencode-agent
    Contributor

    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:

    1. Switch the composer to Plan (Shift+Tab) and submit a prompt. The model's first step takes a few seconds and then calls read.
    2. While that turn is still running, switch to Build and submit a second prompt.
    3. 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

    Screenshot

    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"
    
    const bodies: unknown[] = []
    const out = process.env.REPRO_OUT ?? "/workspace/repro/requests.json"
    
    export default OpenCodeDriver.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* () {
          let step = 0
          yield* llm.serve((request) => {
            const body = request.body as { tools?: unknown[] }
            // Title generation requests carry no tools; answer them without counting.
            if (!body.tools) return Stream.make(Llm.text("Title"))
            const index = 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.
              return Stream.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.
              return Stream.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"),
              )
            return Stream.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.

  2. houman-moallemi commented on Oct 26, 2026

    @houman-moallemi
    Author

    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

    "permissions": [{ "action": "*", "resource": "*", "effect": "allow" }]

    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.

  3. MorningStar0709 commented on Oct 30, 2026

    @MorningStar0709

    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.

  4. MorningStar0709 commented on Oct 30, 2026

    @MorningStar0709

    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-ops
    select count(*) 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 null
        and json_extract(data,'$.agent') = json_extract(data,'$.previous'))
    select count(*),
      sum(case when exists (
        select 1 from session_message m2
        where m2.session_id = noops.session_id
          and json_extract(m2.data,'$.text') is not null
          and json_extract(m2.data,'$.text') not like '<system-reminder>%'
          and m2.time_created between noops.time_created - 200
                                    and noops.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 = 1 and last_real is not null
    group by bucket order by 2 desc;
    -- <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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions