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 }),