Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions docs/design/2026-07-26-manual-plan-exit-notice-delivery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Manual Plan-Exit Notice Delivery

## Problem

Plan mode is reinforced by a recurring reminder on model-bound user turns. When
the approval mode changes outside the approved `exit_plan_mode` flow, merely
stopping that reminder is not a reliable signal that Plan mode ended.

The existing one-shot notice is assembled in `GeminiClient` for UserQuery and
Cron turns. That boundary misses model requests sent through other paths,
including tool-result continuations, steering, hooks, ACP/daemon direct sends,
and interactive agents. A single pending boolean on `Config` also lets one
conversation consume a notice intended for every live conversation sharing the
mode.

## Scope

The guarantee applies to live conversations in the current process. It does not
persist notices across process restarts and does not change approval checks,
plan approval, or tool execution.

Notices are enabled for:

- the main chat created by `GeminiClient.startChat`, including TUI,
non-interactive, ACP, daemon/Web UI, and replacement chats after compression;
- chats created by `AgentCore.createChat` with `interactive: true`.

They remain disabled for fork/speculation, headless agents, workflows, memory
and compaction side queries, and every other `GeminiChat` unless explicitly
enabled.

## State and Ownership

`Config` keeps two independent pieces of in-memory state:

- a mode event `{ version, kind }`, where `kind` is `clear` or `manual-exit`;
- a conversation cursor `{ seenVersion }`.

The event is owned with the approval mode. A `Config` created with
`Object.create(parent)` inherits both the parent's approval mode and current
event. On the first write that creates an own approval mode, it copies the
current event and then becomes isolated from later parent events.

The cursor is always lazily owned by the receiving `Config`. The main
conversation and each interactive agent can therefore claim the same inherited
event independently. Recreating a chat with the same `Config` retains its
cursor and does not deliver the event again.

Mode transitions update the event as follows:

- non-Plan to Plan increments the version and writes `clear`;
- Plan to non-Plan increments the version and writes `manual-exit`, except an
approved `exit_plan_mode` writes `clear`;
- non-Plan to non-Plan does not create an event.

Entering Plan clears an undelivered older exit. A delivery reads the latest
approval mode, so a later non-Plan-to-non-Plan switch changes the mode named in
the pending notice without creating another notice.

## Delivery and Failure Semantics

`GeminiChat` exposes an idempotent opt-in. On each send it finishes asynchronous
compression and hard-rescue checks, then synchronously claims a pending event
immediately before committing the user content to history. The notice is added
as the final text part, preserving any function response parts before it.

The linearization point is the successful history commit containing the notice.
Provider retries and fallbacks reuse that committed request and do not append a
second notice to history. If synchronous send setup throws and rolls back the
history push, the claim is restored only when the same manual-exit event is
still current, the mode is still non-Plan, and the cursor still points at that
version. A later mode event makes an old restore stale and harmless.

The implementation cannot determine whether a provider received a failed
transport request. A transport retry may send the same request more than once,
but live chat history contains the notice at most once.

Context-overflow recovery is the exception to reusing the original request:
reactive compression replaces live history before rebuilding the retry payload.
If its compressed history no longer contains the committed notice, the chat
re-appends that exact text before retrying. When compression already ends in a
user turn, the notice is added as its last part rather than creating adjacent
user turns.

## Notice

```text
<system-reminder>
The approval mode changed outside the approved exit_plan_mode flow.
The current approval mode is: ${currentMode}.
Plan mode is no longer active. This notice supersedes any earlier reminder that Plan mode is active. Do not call exit_plan_mode; no plan approval is pending. Continue under the current mode's permissions and confirmation requirements.
</system-reminder>
```

## Verification

Unit tests cover transition semantics, inherited event ownership, independent
conversation cursors, stale restore behavior, opt-in delivery, part ordering,
setup rollback, retries, chat recreation, and chat ownership. The E2E plan
covers PTY, ACP, interactive agents, and approved plan exits.
52 changes: 51 additions & 1 deletion packages/core/src/agents/backends/InProcessBackend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { AgentSpawnConfig } from './types.js';
import { AgentCore } from '../runtime/agent-core.js';
import { getTeammateContext } from '../team/identity.js';
import { createContentGenerator } from '../../core/contentGenerator.js';
import { ApprovalMode, type Config } from '../../config/config.js';
import { ApprovalMode, Config } from '../../config/config.js';

const DEFAULT_MODE = 'default' as ApprovalMode;
const PLAN_MODE = 'plan' as ApprovalMode;
Expand Down Expand Up @@ -671,6 +671,56 @@ describe('InProcessBackend', () => {
expect(parentConfig.setApprovalMode).not.toHaveBeenCalled();
});

