Skip to content

fix(core): apply prompt agent selection at the delivery boundary - #59290

Open
houman-moallemi wants to merge 4 commits into
anomalyco:devfrom
houman-moallemi:prompt-agent-selection
Open

houman-moallemi wants to merge 4 commits into
anomalyco:devfrom
houman-moallemi:prompt-agent-selection

Conversation

@houman-moallemi

@houman-moallemi houman-moallemi commented Oct 26, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #59289

Type of change

  • Bug fix

What does this PR do?

Submitting a prompt while a turn is running called session.switchAgent eagerly. That publishes agent.selected immediately, the projector persists it, and the runner re-reads the agent at every step boundary — so the running turn got reconfigured mid-flight: new permissions, new tool snapshot, and the plan plugin told the model it was no longer restricted. Reproduced on 2.1.4 (V2, dev): a plan turn kept executing after the composer was flipped to build and the next prompt submitted, and it gained write access.

The fix carries the agent selection on the admitted prompt instead of applying it out of band. Admission queues a prompt whose agent differs from the session's, so an agent change can only ever land at a turn boundary. Promotion publishes agent.selected (with previous) right before the message becomes visible, and the runner re-takes its context selection when the persisted agent actually changed. The TUI and app composers pass the selection on the prompt rather than calling switchAgent first. Model switching behavior is untouched.

How did you verify your code works?

  • Two new runner scenarios: an agent-changing prompt queues while busy, the running turn keeps its agent, and the switch lands at the delivery boundary (agent.selected + agent-switched message); a selection matching the session agent still steers. Both fail on the pre-fix core (verified by stashing the source changes).
  • Admission tests: differing agent queues, matching agent steers.
  • Updated TUI transport and app composer tests for the new call pattern; schema contract suite passes; typecheck clean on schema, protocol, server, core, client, tui, app.

Visual

Optional, for user-facing changes: a small looping animated SVG (240×72) of the change, shown in the desktop app's "What's new in beta". Replace the block below or delete this section.

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" width="240" height="72" role="img" aria-label="Composer switches to build but the running turn stays plan; the queued prompt starts the next turn as build">
  <style>
    .t { font: 11px ui-sans-serif, system-ui, sans-serif; fill: currentColor; }
    .m { font: 9px ui-sans-serif, system-ui, sans-serif; fill: currentColor; opacity: .55; }
    .box { fill: none; stroke: currentColor; stroke-width: 1; opacity: .45; }
    .dash { stroke: currentColor; stroke-width: 1; stroke-dasharray: 3 4; opacity: .3; }
    .pillPlan { opacity: 0; animation: pout 4s linear infinite; }
    .pillBuild { animation: pin 4s linear infinite; }
    .nextPlan { opacity: 0; animation: pout 4s linear infinite; animation-delay: -0.06s; }
    .nextBuild { animation: pin 4s linear infinite; animation-delay: -0.06s; }
    .dot { animation: ride 4s linear infinite; }
    .live { animation: pulse 1.2s ease-in-out infinite; }
    .live.d2 { animation-delay: .2s } .live.d3 { animation-delay: .4s }
    @keyframes pout { 0%, 28% { opacity: 1 } 34%, 100% { opacity: 0 } }
    @keyframes pin { 0%, 28% { opacity: 0 } 34%, 100% { opacity: 1 } }
    @keyframes ride {
      0%, 34% { transform: translate(0 0); opacity: 0 }
      38% { opacity: .9 }
      72% { transform: translate(-52px 0); opacity: .9 }
      76%, 100% { transform: translate(-64px 0); opacity: 0 }
    }
    @keyframes pulse { 0%, 100% { opacity: .25 } 50% { opacity: .95 } }
    @media (prefers-reduced-motion: reduce) { .pillPlan, .nextPlan, .dot, .live { animation: none } }
  </style>
  <rect x="6" y="10" width="96" height="24" rx="7" class="box" stroke-dasharray="4 3"/>
  <text x="24" y="26" class="t">plan</text>
  <circle cx="66" cy="22" r="1.8" class="live" fill="currentColor" stroke="none"/>
  <circle cx="72" cy="22" r="1.8" class="live d2" fill="currentColor" stroke="none"/>
  <circle cx="78" cy="22" r="1.8" class="live d3" fill="currentColor" stroke="none"/>
  <text x="6" y="48" class="m">running turn — stays plan, permissions intact</text>
  <line x1="118" y1="10" x2="118" y2="62" class="dash"/>
  <rect x="134" y="10" width="100" height="24" rx="7" class="box"/>
  <text x="146" y="26" class="t pillPlan">plan</text>
  <text x="146" y="26" class="t pillBuild">build</text>
  <rect x="134" y="42" width="100" height="24" rx="7" class="box" stroke-dasharray="4 3"/>
  <text x="142" y="58" class="m nextPlan">queued as plan</text>
  <text x="146" y="58" class="t nextBuild">build</text>
  <circle cx="210" cy="34" r="3.2" fill="currentColor" stroke="none" class="dot"/>
