Skip to content

fix(plugin): persist the Plan-mode reminder before the first prompt under default_agent - #58395

Open
OrangerXu wants to merge 2 commits into
anomalyco:devfrom
OrangerXu:fix/plan-reminder-ordering
Open

OrangerXu wants to merge 2 commits into
anomalyco:devfrom
OrangerXu:fix/plan-reminder-ordering

Conversation

@OrangerXu

@OrangerXu OrangerXu commented Oct 23, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #58287

Type of change

  • Bug fix

What does this PR do?

default_agent resolves silently inside context selection, so a session created without an explicit agent never emits an agent-switch event. The plan plugin's context reconcile then fires for the first time mid-generation: its synthetic reminder is admitted while the first turn runs, the inbox promotes it as new input when that turn ends, and the model starts a second, unprompted turn. With default_agent: "plan", every single-shot run answered the prompt and then invented a task to work on.

The plan plugin now hooks the prompt path: when the session carries no explicit agent and the configured default resolves to plan, the enter reminder is persisted before the user's message is admitted — the same ordering an explicit --agent plan already gets at session creation. A per-session guard makes it idempotent; the context reconcile stays as the safety net for compaction-stripped reminders. I verified the ordering claim by tracing SessionEvent.Created (agent undefined) vs switchAgent (agent set) through to the plugin's event subscription and the inbox's steer promotion.

How did you verify your code works?

bun test test/plugin/plan.test.ts (14/14) and the plugin + session-runner suites (435/435) pass, with two new cases: the reminder persists on the first prompt for an agent-less session under a plan default and does not re-inject on the second; nothing persists when the session carries an explicit agent. bun run typecheck is clean across ai and core. CI on the PR branch is green.

Screenshots / recordings

Not a UI change.

Checklist

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

Added by the opencode bot (AI-generated).

Related: #57013

…nder default_agent

default_agent resolves silently inside context selection, so a session
created without an explicit agent never emits an agent-switch event. The
context reconcile then first fires mid-generation: its synthetic is
admitted while the first turn runs, the inbox promotes it as new input
when that turn ends, and the model starts a second, unprompted turn -
with default_agent: plan, every single-shot run answered the prompt and
then invented a task to work on (anomalyco#58287).

The plan plugin now hooks the prompt path: when the session carries no
explicit agent and the default resolves to plan, the enter reminder is
persisted before the user's message is admitted - the exact ordering an
explicit --agent plan already gets at session creation. The context
reconcile stays as the safety net for compaction-stripped reminders.

Fixes anomalyco#58287
@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label Oct 23, 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.

Thanks for tracking down the cause. As written, though, this doesn't fix #58287. I ran the issue's repro from source (mock provider, default_agent: "plan", opencode run without --agent). The PR head behaves exactly like the base: the model gets a second request, prompt → PROBE_C → plan reminder, and the reply prints twice.

The reason is that the new prompt hook is never registered, because ctx.session.hook(...) is missing yield* (see the inline comment). When I added yield* locally, the repro made a single request with the reminder before the prompt and printed one reply, both with and without --agent plan. So the approach itself works.

The new tests didn't catch this because the stub session.hook saves the callback as soon as it's called, without running the returned Effect. Please make the stub record the callback only when its Effect runs (e.g. inside Effect.sync), so the test fails without yield*.

Public API: unchanged. This only changes the built-in plan plugin.

Related: #57013

Comment thread packages/core/src/plugin/plan.ts Outdated
// Persisting the reminder before the user's message is admitted gives the
// ordering an explicit `--agent plan` already produces at session creation.
const defaulted = new Set<Session.ID>()
ctx.session.hook("prompt", (event) =>

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 needs yield* ctx.session.hook("prompt", ...). hook returns an Effect<Registration, never, Scope> and registration happens only when that Effect runs (PluginHooks.register), so without yield* the hook is never added. With the PR as-is, the #58287 repro still makes the second, unprompted request. With yield* added it makes a single request and prints one reply.

Comment thread packages/core/test/plugin/plan.test.ts Outdated
get: (input) => Effect.succeed({ ...sessionInfo, id: input.sessionID, agent: currentAgent }),
hook: (name, callback) => {
if (name === "context") contextHook = callback as (input: SessionContext) => Effect.Effect<void>
if (name === "prompt") promptHook = callback as unknown as (input: SessionHooks["prompt"]) => Effect.Effect<void>

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 stub saves the prompt callback as soon as hook is called, without running the returned Effect. That's why these tests pass even though the plugin never registers the hook. Please record the callback inside the returned Effect (e.g. Effect.sync(() => { ...; return { dispose: Effect.void } })), so the new test fails without yield*. The _sessionAgent field and the as unknown as cast in promptEvent can also go, since the hook doesn't read them.

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

Copy link
Copy Markdown
Contributor

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

…lly lands

The prompt hook was registered without yield*, and hook only registers when
its returned Effect runs (PluginHooks.register) - so the hook never landed,
the anomalyco#58287 repro still made the second, unprompted request, and the PR
behaved exactly like the base. The registration is yielded now, and the
test stub records the callback only when the returned Effect runs (matching
real registration), so the new tests fail without the yield* instead of
passing vacuously.
@OrangerXu

Copy link
Copy Markdown
Author

Good catch, and thank you for running the repro both ways — fixed in cb7650c.

  • yield* restored on the prompt-hook registration; the reason it was dropped was an earlier typecheck error whose real cause was the callback returning Effect.void instead of undefined inside Effect.gen (and ctx.session.get's unknown error channel, now orDied). With those fixed the registration typechecks fine with yield*.
  • The test stub now records the callback inside Effect.sync(...) (matching PluginHooks.register semantics), so the new tests fail without the yield* — red/green verified locally: removing the yield* fails the suite, restoring it gives 14/14, plugin+runner suites 435/435, bun run typecheck clean.

@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.

Both earlier points are fixed. The prompt hook is now registered with yield*, and the test stub records the callback only when its Effect runs, so the tests fail without the yield* (I checked). I re-ran the #58287 repro from source with a mock provider and default_agent: "plan". The base still makes the extra prompt → PROBE_C → plan reminder request. With this PR, both opencode run and opencode run --agent plan make a single request, with the reminder before the prompt, and print one reply. plan.test.ts passes 14/14.

Public API: unchanged. This only changes the built-in plan plugin.

No further concerns.

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.

cli: default_agent=plan appends plan-mode reminder as a synthetic turn after the first response

1 participant