mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(web-shell): separate Plan workflow from execution permissions (#11423)
Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
1 parent
b661a2cdb2
commit
7bf0cafab4
54 files changed
+3689
-387
No files matched your search
@@ -0,0 +1,55 @@
|
||||
# DAC Chat Plan toggle
|
||||
|
||||
## Problem and scope
|
||||
|
||||
Web Shell uses one approval-mode value for both planning and execution permission. Plan appears in the permission menu, and confirming a plan may choose a different execution policy. DAC Chat needs a separate Plan toggle immediately after the permission control. The existing single-row responsive toolbar remains: narrow widths collapse labels to icons. No toolbar reorganization or CLI/TUI semantics change is included.
|
||||
|
||||
Generating a plan leaves Plan enabled until the user approves it. Approval exits planning and executes under the policy selected when the user confirms. Rejection leaves planning enabled. Changing permission while planning updates only the policy for execution and never auto-confirms the plan. Manual toggle-off exits planning under the selected permission; it does not submit the pending plan approval. Existing running-turn switch timing is preserved; this feature does not add cancellation or model steering.
|
||||
|
||||
## Runtime and transport
|
||||
|
||||
Retain runtime ApprovalMode.PLAN and its existing tool restrictions. Add an optional DAC-only selected execution policy to Config, populated exclusively by an explicit planMode boolean on the existing session approval-mode control. Legacy requests omit the flag and retain their behavior. Validate the flag and reject PLAN as an execution policy. Core applies the existing derived-config and folder-trust constraints before changing state. Updating execution policy while already planning does not end or restart the planning lifecycle.
|
||||
|
||||
For DAC selections, RestorePrevious captures the selected execution policy in the confirmation callback. Legacy confirmations still restore their original snapshot. Execution retains cancellation and approval-mode-revision checks. Policies chosen after confirmation must not silently alter that already-approved decision.
|
||||
|
||||
The existing route remains live-session-owner scoped through withOwnerMutableSession. Use its resolved runtime, bridge and Config throughout, with existing unknown/removed/draining failure behavior. No primary-runtime fallback, new workspace route or permission persistence is introduced. Transport the selected policy in approval responses, mode notifications and session-mode metadata; preserve it through bridge reconciliation, snapshots and client hydration. The effective runtime mode remains authoritative for whether the Plan toggle is on.
|
||||
|
||||
## Web Shell
|
||||
|
||||
Add a Plan Switch after approvalMode in ChatEditor, using the shared Switch primitive. The host must explicitly include `plan` in `composerToolbarActions` or `composerToolbarAdditionalActions`; omitted configuration hides it in main and split panes. The standalone browser host explicitly opts in. Plan never falls back into the permission menu. Wide layouts show the Plan label and switch; narrow layouts replace the label with the Plan icon and retain the switch. Preserve the single row, tooltip, accessible name and checked state. Hide the entire Composer and its status area while a plan awaits approval, matching ordinary permission and AskUserQuestion overlays. Restore them when the overlay resolves. Floating plan cards scroll only their plan content, keeping the heading and approval buttons visible, including text-only plans. Execution permission remains adjustable during planning before the approval overlay appears. Remove Plan only from Web Shell permission menus/cycling. Keep Auto alongside Default, Auto Edit and YOLO. Do not change the shared SDK approval enum or the agent-definition editor.
|
||||
|
||||
Main and split panes use the same daemon action contract. Plan confirmations expose one approval action labelled with the chosen execution policy and a rejection action. A permission change during Plan cannot auto-submit pending approval. Briefly disable plan confirmation while a mode update is pending, and prevent new mode updates during plan-approval handoff until the runtime exits Plan or the handoff fails; neither request may overtake the other. Buttons reflect only successful state changes; failures keep the previous state. Session-owner guards prevent late responses updating replacement sessions.
|
||||
|
||||
/plan toggles, /plan on enables, /plan off disables through the same action. Preserve /plan <prompt> as enter-and-send and /plan exit as a compatibility alias for off. A new session is created with its execution policy, then Plan is applied before submitting the first prompt; preparation failure prevents that prompt.
|
||||
|
||||
## Affected areas
|
||||
|
||||
- Core Config and exit-plan confirmation, with legacy and DAC regression coverage.
|
||||
- Daemon session approval route, ACP control handler and mode metadata/notifications.
|
||||
- ACP bridge control, cached snapshots and mode-event forwarding.
|
||||
- TypeScript daemon client/state types and Web Shell session synchronization.
|
||||
- Web Shell Composer, approval dialog, main/split command and permission wiring, translations and focused component tests.
|
||||
|
||||
## Verification
|
||||
|
||||
Exercise Plan with every non-Plan permission, permission changes before and while a plan awaits confirmation, approve/reject/manual exit, slash commands, welcome-session preparation, session switching and reconnect hydration. Verify core legacy confirmation snapshots remain unchanged. Validate toolbar order and one-row compact layout. Build, typecheck, bundle, focused tests and two clean full-diff audits are required.
|
||||
|
||||
## Open questions
|
||||
|
||||
None for the requested behavior. Cross-process restoration beyond existing session-mode persistence and new mid-turn steering guarantees are out of scope.
|
||||
|
||||
## Review corrections
|
||||
|
||||
Metadata reads that omit modes, fail, or are skipped during event-stream restart preserve the existing mode and execution policy together. An authoritative non-Plan mode clears the policy. Plan approval receives only the daemon-reported execution policy; a Composer display fallback must never label the approval as executing under a permission the daemon did not report.
|
||||
|
||||
For explicit DAC `planMode` controls, `persist: true` saves only the execution policy as the workspace default for future sessions. The requesting session publishes its actual Plan state and selected policy. Existing peer sessions receive neither a mode event nor a cache update. Requests without `planMode` retain the previous workspace-default broadcast behavior.
|
||||
|
||||
Regression checks cover metadata reuse after prompt restart, missing-mode context responses followed by a real Plan exit, approval with/without reported execution policy in main and split panes, DAC persistence with Plan/non-Plan peers, and the existing legacy persistence tests.
|
||||
|
||||
## Confirmation policy precondition
|
||||
|
||||
DAC RestorePrevious responses include the execution policy displayed by the approving client. The bridge preserves this permission-response extension and Session forwards it to the tool. Core reads the current DAC policy once, rejects a missing or different expected policy, and otherwise freezes that value until execution. Legacy Plan without a DAC policy keeps its pre-plan snapshot behavior. Policy equality is sufficient: a change away and back to the same policy does not change the permission the user approved. The lifecycle revision remains separate.
|
||||
|
||||
A rejected precondition consumes the old permission request and returns a tool error while retaining Plan. The model must request plan approval again; HTTP acknowledgement does not imply that Plan exited. No client-side retry of the consumed approval is attempted.
|
||||
|
||||
The Plan Switch retains its accessible name and checked state and directly references a persistent, dynamic execution-policy description. Mode controls becoming busy close the open permission menu. Slash completion includes the existing exit alias. Unused Plan & Review mode labels are removed; agent-editor Plan labels and workflow review translations remain.
|
||||
@@ -21936,6 +21936,66 @@ describe('createAcpSessionBridge', () => {
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it.each(['yolo', 123, undefined])(
|
||||
'forwards only a string expected Plan execution policy (%s)',
|
||||
async (expectedPlanExecutionMode) => {
|
||||
const { bridge, session, conn } = await setupForPermission();
|
||||
const subAbort = new AbortController();
|
||||
const events = bridge
|
||||
.subscribeEvents(session.sessionId, {
|
||||
signal: subAbort.signal,
|
||||
})
|
||||
[Symbol.asyncIterator]();
|
||||
try {
|
||||
const pending = (
|
||||
conn as unknown as {
|
||||
requestPermission(p: unknown): Promise<unknown>;
|
||||
}
|
||||
).requestPermission({
|
||||
sessionId: session.sessionId,
|
||||
toolCall: {
|
||||
toolCallId: 'exit-plan',
|
||||
title: 'Exit plan mode',
|
||||
_meta: { toolName: 'exit_plan_mode' },
|
||||
},
|
||||
options: [
|
||||
{
|
||||
optionId: 'restore_previous',
|
||||
name: 'Approve',
|
||||
kind: 'allow_once',
|
||||
},
|
||||
],
|
||||
});
|
||||
const event = await events.next();
|
||||
const { requestId } = event.value!.data as { requestId: string };
|
||||
expect(
|
||||
bridge.respondToSessionPermission(
|
||||
session.sessionId,
|
||||
requestId,
|
||||
{
|
||||
outcome: { outcome: 'selected', optionId: 'restore_previous' },
|
||||
expectedPlanExecutionMode,
|
||||
answers: { '0': 'Keep answers too' },
|
||||
ignored: true,
|
||||
} as RequestPermissionResponse,
|
||||
{ clientId: session.clientId },
|
||||
),
|
||||
).toBe(true);
|
||||
const response = await pending;
|
||||
expect(response).toEqual({
|
||||
outcome: { outcome: 'selected', optionId: 'restore_previous' },
|
||||
answers: { '0': 'Keep answers too' },
|
||||
...(typeof expectedPlanExecutionMode === 'string'
|
||||
? { expectedPlanExecutionMode }
|
||||
: {}),
|
||||
});
|
||||
} finally {
|
||||
subAbort.abort();
|
||||
await bridge.shutdown();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('forwards session-scoped permission answers without arbitrary metadata', async () => {
|
||||
const { bridge, session, conn } = await setupForPermission();
|
||||
|
||||
@@ -26194,6 +26254,301 @@ describe('createAcpSessionBridge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves DAC plan policies through controls, notifications and reconnect snapshots', async () => {
|
||||
let currentMode = 'plan';
|
||||
let planExecutionMode: string | undefined = 'yolo';
|
||||
let reconcileMode: string | undefined;
|
||||
let connection: AgentSideConnection;
|
||||
const controls: Array<Record<string, unknown>> = [];
|
||||
const factory: ChannelFactory = async () => {
|
||||
const { clientStream, agentStream } = createInMemoryChannel();
|
||||
const modes = () => ({
|
||||
currentModeId: currentMode,
|
||||
availableModes: [],
|
||||
_meta: { planExecutionMode },
|
||||
});
|
||||
const agent = new FakeAgent({
|
||||
newSessionImpl: (params) =>
|
||||
Promise.resolve({ sessionId: `sess:${params.cwd}`, modes: modes() }),
|
||||
extMethodImpl: (method, params) => {
|
||||
if (method === SERVE_STATUS_EXT_METHODS.sessionContext) {
|
||||
if (reconcileMode) {
|
||||
planExecutionMode = reconcileMode;
|
||||
reconcileMode = undefined;
|
||||
}
|
||||
return Promise.resolve({ state: { modes: modes() } });
|
||||
}
|
||||
if (method === SERVE_CONTROL_EXT_METHODS.sessionApprovalMode) {
|
||||
const control = params as Record<string, unknown>;
|
||||
controls.push(control);
|
||||
const previous = currentMode;
|
||||
currentMode = control['planMode']
|
||||
? 'plan'
|
||||
: String(control['mode']);
|
||||
planExecutionMode = control['planMode']
|
||||
? String(control['mode'])
|
||||
: undefined;
|
||||
return Promise.resolve({
|
||||
previous,
|
||||
current: currentMode,
|
||||
planExecutionMode,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({});
|
||||
},
|
||||
});
|
||||
connection = new AgentSideConnection(() => agent as Agent, agentStream);
|
||||
return {
|
||||
stream: clientStream,
|
||||
exited: new Promise<
|
||||
| { exitCode: number | null; signalCode: NodeJS.Signals | null }
|
||||
| undefined
|
||||
>(() => {}),
|
||||
kill: async () => {},
|
||||
killSync: () => {},
|
||||
};
|
||||
};
|
||||
const bridge = makeBridge({ channelFactory: factory });
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
const abort = new AbortController();
|
||||
const events = bridge
|
||||
.subscribeEvents(session.sessionId, {
|
||||
snapshot: true,
|
||||
signal: abort.signal,
|
||||
})
|
||||
[Symbol.asyncIterator]();
|
||||
const nextMode = async () => {
|
||||
for (;;) {
|
||||
const event = await events.next();
|
||||
if (event.done) throw new Error('Mode stream ended');
|
||||
if (event.value.type === 'approval_mode_changed')
|
||||
return event.value.data;
|
||||
}
|
||||
};
|
||||
try {
|
||||
expect((await events.next()).value?.data).toMatchObject({
|
||||
currentApprovalMode: 'plan',
|
||||
planExecutionMode: 'yolo',
|
||||
});
|
||||
expect(bridge.getDaemonStatusSnapshot().sessions[0]).toMatchObject({
|
||||
currentApprovalMode: 'plan',
|
||||
planExecutionMode: 'yolo',
|
||||
});
|
||||
await expect(
|
||||
bridge.setSessionApprovalMode(session.sessionId, ApprovalMode.DEFAULT, {
|
||||
persist: false,
|
||||
planMode: true,
|
||||
}),
|
||||
).resolves.toMatchObject({ mode: 'plan', planExecutionMode: 'default' });
|
||||
expect(controls).toEqual([
|
||||
{ sessionId: session.sessionId, mode: 'default', planMode: true },
|
||||
]);
|
||||
expect(await nextMode()).toMatchObject({
|
||||
next: 'plan',
|
||||
planExecutionMode: 'default',
|
||||
});
|
||||
const reconnect = bridge
|
||||
.subscribeEvents(session.sessionId, {
|
||||
snapshot: true,
|
||||
signal: abort.signal,
|
||||
})
|
||||
[Symbol.asyncIterator]();
|
||||
expect((await reconnect.next()).value?.data).toMatchObject({
|
||||
currentApprovalMode: 'plan',
|
||||
planExecutionMode: 'default',
|
||||
});
|
||||
reconcileMode = 'yolo';
|
||||
await bridge.setSessionApprovalMode(
|
||||
session.sessionId,
|
||||
ApprovalMode.DEFAULT,
|
||||
{
|
||||
persist: false,
|
||||
planMode: true,
|
||||
},
|
||||
);
|
||||
expect(await nextMode()).toMatchObject({
|
||||
next: 'plan',
|
||||
planExecutionMode: 'default',
|
||||
});
|
||||
expect(await nextMode()).toMatchObject({
|
||||
next: 'plan',
|
||||
planExecutionMode: 'yolo',
|
||||
});
|
||||
await connection!.extNotification('qwen/notify/session/mode-update', {
|
||||
v: 1,
|
||||
sessionId: session.sessionId,
|
||||
currentModeId: 'plan',
|
||||
planExecutionMode: 'auto-edit',
|
||||
});
|
||||
expect(await nextMode()).toMatchObject({
|
||||
next: 'plan',
|
||||
planExecutionMode: 'auto-edit',
|
||||
});
|
||||
expect(bridge.getDaemonStatusSnapshot().sessions[0]).toMatchObject({
|
||||
currentApprovalMode: 'plan',
|
||||
planExecutionMode: 'auto-edit',
|
||||
});
|
||||
await bridge.setSessionApprovalMode(
|
||||
session.sessionId,
|
||||
ApprovalMode.AUTO_EDIT,
|
||||
{
|
||||
persist: false,
|
||||
planMode: false,
|
||||
},
|
||||
);
|
||||
const exited = await nextMode();
|
||||
expect(exited).toMatchObject({ next: 'auto-edit' });
|
||||
expect(exited).not.toHaveProperty('planExecutionMode');
|
||||
expect(bridge.getDaemonStatusSnapshot().sessions[0]).toMatchObject({
|
||||
currentApprovalMode: 'auto-edit',
|
||||
});
|
||||
expect(bridge.getDaemonStatusSnapshot().sessions[0]).not.toHaveProperty(
|
||||
'planExecutionMode',
|
||||
);
|
||||
} finally {
|
||||
abort.abort();
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'keeps peer Plan and execution state unchanged when persisting DAC planMode=%s',
|
||||
async (planMode) => {
|
||||
const modesById = new Map<
|
||||
string,
|
||||
{
|
||||
currentModeId: string;
|
||||
availableModes: [];
|
||||
_meta: { planExecutionMode: string | undefined };
|
||||
}
|
||||
>();
|
||||
const factory: ChannelFactory = async () => {
|
||||
const { clientStream, agentStream } = createInMemoryChannel();
|
||||
const agent = new FakeAgent({
|
||||
newSessionImpl: () => {
|
||||
const index = modesById.size;
|
||||
const sessionId = `dac-persist-${index}`;
|
||||
const modes = {
|
||||
currentModeId: index === 1 ? 'plan' : 'auto-edit',
|
||||
availableModes: [] as [],
|
||||
_meta: { planExecutionMode: index === 1 ? 'default' : undefined },
|
||||
};
|
||||
modesById.set(sessionId, modes);
|
||||
return Promise.resolve({ sessionId, modes });
|
||||
},
|
||||
extMethodImpl: (method, params) => {
|
||||
const control = params as Record<string, unknown>;
|
||||
const modes = modesById.get(String(control['sessionId']));
|
||||
if (method === SERVE_STATUS_EXT_METHODS.sessionContext)
|
||||
return Promise.resolve({ state: { modes } });
|
||||
if (
|
||||
method === SERVE_CONTROL_EXT_METHODS.sessionApprovalMode &&
|
||||
modes
|
||||
) {
|
||||
const previous = modes.currentModeId;
|
||||
modes.currentModeId = control['planMode']
|
||||
? 'plan'
|
||||
: String(control['mode']);
|
||||
modes._meta.planExecutionMode = control['planMode']
|
||||
? String(control['mode'])
|
||||
: undefined;
|
||||
return Promise.resolve({
|
||||
previous,
|
||||
current: modes.currentModeId,
|
||||
planExecutionMode: modes._meta.planExecutionMode,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({});
|
||||
},
|
||||
});
|
||||
new AgentSideConnection(() => agent as Agent, agentStream);
|
||||
return {
|
||||
stream: clientStream,
|
||||
exited: new Promise<
|
||||
| { exitCode: number | null; signalCode: NodeJS.Signals | null }
|
||||
| undefined
|
||||
>(() => {}),
|
||||
kill: async () => {},
|
||||
killSync: () => {},
|
||||
};
|
||||
};
|
||||
const persistApprovalMode = vi.fn(async () => {});
|
||||
const bridge = makeBridge({
|
||||
channelFactory: factory,
|
||||
persistApprovalMode,
|
||||
});
|
||||
const sessions = [];
|
||||
for (let index = 0; index < 3; index++) {
|
||||
sessions.push(
|
||||
await bridge.spawnOrAttach({
|
||||
workspaceCwd: WS_A,
|
||||
sessionScope: 'thread',
|
||||
}),
|
||||
);
|
||||
}
|
||||
expect(new Set(sessions.map((session) => session.sessionId)).size).toBe(
|
||||
3,
|
||||
);
|
||||
const abort = new AbortController();
|
||||
const modeEvents: unknown[][] = [[], [], []];
|
||||
const collecting = sessions.map(async (session, index) => {
|
||||
for await (const event of bridge.subscribeEvents(session.sessionId, {
|
||||
signal: abort.signal,
|
||||
})) {
|
||||
if (event.type === 'approval_mode_changed')
|
||||
modeEvents[index]!.push(event.data);
|
||||
}
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
bridge.setSessionApprovalMode(
|
||||
sessions[0]!.sessionId,
|
||||
ApprovalMode.YOLO,
|
||||
{ planMode, persist: true },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
mode: planMode ? 'plan' : 'yolo',
|
||||
persisted: true,
|
||||
});
|
||||
expect(persistApprovalMode).toHaveBeenCalledWith(
|
||||
WS_A,
|
||||
ApprovalMode.YOLO,
|
||||
);
|
||||
for (const index of [1, 2]) {
|
||||
const snapshot = bridge
|
||||
.subscribeEvents(sessions[index]!.sessionId, {
|
||||
snapshot: true,
|
||||
signal: abort.signal,
|
||||
})
|
||||
[Symbol.asyncIterator]();
|
||||
const event = (await snapshot.next()).value;
|
||||
expect(event?.type).toBe('session_snapshot');
|
||||
expect(event?.data).toMatchObject({
|
||||
currentApprovalMode: index === 1 ? 'plan' : 'auto-edit',
|
||||
});
|
||||
if (index === 1)
|
||||
expect(event?.data).toMatchObject({ planExecutionMode: 'default' });
|
||||
else expect(event?.data).not.toHaveProperty('planExecutionMode');
|
||||
}
|
||||
await vi.waitFor(() => expect(modeEvents[0]).toHaveLength(1));
|
||||
expect(modeEvents[0]![0]).toMatchObject({
|
||||
next: planMode ? 'plan' : 'yolo',
|
||||
persisted: true,
|
||||
});
|
||||
if (planMode)
|
||||
expect(modeEvents[0]![0]).toMatchObject({
|
||||
planExecutionMode: 'yolo',
|
||||
});
|
||||
else expect(modeEvents[0]![0]).not.toHaveProperty('planExecutionMode');
|
||||
expect(modeEvents.slice(1)).toEqual([[], []]);
|
||||
} finally {
|
||||
abort.abort();
|
||||
await Promise.all(collecting);
|
||||
await bridge.shutdown();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
describe('setSessionApprovalMode (#4175 Wave 4 PR 17)', () => {
|
||||
/**
|
||||
* #4282 fold-in 4 (qwen-latest C1). Build a channel factory whose
|
||||
|
||||
@@ -1285,6 +1285,7 @@ interface SessionEntry {
|
||||
currentModelId?: string;
|
||||
/** §2.3: cached approval mode, updated by every `publishApprovalModeChanged` call. */
|
||||
currentApprovalMode?: string;
|
||||
planExecutionMode?: string;
|
||||
/** §2.3: monotonic counter bumped on every `model_switched` publish. */
|
||||
modelPublishGeneration: number;
|
||||
/** §2.3: monotonic counter bumped on every `approval_mode_changed` publish. */
|
||||
@@ -1487,8 +1488,7 @@ function extractPermissionResponseMetadata(
|
||||
response: unknown,
|
||||
): Readonly<Record<string, unknown>> | undefined {
|
||||
if (response === null || typeof response !== 'object') return undefined;
|
||||
// Keep this extension deliberately narrow. Today the only non-ACP field
|
||||
// expected by the agent is AskUserQuestion's `answers` payload.
|
||||
const metadata: Record<string, unknown> = {};
|
||||
const answers = (response as { readonly answers?: unknown }).answers;
|
||||
if (
|
||||
answers !== null &&
|
||||
@@ -1497,10 +1497,16 @@ function extractPermissionResponseMetadata(
|
||||
) {
|
||||
const entries = Object.entries(answers as Record<string, unknown>);
|
||||
if (entries.every(([, v]) => typeof v === 'string')) {
|
||||
return { answers };
|
||||
metadata['answers'] = answers;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
const expectedPlanExecutionMode = (
|
||||
response as { readonly expectedPlanExecutionMode?: unknown }
|
||||
).expectedPlanExecutionMode;
|
||||
if (typeof expectedPlanExecutionMode === 'string') {
|
||||
metadata['expectedPlanExecutionMode'] = expectedPlanExecutionMode;
|
||||
}
|
||||
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||
}
|
||||
|
||||
function parseWorkspaceMemoryRememberResult(
|
||||
@@ -4662,7 +4668,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
publishModelSwitched(entry as SessionEntry, modelId, originator),
|
||||
// A2: centralised approval_mode_changed publish on in-session mode
|
||||
// promotion. `previous` is read from the bridge state cache.
|
||||
(entry, modeId, originator) => {
|
||||
(entry, modeId, originator, planExecutionMode) => {
|
||||
const se = entry as SessionEntry;
|
||||
publishApprovalModeChanged(
|
||||
se,
|
||||
@@ -4670,6 +4676,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
previous: se.currentApprovalMode ?? 'default',
|
||||
next: modeId,
|
||||
persisted: false,
|
||||
planExecutionMode,
|
||||
},
|
||||
originator,
|
||||
);
|
||||
@@ -5952,11 +5959,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
mode: ApprovalMode,
|
||||
persist: boolean,
|
||||
originatorClientId?: string,
|
||||
planMode?: boolean,
|
||||
): Promise<{
|
||||
sessionId: string;
|
||||
mode: ApprovalMode;
|
||||
previous: ApprovalMode;
|
||||
persisted: boolean;
|
||||
planExecutionMode?: ApprovalMode;
|
||||
}> {
|
||||
if (persist && !persistApprovalMode) {
|
||||
throw new Error(
|
||||
@@ -5975,13 +5984,21 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
withTimeout(
|
||||
entry.connection.extMethod(
|
||||
SERVE_CONTROL_EXT_METHODS.sessionApprovalMode,
|
||||
{ sessionId: entry.sessionId, mode },
|
||||
{
|
||||
sessionId: entry.sessionId,
|
||||
mode,
|
||||
...(planMode !== undefined ? { planMode } : {}),
|
||||
},
|
||||
),
|
||||
initTimeoutMs,
|
||||
SERVE_CONTROL_EXT_METHODS.sessionApprovalMode,
|
||||
),
|
||||
getTransportClosedReject(entry),
|
||||
])) as { previous: ApprovalMode; current: ApprovalMode };
|
||||
])) as {
|
||||
previous: ApprovalMode;
|
||||
current: ApprovalMode;
|
||||
planExecutionMode?: ApprovalMode;
|
||||
};
|
||||
|
||||
if (
|
||||
typeof response.current !== 'string' ||
|
||||
@@ -5992,6 +6009,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
response.planExecutionMode !== undefined &&
|
||||
(response.planExecutionMode === 'plan' ||
|
||||
!KNOWN_APPROVAL_MODES.has(response.planExecutionMode))
|
||||
) {
|
||||
throw new Error('Agent returned an invalid plan execution mode');
|
||||
}
|
||||
const planExecutionMode =
|
||||
response.current === 'plan' ? response.planExecutionMode : undefined;
|
||||
|
||||
let persisted = false;
|
||||
if (persist) {
|
||||
try {
|
||||
@@ -6015,10 +6042,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
previous: response.previous,
|
||||
next: response.current,
|
||||
persisted,
|
||||
planExecutionMode,
|
||||
},
|
||||
originatorClientId,
|
||||
);
|
||||
if (persisted) {
|
||||
// DAC controls remain session-scoped; persistence only changes the
|
||||
// workspace default used by future sessions.
|
||||
if (persisted && planMode === undefined) {
|
||||
broadcastWorkspaceEvent(
|
||||
{
|
||||
type: 'approval_mode_changed',
|
||||
@@ -6037,6 +6067,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
continue;
|
||||
}
|
||||
peer.currentApprovalMode = response.current;
|
||||
peer.planExecutionMode = undefined;
|
||||
}
|
||||
}
|
||||
succeeded = true;
|
||||
@@ -6045,6 +6076,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
mode: response.current,
|
||||
previous: response.previous,
|
||||
persisted,
|
||||
...(planExecutionMode ? { planExecutionMode } : {}),
|
||||
};
|
||||
} finally {
|
||||
entry.approvalModeRoundtripInFlight = false;
|
||||
@@ -6914,10 +6946,17 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
|
||||
const publishApprovalModeChanged = (
|
||||
entry: SessionEntry,
|
||||
payload: { previous: string; next: string; persisted: boolean },
|
||||
payload: {
|
||||
previous: string;
|
||||
next: string;
|
||||
persisted: boolean;
|
||||
planExecutionMode?: string;
|
||||
},
|
||||
originatorClientId: string | undefined,
|
||||
): void => {
|
||||
entry.currentApprovalMode = payload.next;
|
||||
entry.planExecutionMode =
|
||||
payload.next === 'plan' ? payload.planExecutionMode : undefined;
|
||||
entry.approvalModePublishGeneration++;
|
||||
// See `publishModelSwitched`: `publish()` never throws, so no wrapper.
|
||||
entry.events.publish({
|
||||
@@ -6928,6 +6967,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
previous: payload.previous,
|
||||
next: payload.next,
|
||||
persisted: payload.persisted,
|
||||
...(entry.planExecutionMode
|
||||
? { planExecutionMode: entry.planExecutionMode }
|
||||
: {}),
|
||||
},
|
||||
...(originatorClientId ? { originatorClientId } : {}),
|
||||
});
|
||||
@@ -6990,9 +7032,18 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
publishModelSwitched(entry, actual, undefined);
|
||||
}
|
||||
} else {
|
||||
const actual = (
|
||||
status?.state?.modes as { currentModeId?: string } | undefined
|
||||
)?.currentModeId;
|
||||
const modes = status?.state?.modes as
|
||||
| { currentModeId?: string; _meta?: Record<string, unknown> | null }
|
||||
| undefined;
|
||||
const actual = modes?.currentModeId;
|
||||
const selected = modes?._meta?.['planExecutionMode'];
|
||||
const planExecutionMode =
|
||||
actual === 'plan' &&
|
||||
typeof selected === 'string' &&
|
||||
selected !== 'plan' &&
|
||||
KNOWN_APPROVAL_MODES.has(selected)
|
||||
? selected
|
||||
: undefined;
|
||||
// Same enum backstop as the demux path (`handleInSessionModeUpdate`):
|
||||
// `actual` is an agent-supplied id typed `unknown`, and the SDK's
|
||||
// `isApprovalModeChangedData` is a structural check (deliberately
|
||||
@@ -7003,7 +7054,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
writeStderrLine(
|
||||
`[reconcile] session=${entry.sessionId} target=approvalMode action=dropped reason=unknown_mode mode=${actual}`,
|
||||
);
|
||||
} else if (actual && actual !== entry.currentApprovalMode) {
|
||||
} else if (
|
||||
actual &&
|
||||
(actual !== entry.currentApprovalMode ||
|
||||
planExecutionMode !== entry.planExecutionMode)
|
||||
) {
|
||||
writeStderrLine(
|
||||
`[reconcile] session=${entry.sessionId} target=approvalMode action=corrected cached=${entry.currentApprovalMode ?? '<unset>'} actual=${actual}`,
|
||||
);
|
||||
@@ -7013,6 +7068,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
previous: entry.currentApprovalMode ?? 'default',
|
||||
next: actual,
|
||||
persisted: false,
|
||||
planExecutionMode,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -7370,7 +7426,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
entry: SessionEntry,
|
||||
resp: {
|
||||
models?: { currentModelId?: unknown } | null;
|
||||
modes?: { currentModeId?: unknown } | null;
|
||||
modes?: {
|
||||
currentModeId?: unknown;
|
||||
_meta?: Record<string, unknown> | null;
|
||||
} | null;
|
||||
},
|
||||
): void => {
|
||||
const model = resp.models?.currentModelId;
|
||||
@@ -7384,6 +7443,14 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
const mode = resp.modes?.currentModeId;
|
||||
if (typeof mode === 'string' && KNOWN_APPROVAL_MODES.has(mode)) {
|
||||
entry.currentApprovalMode = mode;
|
||||
const selected = resp.modes?._meta?.['planExecutionMode'];
|
||||
entry.planExecutionMode =
|
||||
mode === 'plan' &&
|
||||
typeof selected === 'string' &&
|
||||
selected !== 'plan' &&
|
||||
KNOWN_APPROVAL_MODES.has(selected)
|
||||
? selected
|
||||
: undefined;
|
||||
} else if (mode != null) {
|
||||
writeStderrLine(
|
||||
`[seed] session=${entry.sessionId} target=approvalMode action=dropped value=${JSON.stringify(mode)} reason=${typeof mode !== 'string' ? 'invalid_type' : 'unknown_mode'}`,
|
||||
@@ -9416,6 +9483,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
...(entry.currentApprovalMode
|
||||
? { currentApprovalMode: entry.currentApprovalMode }
|
||||
: {}),
|
||||
...(entry.planExecutionMode
|
||||
? { planExecutionMode: entry.planExecutionMode }
|
||||
: {}),
|
||||
maxJournalEvents: journalLimits?.maxEvents ?? maxJournalEvents,
|
||||
maxJournalBytes: journalLimits?.maxBytes ?? maxJournalBytes,
|
||||
};
|
||||
@@ -10891,6 +10961,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
sessionId: entry.sessionId,
|
||||
currentModelId: entry.currentModelId ?? null,
|
||||
currentApprovalMode: entry.currentApprovalMode ?? null,
|
||||
...(entry.planExecutionMode
|
||||
? { planExecutionMode: entry.planExecutionMode }
|
||||
: {}),
|
||||
recordingDegraded: entry.recordingDegraded,
|
||||
},
|
||||
});
|
||||
@@ -13083,6 +13156,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
mode,
|
||||
opts.persist,
|
||||
originatorClientId,
|
||||
opts.planMode,
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -1230,6 +1230,47 @@ describe('BridgeClient — A2UI session update publishing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('BridgeClient — mode promotion fallback', () => {
|
||||
it.each(['plan', 'auto-edit'])(
|
||||
'publishes %s policy metadata without an onModePromoted callback',
|
||||
async (currentModeId) => {
|
||||
const publish = vi.fn();
|
||||
const entry = { sessionId: 'sess:mode', events: { publish } };
|
||||
const client = new BridgeClient(
|
||||
(() => entry) as never,
|
||||
vi.fn(),
|
||||
{ request: vi.fn() },
|
||||
0,
|
||||
Infinity,
|
||||
);
|
||||
|
||||
await client.extNotification('qwen/notify/session/mode-update', {
|
||||
v: 1,
|
||||
sessionId: entry.sessionId,
|
||||
currentModeId,
|
||||
planExecutionMode: 'yolo',
|
||||
});
|
||||
|
||||
const promoted = publish.mock.calls.find(
|
||||
([event]) => event.type === 'approval_mode_changed',
|
||||
)?.[0];
|
||||
expect(promoted).toMatchObject({
|
||||
data: {
|
||||
sessionId: entry.sessionId,
|
||||
previous: 'default',
|
||||
next: currentModeId,
|
||||
persisted: false,
|
||||
},
|
||||
});
|
||||
if (currentModeId === 'plan') {
|
||||
expect(promoted.data.planExecutionMode).toBe('yolo');
|
||||
} else {
|
||||
expect(promoted.data).not.toHaveProperty('planExecutionMode');
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('BridgeClient — original timestamp preservation', () => {
|
||||
const noPermissionFlow = () => {
|
||||
throw new Error('test: permission flow should not run');
|
||||
|
||||
@@ -828,11 +828,14 @@ export class BridgeClient implements Client {
|
||||
* Called by the A2 `current_mode_update` demux when the agent
|
||||
* switches approval mode in-session (exit_plan_mode, ProceedAlways,
|
||||
* /mode). `previous` is read from the bridge state cache.
|
||||
* `planExecutionMode` is the validated non-Plan execution policy while
|
||||
* modeId is Plan; undefined outside Plan or when no valid policy is sent.
|
||||
*/
|
||||
private readonly onModePromoted?: (
|
||||
entry: BridgeClientSessionEntry,
|
||||
modeId: string,
|
||||
originatorClientId: string | undefined,
|
||||
planExecutionMode?: string,
|
||||
) => void,
|
||||
/**
|
||||
* Reverse tool channel (issue #5626, Phase 2). Resolves the
|
||||
@@ -2849,6 +2852,14 @@ export class BridgeClient implements Client {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const selected = params['planExecutionMode'];
|
||||
const planExecutionMode =
|
||||
currentModeId === 'plan' &&
|
||||
typeof selected === 'string' &&
|
||||
selected !== 'plan' &&
|
||||
KNOWN_APPROVAL_MODES.has(selected)
|
||||
? selected
|
||||
: undefined;
|
||||
const entry = this.resolveEntry(sessionId);
|
||||
if (!entry) {
|
||||
writeStderrLine(
|
||||
@@ -2867,6 +2878,7 @@ export class BridgeClient implements Client {
|
||||
entry,
|
||||
currentModeId,
|
||||
entry.activePromptOriginatorClientId,
|
||||
planExecutionMode,
|
||||
);
|
||||
} else {
|
||||
// Fallback path (no `onModePromoted` injected — tests / non-bridge
|
||||
@@ -2890,6 +2902,7 @@ export class BridgeClient implements Client {
|
||||
previous: 'default',
|
||||
next: currentModeId,
|
||||
persisted: false,
|
||||
...(planExecutionMode ? { planExecutionMode } : {}),
|
||||
},
|
||||
...(entry.activePromptOriginatorClientId
|
||||
? { originatorClientId: entry.activePromptOriginatorClientId }
|
||||
|
||||
@@ -1305,6 +1305,8 @@ export interface BridgeDaemonSessionDiagnostic {
|
||||
lastSeenAt?: number;
|
||||
currentModelId?: string;
|
||||
currentApprovalMode?: string;
|
||||
/** Selected execution policy while the session is in Plan. */
|
||||
planExecutionMode?: string;
|
||||
/**
|
||||
* The session's EFFECTIVE live-journal caps right now — the configured
|
||||
* baseline, or higher when adaptive growth raised them mid-turn. One
|
||||
@@ -2202,13 +2204,14 @@ export interface AcpSessionBridge extends WorkspaceEventBridge {
|
||||
setSessionApprovalMode(
|
||||
sessionId: string,
|
||||
mode: ApprovalMode,
|
||||
opts: { persist: boolean },
|
||||
opts: { persist: boolean; planMode?: boolean },
|
||||
context?: BridgeClientRequestContext,
|
||||
): Promise<{
|
||||
sessionId: string;
|
||||
mode: ApprovalMode;
|
||||
previous: ApprovalMode;
|
||||
persisted: boolean;
|
||||
planExecutionMode?: ApprovalMode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -5719,6 +5719,73 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('updates DAC execution policy without restarting plan and reports it in context', async () => {
|
||||
const sessionId = '11111111-1111-1111-1111-111111111111';
|
||||
const innerConfig = await setupSessionMocks(sessionId);
|
||||
let approvalMode = 'default';
|
||||
let selectedMode: string | undefined;
|
||||
const dacConfig = Object.assign(innerConfig, {
|
||||
getApprovalMode: vi.fn(() => approvalMode),
|
||||
getPlanExecutionMode: vi.fn(() => selectedMode),
|
||||
setPlanMode: vi.fn((enabled: boolean, mode: string) => {
|
||||
approvalMode = enabled ? 'plan' : mode;
|
||||
selectedMode = enabled ? mode : undefined;
|
||||
}),
|
||||
setDisabledTools: vi.fn(),
|
||||
});
|
||||
const { agent, agentPromise } = await bootAcpAgent();
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
try {
|
||||
for (const mode of ['yolo', 'default']) {
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionApprovalMode, {
|
||||
sessionId,
|
||||
mode,
|
||||
planMode: true,
|
||||
}),
|
||||
).resolves.toMatchObject({ current: 'plan', planExecutionMode: mode });
|
||||
}
|
||||
expect(dacConfig.setPlanMode).toHaveBeenLastCalledWith(true, 'default');
|
||||
expect(
|
||||
lastSessionMock?.clearActiveTodoPlanRevision,
|
||||
).toHaveBeenCalledOnce();
|
||||
await expect(
|
||||
agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionContext, { sessionId }),
|
||||
).resolves.toMatchObject({
|
||||
state: {
|
||||
modes: {
|
||||
currentModeId: 'plan',
|
||||
_meta: { planExecutionMode: 'default' },
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const params of [
|
||||
{ mode: 'plan', planMode: true },
|
||||
{ mode: 'default', planMode: 'true' },
|
||||
]) {
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionApprovalMode, {
|
||||
sessionId,
|
||||
...params,
|
||||
}),
|
||||
).rejects.toThrow('planMode must be a boolean');
|
||||
}
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionApprovalMode, {
|
||||
sessionId,
|
||||
mode: 'default',
|
||||
planMode: false,
|
||||
}),
|
||||
).resolves.toEqual({ previous: 'plan', current: 'default' });
|
||||
expect(
|
||||
lastSessionMock?.clearActiveTodoPlanRevision,
|
||||
).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
}
|
||||
});
|
||||
|
||||
it('clears Todo Stop Guard trust when approval mode enters plan', async () => {
|
||||
const sessionId = '11111111-1111-1111-1111-111111111111';
|
||||
const innerConfig = await setupSessionMocks(sessionId);
|
||||
|
||||
@@ -11121,6 +11121,7 @@ class QwenAgent implements Agent {
|
||||
case SERVE_CONTROL_EXT_METHODS.sessionApprovalMode: {
|
||||
const sessionId = params['sessionId'];
|
||||
const mode = params['mode'];
|
||||
const planMode = params['planMode'];
|
||||
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
@@ -11136,11 +11137,24 @@ class QwenAgent implements Agent {
|
||||
`Invalid approval mode; allowed: ${APPROVAL_MODES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
planMode !== undefined &&
|
||||
(typeof planMode !== 'boolean' || mode === ApprovalMode.PLAN)
|
||||
) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
'planMode must be a boolean with a non-plan execution mode',
|
||||
);
|
||||
}
|
||||
const session = this.sessionOrThrow(sessionId);
|
||||
const config = session.getConfig();
|
||||
const previous = config.getApprovalMode();
|
||||
try {
|
||||
config.setApprovalMode(mode as ApprovalMode);
|
||||
if (typeof planMode === 'boolean') {
|
||||
config.setPlanMode(planMode, mode as ApprovalMode);
|
||||
} else {
|
||||
config.setApprovalMode(mode as ApprovalMode);
|
||||
}
|
||||
} catch (err) {
|
||||
// `TrustGateError` is the core's structured rejection for
|
||||
// untrusted-folder + privileged-mode. We re-raise it as a
|
||||
@@ -11164,7 +11178,16 @@ class QwenAgent implements Agent {
|
||||
} else if (previous === 'plan') {
|
||||
session.clearActiveTodoPlanRevision();
|
||||
}
|
||||
return { previous, current };
|
||||
return {
|
||||
previous,
|
||||
current,
|
||||
...(current === ApprovalMode.PLAN
|
||||
? {
|
||||
planExecutionMode:
|
||||
config.getPlanExecutionMode?.() ?? config.getPrePlanMode?.(),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
case SERVE_CONTROL_EXT_METHODS.workspaceSessionWorkflow: {
|
||||
const enabled = params['enabled'];
|
||||
@@ -14587,6 +14610,14 @@ class QwenAgent implements Agent {
|
||||
|
||||
return {
|
||||
currentModeId: currentApprovalMode as ApprovalModeValue,
|
||||
...(currentApprovalMode === ApprovalMode.PLAN
|
||||
? {
|
||||
_meta: {
|
||||
planExecutionMode:
|
||||
config.getPlanExecutionMode?.() ?? config.getPrePlanMode?.(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
availableModes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { Agent, AgentSideConnection } from '@agentclientprotocol/sdk';
|
||||
import { ApprovalMode, Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
SERVE_CONTROL_EXT_METHODS,
|
||||
SERVE_STATUS_EXT_METHODS,
|
||||
} from '@qwen-code/acp-bridge/status';
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import type { CliArgs } from '../config/config.js';
|
||||
import { LoadedSettings } from '../config/settings.js';
|
||||
import { runAcpAgent } from './acpAgent.js';
|
||||
import { Session } from './session/Session.js';
|
||||
|
||||
const transport = vi.hoisted(() => {
|
||||
let close = () => {};
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
close = resolve;
|
||||
});
|
||||
return {
|
||||
agent: undefined as Agent | undefined,
|
||||
close,
|
||||
closed,
|
||||
sessionUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
extNotification: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@agentclientprotocol/sdk', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@agentclientprotocol/sdk')>()),
|
||||
AgentSideConnection: class {
|
||||
closed = transport.closed;
|
||||
sessionUpdate = transport.sessionUpdate;
|
||||
extNotification = transport.extNotification;
|
||||
|
||||
constructor(factory: (connection: AgentSideConnection) => Agent) {
|
||||
transport.agent = factory(this as unknown as AgentSideConnection);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/acp-bridge/ndJsonStream', () => ({
|
||||
ndJsonStream: vi.fn(),
|
||||
}));
|
||||
|
||||
it('carries real Config Plan policy through ACP control, context and mode notifications', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'acp-plan-config-'));
|
||||
vi.stubEnv('QWEN_HOME', directory);
|
||||
vi.stubEnv('QWEN_RUNTIME_DIR', directory);
|
||||
vi.stubEnv('QWEN_SERVE_NO_MCP_POOL', '1');
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
info: console.info,
|
||||
debug: console.debug,
|
||||
};
|
||||
const config = new Config({
|
||||
sessionId: 'dac-plan-config',
|
||||
cwd: directory,
|
||||
targetDir: directory,
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
trustedFolder: true,
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
chatRecording: false,
|
||||
usageStatisticsEnabled: false,
|
||||
telemetry: { enabled: false },
|
||||
overrideExtensions: [],
|
||||
});
|
||||
const settingsFile = {
|
||||
path: join(directory, 'settings.json'),
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
};
|
||||
const settings = new LoadedSettings(
|
||||
settingsFile,
|
||||
settingsFile,
|
||||
settingsFile,
|
||||
settingsFile,
|
||||
true,
|
||||
new Set(),
|
||||
);
|
||||
// No model or MCP initialization is needed for the ACP control surface.
|
||||
vi.spyOn(config, 'initialize').mockResolvedValue(undefined);
|
||||
const running = runAcpAgent(config, settings, {} as CliArgs);
|
||||
let session: Session | undefined;
|
||||
try {
|
||||
await vi.waitFor(() => expect(transport.agent).toBeDefined());
|
||||
const agent = transport.agent!;
|
||||
session = new Session(
|
||||
config.getSessionId(),
|
||||
config,
|
||||
transport as unknown as AgentSideConnection,
|
||||
settings,
|
||||
);
|
||||
// Install the fixture at the same registry consumed by the real ext routes.
|
||||
(agent as unknown as { sessions: Map<string, Session> }).sessions.set(
|
||||
config.getSessionId(),
|
||||
session,
|
||||
);
|
||||
for (const [planMode, policy] of [
|
||||
[true, ApprovalMode.YOLO],
|
||||
[true, ApprovalMode.AUTO_EDIT],
|
||||
[false, ApprovalMode.AUTO_EDIT],
|
||||
] as const) {
|
||||
const result = await agent.extMethod!(
|
||||
SERVE_CONTROL_EXT_METHODS.sessionApprovalMode,
|
||||
{ sessionId: config.getSessionId(), mode: policy, planMode },
|
||||
);
|
||||
const mode = planMode ? ApprovalMode.PLAN : policy;
|
||||
expect(config.getApprovalMode()).toBe(mode);
|
||||
expect(config.getPlanExecutionMode()).toBe(planMode ? policy : undefined);
|
||||
expect(result).toMatchObject({ current: mode });
|
||||
expect(result['planExecutionMode']).toBe(planMode ? policy : undefined);
|
||||
const context = await agent.extMethod!(
|
||||
SERVE_STATUS_EXT_METHODS.sessionContext,
|
||||
{ sessionId: config.getSessionId() },
|
||||
);
|
||||
expect(context).toMatchObject({
|
||||
state: {
|
||||
modes: {
|
||||
currentModeId: mode,
|
||||
...(planMode ? { _meta: { planExecutionMode: policy } } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!planMode) {
|
||||
expect(context).not.toHaveProperty(
|
||||
'state.modes._meta.planExecutionMode',
|
||||
);
|
||||
}
|
||||
await session.setMode({ sessionId: config.getSessionId(), modeId: mode });
|
||||
const notification = transport.extNotification.mock.lastCall;
|
||||
expect(notification?.[0]).toBe('qwen/notify/session/mode-update');
|
||||
expect(notification?.[1]).toMatchObject({ currentModeId: mode });
|
||||
expect(notification?.[1]['planExecutionMode']).toBe(
|
||||
planMode ? policy : undefined,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
transport.close();
|
||||
await running;
|
||||
await config.shutdown({ shutdownTelemetry: false });
|
||||
Object.assign(console, originalConsole);
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
SYSTEM_REMINDER_CLOSE,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import * as core from '@qwen-code/qwen-code-core';
|
||||
import { ExitPlanModeTool } from '@qwen-code/qwen-code-core/tools/exitPlanMode.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
@@ -5463,9 +5464,9 @@ describe('Session', () => {
|
||||
// `_meta.qwenTodoApproval` binding instead of poking the private
|
||||
// `activeTodoPlanRevision` field. Mirrors the it.each harness in the
|
||||
// prompt describe block.
|
||||
async function runExitPlanModeApprovalPrompt(): Promise<
|
||||
Parameters<AgentSideConnection['requestPermission']>[0]
|
||||
> {
|
||||
async function runExitPlanModeApprovalPrompt(
|
||||
onConfirm = vi.fn().mockResolvedValue(undefined),
|
||||
): Promise<Parameters<AgentSideConnection['requestPermission']>[0]> {
|
||||
let mode = ApprovalMode.PLAN;
|
||||
const hookSpy = vi
|
||||
.spyOn(core, 'firePermissionRequestHook')
|
||||
@@ -5484,7 +5485,7 @@ describe('Session', () => {
|
||||
title: 'Approve plan',
|
||||
plan: 'Original plan',
|
||||
hideAlwaysAllow: true,
|
||||
onConfirm: vi.fn().mockResolvedValue(undefined),
|
||||
onConfirm,
|
||||
}),
|
||||
getDescription: vi.fn().mockReturnValue('Plan:'),
|
||||
toolLocations: vi.fn().mockReturnValue([]),
|
||||
@@ -5535,6 +5536,156 @@ describe('Session', () => {
|
||||
return calls.at(-1)![0];
|
||||
}
|
||||
|
||||
it('forwards the expected Plan execution policy from permission response to confirmation', async () => {
|
||||
const onConfirm = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(mockClient.requestPermission).mockImplementationOnce(
|
||||
async () => ({
|
||||
outcome: { outcome: 'selected', optionId: 'proceed_once' },
|
||||
expectedPlanExecutionMode: 'yolo',
|
||||
}),
|
||||
);
|
||||
|
||||
await runExitPlanModeApprovalPrompt(onConfirm);
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
core.ToolConfirmationOutcome.ProceedOnce,
|
||||
expect.objectContaining({ expectedPlanExecutionMode: 'yolo' }),
|
||||
);
|
||||
});
|
||||
|
||||
describe('DAC plan approval through Session', () => {
|
||||
let mode: ApprovalMode;
|
||||
let policy: ApprovalMode | undefined;
|
||||
let revision: number;
|
||||
|
||||
beforeEach(() => {
|
||||
mode = ApprovalMode.PLAN;
|
||||
policy = ApprovalMode.YOLO;
|
||||
revision = 1;
|
||||
mockConfig.getApprovalMode = vi.fn(() => mode);
|
||||
mockConfig.getPlanExecutionMode = vi.fn(() => policy);
|
||||
mockConfig.getPrePlanMode = vi.fn(() => ApprovalMode.DEFAULT);
|
||||
mockConfig.getApprovalModeRevision = vi.fn(() => revision);
|
||||
mockConfig.setApprovalMode = vi.fn((next) => {
|
||||
mode = next;
|
||||
policy = undefined;
|
||||
revision++;
|
||||
});
|
||||
mockConfig.getTeamManager = vi.fn();
|
||||
mockConfig.savePlan = vi.fn();
|
||||
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(true);
|
||||
mockToolRegistry.getTool.mockReturnValue(
|
||||
new ExitPlanModeTool(mockConfig),
|
||||
);
|
||||
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
|
||||
createStreamWithChunks([
|
||||
{
|
||||
type: core.StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [
|
||||
{
|
||||
id: 'call-dac-plan',
|
||||
name: core.ToolNames.EXIT_PLAN_MODE,
|
||||
args: { plan: 'DAC plan' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([undefined, ApprovalMode.YOLO])(
|
||||
'consumes a missing or stale DAC policy (%s) as a tool error and keeps Plan',
|
||||
async (expectedPlanExecutionMode) => {
|
||||
vi.mocked(mockClient.requestPermission).mockImplementationOnce(
|
||||
async () => {
|
||||
policy = ApprovalMode.AUTO_EDIT;
|
||||
return {
|
||||
outcome: { outcome: 'selected', optionId: 'restore_previous' },
|
||||
...(expectedPlanExecutionMode
|
||||
? { expectedPlanExecutionMode }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [{ type: 'text', text: 'approve the plan' }],
|
||||
});
|
||||
|
||||
expect(result.stopReason).toBe('end_turn');
|
||||
expect(mockClient.requestPermission).toHaveBeenCalledOnce();
|
||||
expect(mockChatRecordingService.recordToolResult).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
functionResponse: expect.objectContaining({
|
||||
id: 'call-dac-plan',
|
||||
response: {
|
||||
error: expect.stringContaining(
|
||||
'Execution permission changed',
|
||||
),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
expect.objectContaining({
|
||||
status: 'error',
|
||||
errorType: core.ToolErrorType.EXECUTION_DENIED,
|
||||
executionStatus: 'not_started',
|
||||
}),
|
||||
);
|
||||
expect(mode).toBe(ApprovalMode.PLAN);
|
||||
expect(policy).toBe(ApprovalMode.AUTO_EDIT);
|
||||
expect(mockConfig.setApprovalMode).not.toHaveBeenCalled();
|
||||
expect(mockConfig.savePlan).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('settles an old approval after manual Plan exit without executing its plan', async () => {
|
||||
let respond!: (response: RequestPermissionResponse) => void;
|
||||
vi.mocked(mockClient.requestPermission).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
respond = resolve;
|
||||
}),
|
||||
);
|
||||
const prompt = session.prompt({
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [{ type: 'text', text: 'approve the plan' }],
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(mockClient.requestPermission).toHaveBeenCalledOnce(),
|
||||
);
|
||||
|
||||
await session.setMode({ sessionId: 'test-session-id', modeId: 'yolo' });
|
||||
expect(mode).toBe(ApprovalMode.YOLO);
|
||||
respond({
|
||||
outcome: { outcome: 'selected', optionId: 'restore_previous' },
|
||||
});
|
||||
|
||||
expect((await prompt).stopReason).toBe('end_turn');
|
||||
expect(mockChatRecordingService.recordToolResult).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
functionResponse: expect.objectContaining({
|
||||
id: 'call-dac-plan',
|
||||
response: {
|
||||
output: expect.stringContaining('Plan approval is stale'),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
expect.objectContaining({ status: 'success' }),
|
||||
);
|
||||
expect(mockConfig.setApprovalMode).toHaveBeenCalledOnce();
|
||||
expect(mockConfig.savePlan).not.toHaveBeenCalled();
|
||||
expect(mockClient.requestPermission).toHaveBeenCalledOnce();
|
||||
expect(mode).toBe(ApprovalMode.YOLO);
|
||||
});
|
||||
});
|
||||
|
||||
function enableSessionWorkflowRevisionContext(): void {
|
||||
let revision:
|
||||
| { planId: string; sourceCallId: string; todoIds: readonly string[] }
|
||||
@@ -5818,6 +5969,22 @@ describe('Session', () => {
|
||||
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
|
||||
it('reports the execution policy when entering Plan', async () => {
|
||||
mockConfig.getPrePlanMode = vi.fn(() => ApprovalMode.YOLO);
|
||||
await session.setMode({
|
||||
sessionId: 'test-session-id',
|
||||
modeId: 'plan',
|
||||
});
|
||||
|
||||
expect(mockClient.extNotification).toHaveBeenCalledWith(
|
||||
'qwen/notify/session/mode-update',
|
||||
expect.objectContaining({
|
||||
currentModeId: 'plan',
|
||||
planExecutionMode: 'yolo',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('emits a current_mode_update extNotification after switching (A2)', async () => {
|
||||
await session.setMode({
|
||||
sessionId: 'test-session-id',
|
||||
|
||||
@@ -10596,6 +10596,13 @@ export class Session implements SessionContext {
|
||||
v: 1,
|
||||
sessionId: this.sessionId,
|
||||
currentModeId: params.modeId,
|
||||
...(approvalMode === ApprovalMode.PLAN
|
||||
? {
|
||||
planExecutionMode:
|
||||
this.config.getPlanExecutionMode?.() ??
|
||||
this.config.getPrePlanMode?.(),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
.catch((error) => {
|
||||
// Advisory only; a failed notification must not fail the mode
|
||||
@@ -10963,6 +10970,13 @@ export class Session implements SessionContext {
|
||||
v: 1,
|
||||
sessionId: this.sessionId,
|
||||
currentModeId: newModeId,
|
||||
...(newModeId === ApprovalMode.PLAN
|
||||
? {
|
||||
planExecutionMode:
|
||||
this.config.getPlanExecutionMode?.() ??
|
||||
this.config.getPrePlanMode?.(),
|
||||
}
|
||||
: {}),
|
||||
legacyFrameSent,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -12870,6 +12884,7 @@ export class Session implements SessionContext {
|
||||
|
||||
let output: RequestPermissionResponse & {
|
||||
answers?: Record<string, string>;
|
||||
expectedPlanExecutionMode?: string;
|
||||
};
|
||||
let outcome: ToolConfirmationOutcome;
|
||||
try {
|
||||
@@ -12878,6 +12893,7 @@ export class Session implements SessionContext {
|
||||
activeToolAbortSignal,
|
||||
)) as RequestPermissionResponse & {
|
||||
answers?: Record<string, string>;
|
||||
expectedPlanExecutionMode?: string;
|
||||
};
|
||||
const permissionRequestCancellation =
|
||||
cancelBeforeExecutionIfAborted(toolName);
|
||||
@@ -12950,6 +12966,12 @@ export class Session implements SessionContext {
|
||||
|
||||
let confirmationPayload: ToolConfirmationPayload | undefined = {
|
||||
answers: output.answers,
|
||||
...(output.expectedPlanExecutionMode !== undefined
|
||||
? {
|
||||
expectedPlanExecutionMode:
|
||||
output.expectedPlanExecutionMode,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (planShellDecision.classification !== 'not-applicable') {
|
||||
const approval = await validatePlanModeShellApproval({
|
||||
|
||||
@@ -3169,6 +3169,26 @@ describe('multi-workspace session dispatch', () => {
|
||||
expect(primaryBridge.setApprovalModeCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('routes DAC planning controls to the live-session owner without a primary fallback', async () => {
|
||||
const { app, primaryBridge, secondaryBridge } = makeHarness();
|
||||
const res = await request(app)
|
||||
.post('/session/22222222-2222-4222-a222-222222222222/approval-mode')
|
||||
.set('Host', host())
|
||||
.set('X-Qwen-Client-Id', 'secondary-client')
|
||||
.send({ mode: 'auto-edit', planMode: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(secondaryBridge.setApprovalModeCalls).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: '22222222-2222-4222-a222-222222222222',
|
||||
mode: 'auto-edit',
|
||||
opts: { persist: false, planMode: true },
|
||||
context: { clientId: 'secondary-client' },
|
||||
}),
|
||||
]);
|
||||
expect(primaryBridge.setApprovalModeCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('still rejects model/approval-mode mutations on an untrusted non-primary session', async () => {
|
||||
// Opening these routes to non-primary owners must not bypass the trust
|
||||
// gate: an untrusted workspace runtime is refused before the bridge runs.
|
||||
|
||||
@@ -9107,6 +9107,7 @@ export function registerSessionRoutes(
|
||||
const body = safeBody(req);
|
||||
const mode = body['mode'];
|
||||
const persist = body['persist'];
|
||||
const planMode = body['planMode'];
|
||||
if (
|
||||
typeof mode !== 'string' ||
|
||||
!APPROVAL_MODES.includes(mode as ApprovalMode)
|
||||
@@ -9125,12 +9126,26 @@ export function registerSessionRoutes(
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
planMode !== undefined &&
|
||||
(typeof planMode !== 'boolean' || mode === 'plan')
|
||||
) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
'`planMode` must be a boolean with a non-plan execution mode',
|
||||
code: 'invalid_plan_mode',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const clientId = parseClientIdHeader(req, res);
|
||||
if (clientId === null) return;
|
||||
const response = await runtime.bridge.setSessionApprovalMode(
|
||||
sessionId,
|
||||
mode as ApprovalMode,
|
||||
{ persist: persist === true },
|
||||
{
|
||||
persist: persist === true,
|
||||
...(typeof planMode === 'boolean' ? { planMode } : {}),
|
||||
},
|
||||
clientId !== undefined ? { clientId } : undefined,
|
||||
);
|
||||
res.status(200).json(response);
|
||||
|
||||
@@ -25070,6 +25070,46 @@ describe('createServeApp', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'forwards DAC planMode=%s with its execution policy',
|
||||
async (planMode) => {
|
||||
const bridge = fakeBridge();
|
||||
const app = createServeApp(tokenOpts, undefined, { bridge });
|
||||
const res = await auth(
|
||||
request(app).post('/session/session-A/approval-mode'),
|
||||
).send({ mode: 'yolo', planMode });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(bridge.setApprovalModeCalls).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: 'session-A',
|
||||
mode: 'yolo',
|
||||
opts: { persist: false, planMode },
|
||||
}),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ mode: 'plan', planMode: true },
|
||||
{ mode: 'plan', planMode: false },
|
||||
{ mode: 'yolo', planMode: 'true' },
|
||||
{ mode: 'default', planMode: null },
|
||||
])(
|
||||
'rejects invalid DAC planning control %j before the bridge',
|
||||
async (body) => {
|
||||
const bridge = fakeBridge();
|
||||
const app = createServeApp(tokenOpts, undefined, { bridge });
|
||||
const res = await auth(
|
||||
request(app).post('/session/session-A/approval-mode'),
|
||||
).send(body);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('invalid_plan_mode');
|
||||
expect(bridge.setApprovalModeCalls).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('forwards persist:true to the bridge', async () => {
|
||||
const bridge = fakeBridge();
|
||||
const app = createServeApp(tokenOpts, undefined, { bridge });
|
||||
|
||||
@@ -11241,6 +11241,79 @@ describe('setApprovalMode with folder trust', () => {
|
||||
expect(() => config.setApprovalMode(ApprovalMode.PLAN)).not.toThrow();
|
||||
});
|
||||
|
||||
describe('DAC plan workflow', () => {
|
||||
it.each([
|
||||
ApprovalMode.DEFAULT,
|
||||
ApprovalMode.AUTO_EDIT,
|
||||
ApprovalMode.AUTO,
|
||||
ApprovalMode.YOLO,
|
||||
])('keeps planning while selecting %s for execution', (mode) => {
|
||||
const config = new Config(baseParams);
|
||||
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
|
||||
config.setApprovalMode(ApprovalMode.YOLO);
|
||||
config.setPlanMode(true, ApprovalMode.YOLO);
|
||||
const revision = config.getApprovalModeRevision();
|
||||
|
||||
config.setPlanMode(true, mode);
|
||||
|
||||
expect(config.getApprovalMode()).toBe(ApprovalMode.PLAN);
|
||||
expect(config.getPlanExecutionMode()).toBe(mode);
|
||||
expect(config.getPrePlanMode()).toBe(ApprovalMode.YOLO);
|
||||
expect(config.getApprovalModeRevision()).toBe(revision);
|
||||
expect(config.consumePendingManualPlanExitNotice()).toBe(false);
|
||||
config.setPlanMode(false, mode);
|
||||
expect(config.getApprovalMode()).toBe(mode);
|
||||
expect(config.getPlanExecutionMode()).toBeUndefined();
|
||||
expect(config.consumePendingManualPlanExitNotice()).toBe(true);
|
||||
});
|
||||
|
||||
it('clears the selected policy on approved or legacy exits', () => {
|
||||
const config = new Config(baseParams);
|
||||
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
|
||||
config.setPlanMode(true, ApprovalMode.YOLO);
|
||||
config.setApprovalMode(ApprovalMode.YOLO, {
|
||||
fromApprovedPlanExit: true,
|
||||
});
|
||||
expect(config.getPlanExecutionMode()).toBeUndefined();
|
||||
config.setPlanMode(true, ApprovalMode.AUTO_EDIT);
|
||||
config.setApprovalMode(ApprovalMode.DEFAULT);
|
||||
expect(config.getPlanExecutionMode()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects privileged policies before changing an untrusted config', () => {
|
||||
const config = new Config(baseParams);
|
||||
config.setApprovalMode(ApprovalMode.DEFAULT);
|
||||
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(false);
|
||||
expect(() => config.setPlanMode(true, ApprovalMode.YOLO)).toThrow(
|
||||
TrustGateError,
|
||||
);
|
||||
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
|
||||
expect(config.getPlanExecutionMode()).toBeUndefined();
|
||||
config.setPlanMode(true, ApprovalMode.DEFAULT);
|
||||
expect(() => config.setPlanMode(true, ApprovalMode.AUTO_EDIT)).toThrow(
|
||||
TrustGateError,
|
||||
);
|
||||
expect(config.getApprovalMode()).toBe(ApprovalMode.PLAN);
|
||||
expect(config.getPlanExecutionMode()).toBe(ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
it('rejects Plan as an execution policy and isolates derived configs', () => {
|
||||
const config = new Config(baseParams);
|
||||
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
|
||||
expect(() => config.setPlanMode(true, ApprovalMode.PLAN)).toThrow(
|
||||
'Plan is not an execution approval mode',
|
||||
);
|
||||
config.setPlanMode(true, ApprovalMode.YOLO);
|
||||
const child = deriveConfig(config);
|
||||
expect(child.getPlanExecutionMode()).toBeUndefined();
|
||||
expect(() => child.setPlanMode(false, ApprovalMode.DEFAULT)).toThrow(
|
||||
'Derived Configs cannot change plan workflow mode',
|
||||
);
|
||||
expect(config.getApprovalMode()).toBe(ApprovalMode.PLAN);
|
||||
expect(config.getPlanExecutionMode()).toBe(ApprovalMode.YOLO);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prePlanMode tracking', () => {
|
||||
it('should save pre-plan mode when entering plan mode', () => {
|
||||
const config = new Config(baseParams);
|
||||
|
||||
@@ -2336,6 +2336,7 @@ export class Config {
|
||||
private readonly contextRuleExcludes: string[];
|
||||
private approvalMode: ApprovalMode;
|
||||
private prePlanMode?: ApprovalMode;
|
||||
private planExecutionMode?: ApprovalMode;
|
||||
private approvalModeRevision = 0;
|
||||
private manualPlanExitNoticeEventState: ManualPlanExitNoticeEventState = {
|
||||
version: 0,
|
||||
@@ -7185,6 +7186,28 @@ export class Config {
|
||||
return this.prePlanMode ?? ApprovalMode.DEFAULT;
|
||||
}
|
||||
|
||||
getPlanExecutionMode(): ApprovalMode | undefined {
|
||||
return Object.hasOwn(this, 'planExecutionMode')
|
||||
? this.planExecutionMode
|
||||
: undefined;
|
||||
}
|
||||
|
||||
setPlanMode(enabled: boolean, executionMode: ApprovalMode): void {
|
||||
if (isDerivedConfig(this)) {
|
||||
throw new Error('Derived Configs cannot change plan workflow mode');
|
||||
}
|
||||
if (executionMode === ApprovalMode.PLAN) {
|
||||
throw new Error('Plan is not an execution approval mode');
|
||||
}
|
||||
if (!this.isTrustedFolder() && executionMode !== ApprovalMode.DEFAULT) {
|
||||
throw new TrustGateError(
|
||||
'Cannot enable privileged approval modes in an untrusted folder.',
|
||||
);
|
||||
}
|
||||
this.setApprovalMode(enabled ? ApprovalMode.PLAN : executionMode);
|
||||
this.planExecutionMode = enabled ? executionMode : undefined;
|
||||
}
|
||||
|
||||
getApprovalModeRevision(): number {
|
||||
return this.approvalModeRevision;
|
||||
}
|
||||
@@ -7295,6 +7318,7 @@ export class Config {
|
||||
this.autoModeDenialState = resetDenialState();
|
||||
}
|
||||
this.approvalMode = mode;
|
||||
if (mode !== ApprovalMode.PLAN) this.planExecutionMode = undefined;
|
||||
if (fromMode !== mode) {
|
||||
this.approvalModeRevision++;
|
||||
}
|
||||
|
||||
@@ -171,6 +171,63 @@ describe('ExitPlanModeTool', () => {
|
||||
expect(approvalMode).toBe(ApprovalMode.YOLO);
|
||||
});
|
||||
|
||||
it('captures the DAC execution policy at approval and freezes it through execute', async () => {
|
||||
prePlanMode = ApprovalMode.YOLO;
|
||||
let selectedMode = ApprovalMode.AUTO_EDIT;
|
||||
config.getPlanExecutionMode = vi.fn(() => selectedMode);
|
||||
const invocation = tool.build({ plan: 'DAC plan' });
|
||||
const confirmation = await invocation.getConfirmationDetails(
|
||||
new AbortController().signal,
|
||||
);
|
||||
selectedMode = ApprovalMode.DEFAULT;
|
||||
await confirmation.onConfirm(ToolConfirmationOutcome.RestorePrevious, {
|
||||
expectedPlanExecutionMode: ApprovalMode.DEFAULT,
|
||||
});
|
||||
selectedMode = ApprovalMode.YOLO;
|
||||
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(approvalMode).toBe(ApprovalMode.DEFAULT);
|
||||
expect(config.setApprovalMode).toHaveBeenCalledWith(ApprovalMode.DEFAULT, {
|
||||
fromApprovedPlanExit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([undefined, ApprovalMode.AUTO_EDIT])(
|
||||
'rejects a DAC approval with a missing or stale policy (%s)',
|
||||
async (expectedPlanExecutionMode) => {
|
||||
let selectedMode = ApprovalMode.AUTO_EDIT;
|
||||
config.getPlanExecutionMode = vi.fn(() => selectedMode);
|
||||
const invocation = tool.build({ plan: 'DAC plan' });
|
||||
const confirmation = await invocation.getConfirmationDetails(
|
||||
new AbortController().signal,
|
||||
);
|
||||
selectedMode = ApprovalMode.YOLO;
|
||||
|
||||
await expect(
|
||||
confirmation.onConfirm(ToolConfirmationOutcome.RestorePrevious, {
|
||||
expectedPlanExecutionMode,
|
||||
}),
|
||||
).rejects.toThrow('Execution permission changed');
|
||||
const rejected = await invocation.execute(new AbortController().signal);
|
||||
expect(rejected.llmContent).toContain('Plan execution was not approved');
|
||||
expect(approvalMode).toBe(ApprovalMode.PLAN);
|
||||
expect(config.setApprovalMode).not.toHaveBeenCalled();
|
||||
expect(config.savePlan).not.toHaveBeenCalled();
|
||||
|
||||
const retry = await invocation.getConfirmationDetails(
|
||||
new AbortController().signal,
|
||||
);
|
||||
await retry.onConfirm(ToolConfirmationOutcome.RestorePrevious, {
|
||||
expectedPlanExecutionMode: ApprovalMode.YOLO,
|
||||
});
|
||||
const approved = await invocation.execute(new AbortController().signal);
|
||||
expect(approved.error).toBeUndefined();
|
||||
expect(approvalMode).toBe(ApprovalMode.YOLO);
|
||||
},
|
||||
);
|
||||
|
||||
it('executes the snapshot belonging to the confirmation that was approved', async () => {
|
||||
const params = { plan: 'First plan' };
|
||||
const invocation = tool.build(params);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type {
|
||||
ToolCallConfirmationDetails,
|
||||
ToolPlanConfirmationDetails,
|
||||
ToolConfirmationPayload,
|
||||
ToolResult,
|
||||
} from './tools.js';
|
||||
import type { PermissionDecision } from '../permissions/types.js';
|
||||
@@ -177,14 +178,29 @@ class ExitPlanModeToolInvocation extends BaseToolInvocation<
|
||||
hideAlwaysAllow: true,
|
||||
plan: snapshot.plan,
|
||||
prePlanMode: snapshot.prePlanMode,
|
||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||
onConfirm: async (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
payload?: ToolConfirmationPayload,
|
||||
) => {
|
||||
switch (outcome) {
|
||||
case ToolConfirmationOutcome.RestorePrevious:
|
||||
case ToolConfirmationOutcome.RestorePrevious: {
|
||||
this.approval = undefined;
|
||||
const executionMode = this.config.getPlanExecutionMode?.();
|
||||
if (
|
||||
executionMode !== undefined &&
|
||||
payload?.expectedPlanExecutionMode !== executionMode
|
||||
) {
|
||||
throw new StructuredToolError(
|
||||
'Execution permission changed or was not confirmed. Request plan approval again with the current permission.',
|
||||
ToolErrorType.EXECUTION_DENIED,
|
||||
);
|
||||
}
|
||||
this.approval = {
|
||||
snapshot,
|
||||
targetMode: snapshot.prePlanMode,
|
||||
targetMode: executionMode ?? snapshot.prePlanMode,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case ToolConfirmationOutcome.ProceedAlways:
|
||||
this.approval = {
|
||||
snapshot,
|
||||
|
||||
@@ -955,6 +955,8 @@ export interface ToolEditConfirmationDetails {
|
||||
}
|
||||
|
||||
export interface ToolConfirmationPayload {
|
||||
/** Execution permission displayed when approving a DAC plan. */
|
||||
expectedPlanExecutionMode?: string;
|
||||
// used to override `modifiedProposedContent` for modifiable tools in the
|
||||
// inline modify flow
|
||||
newContent?: string;
|
||||
|
||||
@@ -3872,7 +3872,7 @@ export class DaemonClient {
|
||||
async setSessionApprovalMode(
|
||||
sessionId: string,
|
||||
mode: DaemonApprovalMode,
|
||||
opts?: { persist?: boolean; clientId?: string },
|
||||
opts?: { persist?: boolean; clientId?: string; planMode?: boolean },
|
||||
): Promise<DaemonApprovalModeResult> {
|
||||
return await this.fetchWithTimeout(
|
||||
`${this.baseUrl}/session/${urlEncode(sessionId)}/approval-mode`,
|
||||
@@ -3884,6 +3884,7 @@ export class DaemonClient {
|
||||
),
|
||||
body: JSON.stringify({
|
||||
mode,
|
||||
...(opts?.planMode !== undefined ? { planMode: opts.planMode } : {}),
|
||||
...(opts?.persist === true ? { persist: true } : {}),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -653,6 +653,7 @@ export interface DaemonAuthDeviceFlowCancelledData {
|
||||
*/
|
||||
export interface DaemonApprovalModeChangedData {
|
||||
sessionId: string;
|
||||
planExecutionMode?: string;
|
||||
previous: string;
|
||||
next: string;
|
||||
persisted: boolean;
|
||||
@@ -954,6 +955,7 @@ export interface DaemonSessionSnapshotData {
|
||||
sessionId: string;
|
||||
currentModelId: string | null;
|
||||
currentApprovalMode: string | null;
|
||||
planExecutionMode?: string;
|
||||
recordingDegraded?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -1372,6 +1374,7 @@ export interface DaemonSessionViewState {
|
||||
* toggled N times this session". Non-terminal.
|
||||
*/
|
||||
approvalMode?: string;
|
||||
planExecutionMode?: string;
|
||||
approvalModeChangedCount: number;
|
||||
lastApprovalModeChange?: DaemonApprovalModeChangedData;
|
||||
/**
|
||||
@@ -1556,6 +1559,7 @@ export function createDaemonSessionViewState(
|
||||
lastWorkspaceMutation: seed.lastWorkspaceMutation,
|
||||
lastWorkspaceMutationType: seed.lastWorkspaceMutationType,
|
||||
approvalMode: seed.approvalMode,
|
||||
planExecutionMode: seed.planExecutionMode,
|
||||
approvalModeChangedCount: seed.approvalModeChangedCount ?? 0,
|
||||
lastApprovalModeChange: seed.lastApprovalModeChange,
|
||||
toolToggleCount: seed.toolToggleCount ?? 0,
|
||||
@@ -2155,6 +2159,8 @@ export function reduceDaemonSessionEvent(
|
||||
return {
|
||||
...base,
|
||||
approvalMode: event.data.next,
|
||||
planExecutionMode:
|
||||
event.data.next === 'plan' ? event.data.planExecutionMode : undefined,
|
||||
approvalModeChangedCount: base.approvalModeChangedCount + 1,
|
||||
lastApprovalModeChange: mergeOriginator(event.data, event),
|
||||
};
|
||||
@@ -2239,7 +2245,13 @@ export function reduceDaemonSessionEvent(
|
||||
? { currentModelId: event.data.currentModelId }
|
||||
: {}),
|
||||
...(event.data.currentApprovalMode != null
|
||||
? { approvalMode: event.data.currentApprovalMode }
|
||||
? {
|
||||
approvalMode: event.data.currentApprovalMode,
|
||||
planExecutionMode:
|
||||
event.data.currentApprovalMode === 'plan'
|
||||
? event.data.planExecutionMode
|
||||
: undefined,
|
||||
}
|
||||
: {}),
|
||||
...(event.data.recordingDegraded !== undefined
|
||||
? { recordingDegraded: event.data.recordingDegraded }
|
||||
@@ -3018,6 +3030,8 @@ function isApprovalModeChangedData(
|
||||
isNonEmptyString(value['sessionId']) &&
|
||||
isNonEmptyString(value['previous']) &&
|
||||
isNonEmptyString(value['next']) &&
|
||||
(value['planExecutionMode'] === undefined ||
|
||||
isNonEmptyString(value['planExecutionMode'])) &&
|
||||
typeof value['persisted'] === 'boolean'
|
||||
);
|
||||
}
|
||||
@@ -3355,6 +3369,8 @@ function isSessionSnapshotData(
|
||||
return (
|
||||
(model === null || typeof model === 'string') &&
|
||||
(mode === null || typeof mode === 'string') &&
|
||||
(value['planExecutionMode'] === undefined ||
|
||||
isNonEmptyString(value['planExecutionMode'])) &&
|
||||
(recordingDegraded === undefined || typeof recordingDegraded === 'boolean')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -632,6 +632,8 @@ export interface DaemonStatusReportSession {
|
||||
lastSeenAt?: number;
|
||||
currentModelId?: string;
|
||||
currentApprovalMode?: string;
|
||||
/** Selected execution policy while the session is in Plan. */
|
||||
planExecutionMode?: string;
|
||||
/**
|
||||
* Effective live-journal caps right now — the baseline, or higher when
|
||||
* adaptive growth raised them mid-turn. Absent on older daemons.
|
||||
@@ -3312,6 +3314,7 @@ export type DaemonApprovalMode = PermissionMode;
|
||||
*/
|
||||
export interface DaemonApprovalModeResult {
|
||||
sessionId: string;
|
||||
planExecutionMode?: string;
|
||||
mode: string;
|
||||
previous: string;
|
||||
persisted: boolean;
|
||||
@@ -4587,6 +4590,8 @@ export type PermissionOutcome =
|
||||
| PermissionOutcomeSelected;
|
||||
|
||||
export interface PermissionResponse {
|
||||
/** Execution permission displayed when approving a DAC plan. */
|
||||
expectedPlanExecutionMode?: string;
|
||||
outcome: PermissionOutcome;
|
||||
/** Answers to ask_user_question, keyed by its `answerKey`. */
|
||||
answers?: Record<string, string>;
|
||||
|
||||
@@ -5542,6 +5542,27 @@ describe('DaemonClient', () => {
|
||||
});
|
||||
|
||||
describe('setSessionApprovalMode (#4175 Wave 4 PR 17)', () => {
|
||||
it.each([true, false])(
|
||||
'forwards explicit Plan workflow state %s alongside execution permission',
|
||||
async (planMode) => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse(200, {
|
||||
sessionId: 's-1',
|
||||
mode: planMode ? 'plan' : 'yolo',
|
||||
...(planMode ? { planExecutionMode: 'yolo' } : {}),
|
||||
previous: 'default',
|
||||
persisted: false,
|
||||
}),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const result = await client.setSessionApprovalMode('s-1', 'yolo', {
|
||||
planMode,
|
||||
});
|
||||
expect(JSON.parse(calls[0]!.body!)).toEqual({ mode: 'yolo', planMode });
|
||||
expect(result.planExecutionMode).toBe(planMode ? 'yolo' : undefined);
|
||||
},
|
||||
);
|
||||
|
||||
it('POSTs the mode and returns the typed result', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse(200, {
|
||||
|
||||
@@ -2245,6 +2245,73 @@ describe('PR 21 — auth device-flow events', () => {
|
||||
// `unrecognizedKnownEventCount` rather than the event-specific
|
||||
// counter), and the envelope-level `originatorClientId` merge.
|
||||
describe('PR 17 mutation events', () => {
|
||||
it('keeps Plan execution permission through events and reconnect, then clears it on exit', () => {
|
||||
let state = createDaemonSessionViewState();
|
||||
for (const [id, mode] of ['yolo', 'auto-edit'].entries()) {
|
||||
state = reduceDaemonSessionEvent(state, {
|
||||
id: id + 1,
|
||||
v: 1,
|
||||
type: 'approval_mode_changed',
|
||||
data: {
|
||||
sessionId: 'sess-1',
|
||||
previous: 'plan',
|
||||
next: 'plan',
|
||||
planExecutionMode: mode,
|
||||
persisted: false,
|
||||
},
|
||||
});
|
||||
expect(state.approvalMode).toBe('plan');
|
||||
expect(state.planExecutionMode).toBe(mode);
|
||||
}
|
||||
state = reduceDaemonSessionEvent(state, {
|
||||
id: 3,
|
||||
v: 1,
|
||||
type: 'session_snapshot',
|
||||
data: {
|
||||
sessionId: 'sess-1',
|
||||
currentModelId: null,
|
||||
currentApprovalMode: 'plan',
|
||||
planExecutionMode: 'default',
|
||||
},
|
||||
});
|
||||
expect(state.planExecutionMode).toBe('default');
|
||||
state = reduceDaemonSessionEvent(state, {
|
||||
id: 4,
|
||||
v: 1,
|
||||
type: 'approval_mode_changed',
|
||||
data: {
|
||||
sessionId: 'sess-1',
|
||||
previous: 'plan',
|
||||
next: 'default',
|
||||
persisted: false,
|
||||
},
|
||||
});
|
||||
expect(state.approvalMode).toBe('default');
|
||||
expect(state.planExecutionMode).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['approval_mode_changed', 'session_snapshot'])(
|
||||
'rejects malformed Plan execution permission in %s',
|
||||
(type) => {
|
||||
expect(
|
||||
asKnownDaemonEvent({
|
||||
id: 1,
|
||||
v: 1,
|
||||
type,
|
||||
data: {
|
||||
sessionId: 'sess-1',
|
||||
previous: 'default',
|
||||
next: 'plan',
|
||||
persisted: false,
|
||||
currentModelId: null,
|
||||
currentApprovalMode: 'plan',
|
||||
planExecutionMode: true,
|
||||
},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('approval_mode_changed: increments counter, copies envelope originator', () => {
|
||||
const next = reduceDaemonSessionEvent(createDaemonSessionViewState(), {
|
||||
id: 5,
|
||||
|
||||
@@ -54,7 +54,8 @@ type MockConnection = {
|
||||
titleSource?: 'manual' | 'auto';
|
||||
workspaceCwd: string;
|
||||
currentModel: string | undefined;
|
||||
currentMode: string;
|
||||
currentMode: string | undefined;
|
||||
planExecutionMode?: string;
|
||||
models: Array<{
|
||||
id: string;
|
||||
label?: string;
|
||||
@@ -115,6 +116,11 @@ function activeGoalSnapshot(
|
||||
}
|
||||
|
||||
type ChatEditorTestProps = {
|
||||
currentMode?: string;
|
||||
planMode?: boolean;
|
||||
modeControlsDisabled?: boolean;
|
||||
onTogglePlan?: () => void;
|
||||
onSelectMode?: (mode: string) => void;
|
||||
onSubmit: (
|
||||
text: string,
|
||||
images?: { data: string; media_type: string }[],
|
||||
@@ -408,6 +414,7 @@ const {
|
||||
switchStarted: true,
|
||||
}),
|
||||
submitPermission: vi.fn().mockResolvedValue(true),
|
||||
respondToPermission: vi.fn().mockResolvedValue(true),
|
||||
clearGoal: vi.fn().mockResolvedValue(undefined),
|
||||
getGoal: vi.fn().mockResolvedValue({
|
||||
snapshot: { v: 2, activity: 'idle', goal: null },
|
||||
@@ -597,6 +604,7 @@ const {
|
||||
}) => Promise<boolean | void> | boolean | void;
|
||||
manageLiveState?: boolean;
|
||||
} | null,
|
||||
latestToolApprovalDisabled: false,
|
||||
latestToolApprovalKeyboardActive: null as boolean | null,
|
||||
toolApprovalKeyboardActiveHistory: [] as Array<boolean | null>,
|
||||
latestToolApprovalPlanTodos: [] as Array<{ id: string }>,
|
||||
@@ -2167,9 +2175,12 @@ vi.doMock('./components/messages/ToolApproval', async () => {
|
||||
return {
|
||||
ToolApproval: (props: {
|
||||
keyboardActive?: boolean;
|
||||
disabled?: boolean;
|
||||
planTodos?: Array<{ id: string }>;
|
||||
planExecutionMode?: string;
|
||||
onConfirm?: (id: string, selectedOption: string) => void | Promise<void>;
|
||||
}) => {
|
||||
testState.latestToolApprovalDisabled = props.disabled ?? false;
|
||||
testState.latestToolApprovalKeyboardActive = props.keyboardActive ?? null;
|
||||
testState.toolApprovalKeyboardActiveHistory.push(
|
||||
props.keyboardActive ?? null,
|
||||
@@ -2180,6 +2191,7 @@ vi.doMock('./components/messages/ToolApproval', async () => {
|
||||
testState.latestToolApprovalOnConfirm = props.onConfirm ?? null;
|
||||
return React.createElement('div', {
|
||||
'data-web-shell-permission-panel': '',
|
||||
'data-plan-execution-mode': props.planExecutionMode,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -6922,6 +6934,190 @@ describe('artifact panel fullscreen', () => {
|
||||
expect(testState.latestAskUserQuestionKeyboardActive).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks same-tick plan handoff until a mode request completes, then permits approval', async () => {
|
||||
mockConnection.currentMode = 'plan';
|
||||
mockConnection.planExecutionMode = 'default';
|
||||
testState.blocks = [makePlanPermissionBlock()];
|
||||
const pendingMode = deferred<{ mode: string; planExecutionMode: string }>();
|
||||
mockSessionActions.setApprovalMode.mockReturnValueOnce(pendingMode.promise);
|
||||
renderApp();
|
||||
await flush();
|
||||
await act(async () => {
|
||||
testState.latestChatEditorProps?.onSelectMode?.('yolo');
|
||||
await expect(
|
||||
testState.latestToolApprovalOnConfirm?.('req-1', 'restore_previous'),
|
||||
).rejects.toThrow('still pending');
|
||||
});
|
||||
expect(mockSessionActions.submitPermission).not.toHaveBeenCalled();
|
||||
expect(testState.latestToolApprovalDisabled).toBe(true);
|
||||
expect(testState.latestChatEditorProps?.modeControlsDisabled).toBe(true);
|
||||
await act(async () => {
|
||||
pendingMode.resolve({ mode: 'plan', planExecutionMode: 'yolo' });
|
||||
});
|
||||
expect(testState.latestToolApprovalDisabled).toBe(false);
|
||||
await act(async () => {
|
||||
await testState.latestToolApprovalOnConfirm?.(
|
||||
'req-1',
|
||||
'restore_previous',
|
||||
);
|
||||
});
|
||||
expect(mockSessionActions.respondToPermission).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
{
|
||||
outcome: { outcome: 'selected', optionId: 'restore_previous' },
|
||||
expectedPlanExecutionMode: 'default',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('holds plan handoff through permission acknowledgement and disappearance until runtime exits Plan', async () => {
|
||||
mockConnection.currentMode = 'plan';
|
||||
mockConnection.planExecutionMode = 'yolo';
|
||||
testState.blocks = [makePlanPermissionBlock()];
|
||||
const permission = deferred<void>();
|
||||
mockSessionActions.respondToPermission.mockReturnValueOnce(
|
||||
permission.promise,
|
||||
);
|
||||
const { rerender } = renderApp();
|
||||
await flush();
|
||||
let submitted: void | Promise<void>;
|
||||
act(() => {
|
||||
submitted = testState.latestToolApprovalOnConfirm?.(
|
||||
'req-1',
|
||||
'restore_previous',
|
||||
);
|
||||
testState.latestChatEditorProps?.onSelectMode?.('default');
|
||||
testState.latestChatEditorProps?.onTogglePlan?.();
|
||||
});
|
||||
expect(mockSessionActions.setApprovalMode).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
permission.resolve();
|
||||
await submitted;
|
||||
});
|
||||
testState.blocks = [];
|
||||
rerender();
|
||||
await flush();
|
||||
expect(testState.latestChatEditorProps?.modeControlsDisabled).toBe(true);
|
||||
testState.blocks = [
|
||||
makePendingPermissionBlock({ requestId: 'background-approval' }),
|
||||
];
|
||||
rerender();
|
||||
await flush();
|
||||
expect(testState.latestChatEditorProps?.modeControlsDisabled).toBe(true);
|
||||
act(() => testState.latestChatEditorProps?.onTogglePlan?.());
|
||||
expect(mockSessionActions.setApprovalMode).not.toHaveBeenCalled();
|
||||
mockConnection.currentMode = 'yolo';
|
||||
rerender();
|
||||
await flush();
|
||||
expect(testState.latestChatEditorProps?.modeControlsDisabled).toBe(false);
|
||||
await act(async () =>
|
||||
testState.latestChatEditorProps?.onSelectMode?.('default'),
|
||||
);
|
||||
expect(mockSessionActions.setApprovalMode).toHaveBeenCalledWith('default', {
|
||||
planMode: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('releases plan handoff after mode or permission failure so both controls can retry', async () => {
|
||||
mockConnection.currentMode = 'plan';
|
||||
testState.blocks = [makePlanPermissionBlock()];
|
||||
renderApp();
|
||||
await flush();
|
||||
mockSessionActions.setApprovalMode.mockRejectedValueOnce(
|
||||
new Error('mode failed'),
|
||||
);
|
||||
await act(async () =>
|
||||
testState.latestChatEditorProps?.onSelectMode?.('yolo'),
|
||||
);
|
||||
expect(testState.latestToolApprovalDisabled).toBe(false);
|
||||
mockSessionActions.submitPermission.mockRejectedValueOnce(
|
||||
new Error('permission failed'),
|
||||
);
|
||||
await act(async () => {
|
||||
await expect(
|
||||
testState.latestToolApprovalOnConfirm?.('req-1', 'restore_previous'),
|
||||
).rejects.toThrow('permission failed');
|
||||
});
|
||||
expect(testState.latestChatEditorProps?.modeControlsDisabled).toBe(false);
|
||||
await act(async () => testState.latestChatEditorProps?.onTogglePlan?.());
|
||||
expect(mockSessionActions.setApprovalMode).toHaveBeenLastCalledWith(
|
||||
'default',
|
||||
{ planMode: false },
|
||||
);
|
||||
});
|
||||
|
||||
it('releases a plan handoff owner without letting its late failure unlock a replacement mode request', async () => {
|
||||
mockConnection.currentMode = 'plan';
|
||||
testState.blocks = [makePlanPermissionBlock()];
|
||||
const permission = deferred<void>();
|
||||
mockSessionActions.submitPermission.mockReturnValueOnce(permission.promise);
|
||||
const { rerender } = renderApp();
|
||||
await flush();
|
||||
let submission: void | Promise<void>;
|
||||
act(() => {
|
||||
submission = testState.latestToolApprovalOnConfirm?.(
|
||||
'req-1',
|
||||
'restore_previous',
|
||||
);
|
||||
});
|
||||
testState.ownerVersion += 1;
|
||||
testState.blocks = [];
|
||||
mockConnection.sessionId = 'replacement';
|
||||
mockConnection.currentMode = 'default';
|
||||
rerender();
|
||||
await flush();
|
||||
expect(testState.latestChatEditorProps?.modeControlsDisabled).toBe(false);
|
||||
const mode = deferred<{ mode: string }>();
|
||||
mockSessionActions.setApprovalMode.mockReturnValueOnce(mode.promise);
|
||||
act(() => testState.latestChatEditorProps?.onSelectMode?.('yolo'));
|
||||
await act(async () => {
|
||||
permission.reject(new Error('old owner failed'));
|
||||
await expect(submission).rejects.toThrow('old owner failed');
|
||||
});
|
||||
expect(testState.latestChatEditorProps?.modeControlsDisabled).toBe(true);
|
||||
await act(async () => {
|
||||
mode.resolve({ mode: 'yolo' });
|
||||
});
|
||||
expect(testState.latestChatEditorProps?.modeControlsDisabled).toBe(false);
|
||||
});
|
||||
|
||||
it('releases a plan handoff when its turn terminates even if Plan stays enabled', async () => {
|
||||
mockConnection.currentMode = 'plan';
|
||||
testState.streamingState = 'responding';
|
||||
testState.blocks = [makePlanPermissionBlock()];
|
||||
const { rerender } = renderApp();
|
||||
await flush();
|
||||
await act(async () => {
|
||||
await testState.latestToolApprovalOnConfirm?.(
|
||||
'req-1',
|
||||
'restore_previous',
|
||||
);
|
||||
});
|
||||
expect(testState.latestChatEditorProps?.modeControlsDisabled).toBe(true);
|
||||
testState.streamingState = 'idle';
|
||||
testState.blocks = [];
|
||||
rerender();
|
||||
await flush();
|
||||
expect(testState.latestChatEditorProps?.modeControlsDisabled).toBe(false);
|
||||
});
|
||||
|
||||
it('releases plan handoff after rejecting a plan without waiting for runtime mode to change', async () => {
|
||||
mockConnection.currentMode = 'plan';
|
||||
testState.blocks = [makePlanPermissionBlock()];
|
||||
renderApp();
|
||||
await flush();
|
||||
await act(async () => {
|
||||
await testState.latestToolApprovalOnConfirm?.('req-1', 'cancel');
|
||||
});
|
||||
expect(testState.latestChatEditorProps?.modeControlsDisabled).toBe(false);
|
||||
await act(async () =>
|
||||
testState.latestChatEditorProps?.onSelectMode?.('yolo'),
|
||||
);
|
||||
expect(mockSessionActions.setApprovalMode).toHaveBeenCalledWith('yolo', {
|
||||
planMode: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('rethrows a rejected permission submission so the overlay re-arms', async () => {
|
||||
// The main-chat approval overlay is onConfirm for every main-chat
|
||||
// approval. ToolApproval re-arms its double-submit guard only when the
|
||||
@@ -9282,6 +9478,7 @@ async function triggerAutoRecap(): Promise<{
|
||||
// (getPermissionRawInput reads toolCall.input) — a bare toolName isn't enough.
|
||||
function makePendingPermissionBlock(
|
||||
overrides: {
|
||||
requestId?: string;
|
||||
resolved?: boolean;
|
||||
toolName?: string;
|
||||
kind?: string;
|
||||
@@ -9299,7 +9496,7 @@ function makePendingPermissionBlock(
|
||||
return {
|
||||
kind: 'permission',
|
||||
resolved: overrides.resolved ?? false,
|
||||
requestId: 'req-1',
|
||||
requestId: overrides.requestId ?? 'req-1',
|
||||
sessionId: 'session-1',
|
||||
title: 'Run ls',
|
||||
toolCall: {
|
||||
@@ -9321,6 +9518,25 @@ function makePendingPermissionBlock(
|
||||
};
|
||||
}
|
||||
|
||||
function makePlanPermissionBlock() {
|
||||
return makePendingPermissionBlock({
|
||||
toolName: 'exit_plan_mode',
|
||||
kind: 'switch_mode',
|
||||
options: [
|
||||
{
|
||||
optionId: 'restore_previous',
|
||||
label: 'Approve',
|
||||
raw: { kind: 'allow_once' },
|
||||
},
|
||||
{
|
||||
optionId: 'cancel',
|
||||
label: 'Continue planning',
|
||||
raw: { kind: 'reject_once' },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Split persistence uses sessionStorage; clear it so one test's split doesn't
|
||||
// auto-restore into the next test's App mount.
|
||||
@@ -9358,6 +9574,7 @@ beforeEach(() => {
|
||||
mockConnection.displayName = 'Session One';
|
||||
mockConnection.titleSource = undefined;
|
||||
mockConnection.currentMode = 'default';
|
||||
mockConnection.planExecutionMode = undefined;
|
||||
mockConnection.currentModel = 'qwen';
|
||||
mockConnection.models = [{ id: 'qwen', label: 'Qwen' }];
|
||||
mockConnection.error = undefined;
|
||||
@@ -9613,7 +9830,12 @@ beforeEach(() => {
|
||||
mockSessionActions.refreshCommands.mockResolvedValue(undefined);
|
||||
mockSessionActions.setModel.mockResolvedValue(undefined);
|
||||
mockSessionActions.setReasoningEffort.mockResolvedValue(undefined);
|
||||
mockSessionActions.setApprovalMode.mockResolvedValue(undefined);
|
||||
mockSessionActions.setApprovalMode.mockImplementation(
|
||||
async (mode: string, options?: { planMode?: boolean }) => ({
|
||||
mode: options?.planMode ? 'plan' : mode,
|
||||
...(options?.planMode ? { planExecutionMode: mode } : {}),
|
||||
}),
|
||||
);
|
||||
mockSessionActions.getRewindSnapshots.mockResolvedValue([]);
|
||||
mockSessionActions.rewindSession.mockResolvedValue(undefined);
|
||||
mockSessionActions.branchSession.mockResolvedValue({
|
||||
@@ -13579,6 +13801,28 @@ describe('App session callbacks', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the Plan entry only when explicitly configured and hides it on removal', () => {
|
||||
const { rerender } = renderApp();
|
||||
expect(
|
||||
testState.latestChatEditorProps?.visibleToolbarActions,
|
||||
).not.toContain('plan');
|
||||
rerender({ composerToolbarActions: ['approvalMode', 'plan'] });
|
||||
expect(testState.latestChatEditorProps?.visibleToolbarActions).toContain(
|
||||
'plan',
|
||||
);
|
||||
rerender({ composerToolbarActions: ['approvalMode'] });
|
||||
expect(
|
||||
testState.latestChatEditorProps?.visibleToolbarActions,
|
||||
).not.toContain('plan');
|
||||
rerender({
|
||||
composerToolbarActions: undefined,
|
||||
composerToolbarAdditionalActions: ['plan'],
|
||||
});
|
||||
expect(testState.latestChatEditorProps?.visibleToolbarActions).toContain(
|
||||
'plan',
|
||||
);
|
||||
});
|
||||
|
||||
it('appends composer actions without replacing context-sensitive defaults', () => {
|
||||
mockConnection.sessionId = undefined;
|
||||
testState.messages = [];
|
||||
@@ -19355,6 +19599,57 @@ describe('App session callbacks', () => {
|
||||
expect(composerWrapper()?.className).not.toContain('composerHidden');
|
||||
});
|
||||
|
||||
it.each([undefined, 'yolo'])(
|
||||
'uses only the reported Plan execution policy for approval: %s',
|
||||
async (planExecutionMode) => {
|
||||
mockConnection.currentMode = 'plan';
|
||||
mockConnection.planExecutionMode = planExecutionMode;
|
||||
testState.blocks = [
|
||||
makePendingPermissionBlock({
|
||||
toolName: 'exit_plan_mode',
|
||||
kind: 'switch_mode',
|
||||
}),
|
||||
];
|
||||
const { container } = renderApp();
|
||||
await flush();
|
||||
expect(
|
||||
container
|
||||
.querySelector('[data-web-shell-permission-panel]')
|
||||
?.getAttribute('data-plan-execution-mode'),
|
||||
).toBe(planExecutionMode ?? null);
|
||||
},
|
||||
);
|
||||
|
||||
it('hides the composer during plan approval and restores it after resolution', async () => {
|
||||
mockConnection.currentMode = 'plan';
|
||||
mockConnection.planExecutionMode = 'default';
|
||||
testState.blocks = [
|
||||
makePendingPermissionBlock({
|
||||
toolName: 'exit_plan_mode',
|
||||
kind: 'switch_mode',
|
||||
}),
|
||||
];
|
||||
const { container, rerender } = renderApp();
|
||||
await flush();
|
||||
const composerWrapper = () =>
|
||||
container.querySelector('[data-web-shell-composer]')?.parentElement;
|
||||
expect(composerWrapper()?.className).toContain('composerHidden');
|
||||
expect(
|
||||
document.querySelector('[data-testid="approval-overlay"]'),
|
||||
).not.toBeNull();
|
||||
expect(mockSessionActions.submitPermission).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
testState.blocks = [];
|
||||
rerender();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(composerWrapper()?.className).not.toContain('composerHidden');
|
||||
expect(
|
||||
document.querySelector('[data-testid="approval-overlay"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the composer while an ask-user question overlay is pending', async () => {
|
||||
const { container, rerender } = renderApp();
|
||||
await flush();
|
||||
@@ -24276,6 +24571,141 @@ describe('App session callbacks', () => {
|
||||
expect(secondHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['default', 'auto-edit', 'auto', 'yolo'])(
|
||||
'shows selected %s separately from Plan and follows approved completion',
|
||||
async (mode) => {
|
||||
mockConnection.currentMode = 'plan';
|
||||
mockConnection.planExecutionMode = mode;
|
||||
const { rerender } = renderApp({
|
||||
composerToolbarAdditionalActions: ['plan'],
|
||||
});
|
||||
await flush();
|
||||
expect(testState.latestChatEditorProps?.currentMode).toBe(mode);
|
||||
expect(testState.latestChatEditorProps?.planMode).toBe(true);
|
||||
expect(testState.latestChatEditorProps?.visibleToolbarActions).toContain(
|
||||
'plan',
|
||||
);
|
||||
mockConnection.currentMode = mode;
|
||||
rerender();
|
||||
await flush();
|
||||
expect(testState.latestChatEditorProps?.currentMode).toBe(mode);
|
||||
expect(testState.latestChatEditorProps?.planMode).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('shares the Plan toggle with on/off commands without sending control words as prompts', async () => {
|
||||
mockConnection.currentMode = 'yolo';
|
||||
renderApp();
|
||||
await flush();
|
||||
await act(async () => {
|
||||
testState.latestChatEditorProps?.onTogglePlan?.();
|
||||
});
|
||||
expect(mockSessionActions.setApprovalMode).toHaveBeenLastCalledWith(
|
||||
'yolo',
|
||||
{ planMode: true },
|
||||
);
|
||||
await act(async () => {
|
||||
testState.latestChatEditorProps?.onSubmit('/plan off');
|
||||
});
|
||||
expect(mockSessionActions.setApprovalMode).toHaveBeenLastCalledWith(
|
||||
'yolo',
|
||||
{ planMode: false },
|
||||
);
|
||||
await act(async () => {
|
||||
testState.latestChatEditorProps?.onSubmit('/plan on');
|
||||
});
|
||||
expect(mockSessionActions.setApprovalMode).toHaveBeenLastCalledWith(
|
||||
'yolo',
|
||||
{ planMode: true },
|
||||
);
|
||||
await act(async () => {
|
||||
testState.latestChatEditorProps?.onSubmit('/plan');
|
||||
});
|
||||
expect(mockSessionActions.setApprovalMode).toHaveBeenLastCalledWith(
|
||||
'yolo',
|
||||
{ planMode: false },
|
||||
);
|
||||
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['/plan', '/plan on', '/plan off', '/plan exit'])(
|
||||
'retains %s during a pending mode request and accepts it after completion',
|
||||
async (command) => {
|
||||
const pendingMode = deferred<{ mode: string }>();
|
||||
mockSessionActions.setApprovalMode.mockReturnValueOnce(
|
||||
pendingMode.promise,
|
||||
);
|
||||
const onToast = vi.fn();
|
||||
renderApp({ onToast });
|
||||
await flush();
|
||||
act(() => {
|
||||
testState.latestChatEditorProps?.onSelectMode?.('yolo');
|
||||
});
|
||||
expect(testState.latestChatEditorProps?.modeControlsDisabled).toBe(true);
|
||||
onToast.mockClear();
|
||||
|
||||
let accepted: boolean | void;
|
||||
act(() => {
|
||||
accepted = testState.latestChatEditorProps?.onSubmit(command);
|
||||
});
|
||||
expect(accepted).toBe(false);
|
||||
expect(mockSessionActions.setApprovalMode).toHaveBeenCalledTimes(1);
|
||||
expect(onToast).toHaveBeenCalled();
|
||||
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
pendingMode.resolve({ mode: 'yolo' });
|
||||
});
|
||||
await act(async () => {
|
||||
accepted = testState.latestChatEditorProps?.onSubmit(command);
|
||||
});
|
||||
expect(accepted).toBe(true);
|
||||
expect(mockSessionActions.setApprovalMode).toHaveBeenCalledTimes(2);
|
||||
expect(mockSessionActions.setApprovalMode).toHaveBeenLastCalledWith(
|
||||
'yolo',
|
||||
{ planMode: command === '/plan' || command === '/plan on' },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('retains both welcome Plan and execution permission when defaults hydrate late', async () => {
|
||||
mockConnection.sessionId = undefined;
|
||||
mockConnection.currentMode = undefined;
|
||||
const { rerender } = renderApp();
|
||||
await flush();
|
||||
act(() => {
|
||||
testState.latestChatEditorProps?.onSelectMode?.('auto-edit');
|
||||
testState.latestChatEditorProps?.onTogglePlan?.();
|
||||
});
|
||||
expect(testState.latestChatEditorProps?.currentMode).toBe('auto-edit');
|
||||
expect(testState.latestChatEditorProps?.planMode).toBe(true);
|
||||
mockConnection.currentMode = 'yolo';
|
||||
rerender();
|
||||
await flush();
|
||||
expect(testState.latestChatEditorProps?.currentMode).toBe('auto-edit');
|
||||
expect(testState.latestChatEditorProps?.planMode).toBe(true);
|
||||
expect(mockSessionActions.setApprovalMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('takes both mode axes from a newly selected session after welcome Plan', async () => {
|
||||
mockConnection.sessionId = undefined;
|
||||
mockConnection.currentMode = undefined;
|
||||
const { rerender } = renderApp();
|
||||
await flush();
|
||||
act(() => {
|
||||
testState.latestChatEditorProps?.onSelectMode?.('yolo');
|
||||
testState.latestChatEditorProps?.onTogglePlan?.();
|
||||
});
|
||||
expect(testState.latestChatEditorProps?.planMode).toBe(true);
|
||||
mockConnection.sessionId = 'replacement-session';
|
||||
mockConnection.currentMode = 'auto-edit';
|
||||
testState.ownerVersion += 1;
|
||||
rerender();
|
||||
await flush();
|
||||
expect(testState.latestChatEditorProps?.currentMode).toBe('auto-edit');
|
||||
expect(testState.latestChatEditorProps?.planMode).toBe(false);
|
||||
});
|
||||
|
||||
it('forwards input annotations for /plan prompts in active sessions', async () => {
|
||||
const annotation: DaemonInputAnnotation = {
|
||||
type: 'reference',
|
||||
@@ -24296,7 +24726,9 @@ describe('App session callbacks', () => {
|
||||
await clickSubmit(container);
|
||||
await flush();
|
||||
|
||||
expect(mockSessionActions.setApprovalMode).toHaveBeenCalledWith('plan');
|
||||
expect(mockSessionActions.setApprovalMode).toHaveBeenCalledWith('default', {
|
||||
planMode: true,
|
||||
});
|
||||
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
|
||||
'@.husky/ explain',
|
||||
expect.objectContaining({
|
||||
@@ -24306,14 +24738,16 @@ describe('App session callbacks', () => {
|
||||
});
|
||||
|
||||
it('does not send a deferred plan prompt into a replacement owner', async () => {
|
||||
const approval = deferred<void>();
|
||||
const approval = deferred<{ mode: string; planExecutionMode: string }>();
|
||||
mockSessionActions.setApprovalMode.mockReturnValueOnce(approval.promise);
|
||||
const { container, rerender } = renderApp();
|
||||
await flush();
|
||||
|
||||
testState.prompt = '/plan explain the migration';
|
||||
await clickSubmit(container);
|
||||
expect(mockSessionActions.setApprovalMode).toHaveBeenCalledWith('plan');
|
||||
expect(mockSessionActions.setApprovalMode).toHaveBeenCalledWith('default', {
|
||||
planMode: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
testState.ownerVersion += 1;
|
||||
@@ -24321,7 +24755,7 @@ describe('App session callbacks', () => {
|
||||
rerender();
|
||||
});
|
||||
await act(async () => {
|
||||
approval.resolve();
|
||||
approval.resolve({ mode: 'plan', planExecutionMode: 'default' });
|
||||
await approval.promise;
|
||||
});
|
||||
|
||||
@@ -24330,14 +24764,16 @@ describe('App session callbacks', () => {
|
||||
});
|
||||
|
||||
it('does not send a deferred plan prompt after an interrupted navigation', async () => {
|
||||
const approval = deferred<void>();
|
||||
const approval = deferred<{ mode: string; planExecutionMode: string }>();
|
||||
mockSessionActions.setApprovalMode.mockReturnValueOnce(approval.promise);
|
||||
const { container, rerender } = renderApp();
|
||||
await flush();
|
||||
|
||||
testState.prompt = '/plan explain the migration';
|
||||
await clickSubmit(container);
|
||||
expect(mockSessionActions.setApprovalMode).toHaveBeenCalledWith('plan');
|
||||
expect(mockSessionActions.setApprovalMode).toHaveBeenCalledWith('default', {
|
||||
planMode: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
mockConnection.loadingTranscript = true;
|
||||
@@ -24348,7 +24784,7 @@ describe('App session callbacks', () => {
|
||||
rerender({});
|
||||
});
|
||||
await act(async () => {
|
||||
approval.resolve();
|
||||
approval.resolve({ mode: 'plan', planExecutionMode: 'default' });
|
||||
await approval.promise;
|
||||
});
|
||||
|
||||
@@ -24358,7 +24794,7 @@ describe('App session callbacks', () => {
|
||||
});
|
||||
|
||||
it('clears deferred plan preparation after a same-session reattach', async () => {
|
||||
const approval = deferred<void>();
|
||||
const approval = deferred<{ mode: string; planExecutionMode: string }>();
|
||||
mockSessionActions.setApprovalMode.mockReturnValueOnce(approval.promise);
|
||||
const { container, rerender } = renderApp();
|
||||
await flush();
|
||||
@@ -24372,7 +24808,7 @@ describe('App session callbacks', () => {
|
||||
rerender();
|
||||
});
|
||||
await act(async () => {
|
||||
approval.resolve();
|
||||
approval.resolve({ mode: 'plan', planExecutionMode: 'default' });
|
||||
await approval.promise;
|
||||
});
|
||||
|
||||
@@ -24381,8 +24817,14 @@ describe('App session callbacks', () => {
|
||||
});
|
||||
|
||||
it('does not let an A-to-B-to-A plan completion clear newer preparation', async () => {
|
||||
const firstApproval = deferred<void>();
|
||||
const secondApproval = deferred<void>();
|
||||
const firstApproval = deferred<{
|
||||
mode: string;
|
||||
planExecutionMode: string;
|
||||
}>();
|
||||
const secondApproval = deferred<{
|
||||
mode: string;
|
||||
planExecutionMode: string;
|
||||
}>();
|
||||
mockSessionActions.setApprovalMode
|
||||
.mockReturnValueOnce(firstApproval.promise)
|
||||
.mockReturnValueOnce(secondApproval.promise);
|
||||
@@ -24410,13 +24852,13 @@ describe('App session callbacks', () => {
|
||||
expect(testState.latestChatEditorProps?.isPreparing).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
firstApproval.resolve();
|
||||
firstApproval.resolve({ mode: 'plan', planExecutionMode: 'default' });
|
||||
await firstApproval.promise;
|
||||
});
|
||||
expect(testState.latestChatEditorProps?.isPreparing).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
secondApproval.resolve();
|
||||
secondApproval.resolve({ mode: 'plan', planExecutionMode: 'default' });
|
||||
await secondApproval.promise;
|
||||
});
|
||||
expect(testState.latestChatEditorProps?.isPreparing).toBe(false);
|
||||
@@ -27466,6 +27908,7 @@ describe('App session callbacks', () => {
|
||||
expect(mockSessionActions.submitPermission).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
'proceed_once',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+229
-105
@@ -15,7 +15,6 @@ import {
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
DAEMON_APPROVAL_MODES,
|
||||
useActions,
|
||||
useConnection,
|
||||
useDaemonFollowupSuggestion,
|
||||
@@ -152,6 +151,7 @@ import {
|
||||
} from './components/panels/EnvironmentPanel';
|
||||
import { ChatContextHeader } from './components/ChatContextHeader';
|
||||
import { WelcomeHeader } from './components/WelcomeHeader';
|
||||
import { EXECUTION_APPROVAL_MODES, parsePlanCommand } from './utils/planMode';
|
||||
import { ApprovalModeDialog } from './components/dialogs/ApprovalModeDialog';
|
||||
import { ResumeDialog } from './components/dialogs/ResumeDialog';
|
||||
import { DialogShell } from './components/dialogs/DialogShell';
|
||||
@@ -408,7 +408,7 @@ import { WebShellPortalRootContext } from './portalRoot';
|
||||
import { CompactModeContext, TodoContextsProvider } from './WebShellContexts';
|
||||
import styles from './App.module.css';
|
||||
|
||||
const MODES_CYCLE = DAEMON_APPROVAL_MODES;
|
||||
const MODES_CYCLE = EXECUTION_APPROVAL_MODES;
|
||||
const MAX_TOASTS = 4;
|
||||
const TOAST_AUTO_DISMISS_MS = 5000;
|
||||
const DEFAULT_REVIEW_PANEL_WIDTH = 500;
|
||||
@@ -1112,7 +1112,7 @@ export interface WebShellProps {
|
||||
messageTurnOutputs?: readonly TurnOutputKind[];
|
||||
/** Imperative handle for externally opening WebShell surfaces. */
|
||||
shellRef?: React.Ref<WebShellApi>;
|
||||
/** Built-in composer toolbar actions to show. Defaults to all actions. */
|
||||
/** Built-in composer toolbar actions to show. Plan must be explicitly included. */
|
||||
composerToolbarActions?: readonly ComposerToolbarAction[];
|
||||
/** Optionally filter main-model entries without changing shared defaults. */
|
||||
mainModelFilter?: (model: ModelDialogModel) => boolean;
|
||||
@@ -9083,11 +9083,18 @@ export function App({
|
||||
});
|
||||
}, [connection.sessionId, onSessionInfoChange, sessionDisplayName]);
|
||||
const [currentMode, setCurrentMode] = useState('default');
|
||||
const [planExecutionMode, setPlanExecutionMode] = useState('default');
|
||||
const executionMode =
|
||||
currentMode === 'plan' ? planExecutionMode : currentMode;
|
||||
const executionModeRef = useRef(executionMode);
|
||||
executionModeRef.current = executionMode;
|
||||
const currentModeRef = useRef(currentMode);
|
||||
currentModeRef.current = currentMode;
|
||||
const sessionSourceTypeRef = useRef(sessionSourceType);
|
||||
sessionSourceTypeRef.current = sessionSourceType;
|
||||
const pendingModeSelectionRef = useRef(false);
|
||||
const setPendingMode = useCallback((modeId: string) => {
|
||||
pendingModeSelectionRef.current = true;
|
||||
currentModeRef.current = modeId;
|
||||
setCurrentMode(modeId);
|
||||
}, []);
|
||||
@@ -9188,8 +9195,8 @@ export function App({
|
||||
reasoningPreviewSupports(reasoningPreview, reasoningIntent.value)
|
||||
? reasoningIntent.value
|
||||
: undefined;
|
||||
const modeId =
|
||||
currentModeRef.current || connectionRef.current.currentMode;
|
||||
const modeId = executionModeRef.current;
|
||||
const planMode = currentModeRef.current === 'plan';
|
||||
const requestedSessionContext =
|
||||
pendingSessionContextRef.current ??
|
||||
connectionRef.current.sessionContext;
|
||||
@@ -9247,6 +9254,7 @@ export function App({
|
||||
modelId,
|
||||
reasoningEffort,
|
||||
modeId,
|
||||
planMode,
|
||||
workspaceCwd: targetWorkspaceCwd,
|
||||
sessionContext: creationSessionContext,
|
||||
worktree:
|
||||
@@ -11092,67 +11100,125 @@ export function App({
|
||||
store.reset();
|
||||
}, [store, t]);
|
||||
|
||||
const handleSetMode = useCallback(
|
||||
(modeId: string) => {
|
||||
if (sessionWriteBlocked) return;
|
||||
if (!isDaemonApprovalMode(modeId)) {
|
||||
const [modeControlsBusy, setModeControlsBusy] = useState(false);
|
||||
const modeTransitionRef = useRef<{
|
||||
owner: { isCurrent: () => boolean };
|
||||
requestId?: string;
|
||||
initialMode?: string;
|
||||
hadActiveTurn?: boolean;
|
||||
} | null>(null);
|
||||
const releaseModeTransition = useCallback(
|
||||
(transition: typeof modeTransitionRef.current) => {
|
||||
if (modeTransitionRef.current !== transition) return;
|
||||
modeTransitionRef.current = null;
|
||||
setModeControlsBusy(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
const transition = modeTransitionRef.current;
|
||||
if (!transition) return;
|
||||
const activeTurn = streamingState !== 'idle' || sessionHasActivePrompt;
|
||||
if (
|
||||
!transition.owner.isCurrent() ||
|
||||
(transition.requestId &&
|
||||
((connection.currentMode !== 'plan' &&
|
||||
connection.currentMode !== transition.initialMode) ||
|
||||
(isExitPlanApprovalRequest(pendingToolApproval) &&
|
||||
pendingToolApproval?.id !== transition.requestId) ||
|
||||
(transition.hadActiveTurn && !activeTurn)))
|
||||
) {
|
||||
releaseModeTransition(transition);
|
||||
} else if (activeTurn) {
|
||||
transition.hadActiveTurn = true;
|
||||
}
|
||||
});
|
||||
|
||||
const setComposerMode = useCallback(
|
||||
async (modeId: string, planMode: boolean): Promise<boolean> => {
|
||||
if (modeTransitionRef.current?.owner.isCurrent()) return false;
|
||||
if (sessionWriteBlocked) return false;
|
||||
if (!isDaemonApprovalMode(modeId) || modeId === 'plan') {
|
||||
reportError(
|
||||
new Error(`Unsupported approval mode: ${modeId}`),
|
||||
new Error(`Unsupported execution approval mode: ${modeId}`),
|
||||
t('local.approvalMode'),
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!connectionRef.current.sessionId) {
|
||||
setPendingMode(modeId);
|
||||
return;
|
||||
executionModeRef.current = modeId;
|
||||
setPlanExecutionMode(modeId);
|
||||
setPendingMode(planMode ? 'plan' : modeId);
|
||||
return true;
|
||||
}
|
||||
const owner = sessionOwnerGuard.capture();
|
||||
sessionActions
|
||||
.setApprovalMode(modeId)
|
||||
.then((result) => {
|
||||
if (!owner.isCurrent()) return;
|
||||
const effectiveMode = result.mode || modeId;
|
||||
setCurrentMode(effectiveMode);
|
||||
const approval = pendingApprovalRef.current;
|
||||
if (!approval) return;
|
||||
const shouldAutoApprove =
|
||||
modeId === 'yolo' ||
|
||||
(modeId === 'auto-edit' && isEditToolPermission(approval));
|
||||
if (shouldAutoApprove) {
|
||||
const allowOnce = approval.options.find(
|
||||
(o) => o.kind === 'allow_once',
|
||||
);
|
||||
if (allowOnce) {
|
||||
const toolDesc = approval.title || '';
|
||||
store.dispatch([
|
||||
{
|
||||
type: 'status',
|
||||
text: t('mode.autoApproved', { tool: toolDesc }),
|
||||
},
|
||||
]);
|
||||
sessionActions
|
||||
.submitPermission(approval.id, allowOnce.id)
|
||||
.catch((error: unknown) => {
|
||||
reportError(error, 'Failed to auto-approve tool call');
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!owner.isCurrent()) return;
|
||||
reportError(error, t('local.approvalMode'));
|
||||
const transition = { owner };
|
||||
modeTransitionRef.current = transition;
|
||||
setModeControlsBusy(true);
|
||||
try {
|
||||
const result = await sessionActions.setApprovalMode(modeId, {
|
||||
planMode,
|
||||
});
|
||||
if (!owner.isCurrent()) return false;
|
||||
setPendingMode(result.mode || (planMode ? 'plan' : modeId));
|
||||
executionModeRef.current = result.planExecutionMode || modeId;
|
||||
setPlanExecutionMode(executionModeRef.current);
|
||||
const approval = pendingApprovalRef.current;
|
||||
if (
|
||||
!planMode &&
|
||||
approval &&
|
||||
!isExitPlanApprovalRequest(approval) &&
|
||||
(modeId === 'yolo' ||
|
||||
(modeId === 'auto-edit' && isEditToolPermission(approval)))
|
||||
) {
|
||||
const allowOnce = approval.options.find(
|
||||
(option) => option.kind === 'allow_once',
|
||||
);
|
||||
if (allowOnce) {
|
||||
store.dispatch([
|
||||
{
|
||||
type: 'status',
|
||||
text: t('mode.autoApproved', { tool: approval.title || '' }),
|
||||
},
|
||||
]);
|
||||
sessionActions
|
||||
.submitPermission(approval.id, allowOnce.id)
|
||||
.catch((error: unknown) =>
|
||||
reportError(error, 'Failed to auto-approve tool call'),
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (owner.isCurrent()) reportError(error, t('local.approvalMode'));
|
||||
return false;
|
||||
} finally {
|
||||
releaseModeTransition(transition);
|
||||
}
|
||||
},
|
||||
[
|
||||
sessionWriteBlocked,
|
||||
reportError,
|
||||
sessionActions,
|
||||
sessionOwnerGuard,
|
||||
releaseModeTransition,
|
||||
setPendingMode,
|
||||
store,
|
||||
t,
|
||||
],
|
||||
);
|
||||
const handleSetMode = useCallback(
|
||||
(modeId: string) => {
|
||||
void setComposerMode(modeId, currentModeRef.current === 'plan');
|
||||
},
|
||||
[setComposerMode],
|
||||
);
|
||||
const handleTogglePlan = useCallback(() => {
|
||||
void setComposerMode(
|
||||
executionModeRef.current,
|
||||
currentModeRef.current !== 'plan',
|
||||
);
|
||||
}, [setComposerMode]);
|
||||
|
||||
// Drop queued commands on a session switch so the drain never runs a
|
||||
// command against a different workspace's daemon (mirrors useQueuedPrompts).
|
||||
@@ -11438,16 +11504,28 @@ export function App({
|
||||
setCurrentModel((prev) => (wasLateHydration && prev ? prev : (next ?? '')));
|
||||
}, [connection.currentModel, logicalSessionKey]);
|
||||
|
||||
const prevConnectionModeRef = useRef(connection.currentMode);
|
||||
const prevModeSessionKeyRef = useRef(logicalSessionKey);
|
||||
useLayoutEffect(() => {
|
||||
const next = connection.currentMode;
|
||||
const wasLateHydration =
|
||||
prevConnectionModeRef.current === undefined && next !== undefined;
|
||||
prevConnectionModeRef.current = next;
|
||||
setCurrentMode((prev) =>
|
||||
wasLateHydration && prev !== 'default' ? prev : (next ?? 'default'),
|
||||
const sameSession = prevModeSessionKeyRef.current === logicalSessionKey;
|
||||
prevModeSessionKeyRef.current = logicalSessionKey;
|
||||
const preservePendingSelection =
|
||||
sameSession && !connection.sessionId && pendingModeSelectionRef.current;
|
||||
if (!sameSession || connection.sessionId)
|
||||
pendingModeSelectionRef.current = false;
|
||||
if (preservePendingSelection) return;
|
||||
setPlanExecutionMode((previous) =>
|
||||
next === 'plan'
|
||||
? (connection.planExecutionMode ?? (sameSession ? previous : 'default'))
|
||||
: (next ?? 'default'),
|
||||
);
|
||||
}, [connection.currentMode, logicalSessionKey]);
|
||||
setCurrentMode(next ?? 'default');
|
||||
}, [
|
||||
connection.currentMode,
|
||||
connection.planExecutionMode,
|
||||
connection.sessionId,
|
||||
logicalSessionKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connection.loadingTranscript) return;
|
||||
@@ -11714,10 +11792,10 @@ export function App({
|
||||
// findIndex, not indexOf: narrowing currentMode to the tuple member type
|
||||
// silently degrades when the SDK's declaration bundle leaves its
|
||||
// permission-mode import dangling, and the build must survive both states.
|
||||
const idx = MODES_CYCLE.findIndex((mode) => mode === currentMode);
|
||||
const idx = MODES_CYCLE.findIndex((mode) => mode === executionMode);
|
||||
const next = MODES_CYCLE[(idx + 1) % MODES_CYCLE.length];
|
||||
handleSetMode(next);
|
||||
}, [currentMode, handleSetMode]);
|
||||
}, [executionMode, handleSetMode]);
|
||||
|
||||
// Shared by the /context slash command and the status-bar context
|
||||
// indicator. Echoes the command when idle — that also makes the transcript
|
||||
@@ -12495,6 +12573,68 @@ export function App({
|
||||
editorRef.current?.focus();
|
||||
}, [dismissNewSessionSuggestion, newSessionSuggestion]);
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
async (
|
||||
id: string,
|
||||
selectedOption: string,
|
||||
answers?: Record<string, string>,
|
||||
) => {
|
||||
const request = pendingApprovalRef.current;
|
||||
const isPlan = request?.id === id && isExitPlanApprovalRequest(request);
|
||||
if (isPlan && modeTransitionRef.current?.owner.isCurrent()) {
|
||||
throw new Error('Approval mode or plan confirmation is still pending');
|
||||
}
|
||||
const owner = sessionOwnerGuard.capture();
|
||||
const option = request?.options.find(
|
||||
(entry) => entry.id === selectedOption,
|
||||
);
|
||||
const approvesPlan =
|
||||
isPlan &&
|
||||
(option?.kind === 'allow_once' || option?.kind === 'allow_always');
|
||||
const transition = isPlan
|
||||
? {
|
||||
owner,
|
||||
...(approvesPlan
|
||||
? {
|
||||
requestId: id,
|
||||
initialMode: connectionRef.current.currentMode,
|
||||
hadActiveTurn:
|
||||
streamingStateRef.current !== 'idle' ||
|
||||
sessionHasActivePromptRef.current,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: null;
|
||||
if (transition) {
|
||||
modeTransitionRef.current = transition;
|
||||
setModeControlsBusy(true);
|
||||
}
|
||||
try {
|
||||
if (approvesPlan && connection.planExecutionMode !== undefined) {
|
||||
await sessionActions.respondToPermission(id, {
|
||||
outcome: { outcome: 'selected', optionId: selectedOption },
|
||||
expectedPlanExecutionMode: connection.planExecutionMode,
|
||||
});
|
||||
} else {
|
||||
await sessionActions.submitPermission(id, selectedOption, answers);
|
||||
}
|
||||
if (transition && !approvesPlan) releaseModeTransition(transition);
|
||||
} catch (error) {
|
||||
if (transition) releaseModeTransition(transition);
|
||||
if (owner.isCurrent())
|
||||
reportError(error, 'Failed to submit permission choice');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[
|
||||
sessionActions,
|
||||
reportError,
|
||||
sessionOwnerGuard,
|
||||
releaseModeTransition,
|
||||
connection.planExecutionMode,
|
||||
],
|
||||
);
|
||||
|
||||
const respondToPendingPermission = useCallback(
|
||||
async (
|
||||
requestIdOrDecision: string,
|
||||
@@ -12539,10 +12679,10 @@ export function App({
|
||||
if (!option) {
|
||||
return false;
|
||||
}
|
||||
await sessionActions.submitPermission(request.id, option.id);
|
||||
await handleConfirm(request.id, option.id);
|
||||
return true;
|
||||
},
|
||||
[hostOwnsEditDiffPreview, sessionActions],
|
||||
[hostOwnsEditDiffPreview, handleConfirm],
|
||||
);
|
||||
|
||||
const shellApi = useMemo<WebShellApi>(
|
||||
@@ -14118,11 +14258,19 @@ export function App({
|
||||
return true;
|
||||
}
|
||||
if (cmd === 'plan') {
|
||||
if (commandBlocked) return blockCommand();
|
||||
const prompt = text.slice(match[0].length).trim();
|
||||
if (modeTransitionRef.current?.owner.isCurrent()) {
|
||||
pushToast('warning', t('mode.changePending'));
|
||||
return false;
|
||||
}
|
||||
const operation = parsePlanCommand(
|
||||
text.slice(match[0].length),
|
||||
currentModeRef.current === 'plan',
|
||||
);
|
||||
const { prompt } = operation;
|
||||
if (prompt && commandBlocked) return blockCommand();
|
||||
if (!connectionRef.current.sessionId) {
|
||||
setPendingMode('plan');
|
||||
if (prompt) {
|
||||
void setComposerMode(executionModeRef.current, operation.enabled);
|
||||
if (prompt)
|
||||
return submitPromptFromEditor(
|
||||
prompt,
|
||||
images,
|
||||
@@ -14130,23 +14278,21 @@ export function App({
|
||||
'Failed to send plan prompt',
|
||||
{ inputAnnotations: metadata?.inputAnnotations },
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const planPreparationToken = prompt
|
||||
? ++planPreparationTokenRef.current
|
||||
: undefined;
|
||||
const planPromptPreparationOwner = prompt
|
||||
const preparationOwner = prompt
|
||||
? beginPromptPreparation()
|
||||
: undefined;
|
||||
const owner = sessionOwnerGuard.capture();
|
||||
const writeBlockGeneration = sessionWriteBlockGenerationRef.current;
|
||||
sessionActions
|
||||
.setApprovalMode('plan')
|
||||
.then(() => {
|
||||
if (!owner.isCurrent()) return;
|
||||
setPendingMode('plan');
|
||||
void setComposerMode(executionModeRef.current, operation.enabled)
|
||||
.then((applied) => {
|
||||
if (
|
||||
applied &&
|
||||
owner.isCurrent() &&
|
||||
prompt &&
|
||||
!sessionWriteBlockedRef.current &&
|
||||
sessionWriteBlockGenerationRef.current ===
|
||||
@@ -14155,22 +14301,19 @@ export function App({
|
||||
return sendPrompt(prompt, images, files, {
|
||||
clearComposerOnPromptStart: true,
|
||||
inputAnnotations: metadata?.inputAnnotations,
|
||||
}).catch((error: unknown) =>
|
||||
reportError(error, 'Failed to send plan prompt'),
|
||||
);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!owner.isCurrent()) return;
|
||||
reportError(error, t('mode.plan'));
|
||||
if (owner.isCurrent())
|
||||
reportError(error, 'Failed to send plan prompt');
|
||||
})
|
||||
.finally(() => {
|
||||
if (
|
||||
prompt &&
|
||||
planPreparationTokenRef.current === planPreparationToken
|
||||
) {
|
||||
finishPromptPreparation(planPromptPreparationOwner);
|
||||
}
|
||||
)
|
||||
finishPromptPreparation(preparationOwner);
|
||||
});
|
||||
return prompt ? false : true;
|
||||
}
|
||||
@@ -14815,6 +14958,7 @@ export function App({
|
||||
handleGoalSlashCommand,
|
||||
handleThemeChange,
|
||||
handleSetMode,
|
||||
setComposerMode,
|
||||
handleLanguageChange,
|
||||
blockCommand,
|
||||
createSideTask,
|
||||
@@ -14832,7 +14976,6 @@ export function App({
|
||||
selectedLanguage,
|
||||
setPendingModel,
|
||||
selectWelcomeModel,
|
||||
setPendingMode,
|
||||
setWorkspaceSetting,
|
||||
openVoiceModelPicker,
|
||||
writeVoiceModelForTarget,
|
||||
@@ -14874,27 +15017,6 @@ export function App({
|
||||
[resumeChatBottomFollow],
|
||||
);
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
(id: string, selectedOption: string, answers?: Record<string, string>) => {
|
||||
const owner = sessionOwnerGuard.capture();
|
||||
// Return the submission promise (and rethrow a rejection) so the
|
||||
// ToolApproval re-arm contract engages: its confirm() resets the
|
||||
// double-submit guard only when the returned promise rejects.
|
||||
// Swallowing the rejection here would leave submittedRef latched on a
|
||||
// transient daemon/WS failure, blocking every retry for this request.
|
||||
// Same shape as ChatPane.handleConfirm.
|
||||
return sessionActions
|
||||
.submitPermission(id, selectedOption, answers)
|
||||
.then(() => undefined)
|
||||
.catch((error: unknown) => {
|
||||
if (owner.isCurrent()) {
|
||||
reportError(error, 'Failed to submit permission choice');
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
[sessionActions, reportError, sessionOwnerGuard],
|
||||
);
|
||||
const handleAskUserConfirm = useCallback(
|
||||
(id: string, selectedOption: string, answers?: Record<string, string>) =>
|
||||
sessionActions.submitPermission(id, selectedOption, answers),
|
||||
@@ -16344,7 +16466,7 @@ export function App({
|
||||
onClose={() => setShowApprovalModeDialog(false)}
|
||||
>
|
||||
<ApprovalModeDialog
|
||||
currentMode={currentMode}
|
||||
currentMode={executionMode}
|
||||
sessionWorkflowEnabled={sessionWorkflowEnabled}
|
||||
onSelect={(modeId) => {
|
||||
handleSetMode(modeId);
|
||||
@@ -17662,6 +17784,7 @@ export function App({
|
||||
belong to the outer session, not the panes). */}
|
||||
<WebShellCustomizationProvider value={customization}>
|
||||
<SplitView
|
||||
planControlVisible={visibleComposerToolbarActions.includes('plan')}
|
||||
sessionIds={splitSessionIds}
|
||||
showSessionDetails={
|
||||
(sidebarOptions.sessionActions?.items ??
|
||||
@@ -18031,6 +18154,8 @@ export function App({
|
||||
className={styles.approvalOverlay}
|
||||
>
|
||||
<ToolApproval
|
||||
disabled={isExitPlanApprovalRequest(pendingToolApproval) && modeControlsBusy}
|
||||
planExecutionMode={connection.planExecutionMode}
|
||||
request={pendingToolApproval}
|
||||
onConfirm={handleConfirm}
|
||||
variant="floating"
|
||||
@@ -18063,10 +18188,6 @@ export function App({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* A pending approval overlay owns the footer: drop the
|
||||
composer out of layout (kept mounted so the draft
|
||||
survives) instead of leaving a live input below the
|
||||
dialog. */}
|
||||
<div
|
||||
className={
|
||||
approvalOverlayActive && mainView === 'chat'
|
||||
@@ -18412,7 +18533,10 @@ export function App({
|
||||
onFocusFooter={handleFocusTaskPill}
|
||||
onPopQueuedMessages={editLastQueuedPrompt}
|
||||
onClearQueuedMessages={clearQueuedPrompts}
|
||||
currentMode={currentMode}
|
||||
currentMode={executionMode}
|
||||
modeControlsDisabled={modeControlsBusy}
|
||||
planMode={currentMode === 'plan'}
|
||||
onTogglePlan={handleTogglePlan}
|
||||
sessionWorkflowEnabled={sessionWorkflowEnabled}
|
||||
currentModel={currentModel}
|
||||
gitBranch={
|
||||
|
||||
@@ -1566,11 +1566,19 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolBtn:hover {
|
||||
.toolBtn:not(:disabled):not([data-disabled]):hover {
|
||||
background: var(--chat-editor-bg-tertiary);
|
||||
color: var(--chat-editor-text-primary);
|
||||
}
|
||||
|
||||
.planControl {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.planControl[data-disabled] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.workspaceSelectTrigger {
|
||||
min-width: 0;
|
||||
max-width: 176px;
|
||||
|
||||
@@ -379,6 +379,10 @@ interface ChatEditorRenderProps {
|
||||
renderComposerTagTooltip?: ComposerTagRenderer;
|
||||
onComposerTagClick?: ComposerTagClickHandler;
|
||||
currentMode?: string;
|
||||
planMode?: boolean;
|
||||
modeControlsDisabled?: boolean;
|
||||
onTogglePlan?: () => void;
|
||||
isRunning?: boolean;
|
||||
currentModel?: string;
|
||||
availableModels?: Array<{ id: string; label?: string }>;
|
||||
sessionWorkflowEnabled?: boolean;
|
||||
@@ -1529,51 +1533,269 @@ describe('ChatEditor top composer tag tooltip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatEditor Session Workflow mode rename', () => {
|
||||
it('renames only the plan entry in the mode dropdown while enabled', () => {
|
||||
const container = renderChatEditor({
|
||||
visibleToolbarActions: ['approvalMode'],
|
||||
sessionWorkflowEnabled: true,
|
||||
});
|
||||
describe('ChatEditor Plan toggle', () => {
|
||||
it('associates the Plan switch with its changing accessible description', () => {
|
||||
const props = {
|
||||
visibleToolbarActions: ['plan'] as const,
|
||||
onTogglePlan: vi.fn(),
|
||||
};
|
||||
const container = renderChatEditor(props);
|
||||
for (const state of [
|
||||
{
|
||||
planMode: false,
|
||||
currentMode: 'yolo',
|
||||
description: 'Plan before executing',
|
||||
},
|
||||
{
|
||||
planMode: true,
|
||||
currentMode: 'yolo',
|
||||
description:
|
||||
'Planning; execute with Full Access after approval. Click to exit planning.',
|
||||
},
|
||||
{
|
||||
planMode: true,
|
||||
currentMode: 'auto-edit',
|
||||
description:
|
||||
'Planning; execute with Auto Edit after approval. Click to exit planning.',
|
||||
},
|
||||
{
|
||||
planMode: false,
|
||||
currentMode: 'auto-edit',
|
||||
description: 'Plan before executing',
|
||||
},
|
||||
]) {
|
||||
rerenderChatEditor(container, { ...props, ...state });
|
||||
const button = container.querySelector<HTMLButtonElement>(
|
||||
'[data-web-shell-plan-button]',
|
||||
)!;
|
||||
act(() => button.focus());
|
||||
expect(button.getAttribute('aria-label')).toBe('Plan');
|
||||
const descriptionIds = button.getAttribute('aria-describedby');
|
||||
expect(descriptionIds).toBeTruthy();
|
||||
const description = descriptionIds!
|
||||
.split(/\s+/)
|
||||
.map((id) => document.getElementById(id)?.textContent ?? '')
|
||||
.join(' ');
|
||||
expect(description).toBe(state.description);
|
||||
}
|
||||
});
|
||||
|
||||
act(() => {
|
||||
it('closes an open permission dropdown when mode controls become disabled', () => {
|
||||
const props = {
|
||||
visibleToolbarActions: ['approvalMode', 'plan'] as const,
|
||||
onSelectMode: vi.fn(),
|
||||
onTogglePlan: vi.fn(),
|
||||
};
|
||||
const container = renderChatEditor(props);
|
||||
act(() =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-web-shell-mode-button]')
|
||||
?.click();
|
||||
});
|
||||
|
||||
const popover = document.querySelector('[data-web-shell-toolbar-popover]');
|
||||
expect(popover).not.toBeNull();
|
||||
const labels = Array.from(popover?.querySelectorAll('button') ?? []).map(
|
||||
(button) => button.textContent ?? '',
|
||||
);
|
||||
expect(labels.some((label) => label.includes('Plan & Review (plan)'))).toBe(
|
||||
true,
|
||||
?.click(),
|
||||
);
|
||||
expect(
|
||||
labels.some((label) => label.includes('Ask Approval (default)')),
|
||||
).toBe(true);
|
||||
expect(labels.some((label) => label.includes('Plan (plan)'))).toBe(false);
|
||||
document.querySelector('[data-web-shell-toolbar-popover]'),
|
||||
).not.toBeNull();
|
||||
rerenderChatEditor(container, { ...props, modeControlsDisabled: true });
|
||||
expect(
|
||||
document.querySelector('[data-web-shell-toolbar-popover]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renames the active plan mode chip while enabled', () => {
|
||||
const withWorkflow = renderChatEditor({
|
||||
currentMode: 'plan',
|
||||
sessionWorkflowEnabled: true,
|
||||
it('omits Plan from permissions and retains every execution policy', () => {
|
||||
const onSelectMode = vi.fn();
|
||||
const container = renderChatEditor({
|
||||
visibleToolbarActions: ['approvalMode', 'plan'],
|
||||
planMode: true,
|
||||
currentMode: 'yolo',
|
||||
onTogglePlan: vi.fn(),
|
||||
onSelectMode,
|
||||
});
|
||||
act(() =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-web-shell-mode-button]')
|
||||
?.click(),
|
||||
);
|
||||
const buttons = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>(
|
||||
'[data-web-shell-toolbar-popover] button',
|
||||
),
|
||||
);
|
||||
const labels = buttons.map((button) => button.textContent ?? '');
|
||||
expect(labels.some((label) => label.includes('(plan)'))).toBe(false);
|
||||
for (const mode of ['default', 'auto-edit', 'auto', 'yolo']) {
|
||||
expect(labels.some((label) => label.includes(`(${mode})`))).toBe(true);
|
||||
}
|
||||
act(() =>
|
||||
buttons
|
||||
.find((button) => button.textContent?.includes('(auto-edit)'))
|
||||
?.click(),
|
||||
);
|
||||
expect(onSelectMode).toHaveBeenCalledWith('auto-edit');
|
||||
expect(
|
||||
withWorkflow
|
||||
.querySelector('[data-toolbar-measure="mode:expanded"]')
|
||||
?.textContent?.includes('Plan & Review'),
|
||||
).toBe(true);
|
||||
|
||||
const withoutWorkflow = renderChatEditor({ currentMode: 'plan' });
|
||||
expect(
|
||||
withoutWorkflow
|
||||
.querySelector('[data-toolbar-measure="mode:expanded"]')
|
||||
?.textContent?.includes('Plan & Review'),
|
||||
).toBe(false);
|
||||
container
|
||||
.querySelector('[data-web-shell-plan-button]')
|
||||
?.getAttribute('aria-checked'),
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
it('keeps permission and Plan controls operable while approval disables input', () => {
|
||||
const onSelectMode = vi.fn();
|
||||
const onTogglePlan = vi.fn();
|
||||
const container = renderChatEditor({
|
||||
visibleToolbarActions: ['approvalMode', 'plan'],
|
||||
disabled: true,
|
||||
planMode: true,
|
||||
currentMode: 'default',
|
||||
onSelectMode,
|
||||
onTogglePlan,
|
||||
});
|
||||
act(() =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-web-shell-mode-button]')
|
||||
?.click(),
|
||||
);
|
||||
const option = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>(
|
||||
'[data-web-shell-toolbar-popover] button',
|
||||
),
|
||||
).find((button) => button.textContent?.includes('(yolo)'));
|
||||
expect(option).toBeDefined();
|
||||
act(() => option!.click());
|
||||
expect(onSelectMode).toHaveBeenCalledWith('yolo');
|
||||
act(() =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-web-shell-plan-button]')
|
||||
?.click(),
|
||||
);
|
||||
expect(onTogglePlan).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('disables both controls during a plan handoff and restores them afterward', () => {
|
||||
const onSelectMode = vi.fn();
|
||||
const onTogglePlan = vi.fn();
|
||||
const props = {
|
||||
visibleToolbarActions: ['approvalMode', 'plan'] as const,
|
||||
planMode: true,
|
||||
modeControlsDisabled: true,
|
||||
onSelectMode,
|
||||
onTogglePlan,
|
||||
};
|
||||
const container = renderChatEditor(props);
|
||||
const mode = container.querySelector<HTMLButtonElement>(
|
||||
'[data-web-shell-mode-button]',
|
||||
)!;
|
||||
const plan = container.querySelector<HTMLButtonElement>(
|
||||
'[data-web-shell-plan-button]',
|
||||
)!;
|
||||
expect(mode.disabled).toBe(true);
|
||||
expect(plan.disabled).toBe(true);
|
||||
const planControl = plan.closest('[data-web-shell-plan-control]')!;
|
||||
expect(planControl.hasAttribute('data-disabled')).toBe(true);
|
||||
act(() => {
|
||||
mode.click();
|
||||
plan.click();
|
||||
});
|
||||
expect(onTogglePlan).not.toHaveBeenCalled();
|
||||
expect(
|
||||
document.querySelector('[data-web-shell-toolbar-popover]'),
|
||||
).toBeNull();
|
||||
rerenderChatEditor(container, { ...props, modeControlsDisabled: false });
|
||||
expect(mode.disabled).toBe(false);
|
||||
expect(plan.disabled).toBe(false);
|
||||
expect(planControl.hasAttribute('data-disabled')).toBe(false);
|
||||
act(() => plan.click());
|
||||
expect(onTogglePlan).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('places the controlled toggle immediately after permission and allows use while running', () => {
|
||||
const onTogglePlan = vi.fn();
|
||||
const props = {
|
||||
visibleToolbarActions: ['approvalMode', 'plan', 'model'] as const,
|
||||
currentMode: 'yolo',
|
||||
onTogglePlan,
|
||||
isRunning: true,
|
||||
};
|
||||
const container = renderChatEditor(props);
|
||||
const button = container.querySelector<HTMLButtonElement>(
|
||||
'[data-web-shell-plan-button]',
|
||||
)!;
|
||||
const controls = Array.from(
|
||||
container.querySelectorAll('[data-web-shell-toolbar-leading] button'),
|
||||
);
|
||||
expect(controls.indexOf(button)).toBe(
|
||||
controls.indexOf(
|
||||
container.querySelector('[data-web-shell-mode-button]')!,
|
||||
) + 1,
|
||||
);
|
||||
expect(button.getAttribute('role')).toBe('switch');
|
||||
expect(button.getAttribute('aria-label')).toBe('Plan');
|
||||
expect(button.getAttribute('aria-checked')).toBe('false');
|
||||
act(() => button.click());
|
||||
expect(onTogglePlan).toHaveBeenCalledOnce();
|
||||
expect(button.getAttribute('aria-checked')).toBe('false');
|
||||
rerenderChatEditor(container, { ...props, planMode: true });
|
||||
expect(button.getAttribute('aria-checked')).toBe('true');
|
||||
rerenderChatEditor(container, { ...props, planMode: false });
|
||||
expect(button.getAttribute('aria-checked')).toBe('false');
|
||||
expect(
|
||||
container.querySelector('[data-toolbar-measure="mode:expanded"]')
|
||||
?.textContent,
|
||||
).toContain('Full Access');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ width: 0, label: '' },
|
||||
{ width: 300, label: 'Plan' },
|
||||
])(
|
||||
'fits the Plan label to available toolbar width $width',
|
||||
({ width, label }) => {
|
||||
const bounds = vi
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function () {
|
||||
if (this.matches('[data-toolbar-measure="plan:expanded"]'))
|
||||
return { width: 72 } as DOMRect;
|
||||
if (this.matches('[data-toolbar-measure="plan:collapsed"]'))
|
||||
return { width: 54 } as DOMRect;
|
||||
if (this.querySelector(':scope > [data-web-shell-toolbar-leading]'))
|
||||
return { width } as DOMRect;
|
||||
return { width: 0 } as DOMRect;
|
||||
});
|
||||
try {
|
||||
const container = renderChatEditor({
|
||||
visibleToolbarActions: ['approvalMode', 'plan'],
|
||||
onTogglePlan: vi.fn(),
|
||||
});
|
||||
const button = container.querySelector('[data-web-shell-plan-button]')!;
|
||||
const control = container.querySelector(
|
||||
'[data-web-shell-plan-control]',
|
||||
)!;
|
||||
expect(control.textContent).toBe(label);
|
||||
expect(control.querySelector('[aria-hidden="true"]') !== null).toBe(
|
||||
label === '',
|
||||
);
|
||||
expect(
|
||||
button.querySelector('[data-slot="switch-thumb"]'),
|
||||
).not.toBeNull();
|
||||
expect(button.getAttribute('role')).toBe('switch');
|
||||
expect(button.getAttribute('aria-label')).toBe('Plan');
|
||||
} finally {
|
||||
bounds.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([undefined, [], ['approvalMode']] as const)(
|
||||
'requires an explicit Plan toolbar action: %j',
|
||||
(visibleToolbarActions) => {
|
||||
const container = renderChatEditor({
|
||||
visibleToolbarActions,
|
||||
onTogglePlan: vi.fn(),
|
||||
});
|
||||
expect(
|
||||
container.querySelector('[data-web-shell-plan-button]'),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('ChatEditor toolbar popovers', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useImperativeHandle,
|
||||
useId,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
@@ -130,6 +131,7 @@ const MAX_DROP_DIALOG_ROWS = 100;
|
||||
|
||||
export type ComposerToolbarAction =
|
||||
| 'approvalMode'
|
||||
| 'plan'
|
||||
| 'contextUsage'
|
||||
| 'gitBranch'
|
||||
| 'model'
|
||||
@@ -138,10 +140,7 @@ export type ComposerToolbarAction =
|
||||
| 'widthMode'
|
||||
| 'voice'
|
||||
| 'workspace'
|
||||
// Unlike the actions above, `addMenu` is never shown unless the host
|
||||
// lists it explicitly in `visibleToolbarActions` — the generic gate
|
||||
// defaults to "show" when the prop is absent, so `+` checks the prop
|
||||
// directly to stay off by default.
|
||||
// Like Plan, addMenu is only shown when explicitly listed by the host.
|
||||
| 'addMenu';
|
||||
|
||||
// Dropped folders surface in `dataTransfer.files` as 0-byte Files; only the
|
||||
@@ -163,6 +162,7 @@ function collectDroppedFiles(dataTransfer: DataTransfer): File[] {
|
||||
|
||||
const ACTIVE_TOOLBAR_ACTIONS = [
|
||||
'approvalMode',
|
||||
'plan',
|
||||
'commands',
|
||||
'contextUsage',
|
||||
'gitBranch',
|
||||
@@ -203,6 +203,9 @@ interface ChatEditorProps {
|
||||
onPopQueuedMessages?: () => boolean;
|
||||
onClearQueuedMessages?: () => boolean;
|
||||
currentMode?: string;
|
||||
planMode?: boolean;
|
||||
modeControlsDisabled?: boolean;
|
||||
onTogglePlan?: () => void;
|
||||
sessionWorkflowEnabled?: boolean;
|
||||
currentModel?: string;
|
||||
gitBranch?: string;
|
||||
@@ -1500,7 +1503,9 @@ export const ChatEditor = memo(
|
||||
queuedMessages = [],
|
||||
onPopQueuedMessages,
|
||||
currentMode = 'default',
|
||||
sessionWorkflowEnabled = false,
|
||||
planMode = false,
|
||||
modeControlsDisabled = false,
|
||||
onTogglePlan,
|
||||
currentModel = '',
|
||||
gitBranch,
|
||||
gitWorktree,
|
||||
@@ -2037,7 +2042,11 @@ export const ChatEditor = memo(
|
||||
onAttachmentsChange?.(core.hasAttachments);
|
||||
}, [core.hasAttachments, onAttachmentsChange]);
|
||||
|
||||
const planDescriptionId = useId();
|
||||
const [modeDropdownOpen, setModeDropdownOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (modeControlsDisabled) setModeDropdownOpen(false);
|
||||
}, [modeControlsDisabled]);
|
||||
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
|
||||
const [quickActionsOpen, setQuickActionsOpen] = useState(false);
|
||||
const [branchPickerOpen, setBranchPickerOpen] = useState(false);
|
||||
@@ -2060,6 +2069,7 @@ export const ChatEditor = memo(
|
||||
workspace: false,
|
||||
gitBranch: false,
|
||||
mode: false,
|
||||
plan: false,
|
||||
model: false,
|
||||
});
|
||||
const toolbarLabelVisibilityRef = useRef(toolbarLabelVisibility);
|
||||
@@ -2144,20 +2154,13 @@ export const ChatEditor = memo(
|
||||
|
||||
const modeItems = useMemo<DropdownItem[]>(
|
||||
() =>
|
||||
DAEMON_APPROVAL_MODES.map((id) => ({
|
||||
DAEMON_APPROVAL_MODES.filter((id) => id !== 'plan').map((id) => ({
|
||||
id,
|
||||
label:
|
||||
id === 'plan' && sessionWorkflowEnabled
|
||||
? t('mode.listLabel.planReview')
|
||||
: getModeListLabel(id, t),
|
||||
description: t(
|
||||
id === 'plan' && sessionWorkflowEnabled
|
||||
? 'mode.desc.planReview'
|
||||
: `mode.desc.${id}`,
|
||||
),
|
||||
label: getModeListLabel(id, t),
|
||||
description: t(`mode.desc.${id}`),
|
||||
icon: <ModeIcon mode={id} />,
|
||||
})),
|
||||
[sessionWorkflowEnabled, t],
|
||||
[t],
|
||||
);
|
||||
const visibleActionSet = useMemo(() => {
|
||||
if (!visibleToolbarActions) return null;
|
||||
@@ -2171,6 +2174,9 @@ export const ChatEditor = memo(
|
||||
return visibleActionSet.has(action);
|
||||
};
|
||||
const showModeAction = showToolbarAction('approvalMode');
|
||||
const showPlanAction = Boolean(
|
||||
onTogglePlan && visibleActionSet?.has('plan'),
|
||||
);
|
||||
const showModelAction = showToolbarAction('model');
|
||||
const showCommandAction = showToolbarAction('commands');
|
||||
const commandNames = useMemo(
|
||||
@@ -2306,11 +2312,12 @@ export const ChatEditor = memo(
|
||||
|
||||
const handleModeSelect = useCallback(
|
||||
(modeId: string) => {
|
||||
if (modeControlsDisabled) return;
|
||||
onSelectMode?.(modeId);
|
||||
setModeDropdownOpen(false);
|
||||
core.focus();
|
||||
},
|
||||
[onSelectMode, core],
|
||||
[onSelectMode, core, modeControlsDisabled],
|
||||
);
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
@@ -2439,10 +2446,11 @@ export const ChatEditor = memo(
|
||||
};
|
||||
|
||||
// Mode display label
|
||||
const modeLabel =
|
||||
currentMode === 'plan' && sessionWorkflowEnabled
|
||||
? t('mode.label.planReview')
|
||||
: getModeLabel(currentMode, t);
|
||||
const modeLabel = getModeLabel(currentMode, t);
|
||||
const planLabel = t('mode.label.plan');
|
||||
const planTooltip = planMode
|
||||
? t('plan.toggle.off', { mode: modeLabel })
|
||||
: t('plan.toggle.on');
|
||||
|
||||
const currentModelLabel = currentModel
|
||||
? (availableModels.find((model) => model.id === currentModel)?.label ??
|
||||
@@ -2500,6 +2508,7 @@ export const ChatEditor = memo(
|
||||
const showWorkspaceLabel = toolbarLabelVisibility.workspace;
|
||||
const showGitBranchLabel = toolbarLabelVisibility.gitBranch;
|
||||
const showModeLabel = toolbarLabelVisibility.mode;
|
||||
const showPlanLabel = toolbarLabelVisibility.plan;
|
||||
const showModelLabel = toolbarLabelVisibility.model;
|
||||
const showCancelButton = isRunning && !core.hasContent;
|
||||
const composerPreparing = isPreparing || core.pendingImageBatchCount > 0;
|
||||
@@ -2566,6 +2575,9 @@ export const ChatEditor = memo(
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(showPlanAction
|
||||
? [{ id: 'plan', expansionWidth: expansionWidth('plan') }]
|
||||
: []),
|
||||
...(showModelAction
|
||||
? [
|
||||
{
|
||||
@@ -2609,6 +2621,7 @@ export const ChatEditor = memo(
|
||||
workspace: itemVisibility.workspace ?? false,
|
||||
gitBranch: itemVisibility.gitBranch ?? false,
|
||||
mode: itemVisibility.mode ?? false,
|
||||
plan: itemVisibility.plan ?? false,
|
||||
model: itemVisibility.model ?? false,
|
||||
};
|
||||
const unchanged = Object.keys(next).every(
|
||||
@@ -2668,11 +2681,13 @@ export const ChatEditor = memo(
|
||||
isRunning,
|
||||
modelLabelReady,
|
||||
modeLabel,
|
||||
planLabel,
|
||||
normalizedModelChipLabel,
|
||||
sessionName,
|
||||
showAddMenuAction,
|
||||
showModelAction,
|
||||
showModeAction,
|
||||
showPlanAction,
|
||||
workspaceIndicatorVisible,
|
||||
workspaceName,
|
||||
workspaceSelectVisible,
|
||||
@@ -3270,6 +3285,7 @@ export const ChatEditor = memo(
|
||||
showModeLabel ? '' : styles.toolBtnCompact
|
||||
}`}
|
||||
data-web-shell-mode-button
|
||||
disabled={modeControlsDisabled}
|
||||
data-web-shell-toolbar-popover-trigger
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -3295,6 +3311,47 @@ export const ChatEditor = memo(
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showPlanAction && (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label
|
||||
className={`${styles.toolBtn} ${styles.planControl}`}
|
||||
data-web-shell-plan-control
|
||||
data-disabled={
|
||||
modeControlsDisabled ? '' : undefined
|
||||
}
|
||||
>
|
||||
{showPlanLabel ? (
|
||||
<span className={styles.toolBtnText}>
|
||||
{planLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.toolBtnModeIcon}>
|
||||
<ModeIcon mode="plan" />
|
||||
</span>
|
||||
)}
|
||||
<Switch
|
||||
size="sm"
|
||||
className="after:inset-x-0"
|
||||
data-web-shell-plan-button
|
||||
aria-label={planLabel}
|
||||
aria-describedby={planDescriptionId}
|
||||
checked={planMode}
|
||||
disabled={modeControlsDisabled}
|
||||
onCheckedChange={onTogglePlan}
|
||||
/>
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<span id={planDescriptionId} className="sr-only">
|
||||
{planTooltip}
|
||||
</span>
|
||||
<TooltipContent side="top">
|
||||
{planTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{showModelAction && (
|
||||
<div
|
||||
className={`${styles.dropdownWrapper} ${
|
||||
@@ -3709,6 +3766,26 @@ export const ChatEditor = memo(
|
||||
<ChevronDownIcon />
|
||||
</span>
|
||||
</span>
|
||||
{showPlanAction && (
|
||||
<>
|
||||
<span
|
||||
data-toolbar-measure="plan:collapsed"
|
||||
className={`${styles.toolBtn} ${styles.planControl}`}
|
||||
>
|
||||
<span className={styles.toolBtnModeIcon}>
|
||||
<ModeIcon mode="plan" />
|
||||
</span>
|
||||
<Switch size="sm" tabIndex={-1} checked={planMode} />
|
||||
</span>
|
||||
<span
|
||||
data-toolbar-measure="plan:expanded"
|
||||
className={`${styles.toolBtn} ${styles.planControl}`}
|
||||
>
|
||||
<span className={styles.toolBtnText}>{planLabel}</span>
|
||||
<Switch size="sm" tabIndex={-1} checked={planMode} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
data-toolbar-measure="model:collapsed"
|
||||
className={`${styles.toolBtn} ${styles.modelToolBtn} ${styles.toolBtnCompact}`}
|
||||
|
||||
@@ -48,6 +48,10 @@ let latestOnSubmit:
|
||||
) => boolean)
|
||||
| undefined;
|
||||
let latestChatEditorProps: any;
|
||||
type ToolApprovalTestProps = Parameters<
|
||||
typeof import('./messages/ToolApproval').ToolApproval
|
||||
>[0];
|
||||
let latestToolApprovalProps: ToolApprovalTestProps | undefined;
|
||||
let renderRealChatEditor: boolean;
|
||||
let latestFollowupAccept: ((suggestion: string) => void) | undefined;
|
||||
let latestMonitorDetailsOnOpen:
|
||||
@@ -64,6 +68,7 @@ const transcriptDispatch = vi.fn();
|
||||
const appendLocalUserMessage = vi.fn();
|
||||
const sendPrompt = vi.fn(async () => ({}) as any);
|
||||
const submitPermission = vi.fn(async () => true);
|
||||
const respondToPermission = vi.fn(async () => true);
|
||||
const cancel = vi.fn(async () => {});
|
||||
const setApprovalMode = vi.fn(async (mode: string) => ({ mode }));
|
||||
const setModel = vi.fn(async () => ({}) as any);
|
||||
@@ -78,6 +83,7 @@ const getContextUsage = vi.fn();
|
||||
const daemonActions = {
|
||||
sendPrompt,
|
||||
submitPermission,
|
||||
respondToPermission,
|
||||
cancel,
|
||||
setApprovalMode,
|
||||
setModel,
|
||||
@@ -370,18 +376,22 @@ vi.mock('./QueuedPromptDisplay', () => ({
|
||||
),
|
||||
}));
|
||||
vi.mock('./messages/ToolApproval', () => ({
|
||||
ToolApproval: (props: any) => (
|
||||
<button
|
||||
data-testid="tool-approval"
|
||||
data-keyboard-active={String(props.keyboardActive)}
|
||||
data-plan-todos={JSON.stringify(
|
||||
props.planTodos?.map((todo: any) => todo.id) ?? [],
|
||||
)}
|
||||
onClick={() => props.onConfirm(props.request.id, 'proceed')}
|
||||
>
|
||||
approve
|
||||
</button>
|
||||
),
|
||||
ToolApproval: (props: ToolApprovalTestProps) => {
|
||||
latestToolApprovalProps = props;
|
||||
return (
|
||||
<button
|
||||
data-testid="tool-approval"
|
||||
disabled={props.disabled}
|
||||
data-keyboard-active={String(props.keyboardActive)}
|
||||
data-plan-todos={JSON.stringify(
|
||||
props.planTodos?.map((todo) => todo.id) ?? [],
|
||||
)}
|
||||
onClick={() => props.onConfirm(props.request.id, 'proceed')}
|
||||
>
|
||||
approve
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
vi.mock('./messages/AskUserQuestion', () => ({
|
||||
AskUserQuestion: (props: any) => (
|
||||
@@ -421,6 +431,7 @@ beforeEach(() => {
|
||||
messagesState = [{ id: 'm1', role: 'user', content: 'hi' }];
|
||||
latestOnSubmit = undefined;
|
||||
latestChatEditorProps = undefined;
|
||||
latestToolApprovalProps = undefined;
|
||||
renderRealChatEditor = false;
|
||||
sessionHasActivePromptValue = false;
|
||||
queuedPromptStreamingState = undefined;
|
||||
@@ -455,6 +466,7 @@ beforeEach(() => {
|
||||
clearFollowup.mockClear();
|
||||
insertText.mockClear();
|
||||
submitPermission.mockClear();
|
||||
respondToPermission.mockClear();
|
||||
cancel.mockClear();
|
||||
setApprovalMode.mockClear();
|
||||
setModel.mockClear();
|
||||
@@ -1474,6 +1486,47 @@ describe('ChatPane', () => {
|
||||
).toContain('composerHidden');
|
||||
});
|
||||
|
||||
it.each([undefined, 'yolo'])(
|
||||
'uses only the reported Plan execution policy for approval: %s',
|
||||
(planExecutionMode) => {
|
||||
connectionState.currentMode = 'plan';
|
||||
connectionState.planExecutionMode = planExecutionMode;
|
||||
pendingPermission = {
|
||||
id: 'plan-approval',
|
||||
toolName: 'exit_plan_mode',
|
||||
toolKind: 'switch_mode',
|
||||
options: [],
|
||||
};
|
||||
render();
|
||||
expect(latestToolApprovalProps?.planExecutionMode).toBe(
|
||||
planExecutionMode,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('hides the composer during plan approval and restores it after resolution', () => {
|
||||
connectionState.currentMode = 'plan';
|
||||
connectionState.planExecutionMode = 'default';
|
||||
pendingPermission = {
|
||||
id: 'perm-plan',
|
||||
toolName: 'exit_plan_mode',
|
||||
toolKind: 'switch_mode',
|
||||
options: [{ id: 'restore_previous', kind: 'allow_once' }],
|
||||
content: [],
|
||||
};
|
||||
render();
|
||||
const composerWrapper = () =>
|
||||
container!.querySelector('[data-web-shell-composer]')?.parentElement;
|
||||
expect(composerWrapper()?.className).toContain('composerHidden');
|
||||
expect(testid('pane-approval')).not.toBeNull();
|
||||
expect(submitPermission).not.toHaveBeenCalled();
|
||||
|
||||
pendingPermission = null;
|
||||
rerender();
|
||||
expect(composerWrapper()?.className).not.toContain('composerHidden');
|
||||
expect(testid('pane-approval')).toBeNull();
|
||||
});
|
||||
|
||||
it('restores the pane composer after the approval resolves', () => {
|
||||
pendingPermission = { id: 'perm-1', toolName: 'write_file', rawInput: {} };
|
||||
render();
|
||||
@@ -1519,6 +1572,15 @@ describe('ChatPane', () => {
|
||||
expect(testid('pane-messages')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows the Plan entry only when explicitly enabled and hides it on removal', () => {
|
||||
render();
|
||||
expect(latestChatEditorProps.visibleToolbarActions).not.toContain('plan');
|
||||
rerender({ planControlVisible: true });
|
||||
expect(latestChatEditorProps.visibleToolbarActions).toContain('plan');
|
||||
rerender();
|
||||
expect(latestChatEditorProps.visibleToolbarActions).not.toContain('plan');
|
||||
});
|
||||
|
||||
it('adds no workspace toolbar chip on a single-workspace daemon', () => {
|
||||
render({ title: 'Refactor core', workspaceCwd: '/w' });
|
||||
expect(latestChatEditorProps.visibleToolbarActions).toContain('addMenu');
|
||||
@@ -2608,7 +2670,12 @@ describe('ChatPane', () => {
|
||||
});
|
||||
|
||||
it('renders a tool approval and resolves it via submitPermission', () => {
|
||||
pendingPermission = { id: 'perm-1', toolName: 'write_file', rawInput: {} };
|
||||
pendingPermission = {
|
||||
id: 'perm-1',
|
||||
toolName: 'write_file',
|
||||
rawInput: {},
|
||||
options: [{ id: 'proceed', kind: 'allow_once' }],
|
||||
};
|
||||
render();
|
||||
expect(testid('tool-approval')).not.toBeNull();
|
||||
expect(testid('pane-messages')?.getAttribute('data-approval')).toBe('yes');
|
||||
@@ -2898,6 +2965,266 @@ describe('ChatPane', () => {
|
||||
expect(models.map((m: { id: string }) => m.id)).toEqual(['qwen-max']);
|
||||
});
|
||||
|
||||
it.each(['default', 'auto-edit', 'auto', 'yolo'])(
|
||||
'keeps %s selected while Plan is active and restores it on completion',
|
||||
(mode) => {
|
||||
connectionState.currentMode = 'plan';
|
||||
connectionState.planExecutionMode = mode;
|
||||
render({ planControlVisible: true });
|
||||
expect(latestChatEditorProps.currentMode).toBe(mode);
|
||||
expect(latestChatEditorProps.planMode).toBe(true);
|
||||
expect(latestChatEditorProps.visibleToolbarActions).toContain('plan');
|
||||
connectionState.currentMode = mode;
|
||||
rerender();
|
||||
expect(latestChatEditorProps.currentMode).toBe(mode);
|
||||
expect(latestChatEditorProps.planMode).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('blocks same-tick plan handoff during a mode request and permits approval after it settles', async () => {
|
||||
connectionState.currentMode = 'plan';
|
||||
pendingPermission = {
|
||||
id: 'plan-approval',
|
||||
toolName: 'exit_plan_mode',
|
||||
toolKind: 'switch_mode',
|
||||
options: [{ id: 'restore_previous', kind: 'allow_once' }],
|
||||
content: [],
|
||||
};
|
||||
const mode = deferred<{ mode: string }>();
|
||||
setApprovalMode.mockReturnValueOnce(mode.promise);
|
||||
render();
|
||||
await act(async () => {
|
||||
latestChatEditorProps.onSelectMode('yolo');
|
||||
await expect(
|
||||
latestToolApprovalProps!.onConfirm('plan-approval', 'restore_previous'),
|
||||
).rejects.toThrow('still pending');
|
||||
});
|
||||
expect(submitPermission).not.toHaveBeenCalled();
|
||||
expect(latestToolApprovalProps!.disabled).toBe(true);
|
||||
await act(async () => {
|
||||
mode.resolve({ mode: 'plan' });
|
||||
});
|
||||
expect(latestToolApprovalProps!.disabled).toBe(false);
|
||||
await act(async () =>
|
||||
latestToolApprovalProps!.onConfirm('plan-approval', 'restore_previous'),
|
||||
);
|
||||
expect(submitPermission).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('holds plan handoff until authoritative mode exit, blocking permission and Plan changes', async () => {
|
||||
connectionState.currentMode = 'plan';
|
||||
connectionState.planExecutionMode = 'yolo';
|
||||
pendingPermission = {
|
||||
id: 'plan-approval',
|
||||
toolName: 'exit_plan_mode',
|
||||
toolKind: 'switch_mode',
|
||||
options: [{ id: 'restore_previous', kind: 'allow_once' }],
|
||||
content: [],
|
||||
};
|
||||
const permission = deferred<boolean>();
|
||||
respondToPermission.mockReturnValueOnce(permission.promise);
|
||||
render();
|
||||
let submitted: void | Promise<void>;
|
||||
act(() => {
|
||||
submitted = latestToolApprovalProps!.onConfirm(
|
||||
'plan-approval',
|
||||
'restore_previous',
|
||||
);
|
||||
latestChatEditorProps.onSelectMode('default');
|
||||
latestChatEditorProps.onTogglePlan();
|
||||
});
|
||||
expect(respondToPermission).toHaveBeenCalledWith('plan-approval', {
|
||||
outcome: { outcome: 'selected', optionId: 'restore_previous' },
|
||||
expectedPlanExecutionMode: 'yolo',
|
||||
});
|
||||
expect(setApprovalMode).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
permission.resolve(true);
|
||||
await submitted;
|
||||
});
|
||||
pendingPermission = null;
|
||||
rerender();
|
||||
expect(latestChatEditorProps.modeControlsDisabled).toBe(true);
|
||||
pendingPermission = {
|
||||
id: 'background-approval',
|
||||
toolName: 'write_file',
|
||||
toolKind: 'edit',
|
||||
options: [],
|
||||
};
|
||||
rerender();
|
||||
expect(latestChatEditorProps.modeControlsDisabled).toBe(true);
|
||||
connectionState.currentMode = 'yolo';
|
||||
rerender();
|
||||
expect(latestChatEditorProps.modeControlsDisabled).toBe(false);
|
||||
await act(async () => latestChatEditorProps.onTogglePlan());
|
||||
expect(setApprovalMode).toHaveBeenCalledWith('yolo', { planMode: true });
|
||||
});
|
||||
|
||||
it('releases plan handoff on failure and owner replacement', async () => {
|
||||
connectionState.currentMode = 'plan';
|
||||
pendingPermission = {
|
||||
id: 'plan-approval',
|
||||
toolName: 'exit_plan_mode',
|
||||
toolKind: 'switch_mode',
|
||||
options: [{ id: 'restore_previous', kind: 'allow_once' }],
|
||||
content: [],
|
||||
};
|
||||
render();
|
||||
submitPermission.mockRejectedValueOnce(new Error('permission failed'));
|
||||
await act(async () => {
|
||||
await expect(
|
||||
latestToolApprovalProps!.onConfirm('plan-approval', 'restore_previous'),
|
||||
).rejects.toThrow('permission failed');
|
||||
});
|
||||
expect(latestChatEditorProps.modeControlsDisabled).toBe(false);
|
||||
await act(async () =>
|
||||
latestToolApprovalProps!.onConfirm('plan-approval', 'restore_previous'),
|
||||
);
|
||||
expect(latestChatEditorProps.modeControlsDisabled).toBe(true);
|
||||
ownerVersion += 1;
|
||||
rerender();
|
||||
expect(latestChatEditorProps.modeControlsDisabled).toBe(false);
|
||||
setApprovalMode.mockRejectedValueOnce(new Error('mode failed'));
|
||||
await act(async () => latestChatEditorProps.onTogglePlan());
|
||||
expect(latestChatEditorProps.modeControlsDisabled).toBe(false);
|
||||
});
|
||||
|
||||
it('changes execution permission during Plan without approving the pending plan', async () => {
|
||||
connectionState.currentMode = 'plan';
|
||||
connectionState.planExecutionMode = 'default';
|
||||
pendingPermission = {
|
||||
id: 'plan-approval',
|
||||
toolName: 'exit_plan_mode',
|
||||
toolKind: 'switch_mode',
|
||||
options: [{ id: 'restore_previous', kind: 'allow_once' }],
|
||||
content: [],
|
||||
};
|
||||
render();
|
||||
await act(async () => latestChatEditorProps.onSelectMode('yolo'));
|
||||
expect(setApprovalMode).toHaveBeenCalledWith('yolo', { planMode: true });
|
||||
expect(submitPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['/plan', '/plan on', '/plan off', '/plan exit'])(
|
||||
'retains %s during a pending mode request and accepts it after completion',
|
||||
async (command) => {
|
||||
connectionState.currentMode = 'yolo';
|
||||
const pendingMode = deferred<{ mode: string }>();
|
||||
setApprovalMode.mockReturnValueOnce(pendingMode.promise);
|
||||
const onError = vi.fn();
|
||||
render({ onError });
|
||||
act(() => {
|
||||
latestChatEditorProps.onSelectMode('yolo');
|
||||
});
|
||||
expect(latestChatEditorProps.modeControlsDisabled).toBe(true);
|
||||
|
||||
let accepted: boolean | undefined;
|
||||
act(() => {
|
||||
accepted = latestOnSubmit!(command);
|
||||
});
|
||||
expect(accepted).toBe(false);
|
||||
expect(setApprovalMode).toHaveBeenCalledTimes(1);
|
||||
expect(onError).toHaveBeenCalled();
|
||||
expect(sendPrompt).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
pendingMode.resolve({ mode: 'yolo' });
|
||||
});
|
||||
await act(async () => {
|
||||
accepted = latestOnSubmit!(command);
|
||||
});
|
||||
expect(accepted).toBe(true);
|
||||
expect(setApprovalMode).toHaveBeenCalledTimes(2);
|
||||
expect(setApprovalMode).toHaveBeenLastCalledWith('yolo', {
|
||||
planMode: command === '/plan' || command === '/plan on',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['/plan', '/plan on', '/plan off'])(
|
||||
'does not consume %s while disconnected without a session',
|
||||
(command) => {
|
||||
connectionState.status = 'disconnected';
|
||||
connectionState.sessionId = undefined;
|
||||
render();
|
||||
let accepted: boolean | undefined;
|
||||
act(() => {
|
||||
accepted = latestOnSubmit!(command);
|
||||
});
|
||||
expect(accepted).toBe(false);
|
||||
expect(setApprovalMode).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('sends /plan prompts through normal admission callbacks after applying Plan', async () => {
|
||||
const firstPrompt = vi.fn();
|
||||
const commit = vi.fn();
|
||||
connectionState.currentMode = 'yolo';
|
||||
render({ onFirstPromptAdmitted: firstPrompt });
|
||||
await act(async () => {
|
||||
latestOnSubmit!(
|
||||
'/plan explain the migration',
|
||||
undefined,
|
||||
undefined,
|
||||
commit,
|
||||
);
|
||||
});
|
||||
expect(setApprovalMode).toHaveBeenCalledWith('yolo', { planMode: true });
|
||||
expect(sendPrompt).toHaveBeenCalledWith(
|
||||
'explain the migration',
|
||||
expect.objectContaining({
|
||||
onAdmissionStarted: expect.any(Function),
|
||||
onAdmitted: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
act(() => sendPromptAdmit!());
|
||||
expect(commit).toHaveBeenCalledOnce();
|
||||
expect(firstPrompt).toHaveBeenCalledWith('explain the migration');
|
||||
expect(catalogController.promptAdmitted).toHaveBeenCalledWith(
|
||||
'/w',
|
||||
'sess-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('locks uncertain /plan prompt admission instead of permitting a duplicate', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
sendPrompt.mockImplementationOnce(async (_text, options) => {
|
||||
options?.onAdmissionStarted?.();
|
||||
throw new Error('disconnected');
|
||||
});
|
||||
render();
|
||||
await act(async () => {
|
||||
latestOnSubmit!('/plan explain the migration');
|
||||
});
|
||||
expect(testid('pane-prompt-admission-unknown')).not.toBeNull();
|
||||
expect(latestChatEditorProps.disabled).toBe(true);
|
||||
act(() => {
|
||||
latestOnSubmit!('/plan duplicate');
|
||||
});
|
||||
expect(sendPrompt).toHaveBeenCalledOnce();
|
||||
expect(catalogController.promptAdmissionUncertain).toHaveBeenCalledWith(
|
||||
'/w',
|
||||
);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('does not send a prepared /plan prompt into a replacement owner', async () => {
|
||||
const prepared = deferred<{ mode: string }>();
|
||||
setApprovalMode.mockReturnValueOnce(prepared.promise);
|
||||
render();
|
||||
act(() => {
|
||||
latestOnSubmit!('/plan explain');
|
||||
});
|
||||
ownerVersion += 1;
|
||||
connectionState.sessionId = 'replacement';
|
||||
rerender();
|
||||
await act(async () => {
|
||||
prepared.resolve({ mode: 'plan' });
|
||||
});
|
||||
expect(sendPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drives THIS pane's approval mode when one is picked", () => {
|
||||
render();
|
||||
act(() =>
|
||||
@@ -2905,7 +3232,7 @@ describe('ChatPane', () => {
|
||||
new MouseEvent('click', { bubbles: true }),
|
||||
),
|
||||
);
|
||||
expect(setApprovalMode).toHaveBeenCalledWith('yolo');
|
||||
expect(setApprovalMode).toHaveBeenCalledWith('yolo', { planMode: false });
|
||||
});
|
||||
|
||||
it("switches THIS pane's model when one is picked", () => {
|
||||
@@ -2988,7 +3315,7 @@ describe('ChatPane', () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(setApprovalMode).toHaveBeenCalledWith('yolo');
|
||||
expect(setApprovalMode).toHaveBeenCalledWith('yolo', { planMode: false });
|
||||
expect(submitPermission).toHaveBeenCalledWith('perm-yolo', 'allow-1');
|
||||
});
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ import { QueuedPromptDisplay } from './QueuedPromptDisplay';
|
||||
import { GoalStatusStrip } from './GoalStatusStrip';
|
||||
import composerStatusStyles from './ComposerStatusStack.module.css';
|
||||
import { GoalEditDialog } from './dialogs/GoalEditDialog';
|
||||
import { parsePlanCommand } from '../utils/planMode';
|
||||
import { ToolApproval } from './messages/ToolApproval';
|
||||
import { AskUserQuestion } from './messages/AskUserQuestion';
|
||||
import { serializeContextUsageMessage } from './messages/ContextUsageMessage';
|
||||
@@ -127,6 +128,7 @@ import accentStyles from './WorkspaceAccent.module.css';
|
||||
const PANE_TOOLBAR_ACTIONS: readonly ComposerToolbarAction[] = [
|
||||
'addMenu',
|
||||
'approvalMode',
|
||||
'plan',
|
||||
'contextUsage',
|
||||
'model',
|
||||
'voice',
|
||||
@@ -235,6 +237,7 @@ export interface ChatPaneProps {
|
||||
voiceWorkspaces?: readonly DaemonWorkspaceCapability[];
|
||||
/** Enable the app-scoped experimental Session Workflow presentation. */
|
||||
sessionWorkflowEnabled?: boolean;
|
||||
planControlVisible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,6 +273,7 @@ export function ChatPane({
|
||||
voiceWorkspaceRevisions = EMPTY_VOICE_WORKSPACE_REVISIONS,
|
||||
voiceWorkspaces,
|
||||
sessionWorkflowEnabled = false,
|
||||
planControlVisible = false,
|
||||
}: ChatPaneProps) {
|
||||
const { t } = useI18n();
|
||||
const { renderComposerFooter: CustomComposerFooter, askUserFreeTextLabel } =
|
||||
@@ -758,6 +762,108 @@ export function ChatPane({
|
||||
[controlGoal, reportError, sessionOwnerGuard, t],
|
||||
);
|
||||
|
||||
const planPreparationRef = useRef<{ isCurrent: () => boolean } | null>(null);
|
||||
const planMode = connection.currentMode === 'plan';
|
||||
const executionMode = planMode
|
||||
? (connection.planExecutionMode ?? 'default')
|
||||
: (connection.currentMode ?? 'default');
|
||||
const [modeControlsBusy, setModeControlsBusy] = useState(false);
|
||||
const modeTransitionRef = useRef<{
|
||||
owner: { isCurrent: () => boolean };
|
||||
requestId?: string;
|
||||
initialMode?: string;
|
||||
hadActiveTurn?: boolean;
|
||||
} | null>(null);
|
||||
const releaseModeTransition = useCallback(
|
||||
(transition: typeof modeTransitionRef.current) => {
|
||||
if (modeTransitionRef.current !== transition) return;
|
||||
modeTransitionRef.current = null;
|
||||
setModeControlsBusy(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
const transition = modeTransitionRef.current;
|
||||
if (!transition) return;
|
||||
const activeTurn = streamingState !== 'idle' || sessionHasActivePrompt;
|
||||
if (
|
||||
!transition.owner.isCurrent() ||
|
||||
(transition.requestId &&
|
||||
((connection.currentMode !== 'plan' &&
|
||||
connection.currentMode !== transition.initialMode) ||
|
||||
(isExitPlanApprovalRequest(pendingToolApproval) &&
|
||||
pendingToolApproval?.id !== transition.requestId) ||
|
||||
(transition.hadActiveTurn && !activeTurn)))
|
||||
) {
|
||||
releaseModeTransition(transition);
|
||||
} else if (activeTurn) {
|
||||
transition.hadActiveTurn = true;
|
||||
}
|
||||
});
|
||||
|
||||
const setComposerMode = useCallback(
|
||||
async (modeId: string, enabled: boolean): Promise<boolean> => {
|
||||
if (modeTransitionRef.current?.owner.isCurrent()) return false;
|
||||
if (
|
||||
connection.loadingTranscript ||
|
||||
shouldBlockComposerSubmit({
|
||||
connectionStatus: connection.status,
|
||||
hasSession: Boolean(connection.sessionId),
|
||||
})
|
||||
)
|
||||
return false;
|
||||
if (!isDaemonApprovalMode(modeId) || modeId === 'plan') {
|
||||
reportError(
|
||||
new Error(`Unsupported execution approval mode: ${modeId}`),
|
||||
'Failed to set approval mode',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const owner = sessionOwnerGuard.capture();
|
||||
const transition = { owner };
|
||||
modeTransitionRef.current = transition;
|
||||
setModeControlsBusy(true);
|
||||
try {
|
||||
await actions.setApprovalMode(modeId, { planMode: enabled });
|
||||
if (!owner.isCurrent()) return false;
|
||||
const approval = pendingToolApprovalRef.current;
|
||||
if (
|
||||
!enabled &&
|
||||
approval &&
|
||||
!isExitPlanApprovalRequest(approval) &&
|
||||
(modeId === 'yolo' ||
|
||||
(modeId === 'auto-edit' && approval.toolKind === 'edit'))
|
||||
) {
|
||||
const allowOnce = approval.options.find(
|
||||
(option) => option.kind === 'allow_once',
|
||||
);
|
||||
if (allowOnce)
|
||||
void actions
|
||||
.submitPermission(approval.id, allowOnce.id)
|
||||
.catch((error: unknown) =>
|
||||
reportError(error, 'Failed to auto-approve tool call'),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (owner.isCurrent())
|
||||
reportError(error, 'Failed to set approval mode');
|
||||
return false;
|
||||
} finally {
|
||||
releaseModeTransition(transition);
|
||||
}
|
||||
},
|
||||
[
|
||||
actions,
|
||||
connection.loadingTranscript,
|
||||
connection.status,
|
||||
connection.sessionId,
|
||||
reportError,
|
||||
sessionOwnerGuard,
|
||||
releaseModeTransition,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(
|
||||
text: string,
|
||||
@@ -766,10 +872,11 @@ export function ChatPane({
|
||||
commitAccepted?: ComposerSubmitCommit,
|
||||
metadata?: ComposerSubmitMetadata,
|
||||
): boolean => {
|
||||
const trimmed = text.trim();
|
||||
let trimmed = text.trim();
|
||||
if (!trimmed && (images?.length ?? 0) === 0 && (files?.length ?? 0) === 0)
|
||||
return false;
|
||||
if (admissionPayloadLocked) return false;
|
||||
if (admissionPayloadLocked || planPreparationRef.current?.isCurrent())
|
||||
return false;
|
||||
transcriptViewportRef.current?.scrollToBottom();
|
||||
// The host handler is documented as running before Web Shell handles a
|
||||
// slash command, so it gets `/goal` first here exactly as it does in the
|
||||
@@ -780,7 +887,38 @@ export function ChatPane({
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (/^\/goal(?:\s|$)/i.test(trimmed)) {
|
||||
const planCommand = trimmed.match(/^\/plan(?:\s+(.*))?$/is);
|
||||
const planOperation = planCommand
|
||||
? parsePlanCommand(planCommand[1] ?? '', planMode)
|
||||
: undefined;
|
||||
if (planOperation) {
|
||||
if (modeTransitionRef.current?.owner.isCurrent()) {
|
||||
reportError(
|
||||
new Error(t('mode.changePending')),
|
||||
t('local.approvalMode'),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
connection.loadingTranscript ||
|
||||
shouldBlockComposerSubmit({
|
||||
connectionStatus: connection.status,
|
||||
hasSession: Boolean(connection.sessionId),
|
||||
})
|
||||
)
|
||||
return false;
|
||||
if (!planOperation.prompt) {
|
||||
void setComposerMode(executionMode, planOperation.enabled);
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
streamingStateRef.current !== 'idle' ||
|
||||
sessionHasActivePromptRef.current
|
||||
)
|
||||
return false;
|
||||
trimmed = planOperation.prompt;
|
||||
}
|
||||
if (!planOperation && /^\/goal(?:\s|$)/i.test(trimmed)) {
|
||||
// The same guard App.tsx applies before any slash handling: a control
|
||||
// that cannot reach the daemon must leave the text in the composer
|
||||
// instead of consuming it, appending a transcript entry, and failing
|
||||
@@ -854,7 +992,7 @@ export function ChatPane({
|
||||
}
|
||||
};
|
||||
const commandBlockedByGoal =
|
||||
trimmed.startsWith('/') &&
|
||||
text.trim().startsWith('/') &&
|
||||
isGoalGateBlocked({
|
||||
sessionId: connection.sessionId,
|
||||
goalState: connection.goalState,
|
||||
@@ -867,52 +1005,83 @@ export function ChatPane({
|
||||
const admissionOwner = admissionOwnerRef.current;
|
||||
let admissionStarted = false;
|
||||
let admitted = false;
|
||||
actions
|
||||
.sendPrompt(trimmed, {
|
||||
...(images && images.length ? { images } : {}),
|
||||
...(files && files.length ? { files } : {}),
|
||||
...(inputAnnotations ? { inputAnnotations } : {}),
|
||||
onAdmissionStarted: () => {
|
||||
admissionStarted = true;
|
||||
},
|
||||
onAdmitted: () => {
|
||||
const submit = () =>
|
||||
actions
|
||||
.sendPrompt(trimmed, {
|
||||
...(images && images.length ? { images } : {}),
|
||||
...(files && files.length ? { files } : {}),
|
||||
...(inputAnnotations ? { inputAnnotations } : {}),
|
||||
onAdmissionStarted: () => {
|
||||
admissionStarted = true;
|
||||
},
|
||||
onAdmitted: () => {
|
||||
if (admissionOwnerRef.current !== admissionOwner) return;
|
||||
if (connection.sessionId && catalogOwnerCwd) {
|
||||
sessionCatalogController.promptAdmitted(
|
||||
catalogOwnerCwd,
|
||||
connection.sessionId,
|
||||
);
|
||||
}
|
||||
admitted = true;
|
||||
notifyFirstPromptAdmitted();
|
||||
clearFollowup();
|
||||
commitAccepted?.();
|
||||
},
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (admissionOwnerRef.current !== admissionOwner) return;
|
||||
if (connection.sessionId && catalogOwnerCwd) {
|
||||
sessionCatalogController.promptAdmitted(
|
||||
const definitelyRejected =
|
||||
isDefinitelyRejectedPromptAdmission(error);
|
||||
if (admitted || !admissionStarted || definitelyRejected) {
|
||||
reportError(error, 'Failed to send prompt');
|
||||
return;
|
||||
}
|
||||
if (catalogOwnerCwd) {
|
||||
sessionCatalogController.promptAdmissionUncertain(
|
||||
catalogOwnerCwd,
|
||||
connection.sessionId,
|
||||
);
|
||||
}
|
||||
admitted = true;
|
||||
notifyFirstPromptAdmitted();
|
||||
clearFollowup();
|
||||
commitAccepted?.();
|
||||
},
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (admissionOwnerRef.current !== admissionOwner) return;
|
||||
const definitelyRejected =
|
||||
isDefinitelyRejectedPromptAdmission(error);
|
||||
if (admitted || !admissionStarted || definitelyRejected) {
|
||||
reportError(error, 'Failed to send prompt');
|
||||
return;
|
||||
}
|
||||
if (catalogOwnerCwd) {
|
||||
sessionCatalogController.promptAdmissionUncertain(
|
||||
catalogOwnerCwd,
|
||||
setUnknownPromptAdmission({
|
||||
owner: admissionOwner,
|
||||
commitAccepted,
|
||||
payloadAvailable: true,
|
||||
});
|
||||
onImageIngestionNotice?.('warning', t('queue.admissionUnknown'));
|
||||
console.warn(
|
||||
'[ChatPane] prompt admission outcome is unknown',
|
||||
error,
|
||||
);
|
||||
}
|
||||
setUnknownPromptAdmission({
|
||||
owner: admissionOwner,
|
||||
commitAccepted,
|
||||
payloadAvailable: true,
|
||||
});
|
||||
onImageIngestionNotice?.('warning', t('queue.admissionUnknown'));
|
||||
console.warn(
|
||||
'[ChatPane] prompt admission outcome is unknown',
|
||||
error,
|
||||
);
|
||||
});
|
||||
if (planOperation) {
|
||||
const owner = sessionOwnerGuard.capture();
|
||||
planPreparationRef.current = owner;
|
||||
void setComposerMode(executionMode, planOperation.enabled).then(
|
||||
(applied) => {
|
||||
if (planPreparationRef.current === owner)
|
||||
planPreparationRef.current = null;
|
||||
const current = connectionRef.current;
|
||||
if (
|
||||
!applied ||
|
||||
!owner.isCurrent() ||
|
||||
current.loadingTranscript ||
|
||||
shouldBlockComposerSubmit({
|
||||
connectionStatus: current.status,
|
||||
hasSession: Boolean(current.sessionId),
|
||||
}) ||
|
||||
streamingStateRef.current !== 'idle' ||
|
||||
sessionHasActivePromptRef.current ||
|
||||
isGoalGateBlocked({
|
||||
sessionId: current.sessionId,
|
||||
goalState: current.goalState,
|
||||
})
|
||||
)
|
||||
return;
|
||||
void submit();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
void submit();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const queued =
|
||||
@@ -933,10 +1102,15 @@ export function ChatPane({
|
||||
},
|
||||
[
|
||||
actions,
|
||||
executionMode,
|
||||
planMode,
|
||||
setComposerMode,
|
||||
sessionOwnerGuard,
|
||||
admissionPayloadLocked,
|
||||
catalogOwnerCwd,
|
||||
clearFollowup,
|
||||
connection.goalState,
|
||||
connection.loadingTranscript,
|
||||
connection.sessionId,
|
||||
connection.status,
|
||||
controlGoal,
|
||||
@@ -952,17 +1126,67 @@ export function ChatPane({
|
||||
);
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
(id: string, selectedOption: string, answers?: Record<string, string>) => {
|
||||
return actions
|
||||
.submitPermission(id, selectedOption, answers)
|
||||
.then(() => undefined)
|
||||
.catch((error: unknown) => {
|
||||
async (
|
||||
id: string,
|
||||
selectedOption: string,
|
||||
answers?: Record<string, string>,
|
||||
) => {
|
||||
const request = pendingToolApprovalRef.current;
|
||||
const isPlan = request?.id === id && isExitPlanApprovalRequest(request);
|
||||
if (isPlan && modeTransitionRef.current?.owner.isCurrent()) {
|
||||
throw new Error('Approval mode or plan confirmation is still pending');
|
||||
}
|
||||
const owner = sessionOwnerGuard.capture();
|
||||
const option = request?.options.find(
|
||||
(entry) => entry.id === selectedOption,
|
||||
);
|
||||
const approvesPlan =
|
||||
isPlan &&
|
||||
(option?.kind === 'allow_once' || option?.kind === 'allow_always');
|
||||
const transition = isPlan
|
||||
? {
|
||||
owner,
|
||||
...(approvesPlan
|
||||
? {
|
||||
requestId: id,
|
||||
initialMode: connectionRef.current.currentMode,
|
||||
hadActiveTurn:
|
||||
streamingStateRef.current !== 'idle' ||
|
||||
sessionHasActivePromptRef.current,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: null;
|
||||
if (transition) {
|
||||
modeTransitionRef.current = transition;
|
||||
setModeControlsBusy(true);
|
||||
}
|
||||
try {
|
||||
if (approvesPlan && connection.planExecutionMode !== undefined) {
|
||||
await actions.respondToPermission(id, {
|
||||
outcome: { outcome: 'selected', optionId: selectedOption },
|
||||
expectedPlanExecutionMode: connection.planExecutionMode,
|
||||
});
|
||||
} else {
|
||||
await actions.submitPermission(id, selectedOption, answers);
|
||||
}
|
||||
if (transition && !approvesPlan) releaseModeTransition(transition);
|
||||
} catch (error) {
|
||||
if (transition) releaseModeTransition(transition);
|
||||
if (owner.isCurrent())
|
||||
reportError(error, 'Failed to submit permission choice');
|
||||
throw error;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[actions, reportError],
|
||||
[
|
||||
actions,
|
||||
reportError,
|
||||
sessionOwnerGuard,
|
||||
releaseModeTransition,
|
||||
connection.planExecutionMode,
|
||||
],
|
||||
);
|
||||
|
||||
const handleAskUserConfirm = useCallback(
|
||||
(id: string, selectedOption: string, answers?: Record<string, string>) =>
|
||||
actions.submitPermission(id, selectedOption, answers),
|
||||
@@ -1148,44 +1372,13 @@ export function ChatPane({
|
||||
);
|
||||
const handleSelectMode = useCallback(
|
||||
(modeId: string) => {
|
||||
// Modes always arrive from the toolbar's own picker, but narrow anyway so
|
||||
// the daemon action gets a well-typed value (mirrors App's handleSetMode).
|
||||
if (!isDaemonApprovalMode(modeId)) {
|
||||
reportError(
|
||||
new Error(`Unsupported approval mode: ${modeId}`),
|
||||
'Failed to set approval mode',
|
||||
);
|
||||
return;
|
||||
}
|
||||
actions
|
||||
.setApprovalMode(modeId)
|
||||
.then(() => {
|
||||
// Mirror App's handleSetMode: switching THIS pane to yolo (or
|
||||
// auto-edit for an edit tool) auto-approves a tool call already
|
||||
// awaiting approval in this pane, so the shortcut behaves the same as
|
||||
// in the single-session chat.
|
||||
const approval = pendingToolApprovalRef.current;
|
||||
if (!approval) return;
|
||||
const autoApprove =
|
||||
modeId === 'yolo' ||
|
||||
(modeId === 'auto-edit' && approval.toolKind === 'edit');
|
||||
if (!autoApprove) return;
|
||||
const allowOnce = approval.options.find(
|
||||
(option) => option.kind === 'allow_once',
|
||||
);
|
||||
if (!allowOnce) return;
|
||||
actions
|
||||
.submitPermission(approval.id, allowOnce.id)
|
||||
.catch((error: unknown) =>
|
||||
reportError(error, 'Failed to auto-approve tool call'),
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
reportError(error, 'Failed to set approval mode'),
|
||||
);
|
||||
void setComposerMode(modeId, planMode);
|
||||
},
|
||||
[actions, reportError],
|
||||
[setComposerMode, planMode],
|
||||
);
|
||||
const handleTogglePlan = useCallback(() => {
|
||||
void setComposerMode(executionMode, !planMode);
|
||||
}, [setComposerMode, executionMode, planMode]);
|
||||
const handleSelectModel = useCallback(
|
||||
(modelId: string) => {
|
||||
actions
|
||||
@@ -1222,10 +1415,11 @@ export function ChatPane({
|
||||
// `React.memo`, and a fresh `[...]` each render would defeat it.
|
||||
const paneToolbarActions = useMemo(
|
||||
() =>
|
||||
showWorkspaceChip
|
||||
(showWorkspaceChip
|
||||
? [...PANE_TOOLBAR_ACTIONS, 'workspace' as const]
|
||||
: PANE_TOOLBAR_ACTIONS,
|
||||
[showWorkspaceChip],
|
||||
: PANE_TOOLBAR_ACTIONS
|
||||
).filter((action) => action !== 'plan' || planControlVisible),
|
||||
[showWorkspaceChip, planControlVisible],
|
||||
);
|
||||
const headerActions =
|
||||
connection.sessionId && renderHeaderActions
|
||||
@@ -1447,6 +1641,8 @@ export function ChatPane({
|
||||
{pendingToolApproval && (
|
||||
<div className={styles.approval} data-testid="pane-approval">
|
||||
<ToolApproval
|
||||
disabled={isExitPlanApproval && modeControlsBusy}
|
||||
planExecutionMode={connection.planExecutionMode}
|
||||
request={pendingToolApproval}
|
||||
onConfirm={handleConfirm}
|
||||
variant="floating"
|
||||
@@ -1476,10 +1672,6 @@ export function ChatPane({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* A pending approval owns the pane footer: the status/queue/editor
|
||||
area below the approval drops out of layout (kept mounted so the
|
||||
draft survives) instead of leaving a live input under the
|
||||
dialog. */}
|
||||
<div className={approvalActive ? styles.composerHidden : undefined}>
|
||||
{/* Panes keep the composer status compact: spinner + elapsed time +
|
||||
token count + cancel hint, but no rotating "witty" loading
|
||||
@@ -1558,7 +1750,10 @@ export function ChatPane({
|
||||
workspaceName={showWorkspaceChip ? workspaceLabel : undefined}
|
||||
workspaceTitle={paneWorkspaceCwd}
|
||||
workspaceColor={workspaceAccent}
|
||||
currentMode={connection.currentMode ?? 'default'}
|
||||
currentMode={executionMode}
|
||||
modeControlsDisabled={modeControlsBusy}
|
||||
planMode={planMode}
|
||||
onTogglePlan={handleTogglePlan}
|
||||
sessionWorkflowEnabled={sessionWorkflowEnabled}
|
||||
currentModel={connection.currentModel ?? ''}
|
||||
availableModels={availableModels}
|
||||
|
||||
@@ -106,6 +106,7 @@ vi.mock('./ChatPane', () => ({
|
||||
return (
|
||||
<div
|
||||
data-testid="chat-pane"
|
||||
data-plan-visible={String(props.planControlVisible)}
|
||||
data-pane-workspace={props.workspaceCwd}
|
||||
data-session-details={props.sessionSummary?.sessionId}
|
||||
data-maximized={props.isMaximized ? 'true' : 'false'}
|
||||
@@ -1256,6 +1257,20 @@ describe('SplitView', () => {
|
||||
).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it.each([undefined, false, true])(
|
||||
'passes explicit Plan visibility to every pane: %s',
|
||||
(planControlVisible) => {
|
||||
render({ sessionIds: ['s1', 's2'], planControlVisible });
|
||||
const panes = container!.querySelectorAll('[data-testid="chat-pane"]');
|
||||
expect(panes).toHaveLength(2);
|
||||
for (const pane of panes) {
|
||||
expect(pane.getAttribute('data-plan-visible')).toBe(
|
||||
String(planControlVisible ?? false),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('passes renderPaneHeaderActions through to each ChatPane', () => {
|
||||
render({
|
||||
sessionIds: ['s1', 's2'],
|
||||
|
||||
@@ -101,6 +101,7 @@ export interface SplitViewProps {
|
||||
voiceWorkspaceRevisions?: Readonly<Record<string, number>>;
|
||||
voiceWorkspaces?: readonly DaemonWorkspaceCapability[];
|
||||
sessionWorkflowEnabled?: boolean;
|
||||
planControlVisible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,6 +134,7 @@ export function SplitView({
|
||||
voiceWorkspaceRevisions = {},
|
||||
voiceWorkspaces,
|
||||
sessionWorkflowEnabled = false,
|
||||
planControlVisible = false,
|
||||
}: SplitViewProps) {
|
||||
const { t } = useI18n();
|
||||
const connection = useConnection();
|
||||
@@ -654,6 +656,7 @@ export function SplitView({
|
||||
onPaneArtifactsChange={onPaneArtifactsChange}
|
||||
messageTurnOutputs={messageTurnOutputs}
|
||||
sessionWorkflowEnabled={sessionWorkflowEnabled}
|
||||
planControlVisible={planControlVisible}
|
||||
/>
|
||||
</DaemonSessionProvider>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -11,7 +11,7 @@ if (!Element.prototype.scrollIntoView) {
|
||||
}
|
||||
|
||||
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
|
||||
DAEMON_APPROVAL_MODES: ['plan', 'default', 'yolo'],
|
||||
DAEMON_APPROVAL_MODES: ['plan', 'default', 'auto', 'yolo'],
|
||||
}));
|
||||
|
||||
const { ApprovalModeDialog } = await import('./ApprovalModeDialog');
|
||||
@@ -55,44 +55,28 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('ApprovalModeDialog', () => {
|
||||
it('renames only the plan entry when Session Workflow is enabled', () => {
|
||||
it('offers execution permissions without Plan, including Auto', () => {
|
||||
mount(<ApprovalModeDialog currentMode="default" onSelect={vi.fn()} />);
|
||||
expect(container!.querySelector('[data-mode-id="plan"]')).toBeNull();
|
||||
expect(
|
||||
container!.querySelector('[data-mode-id="plan"]')?.textContent,
|
||||
).toContain('Plan (plan)');
|
||||
|
||||
rerender(
|
||||
<ApprovalModeDialog
|
||||
currentMode="default"
|
||||
sessionWorkflowEnabled
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
container!.querySelector('[data-mode-id="plan"]')?.textContent,
|
||||
).toContain('Plan & Review (plan)');
|
||||
expect(
|
||||
container!.querySelector('[data-mode-id="default"]')?.textContent,
|
||||
).toContain('Ask Approval (default)');
|
||||
expect(
|
||||
container!.querySelector('[data-mode-id="yolo"]')?.textContent,
|
||||
).toContain('Full Access (yolo)');
|
||||
container!.querySelector('[data-mode-id="auto"]')?.textContent,
|
||||
).toContain('Classifier Approval (auto)');
|
||||
});
|
||||
|
||||
it('opens with the highlight on the current mode and confirms on Enter', () => {
|
||||
const onSelect = vi.fn();
|
||||
mount(<ApprovalModeDialog currentMode="default" onSelect={onSelect} />);
|
||||
|
||||
expect(activeDescendant()).toBe('mode-opt-1');
|
||||
expect(activeDescendant()).toBe('mode-opt-0');
|
||||
|
||||
press('ArrowDown');
|
||||
expect(activeDescendant()).toBe('mode-opt-2');
|
||||
expect(activeDescendant()).toBe('mode-opt-1');
|
||||
press('Enter');
|
||||
expect(onSelect).toHaveBeenCalledWith('yolo');
|
||||
expect(onSelect).toHaveBeenCalledWith('auto');
|
||||
});
|
||||
|
||||
it('binds aria-selected to the current mode, not the roving highlight', () => {
|
||||
mount(<ApprovalModeDialog currentMode="plan" onSelect={vi.fn()} />);
|
||||
mount(<ApprovalModeDialog currentMode="default" onSelect={vi.fn()} />);
|
||||
const selected = () =>
|
||||
Array.from(container!.querySelectorAll('[aria-selected="true"]'));
|
||||
|
||||
@@ -105,7 +89,7 @@ describe('ApprovalModeDialog', () => {
|
||||
});
|
||||
|
||||
it('re-syncs the highlight when the current mode changes while open', () => {
|
||||
mount(<ApprovalModeDialog currentMode="plan" onSelect={vi.fn()} />);
|
||||
mount(<ApprovalModeDialog currentMode="default" onSelect={vi.fn()} />);
|
||||
expect(activeDescendant()).toBe('mode-opt-0');
|
||||
|
||||
// Another client sharing the session flips approval mode while the dialog
|
||||
@@ -115,7 +99,7 @@ describe('ApprovalModeDialog', () => {
|
||||
});
|
||||
|
||||
it('stops following once the user has navigated', () => {
|
||||
mount(<ApprovalModeDialog currentMode="plan" onSelect={vi.fn()} />);
|
||||
mount(<ApprovalModeDialog currentMode="default" onSelect={vi.fn()} />);
|
||||
|
||||
press('ArrowDown');
|
||||
expect(activeDescendant()).toBe('mode-opt-1');
|
||||
|
||||
@@ -20,23 +20,16 @@ interface ModeItem {
|
||||
|
||||
export function ApprovalModeDialog({
|
||||
currentMode,
|
||||
sessionWorkflowEnabled = false,
|
||||
onSelect,
|
||||
}: ApprovalModeDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const approvalModes: ModeItem[] = DAEMON_APPROVAL_MODES.map((id) => ({
|
||||
const approvalModes: ModeItem[] = DAEMON_APPROVAL_MODES.filter(
|
||||
(id) => id !== 'plan',
|
||||
).map((id) => ({
|
||||
id,
|
||||
name: t(
|
||||
id === 'plan' && sessionWorkflowEnabled
|
||||
? 'mode.listLabel.planReview'
|
||||
: `mode.listLabel.${id}`,
|
||||
),
|
||||
description: t(
|
||||
id === 'plan' && sessionWorkflowEnabled
|
||||
? 'mode.desc.planReview'
|
||||
: `mode.desc.${id}`,
|
||||
),
|
||||
name: t(`mode.listLabel.${id}`),
|
||||
description: t(`mode.desc.${id}`),
|
||||
}));
|
||||
|
||||
const currentIdx = approvalModes.findIndex((m) => m.id === currentMode);
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.floatingWorkflow > :not(.planContent):not(.workflow) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workflow {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
@@ -147,6 +151,7 @@
|
||||
|
||||
.planContent {
|
||||
display: block;
|
||||
min-height: 0;
|
||||
max-height: min(240px, 25vh);
|
||||
-webkit-line-clamp: unset;
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ function rerender(
|
||||
planTodos?: readonly TodoItem[],
|
||||
language: WebShellLanguage = 'en',
|
||||
generateContent?: SessionContentGenerator,
|
||||
planExecutionMode?: string,
|
||||
disabled?: boolean,
|
||||
): void {
|
||||
act(() =>
|
||||
root!.render(
|
||||
@@ -83,6 +85,8 @@ function rerender(
|
||||
keyboardActive={keyboardActive}
|
||||
planTodos={planTodos}
|
||||
generateContent={generateContent}
|
||||
planExecutionMode={planExecutionMode}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
),
|
||||
@@ -95,11 +99,21 @@ function render(
|
||||
planTodos?: readonly TodoItem[],
|
||||
language: WebShellLanguage = 'en',
|
||||
generateContent?: SessionContentGenerator,
|
||||
planExecutionMode?: string,
|
||||
disabled?: boolean,
|
||||
): void {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
rerender(keyboardActive, req, planTodos, language, generateContent);
|
||||
rerender(
|
||||
keyboardActive,
|
||||
req,
|
||||
planTodos,
|
||||
language,
|
||||
generateContent,
|
||||
planExecutionMode,
|
||||
disabled,
|
||||
);
|
||||
}
|
||||
|
||||
function optionButtons(): HTMLButtonElement[] {
|
||||
@@ -317,6 +331,69 @@ describe('ToolApproval accessibility', () => {
|
||||
expect(container!.textContent).not.toContain('确认计划并开始协作?');
|
||||
});
|
||||
|
||||
it('blocks plan handoff clicks and shortcuts while disabled, then allows confirmation', () => {
|
||||
render(undefined, request, undefined, 'en', undefined, undefined, true);
|
||||
act(() => optionButtons()[1].click());
|
||||
pressKey(container!.querySelector('[role="alertdialog"]')!, '2');
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(optionButtons().every((button) => button.disabled)).toBe(true);
|
||||
rerender(undefined, request, undefined, 'en', undefined, undefined, false);
|
||||
act(() => optionButtons()[1].click());
|
||||
expect(onConfirm).toHaveBeenCalledWith(request.id, 'proceed');
|
||||
});
|
||||
|
||||
it('re-arms a plan handoff after the parent rejects a same-tick busy confirmation', async () => {
|
||||
onConfirm.mockRejectedValueOnce(
|
||||
new Error('Approval mode is still pending'),
|
||||
);
|
||||
render();
|
||||
await act(async () => optionButtons()[1].click());
|
||||
act(() => optionButtons()[1].click());
|
||||
expect(onConfirm).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uses the latest execution permission without automatically approving the plan', () => {
|
||||
const req: PermissionRequest = {
|
||||
...planRequest,
|
||||
options: [
|
||||
{ id: 'restore_previous', label: 'Restore YOLO', kind: 'allow_once' },
|
||||
{ id: 'proceed_always', label: 'Auto edits', kind: 'allow_always' },
|
||||
{ id: 'proceed_once', label: 'Default', kind: 'allow_once' },
|
||||
{ id: 'cancel', label: 'Cancel', kind: 'reject_once' },
|
||||
],
|
||||
};
|
||||
render(undefined, req, undefined, 'en', undefined, 'yolo');
|
||||
expect(optionLabels()).toEqual([
|
||||
'Continue planning',
|
||||
'Approve and execute · Full Access',
|
||||
]);
|
||||
rerender(undefined, req, undefined, 'en', undefined, 'default');
|
||||
expect(optionLabels()).toEqual([
|
||||
'Continue planning',
|
||||
'Approve and execute · Ask Approval',
|
||||
]);
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
act(() => optionButtons()[1].click());
|
||||
expect(onConfirm).toHaveBeenCalledWith(req.id, 'restore_previous');
|
||||
});
|
||||
|
||||
it('does not invent a plan approval option missing from the server request', () => {
|
||||
render(undefined, planRequest, undefined, 'en', undefined, 'yolo');
|
||||
expect(optionButtons().map((button) => button.dataset.optionId)).toEqual([
|
||||
'reject',
|
||||
]);
|
||||
act(() => optionButtons()[0].click());
|
||||
expect(onConfirm).toHaveBeenCalledWith(planRequest.id, 'reject');
|
||||
});
|
||||
|
||||
it('keeps ordinary tool permissions unchanged when a plan execution mode is supplied', () => {
|
||||
render(undefined, request, undefined, 'en', undefined, 'yolo');
|
||||
expect(optionButtons().map((button) => button.dataset.optionId)).toEqual([
|
||||
'reject',
|
||||
'proceed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps restore_previous distinct from confirm in a Workflow approval', () => {
|
||||
// The production exit_plan_mode option set: two `allow_once` options whose
|
||||
// outcomes differ materially, so they must never share one label.
|
||||
|
||||
@@ -25,6 +25,7 @@ interface ToolApprovalProps {
|
||||
request: PermissionRequest;
|
||||
onConfirm: (id: string, selectedOption: string) => void | Promise<void>;
|
||||
variant?: 'inline' | 'floating';
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Whether this approval should pull keyboard focus to its safe-default option
|
||||
* when it becomes the topmost (visible) one — on appearance, or when a panel/
|
||||
@@ -37,6 +38,7 @@ interface ToolApprovalProps {
|
||||
*/
|
||||
keyboardActive?: boolean;
|
||||
planTodos?: readonly TodoItem[];
|
||||
planExecutionMode?: string;
|
||||
generateContent?: SessionContentGenerator;
|
||||
}
|
||||
|
||||
@@ -225,17 +227,31 @@ export function ToolApproval({
|
||||
request,
|
||||
onConfirm,
|
||||
variant = 'inline',
|
||||
disabled = false,
|
||||
keyboardActive = true,
|
||||
planTodos = [],
|
||||
planExecutionMode,
|
||||
generateContent,
|
||||
}: ToolApprovalProps) {
|
||||
const { t } = useI18n();
|
||||
const isAgent = isAgentTool(request.toolName);
|
||||
const displayOptions = useMemo(
|
||||
() => prepareDisplayOptions(request.options),
|
||||
[request.options],
|
||||
);
|
||||
const isExitPlanApproval = isExitPlanApprovalRequest(request);
|
||||
const hasPlanExecutionMode =
|
||||
isExitPlanApproval && planExecutionMode !== undefined;
|
||||
const displayOptions = useMemo(
|
||||
() =>
|
||||
prepareDisplayOptions(
|
||||
hasPlanExecutionMode
|
||||
? request.options.filter(
|
||||
(option) =>
|
||||
option.id === 'restore_previous' ||
|
||||
option.kind === 'reject_once' ||
|
||||
option.kind === 'reject_always',
|
||||
)
|
||||
: request.options,
|
||||
),
|
||||
[request.options, hasPlanExecutionMode],
|
||||
);
|
||||
const showsPlanWorkflow = planTodos.length > 0 && isExitPlanApproval;
|
||||
const safeDefaultIndex = useMemo(
|
||||
() => getSafeDefaultIndex(displayOptions, isAgent),
|
||||
@@ -255,7 +271,12 @@ export function ToolApproval({
|
||||
if (key) keyCount.set(key, (keyCount.get(key) ?? 0) + 1);
|
||||
}
|
||||
return (option: PermissionRequest['options'][number]) => {
|
||||
if (showsPlanWorkflow) {
|
||||
if (hasPlanExecutionMode && option.id === 'restore_previous') {
|
||||
return t('approval.option.executePlan', {
|
||||
mode: t(`mode.label.${planExecutionMode}`),
|
||||
});
|
||||
}
|
||||
if (showsPlanWorkflow || hasPlanExecutionMode) {
|
||||
// An exit_plan_mode approval emits two `allow_once` options, so this
|
||||
// cannot relabel by kind alone: `restore_previous` restores the
|
||||
// pre-plan approval mode (YOLO if the user entered plan from YOLO)
|
||||
@@ -276,7 +297,13 @@ export function ToolApproval({
|
||||
if (key && keyCount.get(key) === 1) return t(key);
|
||||
return option.label || (key ? t(key) : '');
|
||||
};
|
||||
}, [displayOptions, showsPlanWorkflow, t]);
|
||||
}, [
|
||||
displayOptions,
|
||||
showsPlanWorkflow,
|
||||
hasPlanExecutionMode,
|
||||
planExecutionMode,
|
||||
t,
|
||||
]);
|
||||
const [selected, setSelected] = useState(safeDefaultIndex);
|
||||
const requestRef = useRef(request);
|
||||
requestRef.current = request;
|
||||
@@ -319,7 +346,7 @@ export function ToolApproval({
|
||||
|
||||
const confirm = useCallback(
|
||||
(optionId: string) => {
|
||||
if (submittedRef.current) return;
|
||||
if (disabled || submittedRef.current) return;
|
||||
submittedRef.current = true;
|
||||
const requestId = requestRef.current.id;
|
||||
const submission = onConfirm(requestId, optionId);
|
||||
@@ -336,7 +363,7 @@ export function ToolApproval({
|
||||
});
|
||||
}
|
||||
},
|
||||
[onConfirm],
|
||||
[onConfirm, disabled],
|
||||
);
|
||||
|
||||
const focusOption = useCallback((index: number) => {
|
||||
@@ -465,7 +492,7 @@ export function ToolApproval({
|
||||
className={
|
||||
variant === 'floating'
|
||||
? `${styles.approval} ${styles.floating}${
|
||||
showsPlanWorkflow ? ` ${styles.floatingWorkflow}` : ''
|
||||
isExitPlanApproval ? ` ${styles.floatingWorkflow}` : ''
|
||||
}`
|
||||
: styles.approval
|
||||
}
|
||||
@@ -560,6 +587,7 @@ export function ToolApproval({
|
||||
isSelected ? styles.optionActive : ''
|
||||
}`}
|
||||
data-web-shell-permission-option
|
||||
disabled={disabled}
|
||||
data-option-id={option.id}
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
role="radio"
|
||||
|
||||
@@ -19,6 +19,7 @@ describe('getLocalCommands', () => {
|
||||
expect(
|
||||
getLocalCommands(en).every((c) => c.source === 'builtin-command'),
|
||||
).toBe(true);
|
||||
expect(byName.get('plan')?.subcommands).toEqual(['on', 'off', 'exit']);
|
||||
expect(byName.get('fork')?.argumentHint).toBe('<directive>');
|
||||
expect(byName.get('btw')?.argumentHint).toBe('<your question>');
|
||||
expect(
|
||||
|
||||
@@ -27,7 +27,12 @@ export function getLocalCommands(
|
||||
argumentHint: 'ui [en|zh-CN]',
|
||||
subcommands: ['ui'],
|
||||
},
|
||||
{ name: 'plan', description: t('local.plan'), argumentHint: '<prompt>' },
|
||||
{
|
||||
name: 'plan',
|
||||
description: t('local.plan'),
|
||||
argumentHint: '[on|off|exit|<prompt>]',
|
||||
subcommands: ['on', 'off', 'exit'],
|
||||
},
|
||||
{
|
||||
name: 'btw',
|
||||
description: t('local.btw'),
|
||||
|
||||
@@ -5378,6 +5378,142 @@ describe('DaemonSessionProvider', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves the Plan execution mode when prompt restart reuses metadata', async () => {
|
||||
const turnComplete = createDeferred<void>();
|
||||
const secondSubscriptionStarted = createDeferred<void>();
|
||||
const turnEvents = createTurnCompleteEvents(turnComplete);
|
||||
const events = vi.fn(async function* planEvents(
|
||||
opts: { signal?: AbortSignal } = {},
|
||||
) {
|
||||
if (events.mock.calls.length === 2) secondSubscriptionStarted.resolve();
|
||||
yield* turnEvents(opts);
|
||||
});
|
||||
const session = createMockSession({
|
||||
context: vi.fn(async () => ({
|
||||
v: 1 as const,
|
||||
sessionId: 'session-1',
|
||||
workspaceCwd: '/mock-workspace',
|
||||
state: {
|
||||
modes: {
|
||||
currentModeId: 'plan',
|
||||
_meta: { planExecutionMode: 'yolo' },
|
||||
},
|
||||
},
|
||||
})),
|
||||
events,
|
||||
});
|
||||
sdkMocks.sessions.push(session);
|
||||
let actions: DaemonUiSessionActions | undefined;
|
||||
let connection: DaemonConnectionState | undefined;
|
||||
|
||||
function Harness() {
|
||||
actions = useDaemonActions();
|
||||
connection = useDaemonConnection();
|
||||
return null;
|
||||
}
|
||||
|
||||
await renderWithProvider(<Harness />, {
|
||||
autoConnect: true,
|
||||
restartEventStreamOnPrompt: true,
|
||||
});
|
||||
expect(connection).toMatchObject({
|
||||
currentMode: 'plan',
|
||||
planExecutionMode: 'yolo',
|
||||
});
|
||||
const contextCalls = vi.mocked(session.context).mock.calls.length;
|
||||
let promptResult: Promise<unknown> | undefined;
|
||||
await act(async () => {
|
||||
promptResult = requireActions(actions).sendPrompt('execute the plan');
|
||||
await secondSubscriptionStarted.promise;
|
||||
});
|
||||
const connectionAfterRestart = connection;
|
||||
expect(events.mock.calls[1]?.[0]).toMatchObject({
|
||||
sseConnectReason: 'prompt_restart',
|
||||
});
|
||||
expect(session.context).toHaveBeenCalledTimes(contextCalls);
|
||||
turnComplete.resolve();
|
||||
await act(async () => {
|
||||
await expect(promptResult).resolves.toEqual({ stopReason: 'end_turn' });
|
||||
});
|
||||
expect(connectionAfterRestart).toMatchObject({
|
||||
currentMode: 'plan',
|
||||
planExecutionMode: 'yolo',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['rejected', 'default'],
|
||||
['missing modes', 'plan'],
|
||||
])(
|
||||
'preserves live Plan policy when context is %s and workspace mode is %s',
|
||||
async (contextResult, workspaceMode) => {
|
||||
sdkMocks.workspaceProviders.mockResolvedValue({
|
||||
v: 1,
|
||||
workspaceCwd: '/mock-workspace',
|
||||
initialized: true,
|
||||
approvalMode: workspaceMode,
|
||||
providers: [],
|
||||
});
|
||||
const restart = createDeferred<void>();
|
||||
const reconnected = createDeferred<void>();
|
||||
const idleEvents = createIdleEvents();
|
||||
const events = vi.fn(async function* reconnectEvents(
|
||||
opts: { signal?: AbortSignal } = {},
|
||||
) {
|
||||
if (events.mock.calls.length === 1) {
|
||||
await restart.promise;
|
||||
return;
|
||||
}
|
||||
reconnected.resolve();
|
||||
yield* idleEvents(opts);
|
||||
});
|
||||
const session = createMockSession({ events });
|
||||
vi.mocked(session.context).mockResolvedValueOnce({
|
||||
v: 1,
|
||||
sessionId: session.sessionId,
|
||||
workspaceCwd: session.workspaceCwd,
|
||||
state: {
|
||||
modes: {
|
||||
currentModeId: 'plan',
|
||||
_meta: { planExecutionMode: 'yolo' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (contextResult === 'rejected') {
|
||||
vi.mocked(session.context).mockRejectedValue(
|
||||
new Error('context failed'),
|
||||
);
|
||||
}
|
||||
sdkMocks.sessions.push(session);
|
||||
let connection: DaemonConnectionState | undefined;
|
||||
|
||||
function Harness() {
|
||||
connection = useDaemonConnection();
|
||||
return null;
|
||||
}
|
||||
|
||||
await renderWithProvider(<Harness />, {
|
||||
autoConnect: true,
|
||||
reconnectDelayMs: 1,
|
||||
maxReconnectDelayMs: 1,
|
||||
});
|
||||
expect(connection).toMatchObject({
|
||||
currentMode: 'plan',
|
||||
planExecutionMode: 'yolo',
|
||||
});
|
||||
await act(async () => {
|
||||
restart.resolve();
|
||||
await reconnected.promise;
|
||||
});
|
||||
expect(session.context).toHaveBeenCalledTimes(2);
|
||||
expect(sdkMocks.workspaceProviders).toHaveBeenCalledTimes(2);
|
||||
expect(connection).toMatchObject({
|
||||
currentMode: 'plan',
|
||||
planExecutionMode: 'yolo',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('restarts the event stream when aborting the subscription throws', async () => {
|
||||
const turnComplete = createDeferred<void>();
|
||||
const secondSubscriptionStarted = createDeferred<void>();
|
||||
|
||||
@@ -82,6 +82,7 @@ import { useOptionalDaemonWorkspace } from '../workspace/DaemonWorkspaceProvider
|
||||
import { loadReadyWorkspaceSkills } from '../workspace/load-ready-skills.js';
|
||||
import {
|
||||
getCurrentMode,
|
||||
getPlanExecutionMode,
|
||||
getSessionDisplayName,
|
||||
getReplayTokenUsage,
|
||||
getTokenCountFromUsage,
|
||||
@@ -3023,8 +3024,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
|
||||
?.contextWindow ??
|
||||
providerContextWindow;
|
||||
const { commands, skills } = mapSupportedCommands(supportedCommands);
|
||||
const currentMode =
|
||||
getCurrentMode(context) ?? providerModelStatus.currentMode;
|
||||
const currentMode = getCurrentMode(context);
|
||||
|
||||
setConnection((current) => {
|
||||
if (
|
||||
@@ -3070,7 +3070,15 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
|
||||
currentModel: configSnapshotCurrent
|
||||
? (sessionCurrentModel ?? current.currentModel)
|
||||
: current.currentModel,
|
||||
currentMode: currentMode ?? current.currentMode,
|
||||
currentMode: configSnapshotCurrent
|
||||
? (currentMode ??
|
||||
current.currentMode ??
|
||||
providerModelStatus.currentMode)
|
||||
: current.currentMode,
|
||||
planExecutionMode:
|
||||
configSnapshotCurrent && currentMode !== undefined
|
||||
? getPlanExecutionMode(context)
|
||||
: current.planExecutionMode,
|
||||
reasoning:
|
||||
configSnapshotCurrent && context !== undefined
|
||||
? mapSessionContextReasoning(context)
|
||||
|
||||
@@ -4315,6 +4315,74 @@ describe('createDaemonSessionActions', () => {
|
||||
expect(getConnection().models?.[0]?.reasoningPreview?.effort).toBe('low');
|
||||
});
|
||||
|
||||
it('preserves both Plan fields when context omits modes and clears policy on a confirmed exit', async () => {
|
||||
const session = createMockSession('session-a');
|
||||
const { actions, getConnection } = createActionsHarness({
|
||||
session,
|
||||
connection: {
|
||||
status: 'connected',
|
||||
sessionId: 'session-a',
|
||||
currentMode: 'plan',
|
||||
planExecutionMode: 'yolo',
|
||||
},
|
||||
});
|
||||
session.context.mockResolvedValueOnce({
|
||||
...contextStatus('session-a'),
|
||||
state: {},
|
||||
});
|
||||
await actions.getContext();
|
||||
expect(getConnection()).toMatchObject({
|
||||
currentMode: 'plan',
|
||||
planExecutionMode: 'yolo',
|
||||
});
|
||||
session.context.mockResolvedValueOnce({
|
||||
...contextStatus('session-a'),
|
||||
state: { modes: { currentModeId: 'default' } },
|
||||
});
|
||||
await actions.getContext();
|
||||
expect(getConnection().currentMode).toBe('default');
|
||||
expect(getConnection().planExecutionMode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the selected Plan permission when an older context read finishes later', async () => {
|
||||
const session = createMockSession('session-a');
|
||||
const staleContext = createDeferred<ReturnType<typeof contextStatus>>();
|
||||
session.context.mockReturnValueOnce(staleContext.promise);
|
||||
session.client.setSessionApprovalMode.mockResolvedValueOnce({
|
||||
sessionId: 'session-a',
|
||||
mode: 'plan',
|
||||
planExecutionMode: 'yolo',
|
||||
previous: 'default',
|
||||
persisted: false,
|
||||
});
|
||||
const { actions, getConnection } = createActionsHarness({ session });
|
||||
const pendingContext = actions.getContext();
|
||||
await actions.setApprovalMode('yolo', { planMode: true });
|
||||
expect(session.client.setSessionApprovalMode).toHaveBeenCalledWith(
|
||||
'session-a',
|
||||
'yolo',
|
||||
expect.objectContaining({ planMode: true }),
|
||||
);
|
||||
staleContext.resolve({
|
||||
...contextStatus('session-a'),
|
||||
state: { modes: { currentModeId: 'default' } },
|
||||
});
|
||||
await pendingContext;
|
||||
expect(getConnection()).toMatchObject({
|
||||
currentMode: 'plan',
|
||||
planExecutionMode: 'yolo',
|
||||
});
|
||||
session.client.setSessionApprovalMode.mockResolvedValueOnce({
|
||||
sessionId: 'session-a',
|
||||
mode: 'yolo',
|
||||
previous: 'plan',
|
||||
persisted: false,
|
||||
});
|
||||
await actions.setApprovalMode('yolo', { planMode: false });
|
||||
expect(getConnection().currentMode).toBe('yolo');
|
||||
expect(getConnection().planExecutionMode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not apply a late approval mode to a replacement attachment', async () => {
|
||||
const source = createMockSession('session-a', 'client-a');
|
||||
const target = createMockSession('session-a', 'client-b');
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from '@qwen-code/sdk/daemon';
|
||||
import { extractHttpStatus, isInvalidClientIdError } from './httpErrors.js';
|
||||
import {
|
||||
getPlanExecutionMode,
|
||||
mapProviderStatus,
|
||||
mapReasoningControls,
|
||||
mapSessionContextReasoning,
|
||||
@@ -344,6 +345,7 @@ export function getConnectionAfterSessionClear(
|
||||
delete next.models;
|
||||
delete next.currentModel;
|
||||
delete next.currentMode;
|
||||
delete next.planExecutionMode;
|
||||
delete next.contextWindow;
|
||||
delete next.providers;
|
||||
delete next.gitBranch;
|
||||
@@ -1510,16 +1512,24 @@ export function createDaemonSessionActions({
|
||||
);
|
||||
try {
|
||||
const result = await withActionTimeout(
|
||||
session.client.setSessionApprovalMode(session.sessionId, mode, {
|
||||
persist: opts?.persist,
|
||||
clientId: session.clientId,
|
||||
}),
|
||||
trackSessionConfigMutation(
|
||||
session,
|
||||
session.client.setSessionApprovalMode(session.sessionId, mode, {
|
||||
persist: opts?.persist,
|
||||
clientId: session.clientId,
|
||||
...(opts?.planMode !== undefined
|
||||
? { planMode: opts.planMode }
|
||||
: {}),
|
||||
}),
|
||||
),
|
||||
'Set approval mode timed out',
|
||||
);
|
||||
if (sessionRef.current === session) {
|
||||
setConnection((current) => ({
|
||||
...current,
|
||||
currentMode: result.mode || mode,
|
||||
planExecutionMode:
|
||||
result.mode === 'plan' ? result.planExecutionMode : undefined,
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
@@ -2053,11 +2063,15 @@ export function createDaemonSessionActions({
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
const currentMode = getModeFromSessionContext(context);
|
||||
return {
|
||||
...current,
|
||||
context,
|
||||
currentMode:
|
||||
getModeFromSessionContext(context) ?? current.currentMode,
|
||||
currentMode: currentMode ?? current.currentMode,
|
||||
planExecutionMode:
|
||||
currentMode !== undefined
|
||||
? getPlanExecutionMode(context)
|
||||
: current.planExecutionMode,
|
||||
currentModel:
|
||||
getModelFromSessionContext(context) ?? current.currentModel,
|
||||
reasoning: mapSessionContextReasoning(context),
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
DaemonWorkspaceSkillsStatus,
|
||||
} from '@qwen-code/sdk/daemon';
|
||||
import {
|
||||
getPlanExecutionMode,
|
||||
getReplayTokenCount,
|
||||
getReplayTokenUsage,
|
||||
mapProviderStatus,
|
||||
@@ -1312,3 +1313,53 @@ describe('updateConnectionFromDaemonEvent', () => {
|
||||
expect(next.skills).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plan connection state', () => {
|
||||
it.each(['approval_mode_changed', 'session_snapshot'])(
|
||||
'maps workflow and execution permission together from %s',
|
||||
(type) => {
|
||||
const planning = applyEvent(
|
||||
{ status: 'connected' },
|
||||
{
|
||||
id: 1,
|
||||
v: 1,
|
||||
type,
|
||||
data: {
|
||||
next: 'plan',
|
||||
currentApprovalMode: 'plan',
|
||||
planExecutionMode: 'yolo',
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(planning).toMatchObject({
|
||||
currentMode: 'plan',
|
||||
planExecutionMode: 'yolo',
|
||||
});
|
||||
const done = applyEvent(planning, {
|
||||
id: 2,
|
||||
v: 1,
|
||||
type,
|
||||
data: { next: 'yolo', currentApprovalMode: 'yolo' },
|
||||
});
|
||||
expect(done.currentMode).toBe('yolo');
|
||||
expect(done.planExecutionMode).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('reads execution permission only while the context is in Plan', () => {
|
||||
const context = {
|
||||
v: 1 as const,
|
||||
sessionId: 's',
|
||||
workspaceCwd: '/workspace',
|
||||
state: {
|
||||
modes: {
|
||||
currentModeId: 'plan',
|
||||
_meta: { planExecutionMode: 'auto-edit' },
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(getPlanExecutionMode(context)).toBe('auto-edit');
|
||||
context.state.modes.currentModeId = 'default';
|
||||
expect(getPlanExecutionMode(context)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -412,11 +412,28 @@ export function updateConnectionFromDaemonEvent(
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'session_snapshot': {
|
||||
const data = getRecord(event.data);
|
||||
const mode = getString(data, 'currentApprovalMode');
|
||||
if (mode)
|
||||
setConnection((current) => ({
|
||||
...current,
|
||||
currentMode: mode,
|
||||
planExecutionMode:
|
||||
mode === 'plan' ? getString(data, 'planExecutionMode') : undefined,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'approval_mode_changed': {
|
||||
const data = getRecord(event.data);
|
||||
const mode = getString(data, 'next') ?? getString(data, 'mode');
|
||||
if (mode) {
|
||||
setConnection((current) => ({ ...current, currentMode: mode }));
|
||||
setConnection((current) => ({
|
||||
...current,
|
||||
currentMode: mode,
|
||||
planExecutionMode:
|
||||
mode === 'plan' ? getString(data, 'planExecutionMode') : undefined,
|
||||
}));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -709,6 +726,14 @@ export function getCurrentMode(
|
||||
return getString(modes, 'currentModeId') ?? getString(modes, 'currentMode');
|
||||
}
|
||||
|
||||
export function getPlanExecutionMode(
|
||||
status: DaemonSessionContextStatus | undefined,
|
||||
): string | undefined {
|
||||
if (getCurrentMode(status) !== 'plan') return undefined;
|
||||
const modes = getRecord(status?.state?.modes);
|
||||
return getString(getRecord(modes?.['_meta']), 'planExecutionMode');
|
||||
}
|
||||
|
||||
export function getCurrentModel(
|
||||
status: DaemonSessionContextStatus | undefined,
|
||||
): string | undefined {
|
||||
|
||||
@@ -114,6 +114,7 @@ export interface DaemonConnectionState {
|
||||
currentModel?: string;
|
||||
reasoning?: DaemonReasoningControls;
|
||||
currentMode?: string;
|
||||
planExecutionMode?: string;
|
||||
displayName?: string;
|
||||
titleSource?: 'manual' | 'auto';
|
||||
/** Latest main-conversation model usage event. */
|
||||
@@ -450,7 +451,7 @@ export interface DaemonSessionActions {
|
||||
): Promise<void>;
|
||||
setApprovalMode(
|
||||
mode: DaemonApprovalMode,
|
||||
opts?: { persist?: boolean },
|
||||
opts?: { persist?: boolean; planMode?: boolean },
|
||||
): Promise<DaemonApprovalModeResult>;
|
||||
respondToPermission(
|
||||
requestId: string,
|
||||
|
||||
@@ -2479,21 +2479,21 @@ const EN: Messages = {
|
||||
'mode.name.auto-edit': 'auto-edit',
|
||||
'mode.name.auto': 'auto',
|
||||
'mode.name.yolo': 'yolo',
|
||||
'plan.toggle.on': 'Plan before executing',
|
||||
'plan.toggle.off': (v) =>
|
||||
`Planning; execute with ${v?.mode} after approval. Click to exit planning.`,
|
||||
'approval.option.executePlan': (v) => `Approve and execute · ${v?.mode}`,
|
||||
'mode.label.plan': 'Plan',
|
||||
'mode.label.planReview': 'Plan & Review',
|
||||
'mode.label.default': 'Ask Approval',
|
||||
'mode.label.auto-edit': 'Auto Edit',
|
||||
'mode.label.auto': 'Classifier Approval',
|
||||
'mode.label.yolo': 'Full Access',
|
||||
'mode.listLabel.plan': 'Plan (plan)',
|
||||
'mode.listLabel.planReview': 'Plan & Review (plan)',
|
||||
'mode.listLabel.default': 'Ask Approval (default)',
|
||||
'mode.listLabel.auto-edit': 'Auto Edit (auto-edit)',
|
||||
'mode.listLabel.auto': 'Classifier Approval (auto)',
|
||||
'mode.listLabel.yolo': 'Full Access (yolo)',
|
||||
'mode.desc.plan': 'Analyze only, do not modify files or execute commands',
|
||||
'mode.desc.planReview':
|
||||
'Use Plan mode and review its workflow when one is available',
|
||||
'mode.desc.default':
|
||||
'Ask before running commands, editing files, or accessing external resources',
|
||||
'mode.desc.auto-edit':
|
||||
@@ -2502,6 +2502,8 @@ const EN: Messages = {
|
||||
'Evaluate tool risk automatically, run safe actions, and confirm risky ones',
|
||||
'mode.desc.yolo': 'Automatically approve all tool calls in trusted contexts',
|
||||
'mode.select': 'Approval Mode',
|
||||
'mode.changePending':
|
||||
'A mode change or plan confirmation is pending. Try again when it finishes.',
|
||||
'mode.autoApproved': ((v) =>
|
||||
v?.tool
|
||||
? `Auto-approved: ${v.tool}`
|
||||
@@ -5935,25 +5937,26 @@ const ZH: Messages = {
|
||||
'mode.name.auto-edit': 'auto-edit',
|
||||
'mode.name.auto': 'auto',
|
||||
'mode.name.yolo': 'yolo',
|
||||
'plan.toggle.on': '执行前先制定计划',
|
||||
'plan.toggle.off': (v) => `规划中,批准后按${v?.mode}执行。点击退出规划。`,
|
||||
'approval.option.executePlan': (v) => `批准并执行 · ${v?.mode}`,
|
||||
'mode.label.plan': '计划',
|
||||
'mode.label.planReview': '计划并审阅',
|
||||
'mode.label.default': '请求批准',
|
||||
'mode.label.auto-edit': '自动编辑',
|
||||
'mode.label.auto': '智能审批',
|
||||
'mode.label.yolo': '完全访问权限',
|
||||
'mode.listLabel.plan': '计划(plan)',
|
||||
'mode.listLabel.planReview': '计划并审阅(plan)',
|
||||
'mode.listLabel.default': '请求批准(default)',
|
||||
'mode.listLabel.auto-edit': '自动编辑(auto-edit)',
|
||||
'mode.listLabel.auto': '智能审批(auto)',
|
||||
'mode.listLabel.yolo': '完全访问权限(yolo)',
|
||||
'mode.desc.plan': '仅分析,不修改文件或执行命令',
|
||||
'mode.desc.planReview': '使用 Plan 模式,并在 Workflow 可用时进行审阅',
|
||||
'mode.desc.default': '执行命令、编辑文件或访问外部资源前请求确认',
|
||||
'mode.desc.auto-edit': '自动批准文件编辑,命令执行等敏感操作仍会询问',
|
||||
'mode.desc.auto': '自动评估工具风险,安全操作直接执行,风险操作再确认',
|
||||
'mode.desc.yolo': '自动批准所有工具调用,适合可信任务环境',
|
||||
'mode.select': '审批模式',
|
||||
'mode.changePending': '模式切换或计划确认尚未完成,请稍后重试。',
|
||||
'mode.autoApproved': ((v) =>
|
||||
v?.tool
|
||||
? `已自动批准:${v.tool}`
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('StandaloneApp', () => {
|
||||
);
|
||||
expect(
|
||||
testState.props?.webShellProps.composerToolbarAdditionalActions,
|
||||
).toEqual(['addMenu']);
|
||||
).toEqual(['addMenu', 'plan']);
|
||||
expect(testState.props?.webShellProps.environmentPanel?.items).toContain(
|
||||
'artifacts',
|
||||
);
|
||||
|
||||
@@ -23,7 +23,7 @@ import './styles/standalone.css';
|
||||
|
||||
const DAEMON_BASE_URL = getDaemonBaseUrl();
|
||||
|
||||
const STANDALONE_COMPOSER_TOOLBAR_ADDITIONS = ['addMenu'] as const;
|
||||
const STANDALONE_COMPOSER_TOOLBAR_ADDITIONS = ['addMenu', 'plan'] as const;
|
||||
|
||||
const LANGUAGE_STORAGE_KEY = 'qwen-code-web-shell-language';
|
||||
const THEME_STORAGE_KEY = 'qwen-code-web-shell-theme';
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EXECUTION_APPROVAL_MODES, parsePlanCommand } from './planMode';
|
||||
|
||||
describe('Plan commands', () => {
|
||||
it('keeps workflow out of execution permissions', () => {
|
||||
expect(EXECUTION_APPROVAL_MODES).toEqual([
|
||||
'default',
|
||||
'auto-edit',
|
||||
'auto',
|
||||
'yolo',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['', false, { enabled: true }],
|
||||
['', true, { enabled: false }],
|
||||
[' on ', true, { enabled: true }],
|
||||
['OFF', true, { enabled: false }],
|
||||
['exit', true, { enabled: false }],
|
||||
[
|
||||
'design this feature',
|
||||
false,
|
||||
{ enabled: true, prompt: 'design this feature' },
|
||||
],
|
||||
])('parses %j from enabled=%s', (args, enabled, expected) => {
|
||||
expect(parsePlanCommand(args, enabled)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { DAEMON_APPROVAL_MODES } from '@qwen-code/web-shell/daemon-react-sdk';
|
||||
|
||||
export const EXECUTION_APPROVAL_MODES = DAEMON_APPROVAL_MODES.filter(
|
||||
(mode) => mode !== 'plan',
|
||||
);
|
||||
|
||||
export function parsePlanCommand(
|
||||
args: string,
|
||||
enabled: boolean,
|
||||
): { enabled: boolean; prompt?: string } {
|
||||
const prompt = args.trim();
|
||||
switch (prompt.toLowerCase()) {
|
||||
case '':
|
||||
return { enabled: !enabled };
|
||||
case 'on':
|
||||
return { enabled: true };
|
||||
case 'off':
|
||||
case 'exit':
|
||||
return { enabled: false };
|
||||
default:
|
||||
return { enabled: true, prompt };
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ function createActions(
|
||||
function prepareSession(
|
||||
args: Omit<CreateSessionArgs, 'getCurrentSessionId'> &
|
||||
Partial<Pick<CreateSessionArgs, 'getCurrentSessionId'>>,
|
||||
): Promise<void> {
|
||||
) {
|
||||
return createAndAttachSessionForPrompt({
|
||||
getCurrentSessionId: () => sessionResult.sessionId,
|
||||
...args,
|
||||
@@ -66,6 +66,77 @@ describe('createAndAttachSessionForPrompt', () => {
|
||||
expect(actions.setModel).toHaveBeenCalledWith('qwen3');
|
||||
});
|
||||
|
||||
it('enables Plan with the selected execution permission after attach', async () => {
|
||||
const order: string[] = [];
|
||||
const actions = createActions({
|
||||
attachSession: vi.fn(async () => {
|
||||
order.push('attach');
|
||||
}),
|
||||
setApprovalMode: vi.fn(async () => {
|
||||
order.push('plan');
|
||||
return { mode: 'plan' };
|
||||
}),
|
||||
});
|
||||
await prepareSession({
|
||||
sessionActions: actions,
|
||||
modeId: 'yolo',
|
||||
planMode: true,
|
||||
});
|
||||
expect(actions.createSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ approvalMode: 'yolo' }),
|
||||
);
|
||||
expect(actions.setApprovalMode).toHaveBeenCalledWith('yolo', {
|
||||
planMode: true,
|
||||
});
|
||||
expect(order).toEqual(['attach', 'plan']);
|
||||
});
|
||||
|
||||
it.each(['rejected', 'unconfirmed'])(
|
||||
'releases the new session if Plan is %s',
|
||||
async (failure) => {
|
||||
const actions = createActions({
|
||||
setApprovalMode: vi.fn(async () => {
|
||||
if (failure === 'rejected') throw new Error('Denied');
|
||||
return { mode: 'yolo' };
|
||||
}),
|
||||
});
|
||||
await expect(
|
||||
prepareSession({
|
||||
sessionActions: actions,
|
||||
modeId: 'yolo',
|
||||
planMode: true,
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
expect(actions.releaseSession).toHaveBeenCalledWith('session-1');
|
||||
expect(actions.clearSession).toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('does not enable Plan on a session selected during model preparation', async () => {
|
||||
let currentSession = 'session-1';
|
||||
const actions = createActions({
|
||||
setModel: vi.fn(async () => {
|
||||
currentSession = 'session-2';
|
||||
return modelResult;
|
||||
}),
|
||||
setApprovalMode: vi.fn(async () => ({ mode: 'plan' })),
|
||||
});
|
||||
await expect(
|
||||
prepareSession({
|
||||
sessionActions: actions,
|
||||
modeId: 'yolo',
|
||||
modelId: 'qwen3',
|
||||
planMode: true,
|
||||
getCurrentSessionId: () => currentSession,
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
).rejects.toThrow('Session changed');
|
||||
expect(actions.setApprovalMode).not.toHaveBeenCalled();
|
||||
expect(actions.releaseSession).toHaveBeenCalledWith('session-1');
|
||||
expect(actions.clearSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates standalone sessions without workspace-only fields', async () => {
|
||||
const actions = createActions();
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ type PromptSessionActions = {
|
||||
attachSession: () => Promise<void>;
|
||||
clearSession: () => Promise<void>;
|
||||
releaseSession: (sessionId: string) => Promise<void>;
|
||||
setApprovalMode?: (
|
||||
mode: DaemonApprovalMode,
|
||||
opts?: { planMode?: boolean },
|
||||
) => Promise<{ mode: string }>;
|
||||
setModel: (modelId: string) => Promise<unknown>;
|
||||
setReasoningEffort: (
|
||||
value: ReasoningSelection,
|
||||
@@ -42,6 +46,7 @@ export async function createAndAttachSessionForPrompt({
|
||||
modelId,
|
||||
reasoningEffort,
|
||||
modeId,
|
||||
planMode = false,
|
||||
workspaceCwd,
|
||||
sessionContext,
|
||||
worktree,
|
||||
@@ -56,6 +61,7 @@ export async function createAndAttachSessionForPrompt({
|
||||
modelId?: string;
|
||||
reasoningEffort?: ReasoningSelection;
|
||||
modeId?: string;
|
||||
planMode?: boolean;
|
||||
workspaceCwd?: string;
|
||||
sessionContext?: DaemonProductSessionContext;
|
||||
worktree?: { slug?: string };
|
||||
@@ -180,6 +186,27 @@ export async function createAndAttachSessionForPrompt({
|
||||
warn('[WebShell] failed to set model for new session:', error);
|
||||
}
|
||||
}
|
||||
if (planMode) {
|
||||
preparationStep = 'enable Plan for new session';
|
||||
if (
|
||||
!approvalMode ||
|
||||
approvalMode === 'plan' ||
|
||||
!sessionActions.setApprovalMode
|
||||
)
|
||||
throw new Error(
|
||||
'Plan requires an execution approval mode and session mode control.',
|
||||
);
|
||||
const currentSessionId = getCurrentSessionId();
|
||||
if (currentSessionId !== undefined && currentSessionId !== sessionId) {
|
||||
throw new Error('Session changed before enabling Plan.');
|
||||
}
|
||||
const result = await sessionActions.setApprovalMode(approvalMode, {
|
||||
planMode: true,
|
||||
});
|
||||
if (result.mode !== 'plan') {
|
||||
throw new Error('Daemon did not confirm Plan mode.');
|
||||
}
|
||||
}
|
||||
if (reasoningEffort) {
|
||||
preparationStep = 'set reasoning effort';
|
||||
await sessionActions.setReasoningEffort(reasoningEffort, {
|
||||
|
||||
Reference in new issue
Block a user