</svg>

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #48356, #58287, #59219, #57013

Public API: adds optional agent to session.prompt (HTTP, SDKs) and SessionInbox.UserPayload, and optional source: "delivery" to the session.agent.selected event (backwards compatible)

@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label Oct 26, 2026

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core part looks right: a prompt that switches agent is queued, and agent.selected is published when it is delivered, so the running turn keeps its agent. I ran the changed core, app and TUI transport tests (all pass) but didn't reproduce the bug end to end in the TUI. Type checks couldn't run here because bun check is missing, on the base too.

Public API: POST /api/session/:sessionID/prompt (session.prompt in both SDKs) gets a new optional agent field, and SessionInbox.UserPayload (the stored inbox payload) gets an optional agent. Both are additive and backwards compatible. Callers that don't send agent behave as before, and switchAgent is unchanged.

Concerns, most important first (details inline):

  • App drops the model choice. In packages/app/src/composer/submit.ts, when the agent differs the whole applySelection is skipped, including switchModel. That happens in idle sessions too. The updated test now expects ["admit", "send"] with no model switch even though the model changes, so the test encodes the regression.
  • The queue can be bypassed. Promotion publishes agent.selected for any delivered user item, steered ones included. If a user moves the queued prompt to steer (steerInbox, the existing move-to-steer UI), the agent switch lands mid-turn again. Either keep agent-changing items in the queue or apply the switch only on queue promotion.
  • Sessions with no stored agent. When session.agent is undefined (for example sessions created through the SDK and prompted without an agent), every steer that carries the default agent is forced into the queue.
  • Other TUI paths still switch straight away. Slash commands and blank Enter in packages/tui/src/component/prompt/index.tsx still call commitSelection() → switchAgent, so a command sent while busy still reconfigures the running turn.
  • Overlap with #54779. It adds agent (and model) to prompt admission too, with different steer behaviour. The two will conflict, so one approach should be settled on.

Related: #48356, #59219, #57013

}

const commit = () => (value.delivery === "steer" ? applySelection(session, value.selection, track, settle) : settle())
const agentChanged = session.current()?.agent !== value.selection.agent

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the agent differs, this skips applySelection entirely, so the model and variant the user picked are never sent (switchModel doesn't run). That happens for idle sessions too, not only busy ones. The updated test (calls: ["admit", "send"] while the model goes from old to the selection's model) now expects this. Could the model switch still run here and only the agent part move onto the prompt?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 663ae8d: applySelection now takes an applyAgent flag, so a steered prompt that changes the agent still runs the revert settle and the model switch — only the agent part rides the prompt payload. Queue delivery keeps the settle-only behavior it had before.

Updated the tests accordingly: the agent-differs cases now expect switch-model again. The retry case surfaced one more detail — the auto-retry reuses the first commit (committed ??= commit()), so a retried admission makes no second model request.