it('copies the inherited plan-exit event into a per-agent mode override', async () => {
const parentConfig = createMockConfig() as unknown as Record<
string,
unknown
>;
Object.assign(parentConfig, {
approvalMode: ApprovalMode.DEFAULT,
manualPlanExitNoticeEventState: {
version: 2,
kind: 'manual-exit',
},
takePendingManualPlanExitNotice:
Config.prototype.takePendingManualPlanExitNotice,
restorePendingManualPlanExitNotice:
Config.prototype.restorePendingManualPlanExitNotice,
});
const backendWithParentMode = new InProcessBackend(
parentConfig as unknown as Config,
);
await backendWithParentMode.init();

const config = createSpawnConfig('agent-1');
config.inProcess!.approvalMode = ApprovalMode.AUTO_EDIT;
await backendWithParentMode.spawnAgent(config);

const MockAgentCore = AgentCore as unknown as ReturnType<typeof vi.fn>;
const { runtimeContext } = destructureAgentCoreCall(
MockAgentCore.mock.calls.at(-1)!,
);
const agentContext = runtimeContext as unknown as Config;
expect(agentContext.takePendingManualPlanExitNotice()).toEqual({
version: 2,
currentMode: ApprovalMode.AUTO_EDIT,
});

Object.assign(parentConfig['manualPlanExitNoticeEventState'] as object, {
version: 3,
kind: 'manual-exit',
});
expect(agentContext.takePendingManualPlanExitNotice()).toBeUndefined();
expect(
Config.prototype.takePendingManualPlanExitNotice.call(
parentConfig as unknown as Config,
),
).toEqual({
version: 3,
currentMode: ApprovalMode.DEFAULT,
});
});

