diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index d28ac910ac..d88d8105cf 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -3069,6 +3069,44 @@ describe("ClaudeAgentSession context window usage", () => { } }); + test("reports the plan mode Claude enters on its own with EnterPlanMode", async () => { + // Claude Code announces a mode it switched to itself as a status message + // carrying the new permissionMode, right after the EnterPlanMode tool call. + const client = new ClaudeAgentClient({ + logger, + queryFactory: createQueryFactoryForTurns([ + [ + { ...createInitMessage(), permissionMode: "acceptEdits" }, + { + type: "system", + subtype: "status", + status: null, + permissionMode: "plan", + session_id: "session-1", + }, + createSuccessResult(), + ], + ]), + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + modeId: "acceptEdits", + }); + + try { + const events = await collectStreamEvents(session, "enter plan mode"); + + expect(events).toContainEqual( + expect.objectContaining({ type: "mode_changed", currentModeId: "plan" }), + ); + expect(await session.getCurrentMode()).toBe("plan"); + } finally { + await session.close(); + } + }); + test("a compaction abandoned mid-turn does not suppress the next compaction marker", async () => { // The first turn starts compacting and then ends without ever reaching a // compact_boundary, so the marker it opened is never resolved. diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 10048356b1..7b16c030a4 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -4321,6 +4321,19 @@ class ClaudeAgentSession implements AgentSession { return; } if (message.subtype === "status") { + // Claude Code reports a mode it switched to on its own, such as entering + // plan mode with the EnterPlanMode tool, through the status message. + if ( + isPermissionMode(message.permissionMode) && + this.observePermissionMode(message.permissionMode) + ) { + events.push({ + type: "mode_changed", + provider: "claude", + currentModeId: this.currentMode, + availableModes: this.availableModes, + }); + } const status = toObjectRecord(message)?.status; if (status === "compacting") { this.compacting = true; @@ -4632,10 +4645,7 @@ class ClaudeAgentSession implements AgentSession { notice = this.createClaudeSessionChangedNotice(existingSessionId, newSessionId); } this.availableModes = DEFAULT_MODES; - this.currentMode = message.permissionMode; - if (this.currentMode !== "plan") { - this.planResumeMode = this.currentMode; - } + this.observePermissionMode(message.permissionMode); this.persistence = null; if (message.model) { const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model); @@ -4654,6 +4664,16 @@ class ClaudeAgentSession implements AgentSession { return { threadStartedSessionId, notice }; } + /** Records a mode Claude Code reports, returning whether it differs from the current one. */ + private observePermissionMode(mode: PermissionMode): boolean { + const changed = this.currentMode !== mode; + this.currentMode = mode; + if (mode !== "plan") { + this.planResumeMode = mode; + } + return changed; + } + private readMissingResumedConversationError(message: SDKMessage): string | null { if (message.type !== "result" || message.subtype !== "error_during_execution") { return null;