fix(claude): show plan mode when Claude enters it on its own (#6074)

When Claude Code calls its EnterPlanMode tool, it reports the new mode
through a system status message with permissionMode "plan". The Claude
provider only read the compacting status from those messages, so the
session kept the previous mode and never emitted mode_changed: the app,
`paseo inspect`, and the stored agent record all kept showing the old
mode.

The provider now records the mode carried by a status message and emits
mode_changed when it differs, sharing the bookkeeping with the init
message so the plan-approval resume mode stays the mode Claude left.

Closes #6063

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-05 03:04:07 +02:00
committed by GitHub
parent fa0f04058d
commit 36e6ccd6c7
2 changed files with 62 additions and 4 deletions
@@ -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.
@@ -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;