it('restores a plan-mode per-agent config to default without mutating the parent config', async () => {
const parentConfig = createMockConfig() as unknown as {
getApprovalMode: ReturnType<typeof vi.fn>;
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/agents/backends/InProcessBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,12 @@ function createApprovalModeConfigOverride(
};

override.approvalMode = initialMode;
override.manualPlanExitNoticeEventState = {
...(override.manualPlanExitNoticeEventState ?? {
version: 0,
kind: 'clear',
}),
};
override.getApprovalMode = Config.prototype.getApprovalMode;
override.prePlanMode =
initialMode === ApprovalMode.PLAN
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/agents/runtime/agent-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,36 @@ import {
runWithInvocationContext,
type InvocationContextV1,
} from '../../utils/invocation-context.js';
import { GeminiChat } from '../../core/geminiChat.js';
import { ContextState } from './agent-headless.js';

describe('AgentCore.createChat manual plan-exit notice ownership', () => {
it('enables notices only for interactive agent chats', async () => {
const core = new AgentCore(
'notice-agent',
{} as Config,
{ renderedSystemPrompt: 'system', initialMessages: [] },
{ model: 'test-model' },
{ max_turns: 1 },
);
const enableSpy = vi.spyOn(
GeminiChat.prototype,
'enableManualPlanExitNotices',
);

const interactiveChat = await core.createChat(new ContextState(), {
interactive: true,
});
expect(interactiveChat).toBeDefined();
expect(enableSpy).toHaveBeenCalledTimes(1);

const headlessChat = await core.createChat(new ContextState());
expect(headlessChat).toBeDefined();
expect(enableSpy).toHaveBeenCalledTimes(1);

enableSpy.mockRestore();
});
});

describe('AgentCore.runInAgentFrames', () => {
// The deferred-approval `respond` callback that AgentCore hands to the
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/agents/runtime/agent-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,9 @@ export class AgentCore {
generationConfig,
startHistory,
);
if (options?.interactive) {
chat.enableManualPlanExitNotices();
}
// Seed the per-chat token count so the auto-compaction threshold
// gate sees the inherited history's true size on the first send.
// Without this, fork subagents start at 0 and the gate NOOPs even
Expand Down
132 changes: 132 additions & 0 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6522,6 +6522,138 @@ describe('setApprovalMode with folder trust', () => {
expect(config.consumePendingManualPlanExitNotice()).toBe(false);
});

it('claims the latest non-plan mode and supports a matching restore', () => {
const config = new Config(baseParams);
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);

config.setApprovalMode(ApprovalMode.PLAN);
config.setApprovalMode(ApprovalMode.DEFAULT);
config.setApprovalMode(ApprovalMode.YOLO);

const notice = config.takePendingManualPlanExitNotice();
expect(notice).toEqual({
version: expect.any(Number),
currentMode: ApprovalMode.YOLO,
});
expect(config.takePendingManualPlanExitNotice()).toBeUndefined();

config.restorePendingManualPlanExitNotice(notice!.version);
expect(config.takePendingManualPlanExitNotice()).toEqual(notice);
});

it('ignores a restore after a newer mode event', () => {
const config = new Config(baseParams);
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);

config.setApprovalMode(ApprovalMode.PLAN);
config.setApprovalMode(ApprovalMode.DEFAULT);
const staleNotice = config.takePendingManualPlanExitNotice()!;

config.setApprovalMode(ApprovalMode.PLAN);
config.setApprovalMode(ApprovalMode.AUTO_EDIT);
config.restorePendingManualPlanExitNotice(staleNotice.version);

const currentNotice = config.takePendingManualPlanExitNotice();
expect(currentNotice?.version).toBeGreaterThan(staleNotice.version);
expect(currentNotice?.currentMode).toBe(ApprovalMode.AUTO_EDIT);
expect(config.takePendingManualPlanExitNotice()).toBeUndefined();
});

it('delivers the same inherited event once to each conversation', () => {
const parent = new Config(baseParams);
vi.spyOn(parent, 'isTrustedFolder').mockReturnValue(true);
const child = Object.create(parent) as Config;

parent.setApprovalMode(ApprovalMode.PLAN);
parent.setApprovalMode(ApprovalMode.DEFAULT);

const parentNotice = parent.takePendingManualPlanExitNotice();
const childNotice = child.takePendingManualPlanExitNotice();
expect(parentNotice).toEqual(childNotice);
expect(parent.takePendingManualPlanExitNotice()).toBeUndefined();
expect(child.takePendingManualPlanExitNotice()).toBeUndefined();
});

it('lets a newly created conversation claim the latest inherited event', () => {
const parent = new Config(baseParams);
vi.spyOn(parent, 'isTrustedFolder').mockReturnValue(true);

parent.setApprovalMode(ApprovalMode.PLAN);
parent.setApprovalMode(ApprovalMode.DEFAULT);
const parentNotice = parent.takePendingManualPlanExitNotice();
const child = Object.create(parent) as Config;

expect(child.takePendingManualPlanExitNotice()).toEqual(parentNotice);
});

it('copies the event when a child first owns its approval mode', () => {
const parent = new Config(baseParams);
vi.spyOn(parent, 'isTrustedFolder').mockReturnValue(true);
const child = Object.create(parent) as Config;

parent.setApprovalMode(ApprovalMode.PLAN);
parent.setApprovalMode(ApprovalMode.DEFAULT);
child.setApprovalMode(ApprovalMode.AUTO_EDIT);

expect(child.takePendingManualPlanExitNotice()?.currentMode).toBe(
ApprovalMode.AUTO_EDIT,
);

parent.setApprovalMode(ApprovalMode.PLAN);
parent.setApprovalMode(ApprovalMode.DEFAULT);
expect(child.takePendingManualPlanExitNotice()).toBeUndefined();
expect(parent.takePendingManualPlanExitNotice()?.currentMode).toBe(
ApprovalMode.DEFAULT,
);

child.setApprovalMode(ApprovalMode.PLAN);
child.setApprovalMode(ApprovalMode.YOLO);
expect(child.takePendingManualPlanExitNotice()?.currentMode).toBe(
ApprovalMode.YOLO,
);
expect(parent.takePendingManualPlanExitNotice()).toBeUndefined();
});

it('isolates an inherited event when approval mode is owned directly', () => {
const parent = new Config(baseParams);
vi.spyOn(parent, 'isTrustedFolder').mockReturnValue(true);
parent.setApprovalMode(ApprovalMode.PLAN);
parent.setApprovalMode(ApprovalMode.DEFAULT);

const child = Object.create(parent) as Config;
Object.defineProperty(child, 'approvalMode', {
value: ApprovalMode.AUTO_EDIT,
writable: true,
configurable: true,
});

expect(child.takePendingManualPlanExitNotice()?.currentMode).toBe(
ApprovalMode.AUTO_EDIT,
);

parent.setApprovalMode(ApprovalMode.PLAN);
parent.setApprovalMode(ApprovalMode.DEFAULT);
expect(child.takePendingManualPlanExitNotice()).toBeUndefined();
expect(parent.takePendingManualPlanExitNotice()?.currentMode).toBe(
ApprovalMode.DEFAULT,
);
});

it('only exposes the latest event after rapid Plan round trips', () => {
const config = new Config(baseParams);
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);

config.setApprovalMode(ApprovalMode.PLAN);
config.setApprovalMode(ApprovalMode.DEFAULT);
config.setApprovalMode(ApprovalMode.PLAN);
config.setApprovalMode(ApprovalMode.YOLO);

expect(config.takePendingManualPlanExitNotice()?.currentMode).toBe(
ApprovalMode.YOLO,
);
expect(config.takePendingManualPlanExitNotice()).toBeUndefined();
});

it('records prePlanMode=yolo for a Shift+Tab cycle into plan mode', () => {
const config = new Config(baseParams);
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
Expand Down
Loading
Loading