From d98bedd8fcdd117a3bb467f01e66204fdfe58717 Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Fri, 5 Jun 2026 11:22:49 +0000 Subject: [PATCH] fix(coding-agent): treat startup metadata as a non-conversation new session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan.defaultOnStartup gate keyed off getEntries().length === 0, but createAgentSession appends startup metadata (model_change / thinking_level_change / service_tier_change) for a brand-new session before InteractiveMode.init() runs. So a genuinely new session reached the guard with entries already present and never entered plan mode (the unit harness bypassed the SDK path, hiding it). Gate on the absence of conversation messages (entry.type === "message") instead. A fresh session — including a --continue/auto-resume that created one — carries only metadata and still honors the default; a session with restored conversation keeps its reconciled mode. Added a regression test seeding model/thinking-level metadata. --- .../src/modes/interactive-mode.ts | 23 +++++++++-------- ...interactive-mode-default-plan-mode.test.ts | 25 +++++++++++++++---- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 4a4e8f387..e78b33ac2 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -611,17 +611,20 @@ export class InteractiveMode implements InteractiveModeContext { await this.#reconcileModeFromSession(); // Brand-new sessions optionally start in plan mode when the user has made - // it the startup default. "Brand-new" is keyed off the session having no - // prior entries — not the CLI resume flags — so `omp --continue` (or - // auto-resume) that finds no recent session and creates a fresh one still - // honors the default, while a session with restored history keeps whatever - // mode #reconcileModeFromSession just reconciled. Scoped to launch (not the - // switch reconciler above) so /new and the plan-approval → execution handoff - // clear never get dragged back into plan mode. #enterPlanMode is idempotent - // and self-guards against an already-active plan/goal mode; it does not check - // plan.enabled itself. + // it the startup default. "Brand-new" means no prior *conversation*: a fresh + // session created via the SDK already carries startup metadata entries + // (model_change / thinking_level_change / service_tier_change appended before + // init), so key off message entries rather than entry count. This way `omp + // --continue` (or auto-resume) that finds no recent session and creates a + // fresh one still honors the default, while a session with restored + // conversation keeps whatever mode #reconcileModeFromSession just reconciled. + // Scoped to launch (not the switch reconciler above) so /new and the + // plan-approval → execution handoff clear never get dragged back into plan + // mode. #enterPlanMode is idempotent and self-guards against an already-active + // plan/goal mode; it does not check plan.enabled itself. + const hasPriorConversation = this.sessionManager.getEntries().some(entry => entry.type === "message"); if ( - this.sessionManager.getEntries().length === 0 && + !hasPriorConversation && this.session.settings.get("plan.defaultOnStartup") && this.session.settings.get("plan.enabled") ) { diff --git a/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts b/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts index e1dbacb7c..66ccc3107 100644 --- a/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts +++ b/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts @@ -116,11 +116,11 @@ describe("InteractiveMode plan.defaultOnStartup", () => { expect(session?.getPlanModeState()).toBeUndefined(); }); - it("does not enter plan mode when the session has restored history", async () => { - // A genuinely resumed session has prior entries; gating on getEntries() - // (not the CLI resume flag) means a `--continue` that created a *fresh*, - // empty session still gets the startup default (the "enters" case above), - // while one with restored history is left in its reconciled mode. + it("does not enter plan mode when the session has restored conversation", async () => { + // A genuinely resumed session has prior conversation messages. Gating on + // message entries (not the CLI resume flag) means a `--continue` that + // created a *fresh* session still gets the startup default (above), while + // one with restored conversation is left in its reconciled mode. const created = createHarness(Settings.isolated({ "plan.defaultOnStartup": true, "compaction.enabled": false })); created.sessionManager.appendMessage({ role: "user", content: "prior turn", timestamp: Date.now() }); @@ -130,6 +130,21 @@ describe("InteractiveMode plan.defaultOnStartup", () => { expect(session?.getPlanModeState()).toBeUndefined(); }); + it("enters plan mode for a fresh session that carries only startup metadata", async () => { + // createAgentSession appends model_change / thinking_level_change for a + // brand-new session before init(); those are not conversation history, so + // the startup default must still apply (regression: gating on entry count + // instead of message entries skipped plan mode for every real new session). + const created = createHarness(Settings.isolated({ "plan.defaultOnStartup": true, "compaction.enabled": false })); + created.sessionManager.appendModelChange("anthropic/claude-sonnet-4-5"); + created.sessionManager.appendThinkingLevelChange("medium"); + + await created.init({ suppressWelcomeIntro: true }); + + expect(created.planModeEnabled).toBe(true); + expect(session?.getPlanModeState()).toMatchObject({ enabled: true }); + }); + it("does not enter plan mode when plan mode is globally disabled", async () => { const created = createHarness( Settings.isolated({ "plan.defaultOnStartup": true, "plan.enabled": false, "compaction.enabled": false }),