Comment thread packages/core/src/session/inbox.ts Outdated
return Effect.gen(function* () {
// Promotion is a turn boundary, the only safe point to apply a prompt's agent
// selection: the projector persists it before the message becomes visible.
if (row.type === "user") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs for every promoted user row, including steers. Forcing delivery: "queue" at admission isn't enough on its own: Session.steerInbox (the existing move-to-steer action in the app and TUI) can turn the queued item into a steer. It then promotes at the next step boundary of the running turn and publishes agent.selected mid-turn, which is the bug this fixes. Consider applying the agent only when a queued item is promoted, or refusing or ignoring the agent change for steered items.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 663ae8d, in two layers:

  • SessionInbox.steer now refuses user rows whose payload agent differs from the stored session agent — the item stays queued instead of becoming a steer.
  • Promotion applies the payload agent only for rows with delivery === "queue", so a steered row can never flip the agent of a running turn (this also closes the flip-back case where an older steer delivers in the same promote window after a queued row already flipped the agent).

Added a test: queueing an agent-changing prompt and calling steerInbox leaves it queued with no InboxDeliveryChanged event.

Comment thread packages/core/src/session/session.ts Outdated
// change reconfigures permissions and tools, which is only safe at a turn boundary.
// Queueing it defers the selection to the idle boundary where promotion applies it.
const request =
input.agent !== undefined && input.agent !== session.agent ? { ...input, delivery: "queue" as const } : input

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When session.agent is undefined (a session created and prompted without an agent, for example through the SDK), any prompt that carries an agent, including the default one the TUI and app always send, counts as an agent change and is forced into the queue, so steering stops working for that session. Should this compare against the resolved default agent rather than the raw stored value?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 663ae8d: prompts into sessions without a stored agent now compare against the configured default (Agent.Service.select(), resolved inside the session's location services so config reloads are honored) rather than the raw stored value. Carrying the default agent still steers; a real change still queues.

Filed a follow-up for the remaining eager paths (slash commands, blank Enter): to be linked once opened.

@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit fixes all three earlier points: steered prompts switch the model again, an agent-changing prompt can't be moved to steer, and sessions without a stored agent compare against the default. I ran the reproduction script from #59289 in the TUI with a simulated model. On the base, the agent switches inside the running Plan turn. With this PR, the Plan turn finishes as Plan and the switch starts the next turn as Build. Before/after screenshots below.

Public API is unchanged since my last review: only the optional agent field on session.prompt and SessionInbox.UserPayload, which is backwards compatible.

New problem, seen in the same run: after the switch, the Build turn is followed by an extra model request whose input is only two copies of the "You are NO LONGER in Plan mode" reminder (step 5 in the after screenshot). Base sends the reminder once and makes no extra request. Details inline.

Also inline: moving an agent-changing prompt to steer now succeeds without moving anything.

Before

After

// selection: the projector persists it before the message becomes visible. Only
// queued rows apply it — a steered row delivers into the running turn, whose
// agent must not change mid-flight.
if (row.type === "user" && row.delivery === "queue") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With agent.selected now published during promotion, right before the runner's next step, the plan plugin adds its leave reminder twice. Its session.agent.selected subscriber sends one through session.synthetic, but asynchronously. Meanwhile its context hook runs for the new step, doesn't see that reminder yet, and adds a second one. Both arrive as steers after the step, so the runner makes another model request with only the two reminders. In the #59289 reproduction: base sends the reminder once (requests 3, counts [0,1,1]); this PR sends 5 requests with counts [0,0,0,1,2], and the last one is that extra step. Since every agent switch in the TUI and app now goes through this path, I'd expect it on every switch, not only while busy (I only ran the busy case). The switch and its reminder should land together with the prompt, for example by publishing the selection before the runner prepares the turn, or by letting the plugin see the pending switch.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4a29b3a: promotion's AgentSelected now carries source: "delivery", and the plan plugin's event subscriber skips those — the switch lands at a turn boundary where the context hook owns the reminder anyway, so it injects synchronously into the new turn's request (one copy, no extra steer, no follow-up request). Explicit switches keep the subscriber-driven reminder as before.

Regression test in test/plugin/plan.test.ts: a delivery-time switch persists nothing from the subscriber, and the context hook then injects the leave reminder exactly once.

Comment thread packages/core/src/session/inbox.ts Outdated
.pipe(Effect.orDie)
// An agent-changing prompt must stay queued: promoting it as a steer would
// apply the selection mid-turn and reconfigure the running turn.
if (payload.agent !== undefined && payload.agent !== (session?.agent ?? undefined)) return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This returns success without moving anything. Session.steerInbox then wakes the runner and the HTTP call succeeds, but the item stays queued and no InboxDeliveryChanged event is published, so a client that moves it optimistically gets no error to undo. A typed failure, like the one for an already-steered item, would let the app and TUI show why. This check also compares against session?.agent rather than the default agent that Session.prompt now uses, so for sessions with no stored agent a queued prompt carrying the default agent can't be moved to steer.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4a29b3a: the refusal moved into Session.steerInbox, which now fails with the same InboxConflictError an already-steered item gets, so app and TUI can show why and undo an optimistic move. The comparison also uses the same effective agent Session.prompt uses (the configured default, resolved through the session's location services), so a session without a stored agent can steer prompts carrying the default agent again.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new push only rebases the branch onto the current dev. Both commits are unchanged, so nothing new needs review, and the two open threads (the duplicate Plan reminder and the silent steer refusal) still apply. I re-ran the #59289 reproduction on the new head. The running Plan turn still keeps its agent. The extra model request with two copies of the "NO LONGER in Plan mode" reminder still appears after the switch.

The public API hasn't changed since my last review: an optional agent field on session.prompt and SessionInbox.UserPayload, which is backwards compatible.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commits fix the steer refusal (thread 5): steerInbox now fails with InboxConflictError and compares against the default agent. They also fix the doubled reminder from thread 4, but not the extra model request. I re-ran the #59289 reproduction in the TUI: the Plan turn still keeps its agent, and each request now carries the leave reminder at most once (counts [0,0,0,1,1], previously [0,0,0,1,2]). After the Build turn answers, though, one more request still runs whose only new input is the reminder ("Step 5 done." in the screenshot). Details inline.

Public API: the session.agent.selected event gets a new optional source: "delivery" field. Together with the optional agent on session.prompt and SessionInbox.UserPayload, all of this is additive and backwards compatible.

The push also rebases onto the latest dev, which pulls in two unrelated upstream commits (V2_HTTP_API_AUDIT.md deleted, install edited). Those come from dev, not from this PR.

Before

After

}
// A delivery-time switch lands at a turn boundary: the context hook below owns
// its reminder synchronously, and a synthetic here would race it and double.
if (event.data.source === "delivery") return undefined

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes the doubled reminder, but the extra model request is still there. The context hook adds the reminder to the request in memory, then saves it with session.synthetic(..., resume: false) while the step is running. That saved message is still pending when the turn ends, so the runner starts another step for it. In the #59289 reproduction the 5th request ends with Next: implement it → Step 4 done. → the leave reminder, and the model answers again. #58287 reports the same hook behaviour for default_agent: plan, and #58395 is trying to fix it there. Because every TUI and app agent switch now goes through this path, each switch costs one unrequested request. Saving the reminder before the delivered prompt (as part of promotion) instead of from the context hook would avoid the extra turn.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c9fb3c0: the context hook no longer persists the reconciled reminder — it only splices it into the current request. The hook re-derives the reminder on every request by design (that is how it already repairs compaction/revert stripping), so the model still sees it exactly once per request. Nothing is left pending when the turn ends, so the runner goes idle instead of starting the reminder-only step. Explicit-switch reminders from the event subscriber are unchanged, and tests now assert in-memory reconciliation without persistence.

Houman Moallemi added 4 commits October 29, 2026 13:03
Submitting a prompt while a turn runs eagerly called session.switchAgent,
which reconfigured the running turn mid-flight: the composer's new agent
replaced the running agent's permissions, tools, and system prompt, and the
plan plugin told the model its restrictions were lifted.

Prompts now carry their agent selection through admission. A selection that
differs from the session's agent is queued at admission, applied by the
promotion projector at the turn boundary, and the runner re-takes its
context selection when the persisted agent changed. Same-selection prompts
keep steering. The TUI and app composers pass the selection on the prompt
instead of switching eagerly.
- applySelection gains an applyAgent mode: steered prompts that change the
  agent still switch the model; only the agent defers to the prompt
- admission.steer refuses agent-differing user rows so move-to-steer cannot
  promote an agent change mid-turn
- promotion applies the payload agent only for queued rows, so steered rows
  can never flip the agent of a running turn
- sessions without a stored agent compare against the configured default
  agent, so SDK sessions still steer prompts carrying it
- test fixtures provide the Agent service where prompt admission resolves it
- promotion's AgentSelected carries source: "delivery"; the plan plugin's
  event subscriber skips it — the context hook owns the mode reminder at a
  turn boundary, so the switch no longer steers an extra request holding
  two reminder copies
- steerInbox fails with InboxConflictError instead of silently refusing:
  clients can undo an optimistic move, and the refusal compares the same
  effective default (configured default agent for sessions without a
  stored one) that prompt admission uses
The context hook persisted each reconciled reminder as a synthetic. A
reminder saved while the turn runs is still pending when the turn ends,
so the runner starts one more step whose only input is the reminder.
The hook re-derives the reminder on every request anyway, so it now only
splices it into the current request; the session stays idle afterwards.
Switch-persisted reminders from explicit switches are unchanged.
@opencodejr opencodejr Bot mentioned this pull request Nov 4, 2026
1 task done

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant