make plan mode reachable in chat
opusplan has been in the picker since 57bbcca9 and did nothing: it means
'Opus while planning, Sonnet at rest', and claude-manager hardcoded
permissionMode: 'bypassPermissions', so a session never entered plan mode
and opusplan rested forever on Sonnet.
verified against a real claude first (SDK 0.3.233), and one finding changed
the design: approving ExitPlanMode drops the harness into 'default', NOT back
into bypassPermissions, and the agent carries on inside the same turn. so the
restore is fired the moment approval is given rather than at turn end, and
canUseTool allows everything that is not ExitPlanMode - for a few hundred ms
it is the only thing between the agent and its tools, and a default-deny there
would jail an agent the platform deliberately does not jail.
canUseTool is passed always. under bypassPermissions the SDK shadows it
entirely (CLAUDE_SDK_CAN_USE_TOOL_SHADOWED) so it costs a log line, and since
it is a query() option read once, passing it unconditionally is what makes the
mode toggleable on a live conversation instead of only at session start.
sticky per session on chat_session_models.plan_mode, beside the model because
they are one decision about what a turn costs. boolean rather than the
PermissionMode string: officer exposes two of the five, and a text column would
accept three the sidecar cannot handle.
plan approval is the only place the agent blocks on officer - a canUseTool
promise held in the sidecar while the plan round-trips to a browser. every path
that ends a session or a turn settles it as refused, plus a 20m timeout, because
a leaked entry is a hung turn rather than a stale value.
not auto-entered for opusplan: the CLI does not either. the toggle says so
instead - 'opusplan rests on Sonnet, turn on Plan for Opus'.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -186,6 +186,49 @@ describe('createTurnStream', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
/*
|
||||
Plan mode's three events, and the one property that is not obvious.
|
||||
|
||||
A plan is the only DURABLE row the transcript needs in order to stay honest after a refresh: the card is
|
||||
interactive, so a plan that replayed without its decision would come back offering to approve something
|
||||
that was approved an hour ago. Both halves therefore have to survive, and `plan:proposed` has to land
|
||||
after the agent's introduction to it rather than inside the sentence.
|
||||
*/
|
||||
test('a plan is durable and lands after the words that introduce it', () => {
|
||||
const { durable } = run([
|
||||
{ type: 'delta', text: 'Here is what I propose:' },
|
||||
{ type: 'plan:proposed', requestId: 'plan_1', plan: '# Plan\n\nDo the thing.' },
|
||||
]);
|
||||
expect(durable).toEqual([
|
||||
{ type: 'assistant:text', text: 'Here is what I propose:' },
|
||||
{ type: 'plan:proposed', requestId: 'plan_1', plan: '# Plan\n\nDo the thing.' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a decision is durable and carries its feedback, so a rejected plan replays with its reason', () => {
|
||||
// The feedback IS what the agent was told to revise against, so the pair only makes sense together.
|
||||
const { durable } = run([{ type: 'plan:decided', requestId: 'plan_1', approved: false, feedback: 'too broad' }]);
|
||||
expect(durable).toEqual([{ type: 'plan:decided', requestId: 'plan_1', approved: false, feedback: 'too broad' }]);
|
||||
});
|
||||
|
||||
test('plan mode going on or off is a durable boundary, like a model switch', () => {
|
||||
// Same reasoning as the model marker: without it, a stretch where the agent researched and refused to
|
||||
// edit anything reads as an agent that lost its nerve.
|
||||
const { durable } = run([
|
||||
{ type: 'delta', text: 'ok' },
|
||||
{ type: 'plan:mode', enabled: true, ok: true },
|
||||
]);
|
||||
expect(durable).toEqual([
|
||||
{ type: 'assistant:text', text: 'ok' },
|
||||
{ type: 'plan:mode', enabled: true, ok: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a refused plan-mode change is recorded with its reason', () => {
|
||||
const { durable } = run([{ type: 'plan:mode', enabled: true, ok: false, error: 'nope' }]);
|
||||
expect(durable).toEqual([{ type: 'plan:mode', enabled: true, ok: false, error: 'nope' }]);
|
||||
});
|
||||
|
||||
test('a refused switch is recorded too, with its reason', () => {
|
||||
// Silence here would be the worst outcome: the turn answers as the OLD model while the picker shows the
|
||||
// new one, which is the exact mismatch this whole change exists to remove.
|
||||
|
||||
Reference in New Issue
Block a user