From 765c2495eba61fd6e1a3fbc2817837e4d4d8cc43 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 02:22:22 +0800 Subject: [PATCH 01/24] fix(cli): validate per-model ACP options against the target model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A probe's `configOptions` only describe the model that was current when it ran: Codex publishes `fast-mode` only while that model has a fast speed tier, and rebuilds the effort list on every model switch. Dispatch already knew this for a SEMANTIC selection, but concrete ids that never pass through that resolver — an Agent Role's stored `runConfig`, `--config-option`, a frozen Operation replayed after recovery — were still checked against the probed snapshot, so a Role pinning a fast-capable model failed create with "Unknown ACP config option for the selected agent: fast-mode" whenever the agent's default model had no fast tier. Apply the same rule to the values themselves. `resolvePerModelConfigOptionSelection` exempts per-model ids from the snapshot check when the turn runs a model other than the probed one, still validating effort strictly wherever the agent published its per-model breakdown, and reports the rest as unverified rather than rejecting them. A turn that runs the probed model keeps the snapshot authoritative, so a genuinely missing control is still refused. Also record the fast toggle in `validatedConfigIds` on a model switch, which the effort branch already did. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/AGENTS.md | 8 +- apps/cli/src/commands/session.test.ts | 50 +++++++++++ apps/cli/src/commands/session.ts | 47 ++++++++-- packages/shared/src/acp-run-config.ts | 83 ++++++++++++++++++ packages/shared/tests/acp-run-config.test.ts | 90 +++++++++++++++++++- 5 files changed, 266 insertions(+), 12 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 028dd16cc..18d7b4f50 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -268,7 +268,13 @@ Two things the dev build does deliberately, both load-bearing: TARGET model and the ids so validated come back as `validatedConfigIds`, which `validateTurnConfigOptionValues(..., skipIds)` must skip (the probed model's list would wrongly reject them). What cannot be checked offline is dispatched as - requested. Runtime rejections remain in debug diagnostics; Codex/Claude mismatches + requested. The rule follows the VALUES, not the caller: concrete ids that never + passed through a semantic selection — an Agent Role's stored `runConfig`, an + explicit `--config-option`, a frozen Operation replayed after recovery — go + through `resolvePerModelConfigOptionSelection` for the same exemption, because a + per-model option's ABSENCE from the snapshot is exactly what the probed model's + lack of the control looks like and can never be the reason to reject another + model's turn. Runtime rejections remain in debug diagnostics; Codex/Claude mismatches for model, reasoning effort, Fast, or Plan are not promoted to visible `agent_warning` notices, while other rejected selections still are. Compatibility exception: Claude Fable models omit the Fast mode option, so an explicit `fast=false` is skipped as diff --git a/apps/cli/src/commands/session.test.ts b/apps/cli/src/commands/session.test.ts index 5d97136ca..884e019c6 100644 --- a/apps/cli/src/commands/session.test.ts +++ b/apps/cli/src/commands/session.test.ts @@ -475,6 +475,56 @@ describe('session command helpers', () => { ).toThrow(/Allowed values/); }); + it('accepts a stored per-model config option dispatched with another model', () => { + // Codex publishes `fast-mode` only while the current model has a fast speed + // tier, so a probe under a model without one produces this snapshot. An + // Agent Role (or a frozen Operation replay) then dispatches the concrete id + // with no semantic selection to resolve. + const capability: AcpCapabilityCacheEntry = { + ...createAcpCapability(), + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'model-a', + options: [ + { value: 'model-a', name: 'Model A' }, + { value: 'model-b', name: 'Model B' }, + ], + }, + ], + models: [], + }; + const roleRunConfig = { modelId: 'model-b', configOptionValues: { 'fast-mode': true } }; + + const requested = applyAgentRunConfigSelection(roleRunConfig, capability); + + expect(requested.unverifiedSelections).toEqual(['fast-mode=true']); + expect(() => + validateTurnConfigOptionValues( + requested.config.configOptionValues, + capability, + requested.validatedConfigIds + ) + ).not.toThrow(); + + // Running the probed model keeps the snapshot authoritative: there the + // missing option really is the model's own answer. + expect(() => { + const probedModel = applyAgentRunConfigSelection( + { modelId: 'model-a', configOptionValues: { 'fast-mode': true } }, + capability + ); + validateTurnConfigOptionValues( + probedModel.config.configOptionValues, + capability, + probedModel.validatedConfigIds + ); + }).toThrow(/Unknown ACP config option/); + }); + it('drops inherited ACP config options that are no longer compatible', () => { expect( filterCompatibleTurnConfigOptionValues( diff --git a/apps/cli/src/commands/session.ts b/apps/cli/src/commands/session.ts index 198bf0d26..f773ce1da 100644 --- a/apps/cli/src/commands/session.ts +++ b/apps/cli/src/commands/session.ts @@ -51,6 +51,7 @@ import { hasAgentRunConfigSelection, resolveAgentRunConfigSelection, resolveBaseBranchPreference, + resolvePerModelConfigOptionSelection, resolveProjectGitHubRepo, type AgentRunConfigSelection, type AcpCapabilityCacheEntry, @@ -1359,23 +1360,39 @@ export function applyAgentRunConfigSelection( unverifiedSelections: readonly string[]; } { const { runConfig, ...rest } = config; - if (!hasAgentRunConfigSelection(runConfig)) { - return { config: rest, validatedConfigIds: new Set(), unverifiedSelections: [] }; - } - const resolved = resolveAgentRunConfigSelection(runConfig, capability); + const resolved = hasAgentRunConfigSelection(runConfig) + ? resolveAgentRunConfigSelection(runConfig, capability) + : {}; const configOptionValues = { ...(rest.configOptionValues ?? {}), ...(resolved.configOptionValues ?? {}), }; + const modeId = resolved.modeId ?? rest.modeId; + const modelId = resolved.modelId ?? rest.modelId; + // Concrete values that never passed through the semantic resolver — an Agent + // Role's stored run config, `--config-option`, a frozen Operation replay — + // still carry per-model options, so the same rule is applied to the merged + // table here. + const perModel = resolvePerModelConfigOptionSelection({ + configOptionValues, + modelId, + capability, + }); return { config: { ...(rest.taskToolsEnabled !== undefined ? { taskToolsEnabled: rest.taskToolsEnabled } : {}), - ...((resolved.modeId ?? rest.modeId) ? { modeId: resolved.modeId ?? rest.modeId } : {}), - ...((resolved.modelId ?? rest.modelId) ? { modelId: resolved.modelId ?? rest.modelId } : {}), + ...(modeId ? { modeId } : {}), + ...(modelId ? { modelId } : {}), ...(Object.keys(configOptionValues).length > 0 ? { configOptionValues } : {}), }, - validatedConfigIds: new Set(resolved.validatedConfigIds ?? []), - unverifiedSelections: resolved.unverifiedSelections ?? [], + validatedConfigIds: new Set([ + ...(resolved.validatedConfigIds ?? []), + ...perModel.validatedConfigIds, + ]), + unverifiedSelections: [ + ...(resolved.unverifiedSelections ?? []), + ...perModel.unverifiedSelections, + ], }; } @@ -3153,7 +3170,19 @@ export async function sendSessionChatResult( agentConfigId: session.agentConfigId, }); validateTurnModeAndModel(dispatchConfig, capability); - validateTurnConfigOptionValues(dispatchConfig.configOptionValues, capability); + // A follow-up turn carries concrete ids only, so the per-model rule is + // applied directly: `configOptions` describes the probed model, and this + // turn may be running another one. + const perModel = resolvePerModelConfigOptionSelection({ + configOptionValues: dispatchConfig.configOptionValues, + modelId: dispatchConfig.modelId, + capability, + }); + validateTurnConfigOptionValues( + dispatchConfig.configOptionValues, + capability, + new Set(perModel.validatedConfigIds) + ); } const effectiveDispatchConfig = withBuiltinDefaultTurnMode(dispatchConfig, session); diff --git a/packages/shared/src/acp-run-config.ts b/packages/shared/src/acp-run-config.ts index 33de3e8e8..deba989ea 100644 --- a/packages/shared/src/acp-run-config.ts +++ b/packages/shared/src/acp-run-config.ts @@ -335,6 +335,7 @@ export const resolveAgentRunConfigSelection = ( // Agents drop the fast toggle entirely for models that lack fast support, // and no agent publishes which models those are. unverifiedSelections.push(`fastMode=${selection.fastMode}`); + validatedConfigIds.push(option.id); } } @@ -359,3 +360,85 @@ export const resolveAgentRunConfigSelection = ( ...(unverifiedSelections.length > 0 ? { unverifiedSelections } : {}), }; }; + +/** + * Whether a bare config id names an option the agent rebuilds per MODEL. + * + * The snapshot is consulted for the option's CATEGORY, because an agent may + * publish effort under its own id, but an id the snapshot omits still matches: + * omission is exactly what a per-model option does for a model that lacks the + * control (Codex publishes `fast-mode` only while the current model has a fast + * speed tier), so it cannot be the reason to reject one. + */ +const isPerModelConfigOptionId = ( + configId: string, + capability: RunConfigCapabilitySource | undefined +): boolean => { + if (isAcpFastModeConfigId(configId)) { + return true; + } + const option = findConfigOption(capability, (candidate) => candidate.id === configId); + return isAcpThoughtLevelConfigOption(option ?? { id: configId }); +}; + +export type PerModelConfigOptionResolution = { + /** + * Ids the caller must exclude from its probed-snapshot check, because this + * module either validated them against the target model or established that + * the snapshot cannot judge them. + */ + validatedConfigIds: string[]; + /** Requested values that could not be verified offline. */ + unverifiedSelections: string[]; +}; + +/** + * The same per-model rule `resolveAgentRunConfigSelection` applies, for + * CONCRETE config option values that never passed through a semantic + * selection: an Agent Role's stored `runConfig`, an explicit `--config-option`, + * or a frozen Operation replayed after recovery. + * + * Only a turn that runs a model OTHER than the probed one is affected — when + * the turn runs the probed model the snapshot describes it exactly and stays + * authoritative. Effort is still validated strictly wherever the agent + * published its per-model breakdown; what is left is dispatched as requested + * and reported in `unverifiedSelections`, because a snapshot of a different + * model cannot prove the agent will reject it. + */ +export const resolvePerModelConfigOptionSelection = (args: { + configOptionValues: Record | undefined; + modelId: string | undefined; + capability: RunConfigCapabilitySource | undefined; +}): PerModelConfigOptionResolution => { + const entries = Object.entries(args.configOptionValues ?? {}); + const probedModelId = findCurrentModelId(args.capability); + const targetModelId = args.modelId ?? probedModelId; + if (entries.length === 0 || targetModelId === undefined || targetModelId === probedModelId) { + return { validatedConfigIds: [], unverifiedSelections: [] }; + } + + const validatedConfigIds: string[] = []; + const unverifiedSelections: string[] = []; + for (const [configId, value] of entries) { + if (!isPerModelConfigOptionId(configId, args.capability)) { + continue; + } + const targetModelEfforts = isAcpFastModeConfigId(configId) + ? undefined + : args.capability?.modelReasoningEfforts?.[targetModelId]; + if (targetModelEfforts) { + if (typeof value !== 'string' || !targetModelEfforts.includes(value)) { + throw new Error( + `Invalid reasoning effort for model ${targetModelId}: ${String(value)}. Allowed values: ${targetModelEfforts.join( + ', ' + )}.` + ); + } + validatedConfigIds.push(configId); + continue; + } + validatedConfigIds.push(configId); + unverifiedSelections.push(`${configId}=${String(value)}`); + } + return { validatedConfigIds, unverifiedSelections }; +}; diff --git a/packages/shared/tests/acp-run-config.test.ts b/packages/shared/tests/acp-run-config.test.ts index 2f219b785..2360114a1 100644 --- a/packages/shared/tests/acp-run-config.test.ts +++ b/packages/shared/tests/acp-run-config.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { deriveModelReasoningEffortsFromLegacyModelIds, resolveAgentRunConfigSelection, + resolvePerModelConfigOptionSelection, summarizeAgentRunConfigCapabilities, type AcpCapabilityCacheEntry, } from '../src'; @@ -146,8 +147,9 @@ describe('agent run config selection', () => { collaboration_mode: 'plan', }, // This agent published no per-model breakdown, and the selection switches - // away from the probed model, so effort/fast cannot be checked offline. - validatedConfigIds: ['reasoning_effort'], + // away from the probed model, so effort/fast cannot be checked offline: + // both are dispatched as requested and excluded from the snapshot check. + validatedConfigIds: ['reasoning_effort', 'fast-mode'], unverifiedSelections: ['reasoningEffort=high', 'fastMode=true'], }); }); @@ -353,3 +355,87 @@ describe('agent run config selection', () => { expect(resolveAgentRunConfigSelection({ planMode: true }, legacy)).toEqual({ modeId: 'plan' }); }); }); + +describe('per-model config options carried as concrete ids', () => { + /** Codex probed under a model without a fast speed tier: no `fast-mode` at all. */ + const withoutFastMode = (): AcpCapabilityCacheEntry => { + const capability = codexCapability(); + return { + ...capability, + configOptions: (capability.configOptions ?? []).filter((option) => option.id !== 'fast-mode'), + }; + }; + + it('keeps a stored fast toggle dispatchable when the turn runs another model', () => { + // An Agent Role's stored run config: concrete ids, no semantic selection. + expect( + resolvePerModelConfigOptionSelection({ + configOptionValues: { 'fast-mode': true, mode: 'agent' }, + modelId: 'gpt-5.4-mini', + capability: codexCapability(), + }) + ).toEqual({ validatedConfigIds: ['fast-mode'], unverifiedSelections: ['fast-mode=true'] }); + + // The probe never saw the option because its model had no fast tier. That + // says nothing about the model this turn selects. + expect( + resolvePerModelConfigOptionSelection({ + configOptionValues: { 'fast-mode': true }, + modelId: 'gpt-5.4-mini', + capability: withoutFastMode(), + }) + ).toEqual({ validatedConfigIds: ['fast-mode'], unverifiedSelections: ['fast-mode=true'] }); + }); + + it('leaves the snapshot authoritative for the model it was probed under', () => { + for (const modelId of ['gpt-5.6-sol', undefined]) { + expect( + resolvePerModelConfigOptionSelection({ + configOptionValues: { 'fast-mode': true }, + modelId, + capability: withoutFastMode(), + }) + ).toEqual({ validatedConfigIds: [], unverifiedSelections: [] }); + } + }); + + it('still validates a stored effort against the target model breakdown', () => { + const capability: AcpCapabilityCacheEntry = { + ...codexCapability(), + modelReasoningEfforts: { + 'gpt-5.6-sol': ['low', 'medium', 'high'], + 'gpt-5.4-mini': ['low', 'medium'], + }, + }; + + expect( + resolvePerModelConfigOptionSelection({ + configOptionValues: { reasoning_effort: 'medium' }, + modelId: 'gpt-5.4-mini', + capability, + }) + ).toEqual({ validatedConfigIds: ['reasoning_effort'], unverifiedSelections: [] }); + + expect(() => + resolvePerModelConfigOptionSelection({ + configOptionValues: { reasoning_effort: 'high' }, + modelId: 'gpt-5.4-mini', + capability, + }) + ).toThrow(/Invalid reasoning effort for model gpt-5\.4-mini.*Allowed values: low, medium/s); + }); + + it('recognizes an agent that publishes effort under its own id', () => { + // Claude: `effort` by category, `fast` as the toggle id. + expect( + resolvePerModelConfigOptionSelection({ + configOptionValues: { effort: 'high', fast: 'on', mode: 'auto' }, + modelId: 'opus', + capability: claudeCapability(), + }) + ).toEqual({ + validatedConfigIds: ['effort', 'fast'], + unverifiedSelections: ['effort=high', 'fast=on'], + }); + }); +}); From 484d31ea8ea481ae55713fc02c41d2f2ec65a3e3 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 10:13:37 +0800 Subject: [PATCH 02/24] feat(cli): warn when the agent's run config differs from the request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The applier suppressed user-visible warnings for exactly the four controls users most need told about — model, reasoning effort, Fast, Plan — on Codex and Claude, because it keyed the notice off a REJECTION and those calls fail routinely when re-sent per turn. But a rejection is the wrong signal in both directions. Codex ACCEPTS `fast-mode` on a model with no fast speed tier and then omits the option from the state it publishes: nothing throws, the turn runs at normal speed, and the user is told nothing. Meanwhile a rejected value that was already effective changed nothing and was reported. Key the notice off divergence instead: after applying the turn's config, compare each requested selection against the state the agent itself publishes, and warn only where they disagree. An `on`/`off` select and a boolean toggle are the same choice, so neither shape alone is a divergence. Where the agent published no config options at all — or for a sensitive id, which the runtime state deliberately omits — the failed call remains the only signal, so it is still used there. Rejections keep going to debug diagnostics unchanged, and `agent_warning` notices are already deduplicated by message per session, so a stuck divergence costs one notice rather than one per turn. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/AGENTS.md | 19 +- apps/cli/src/lib/message-handler.ts | 4 +- .../acp-session-config-applier.test.ts | 167 ++++++++++++------ .../src/session/acp-session-config-applier.ts | 160 +++++++++++++---- 4 files changed, 252 insertions(+), 98 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 18d7b4f50..fe9763d4e 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -274,12 +274,19 @@ Two things the dev build does deliberately, both load-bearing: through `resolvePerModelConfigOptionSelection` for the same exemption, because a per-model option's ABSENCE from the snapshot is exactly what the probed model's lack of the control looks like and can never be the reason to reject another - model's turn. Runtime rejections remain in debug diagnostics; Codex/Claude mismatches - for model, reasoning effort, Fast, or Plan are not promoted to visible - `agent_warning` notices, while other rejected selections still are. Compatibility exception: Claude - Fable models omit the Fast mode option, so an explicit `fast=false` is skipped as - an already-effective no-op; `fast=true` must still be dispatched and retained in debug - diagnostics if rejected. + model's turn. Runtime rejections remain in debug diagnostics; what becomes a visible + `agent_warning` is DIVERGENCE — the state the agent publishes after applying the + turn's config contradicts what was requested. A rejection is not that signal in + either direction: Codex accepts `fast-mode` on a model with no fast speed tier and + then omits the option from its published state, so the state is the only evidence + Fast is off, while a rejected value that was already effective changed nothing and + must stay silent. Only where the agent published no config options at all (or for a + sensitive id, which the runtime state deliberately omits) does the failed call + remain the sole signal. Do not restore a per-agent suppression list: it silenced + exactly the per-model controls users most need told about. Compatibility exception: + Claude Fable models omit the Fast mode option, so an explicit `fast=false` is + skipped as an already-effective no-op and is judged for neither; `fast=true` must + still be dispatched and retained in debug diagnostics if rejected. - MCP `session_list` defaults to 20 (maximum 100), and `session_history` defaults to 10 (maximum 50 and 128 KiB). Keep the MCP surface bounded even though the human CLI retains `session history --all`. `session_list` and `session_status_many` derive busy/idle from diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index ed64fce45..b1f7915c4 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -1744,9 +1744,9 @@ export class MessageHandler { // Not awaited: this is reporting, and the prompt hot path must not block // on a history write. void this.recordAgentWarning(session.sessionId, { - message: `The agent rejected part of the requested run configuration (${warningSelections.join( + message: `The agent did not apply part of the requested run configuration (${warningSelections.join( ', ' - )}) and is using its own values instead. Reasoning effort and fast mode depend on the selected model.`, + )}) and is running with its own values instead. Reasoning effort and fast mode depend on the selected model.`, source: 'configWarning', }); } diff --git a/apps/cli/src/session/acp-session-config-applier.test.ts b/apps/cli/src/session/acp-session-config-applier.test.ts index 0c2978021..b236ef5fe 100644 --- a/apps/cli/src/session/acp-session-config-applier.test.ts +++ b/apps/cli/src/session/acp-session-config-applier.test.ts @@ -109,60 +109,121 @@ describe('applyAcpSessionRunConfig', () => { expect(vi.mocked(logger.debug).mock.calls.flat().join('\n')).not.toContain('private-value'); }); - it.each(['codex', 'claude'])( - 'suppresses known %s run-config mismatch warnings while retaining rejection diagnostics', - async (agentType) => { - const reject = vi.fn(async () => { + it('reports a selection the agent accepted but dropped from its own state', async () => { + // The codex shape: `fast-mode` is accepted without error on a model with no + // fast speed tier, and simply does not come back in the published state, so + // the turn runs at normal speed with nothing thrown. + const agentClient = { + isCreated: () => true, + getConfigOptions: () => [ + { id: 'model', category: 'model', type: 'select', currentValue: 'gpt-5.2' }, + { + id: 'reasoning_effort', + category: 'thought_level', + type: 'select', + currentValue: 'high', + }, + ], + unstable_setSessionModel: vi.fn(async () => undefined), + setSessionConfigOption: vi.fn(async () => undefined), + } as unknown as AgentClient; + + await expect( + applyAcpSessionRunConfig({ + session: { + sessionId: 'session-3' as SessionId, + acpSessionId: 'acp-3' as ACPSessionId, + agentClient, + }, + config: { + cliType: 'builtin', + agentType: 'codex', + modelId: 'gpt-5.2', + configOptionValues: { reasoning_effort: 'high', 'fast-mode': true }, + }, + logger: createLogger(), + }) + ).resolves.toEqual({ + // Nothing was rejected, so diagnostics stay empty: the published state is + // the only evidence Fast is not running. + rejectedSelections: [], + warningSelections: ['fast-mode=true'], + runtimeConfigPatch: { + acpSessionId: 'acp-3', + modelId: 'gpt-5.2', + configOptionValues: { model: 'gpt-5.2', reasoning_effort: 'high' }, + }, + }); + }); + + it('stays quiet when a rejected selection was already effective', async () => { + const agentClient = { + isCreated: () => true, + getConfigOptions: () => [ + { id: 'fast', category: 'model_config', type: 'boolean', currentValue: false }, + { + id: 'effort', + category: 'thought_level', + type: 'select', + currentValue: 'high', + }, + ], + setSessionConfigOption: vi.fn(async () => { throw new Error('rejected'); - }); - const agentClient = { - isCreated: () => true, - getConfigOptions: () => [ - { id: 'effort', category: 'thought_level' }, - { id: 'fast', category: 'fast-mode' }, - { id: 'collaboration_mode', category: 'collaboration_mode' }, - { id: 'custom-option', category: 'custom' }, - ], - setSessionMode: reject, - unstable_setSessionModel: reject, - setSessionConfigOption: reject, - } as unknown as AgentClient; - - await expect( - applyAcpSessionRunConfig({ - session: { - sessionId: 'session-3' as SessionId, - acpSessionId: 'acp-3' as ACPSessionId, - agentClient, - }, - config: { - cliType: 'builtin', - agentType, - modeId: 'plan', - modelId: 'model-a', - configOptionValues: { - effort: 'high', - fast: false, - collaboration_mode: 'plan', - 'custom-option': 'enabled', - }, - }, - logger: createLogger(), - }) - ).resolves.toEqual({ - rejectedSelections: [ - 'mode="plan"', - 'model="model-a"', - 'effort="high"', - 'fast=false', - 'collaboration_mode="plan"', - 'custom-option="enabled"', - ], - warningSelections: ['custom-option="enabled"'], - runtimeConfigPatch: { acpSessionId: 'acp-3', configOptionValues: {} }, - }); - } - ); + }), + } as unknown as AgentClient; + + await expect( + applyAcpSessionRunConfig({ + session: { + sessionId: 'session-6' as SessionId, + acpSessionId: 'acp-6' as ACPSessionId, + agentClient, + }, + config: { + cliType: 'builtin', + agentType: 'claude', + configOptionValues: { fast: false, effort: 'low' }, + }, + logger: createLogger(), + }) + ).resolves.toEqual({ + rejectedSelections: ['fast=false', 'effort="low"'], + // `fast` already held the requested value, so the rejection changed + // nothing; `effort` did not, so it is worth saying. + warningSelections: ['effort="low"'], + runtimeConfigPatch: { + acpSessionId: 'acp-6', + configOptionValues: { fast: false, effort: 'high' }, + }, + }); + }); + + it('treats an on/off select and a boolean toggle as the same choice', async () => { + const agentClient = { + isCreated: () => true, + getConfigOptions: () => [ + { id: 'fast', category: 'model_config', type: 'select', currentValue: 'on' }, + ], + setSessionConfigOption: vi.fn(async () => undefined), + } as unknown as AgentClient; + + await expect( + applyAcpSessionRunConfig({ + session: { + sessionId: 'session-7' as SessionId, + acpSessionId: 'acp-7' as ACPSessionId, + agentClient, + }, + config: { cliType: 'builtin', agentType: 'claude', configOptionValues: { fast: true } }, + logger: createLogger(), + }) + ).resolves.toEqual({ + rejectedSelections: [], + warningSelections: [], + runtimeConfigPatch: { acpSessionId: 'acp-7', configOptionValues: { fast: 'on' } }, + }); + }); it('keeps known run-config rejection warnings for other agents', async () => { const agentClient = { diff --git a/apps/cli/src/session/acp-session-config-applier.ts b/apps/cli/src/session/acp-session-config-applier.ts index 164240551..de6126a34 100644 --- a/apps/cli/src/session/acp-session-config-applier.ts +++ b/apps/cli/src/session/acp-session-config-applier.ts @@ -1,9 +1,7 @@ import { - ACP_PLAN_PERMISSION_MODE_ID, - ACP_REASONING_EFFORT_CONFIG_ID, + ACP_CONFIG_OPTION_OFF_VALUE, + ACP_CONFIG_OPTION_ON_VALUE, isAcpFastModeConfigId, - isAcpPlanModeConfigOption, - isAcpThoughtLevelConfigOption, isSensitiveAcpConfigOptionId, type ACPSessionId, type AcpConfigOptionValue, @@ -67,25 +65,54 @@ type AcpSessionRunConfigApplyResult = { runtimeConfigPatch: SessionAcpRuntimeConfigPatch | null; }; -function isCodexOrClaudeRunConfig(config: AcpSessionRunConfig): boolean { - return config.agentType === 'codex' || config.agentType === 'claude'; -} - -function isKnownRunConfigOption( - configId: string, - agentConfigOptions: ReadonlyArray<{ id: string; category?: string | null }> +/** A boolean toggle and an `on`/`off` select express the same choice. */ +function configValuesMatch( + requested: AcpConfigOptionValue, + effective: AcpConfigOptionValue | undefined ): boolean { - if ( - configId === ACP_REASONING_EFFORT_CONFIG_ID || - isAcpFastModeConfigId(configId) || - isAcpPlanModeConfigOption({ id: configId }) - ) { + if (requested === effective) { return true; } - const option = agentConfigOptions.find((candidate) => candidate.id === configId); - return option - ? isAcpThoughtLevelConfigOption({ id: option.id, category: option.category ?? undefined }) - : false; + const toggle = (value: boolean): string => + value ? ACP_CONFIG_OPTION_ON_VALUE : ACP_CONFIG_OPTION_OFF_VALUE; + if (typeof requested === 'boolean' && typeof effective === 'string') { + return effective === toggle(requested); + } + if (typeof requested === 'string' && typeof effective === 'boolean') { + return requested === toggle(effective); + } + return false; +} + +/** One requested selection, judged against the agent's own answer for it. */ +type AppliedSelection = { + /** Diagnostic label, already redacted for sensitive ids. */ + label: string; + requested: AcpConfigOptionValue; + /** How to read the agent's state for this selection once everything is applied. */ + source: { kind: 'mode' } | { kind: 'model' } | { kind: 'configOption'; configId: string }; + /** The agent threw while applying it. */ + rejected: boolean; +}; + +/** + * Whether the agent's post-apply state contradicts what the turn asked for. + * + * A rejection alone does not answer this, in either direction. Codex ACCEPTS + * `fast-mode` on a model without a fast speed tier and then simply omits the + * option from the state it publishes — the turn runs at normal speed and + * nothing threw — so the published state is the only evidence Fast is not on. + * Conversely a rejected selection that is already effective changed nothing and + * is not worth a notice. Only where the agent published nothing to compare + * against does the failed call remain the sole signal. + */ +function divergesFromAgentState(args: { + requested: AcpConfigOptionValue; + effective: AcpConfigOptionValue | undefined; + known: boolean; + rejected: boolean; +}): boolean { + return args.known ? !configValuesMatch(args.requested, args.effective) : args.rejected; } export async function applyAcpSessionRunConfig(args: { @@ -116,17 +143,10 @@ export async function applyAcpSessionRunConfig(args: { } const rejectedSelections: string[] = []; - const warningSelections: string[] = []; + const appliedSelections: AppliedSelection[] = []; let confirmedLegacyModeId: string | undefined; let confirmedLegacyModelId: string | undefined; const agentConfigOptions = agentClient.getConfigOptions?.() ?? []; - const suppressKnownRunConfigWarnings = isCodexOrClaudeRunConfig(config); - const recordRejection = (selection: string, suppressWarning: boolean): void => { - rejectedSelections.push(selection); - if (!suppressWarning) { - warningSelections.push(selection); - } - }; const modeConfigId = agentConfigOptions.find((option) => option.category === 'mode')?.id ?? 'mode'; const modelConfigId = @@ -136,38 +156,57 @@ export async function applyAcpSessionRunConfig(args: { config.modelId ?? (typeof configOptionModelId === 'string' ? configOptionModelId : undefined); if (config.modeId) { + const label = `mode=${JSON.stringify(config.modeId)}`; + let rejected = false; try { await agentClient.setSessionMode?.(acpSessionId, config.modeId); confirmedLegacyModeId = config.modeId; } catch (error) { - recordRejection( - `mode=${JSON.stringify(config.modeId)}`, - suppressKnownRunConfigWarnings && config.modeId === ACP_PLAN_PERMISSION_MODE_ID - ); + rejected = true; + rejectedSelections.push(label); logger.debug( `[${sessionId}] Failed to set ACP mode ${JSON.stringify(config.modeId)}: ${String(error)}` ); } + appliedSelections.push({ + label, + requested: config.modeId, + source: { kind: 'mode' }, + rejected, + }); } if (config.modelId) { + const label = `model=${JSON.stringify(config.modelId)}`; + let rejected = false; try { await agentClient.unstable_setSessionModel?.(acpSessionId, config.modelId); confirmedLegacyModelId = config.modelId; } catch (error) { - recordRejection(`model=${JSON.stringify(config.modelId)}`, suppressKnownRunConfigWarnings); + rejected = true; + rejectedSelections.push(label); logger.debug( `[${sessionId}] Failed to set ACP model ${JSON.stringify(config.modelId)}: ${String(error)}` ); } + appliedSelections.push({ + label, + requested: config.modelId, + source: { kind: 'model' }, + rejected, + }); } for (const [configId, value] of configOptionEntries) { if (configId === modeConfigId) { + // An explicit `config.modeId` outranks this duplicate, and is judged in + // its place: losing a precedence contest is not the agent disagreeing. if (!config.modeId && typeof value === 'string') { + let rejected = false; try { await agentClient.setSessionMode?.(acpSessionId, value); confirmedLegacyModeId = value; } catch (error) { + rejected = true; logger.debug( `[${sessionId}] Failed to set ACP mode option ${configId}=${formatAcpConfigValueForLog( configId, @@ -175,15 +214,23 @@ export async function applyAcpSessionRunConfig(args: { )}: ${String(error)}` ); } + appliedSelections.push({ + label: `${configId}=${formatAcpConfigValueForLog(configId, value)}`, + requested: value, + source: { kind: 'mode' }, + rejected, + }); } continue; } if (configId === modelConfigId) { if (!config.modelId && typeof value === 'string') { + let rejected = false; try { await agentClient.unstable_setSessionModel?.(acpSessionId, value); confirmedLegacyModelId = value; } catch (error) { + rejected = true; logger.debug( `[${sessionId}] Failed to set ACP model option ${configId}=${formatAcpConfigValueForLog( configId, @@ -191,21 +238,33 @@ export async function applyAcpSessionRunConfig(args: { )}: ${String(error)}` ); } + appliedSelections.push({ + label: `${configId}=${formatAcpConfigValueForLog(configId, value)}`, + requested: value, + source: { kind: 'model' }, + rejected, + }); } continue; } if (shouldSkipFableFastModeDisable({ modelId: targetModelId, configId, value })) { continue; } + const label = `${configId}=${formatAcpConfigValueForLog(configId, value)}`; + let rejected = false; try { await agentClient.setSessionConfigOption(acpSessionId, configId, value); } catch (error) { - recordRejection( - `${configId}=${formatAcpConfigValueForLog(configId, value)}`, - suppressKnownRunConfigWarnings && isKnownRunConfigOption(configId, agentConfigOptions) - ); + rejected = true; + rejectedSelections.push(label); logger.debug(`[${sessionId}] Failed to set ACP config option ${configId}: ${String(error)}`); } + appliedSelections.push({ + label, + requested: value, + source: { kind: 'configOption', configId }, + rejected, + }); } logger.debug(`[${sessionId}] applyAcpSessionRunConfig completed`); @@ -228,6 +287,33 @@ export async function applyAcpSessionRunConfig(args: { if (confirmedLegacyModelId && !runtimeConfigPatch.modelId) { runtimeConfigPatch.modelId = confirmedLegacyModelId; } + + // An agent that publishes no config options at all answered nothing here, and + // the effective table deliberately omits sensitive ids, so neither can be read + // as "the agent dropped it". + const publishesConfigOptions = agentConfigOptions.length > 0; + const effectiveConfigOptionValues = runtimeConfigPatch.configOptionValues ?? {}; + const warningSelections = appliedSelections + .filter((selection) => { + const effective = + selection.source.kind === 'mode' + ? runtimeConfigPatch.modeId + : selection.source.kind === 'model' + ? runtimeConfigPatch.modelId + : effectiveConfigOptionValues[selection.source.configId]; + const known = + selection.source.kind === 'configOption' + ? publishesConfigOptions && !isSensitiveAcpConfigOptionId(selection.source.configId) + : effective !== undefined; + return divergesFromAgentState({ + requested: selection.requested, + effective, + known, + rejected: selection.rejected, + }); + }) + .map((selection) => selection.label); + return { rejectedSelections, warningSelections, From 14727c406d9b57bb4fe117d513c6fd4d1c806477 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 15:07:23 +0800 Subject: [PATCH 03/24] feat(cli): stop letting a capability snapshot reject a run config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A probe's `configOptions` describe the model that was current when it was captured — and that snapshot is rewritten by every session this machine creates, so it describes whichever model ran last. It was nonetheless the authority for rejecting a turn: an id it omitted was "Unknown ACP config option", a value outside the list it recorded was "Allowed values: …", and a model or mode it had not seen was "Unsupported". None of that is evidence about the model the turn actually selects. Snapshots no longer reject anything. What stays a local error is only what cannot be dispatched under any model: a value the option's own declared type cannot carry. Everything else is sent as requested and reconciled against the state the agent publishes, which is the only thing that knows. The per-model exemption machinery goes with it. It existed to carve out the cases where the snapshot would wrongly reject; with rejection gone there is nothing to exempt, so `validatedConfigIds`, the skip set, and `resolvePerModelConfigOptionSelection` are deleted rather than maintained. One thing still fails loudly, and it is a different statement: a missing wire BINDING. When neither the snapshot nor the agent's own convention says how to spell a control, there is no request to send and inventing an id would be a silent no-op — so a semantic `fastMode` for an agent we have no binding for reports that it cannot be encoded, not that it is unsupported. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/src/commands/session.test.ts | 109 +++++------ apps/cli/src/commands/session.ts | 150 +++++++-------- packages/shared/src/acp-run-config.ts | 184 ++++++------------- packages/shared/tests/acp-run-config.test.ts | 144 +++++---------- 4 files changed, 213 insertions(+), 374 deletions(-) diff --git a/apps/cli/src/commands/session.test.ts b/apps/cli/src/commands/session.test.ts index 884e019c6..66d510edb 100644 --- a/apps/cli/src/commands/session.test.ts +++ b/apps/cli/src/commands/session.test.ts @@ -66,7 +66,7 @@ import { updateSessionActivityTimestamps, updateSessionActivityTimestampsBestEffort, validateTurnConfigOptionValues, - validateTurnModeAndModel, + findUnverifiedTurnSelectors, withBuiltinDefaultTurnMode, } from './session'; @@ -367,20 +367,24 @@ describe('session command helpers', () => { }); }); - it('validates ACP config option ids, types, and select values before dispatch', () => { + it('rejects only values the option type cannot carry, in any model', () => { const capability = createAcpCapability(); expect(() => validateTurnConfigOptionValues({ approval_policy: 'never', web_search: true }, capability) ).not.toThrow(); - expect(() => validateTurnConfigOptionValues({ unknown: true }, capability)).toThrow( - /Unknown ACP config option/ - ); + + // The snapshot describes one model. An id it does not carry, and a value + // outside the list it recorded, are both dispatched and reconciled against + // what the agent actually applies. + expect(() => validateTurnConfigOptionValues({ unknown: true }, capability)).not.toThrow(); + expect(() => + validateTurnConfigOptionValues({ approval_policy: 'invalid' }, capability) + ).not.toThrow(); + + // A boolean is not a select value under any model. expect(() => validateTurnConfigOptionValues({ approval_policy: false }, capability)).toThrow( /expects a select value/ ); - expect(() => - validateTurnConfigOptionValues({ approval_policy: 'invalid' }, capability) - ).toThrow(/Allowed values/); expect(() => validateTurnConfigOptionValues({ web_search: 'true' }, capability)).toThrow( /expects a boolean value/ ); @@ -420,9 +424,11 @@ describe('session command helpers', () => { modeId: 'default', }); - expect(() => - applyAgentRunConfigSelection({ runConfig: { fastMode: true } }, capability) - ).toThrow(/does not offer a fast mode option/); + // The snapshot carries no fast toggle, which says nothing about the model + // this turn runs: it is dispatched on the agent's own binding and reported. + const fast = applyAgentRunConfigSelection({ runConfig: { fastMode: true } }, capability); + expect(fast.config.configOptionValues).toEqual({ 'fast-mode': true }); + expect(fast.unverifiedSelections).toEqual(['fastMode=true']); }); it('validates effort against the selected model and skips the probed-model snapshot check', () => { @@ -460,19 +466,13 @@ describe('session command helpers', () => { ); expect(requested.config.configOptionValues).toEqual({ reasoning_effort: 'xhigh' }); - expect(requested.validatedConfigIds.has('reasoning_effort')).toBe(true); - // `xhigh` is absent from the probed model's option list, so the snapshot - // check must skip it rather than reject a value valid for model-b. - expect(() => - validateTurnConfigOptionValues( - requested.config.configOptionValues, - capability, - requested.validatedConfigIds - ) - ).not.toThrow(); + // Confirmed against the breakdown for model-b, so nothing to report… + expect(requested.unverifiedSelections).toEqual([]); + // …and `xhigh` being absent from the probed model's option list is not a + // reason to reject a value the agent published for the model being run. expect(() => validateTurnConfigOptionValues(requested.config.configOptionValues, capability) - ).toThrow(/Allowed values/); + ).not.toThrow(); }); it('accepts a stored per-model config option dispatched with another model', () => { @@ -501,56 +501,48 @@ describe('session command helpers', () => { const requested = applyAgentRunConfigSelection(roleRunConfig, capability); - expect(requested.unverifiedSelections).toEqual(['fast-mode=true']); expect(() => - validateTurnConfigOptionValues( - requested.config.configOptionValues, - capability, - requested.validatedConfigIds - ) + validateTurnConfigOptionValues(requested.config.configOptionValues, capability) ).not.toThrow(); - // Running the probed model keeps the snapshot authoritative: there the - // missing option really is the model's own answer. - expect(() => { - const probedModel = applyAgentRunConfigSelection( - { modelId: 'model-a', configOptionValues: { 'fast-mode': true } }, - capability - ); + // Same for the probed model: a snapshot that never carried the toggle is + // still only a snapshot, and the runtime settles whether Fast is on. + expect(() => validateTurnConfigOptionValues( - probedModel.config.configOptionValues, - capability, - probedModel.validatedConfigIds - ); - }).toThrow(/Unknown ACP config option/); + applyAgentRunConfigSelection( + { modelId: 'model-a', configOptionValues: { 'fast-mode': true } }, + capability + ).config.configOptionValues, + capability + ) + ).not.toThrow(); }); - it('drops inherited ACP config options that are no longer compatible', () => { + it('drops inherited ACP config options only when the type cannot carry them', () => { expect( filterCompatibleTurnConfigOptionValues( { approval_policy: 'never', web_search: 'true', removed: false }, createAcpCapability() ) - ).toEqual({ approval_policy: 'never' }); + // `web_search` is a boolean option handed a string: undispatchable. + // `removed` is simply absent from this snapshot, which the model this + // turn runs may well still have. + ).toEqual({ approval_policy: 'never', removed: false }); }); - it('validates explicit mode and model selectors against agent capabilities', () => { + it('reports mode and model selectors the snapshot cannot confirm, without rejecting them', () => { const capability = createAcpCapability(); - expect(() => - validateTurnModeAndModel({ modeId: 'default', modelId: 'model-a' }, capability) - ).not.toThrow(); - expect(() => validateTurnModeAndModel({ modeId: 'plan' }, capability)).toThrow( - 'Unsupported ACP mode' - ); - expect(() => validateTurnModeAndModel({ modelId: 'model-b' }, capability)).toThrow( - 'Unsupported ACP model' - ); - expect(() => validateTurnModeAndModel({ modeId: 'default' }, undefined)).toThrow( - 'Unsupported ACP mode' + expect( + findUnverifiedTurnSelectors({ modeId: 'default', modelId: 'model-a' }, capability) + ).toEqual([]); + expect(findUnverifiedTurnSelectors({ modeId: 'plan', modelId: 'model-b' }, capability)).toEqual( + ['mode=plan', 'model=model-b'] ); + // No snapshot at all confirms nothing — and still blocks nothing. + expect(findUnverifiedTurnSelectors({ modeId: 'default' }, undefined)).toEqual(['mode=default']); }); - it('drops incompatible inherited mode and model selectors', () => { + it('keeps inherited mode and model selectors the snapshot does not list', () => { expect( filterCompatibleInheritedTurnConfig( { @@ -561,6 +553,7 @@ describe('session command helpers', () => { createAcpCapability() ) ).toEqual({ + modeId: 'plan', modelId: 'model-a', configOptionValues: { approval_policy: 'never' }, }); @@ -590,9 +583,9 @@ describe('session command helpers', () => { }, ], }; - expect(() => - validateTurnModeAndModel({ modeId: 'plan', modelId: 'model-b' }, capability) - ).not.toThrow(); + expect(findUnverifiedTurnSelectors({ modeId: 'plan', modelId: 'model-b' }, capability)).toEqual( + [] + ); }); it('sorts sessions with invalid createdAt timestamps deterministically', () => { diff --git a/apps/cli/src/commands/session.ts b/apps/cli/src/commands/session.ts index f773ce1da..03a130d58 100644 --- a/apps/cli/src/commands/session.ts +++ b/apps/cli/src/commands/session.ts @@ -51,7 +51,6 @@ import { hasAgentRunConfigSelection, resolveAgentRunConfigSelection, resolveBaseBranchPreference, - resolvePerModelConfigOptionSelection, resolveProjectGitHubRepo, type AgentRunConfigSelection, type AcpCapabilityCacheEntry, @@ -1347,16 +1346,15 @@ export type ResolvedTurnDispatchConfig = { * option values the target agent advertises. Explicit ids on the config win over * the semantic selection only where the selection produced nothing. * - * Returns the ids the resolver validated against the TARGET model so the caller - * can exclude them from the probed-model snapshot check, plus any selection that - * could not be verified offline. + * Also returns every selection that could not be confirmed offline, for the + * dispatch record. None of them blocks the turn: the snapshot describes the + * model it was captured under, and the runtime is what settles the rest. */ export function applyAgentRunConfigSelection( config: ResolvedTurnDispatchConfig, capability: AcpCapabilityCacheEntry | undefined ): { config: ResolvedTurnDispatchConfig; - validatedConfigIds: ReadonlySet; unverifiedSelections: readonly string[]; } { const { runConfig, ...rest } = config; @@ -1369,29 +1367,17 @@ export function applyAgentRunConfigSelection( }; const modeId = resolved.modeId ?? rest.modeId; const modelId = resolved.modelId ?? rest.modelId; - // Concrete values that never passed through the semantic resolver — an Agent - // Role's stored run config, `--config-option`, a frozen Operation replay — - // still carry per-model options, so the same rule is applied to the merged - // table here. - const perModel = resolvePerModelConfigOptionSelection({ - configOptionValues, - modelId, - capability, - }); + const config_ = { + ...(rest.taskToolsEnabled !== undefined ? { taskToolsEnabled: rest.taskToolsEnabled } : {}), + ...(modeId ? { modeId } : {}), + ...(modelId ? { modelId } : {}), + ...(Object.keys(configOptionValues).length > 0 ? { configOptionValues } : {}), + }; return { - config: { - ...(rest.taskToolsEnabled !== undefined ? { taskToolsEnabled: rest.taskToolsEnabled } : {}), - ...(modeId ? { modeId } : {}), - ...(modelId ? { modelId } : {}), - ...(Object.keys(configOptionValues).length > 0 ? { configOptionValues } : {}), - }, - validatedConfigIds: new Set([ - ...(resolved.validatedConfigIds ?? []), - ...perModel.validatedConfigIds, - ]), + config: config_, unverifiedSelections: [ ...(resolved.unverifiedSelections ?? []), - ...perModel.unverifiedSelections, + ...findUnverifiedTurnSelectors(config_, capability), ], }; } @@ -1463,7 +1449,7 @@ function mergeTurnDispatchConfig( }; } -function validateConfigOptionValue( +function validateConfigOptionShape( option: AcpConfigOptionSummary, value: string | boolean ): string | undefined { @@ -1472,59 +1458,79 @@ function validateConfigOptionValue( ? undefined : `Config option "${option.id}" expects a boolean value.`; } - if (typeof value !== 'string') { - return `Config option "${option.id}" expects a select value.`; - } - if (!option.options.some((candidate) => candidate.value === value)) { - return `Invalid value for config option "${option.id}": ${value}. Allowed values: ${option.options - .map((candidate) => candidate.value) - .join(', ')}.`; - } - return undefined; + return typeof value === 'string' + ? undefined + : `Config option "${option.id}" expects a select value.`; } +/** + * Rejects only what cannot be dispatched at all. + * + * The capability snapshot describes ONE model — the one that was current when + * it was captured — so an id it omits, or a value outside the list it recorded, + * is not evidence about the model this turn runs. Those are dispatched and + * reconciled against what the agent actually applied. What stays a local error + * is a value the option's own declared TYPE cannot carry: a boolean toggle + * cannot take a string, and a select cannot take a boolean, in any model. + */ export function validateTurnConfigOptionValues( values: Record | undefined, - capability: AcpCapabilityCacheEntry | undefined, - /** - * Ids already validated against the model actually being selected. The - * capability's `configOptions` only describe the probed model, so re-checking - * them here would reject values that are valid for the target model. - */ - skipIds?: ReadonlySet + capability: AcpCapabilityCacheEntry | undefined ): void { - const entries = Object.entries(values ?? {}).filter(([id]) => !skipIds?.has(id)); - if (entries.length === 0) { + const entries = Object.entries(values ?? {}); + if (entries.length === 0 || !capability?.configOptions) { return; } - if (!capability?.configOptions) { - throw new Error('ACP config options are unavailable for the selected agent.'); - } const optionsById = new Map(capability.configOptions.map((option) => [option.id, option])); for (const [id, value] of entries) { const option = optionsById.get(id); if (!option) { - throw new Error(`Unknown ACP config option for the selected agent: ${id}.`); + continue; } - const error = validateConfigOptionValue(option, value); + const error = validateConfigOptionShape(option, value); if (error) { throw new Error(error); } } } +/** + * Selections the snapshot cannot confirm, for the dispatch record. Never an + * error: the snapshot's mode/model lists belong to the probed session. + */ +export function findUnverifiedTurnSelectors( + config: Pick, + capability: AcpCapabilityCacheEntry | undefined +): string[] { + const unverified: string[] = []; + if (config.modeId && !getSupportedTurnSelectorIds(capability, 'mode').has(config.modeId)) { + unverified.push(`mode=${config.modeId}`); + } + if (config.modelId && !getSupportedTurnSelectorIds(capability, 'model').has(config.modelId)) { + unverified.push(`model=${config.modelId}`); + } + return unverified; +} + +/** + * Drops only what cannot be dispatched. An id the snapshot does not carry is + * kept: the snapshot describes one model, and an inherited value may well + * belong to another one. + */ export function filterCompatibleTurnConfigOptionValues( values: Record | undefined, capability: AcpCapabilityCacheEntry | undefined ): Record | undefined { - if (!values || !capability?.configOptions) { + if (!values) { return undefined; } - const optionsById = new Map(capability.configOptions.map((option) => [option.id, option])); + const optionsById = new Map( + (capability?.configOptions ?? []).map((option) => [option.id, option]) + ); const compatible = Object.fromEntries( Object.entries(values).filter(([id, value]) => { const option = optionsById.get(id); - return option !== undefined && validateConfigOptionValue(option, value) === undefined; + return option === undefined || validateConfigOptionShape(option, value) === undefined; }) ); return Object.keys(compatible).length > 0 ? compatible : undefined; @@ -1552,18 +1558,6 @@ const getSupportedTurnSelectorIds = ( return ids; }; -export function validateTurnModeAndModel( - config: Pick, - capability: AcpCapabilityCacheEntry | undefined -): void { - if (config.modeId && !getSupportedTurnSelectorIds(capability, 'mode').has(config.modeId)) { - throw new Error(`Unsupported ACP mode for the selected agent: ${config.modeId}.`); - } - if (config.modelId && !getSupportedTurnSelectorIds(capability, 'model').has(config.modelId)) { - throw new Error(`Unsupported ACP model for the selected agent: ${config.modelId}.`); - } -} - export function filterCompatibleInheritedTurnConfig( config: ResolvedTurnDispatchConfig | undefined, capability: AcpCapabilityCacheEntry | undefined @@ -1571,15 +1565,13 @@ export function filterCompatibleInheritedTurnConfig( if (!config) { return undefined; } - const supportedModes = getSupportedTurnSelectorIds(capability, 'mode'); - const supportedModels = getSupportedTurnSelectorIds(capability, 'model'); const configOptionValues = filterCompatibleTurnConfigOptionValues( config.configOptionValues, capability ); return { - ...(config.modeId && supportedModes.has(config.modeId) ? { modeId: config.modeId } : {}), - ...(config.modelId && supportedModels.has(config.modelId) ? { modelId: config.modelId } : {}), + ...(config.modeId ? { modeId: config.modeId } : {}), + ...(config.modelId ? { modelId: config.modelId } : {}), ...(configOptionValues ? { configOptionValues } : {}), ...(config.taskToolsEnabled !== undefined ? { taskToolsEnabled: config.taskToolsEnabled } : {}), }; @@ -2790,12 +2782,7 @@ async function resolveEffectiveSessionCreateDispatchConfig(args: { }) : undefined; const requested = applyAgentRunConfigSelection(dispatchConfig, capability); - validateTurnModeAndModel(requested.config, capability); - validateTurnConfigOptionValues( - requested.config.configOptionValues, - capability, - requested.validatedConfigIds - ); + validateTurnConfigOptionValues(requested.config.configOptionValues, capability); return { ...withBuiltinDefaultTurnMode( mergeTurnDispatchConfig( @@ -3169,20 +3156,7 @@ export async function sendSessionChatResult( machineId: session.machineId, agentConfigId: session.agentConfigId, }); - validateTurnModeAndModel(dispatchConfig, capability); - // A follow-up turn carries concrete ids only, so the per-model rule is - // applied directly: `configOptions` describes the probed model, and this - // turn may be running another one. - const perModel = resolvePerModelConfigOptionSelection({ - configOptionValues: dispatchConfig.configOptionValues, - modelId: dispatchConfig.modelId, - capability, - }); - validateTurnConfigOptionValues( - dispatchConfig.configOptionValues, - capability, - new Set(perModel.validatedConfigIds) - ); + validateTurnConfigOptionValues(dispatchConfig.configOptionValues, capability); } const effectiveDispatchConfig = withBuiltinDefaultTurnMode(dispatchConfig, session); diff --git a/packages/shared/src/acp-run-config.ts b/packages/shared/src/acp-run-config.ts index deba989ea..817b38a09 100644 --- a/packages/shared/src/acp-run-config.ts +++ b/packages/shared/src/acp-run-config.ts @@ -84,13 +84,6 @@ export type AgentRunConfigResolution = { modeId?: string; modelId?: string; configOptionValues?: Record; - /** - * Config option ids this module already validated against the TARGET model. - * The caller must skip them in its snapshot-based validation, which only - * knows the probed model's option list and would otherwise reject a value - * that is valid for the model actually being selected. - */ - validatedConfigIds?: string[]; /** * Requested controls that could not be verified offline because the agent * publishes no per-model breakdown for them. They are dispatched as @@ -102,7 +95,8 @@ export type AgentRunConfigResolution = { type RunConfigCapabilitySource = Pick< AcpCapabilityCacheEntry, 'modes' | 'models' | 'configOptions' | 'modelReasoningEfforts' ->; +> & + Partial>; /** * Recovers the per-model effort breakdown from a legacy `model[effort]` model @@ -174,6 +168,30 @@ const findFastModeOption = ( (option) => isAcpFastModeConfigId(option.id) && isToggleOption(option) ); +/** + * Wire binding for the fast toggle when the snapshot does not carry the option. + * + * A binding hint answers "how would this agent spell it", never "does this + * model support it". Both builtin agents publish the toggle as a boolean while + * the client advertises boolean config options — which Lody always does — so + * the shape is known even when the captured model had no fast tier. An agent we + * have no convention for gets no guess: dispatching an invented id would be a + * silent no-op, which is worse than saying we cannot encode the request. + */ +const findFastModeBindingHint = ( + capability: RunConfigCapabilitySource | undefined, + enabled: boolean +): { configId: string; value: AcpConfigOptionValue } | undefined => { + switch (capability?.agentType?.toLowerCase()) { + case 'codex': + return { configId: 'fast-mode', value: enabled }; + case 'claude': + return { configId: 'fast', value: enabled }; + default: + return undefined; + } +}; + const findReasoningEffortOption = ( capability: RunConfigCapabilitySource | undefined ): AcpConfigOptionSummary | undefined => @@ -266,18 +284,18 @@ export const summarizeAgentRunConfigCapabilities = ( /** * Maps a semantic selection onto the target agent's concrete ACP ids. * - * Throws when the agent does not offer the requested control, so an unsupported - * request fails loudly instead of silently running with different settings. + * INVARIANT: a capability snapshot never rejects a selection. `configOptions` + * only ever describes the model that was current when it was captured — agents + * rebuild those options on every model switch — so neither a missing option nor + * a value outside its list says anything about the model this turn selects. + * Everything it cannot confirm is dispatched as requested and reported in + * `unverifiedSelections`; the runtime compares what the agent actually applied + * and surfaces a visible warning when they differ. * - * Reasoning effort and fast mode are per MODEL: an agent rebuilds those options - * every time the model changes, and `configOptions` only ever describes the - * model that was current at probe time. So effort is validated against the - * model actually being selected whenever the agent published that breakdown - * (`modelReasoningEfforts`); the ids validated that way come back in - * `validatedConfigIds` for the caller to exclude from its snapshot check. - * What cannot be checked offline is reported in `unverifiedSelections` and - * dispatched as requested — the runtime surfaces a visible warning if the agent - * rejects it, rather than silently running with different settings. + * The one thing that still throws is a missing BINDING: when neither the + * snapshot nor the agent's own convention says how to spell a control on the + * wire, there is no request to send, and inventing an id would be a silent + * no-op. That is a different statement from "the agent does not support it". */ export const resolveAgentRunConfigSelection = ( selection: AgentRunConfigSelection | undefined, @@ -293,7 +311,6 @@ export const resolveAgentRunConfigSelection = ( } const configOptionValues: Record = {}; - const validatedConfigIds: string[] = []; const unverifiedSelections: string[] = []; const probedModelId = findCurrentModelId(capability); const targetModelId = selection.modelId ?? probedModelId; @@ -305,37 +322,35 @@ export const resolveAgentRunConfigSelection = ( const targetModelEfforts = targetModelId ? capability.modelReasoningEfforts?.[targetModelId] : undefined; - if (!option && !targetModelEfforts) { - throw new Error('The selected agent does not offer a reasoning effort option.'); - } - const configId = option?.id ?? ACP_REASONING_EFFORT_CONFIG_ID; - if (targetModelEfforts) { - if (!targetModelEfforts.includes(selection.reasoningEffort)) { - throw new Error( - `Invalid reasoning effort for model ${targetModelId}: ${selection.reasoningEffort}. Allowed values: ${targetModelEfforts.join(', ')}.` - ); - } - // Validated against the target model; the caller's snapshot check knows - // only the probed model's list and could reject a legitimate value. - validatedConfigIds.push(configId); - } else if (switchesModel) { + // The published per-model breakdown speaks for the selected model; the + // snapshot's own list speaks only for the model it was captured under. + const confirmed = targetModelEfforts + ? targetModelEfforts.includes(selection.reasoningEffort) + : !switchesModel && + option?.options.some((value) => value.value === selection.reasoningEffort) === true; + if (!confirmed) { unverifiedSelections.push(`reasoningEffort=${selection.reasoningEffort}`); - validatedConfigIds.push(configId); } - configOptionValues[configId] = selection.reasoningEffort; + configOptionValues[option?.id ?? ACP_REASONING_EFFORT_CONFIG_ID] = selection.reasoningEffort; } if (selection.fastMode !== undefined) { + // Binding, not support: the snapshot may omit the toggle simply because the + // model it was captured under had no fast tier, so its absence cannot + // decide anything. What it CAN decide is the wire shape, and when it does + // not know that either the agent's own convention does. const option = findFastModeOption(capability); - if (!option) { - throw new Error('The selected agent does not offer a fast mode option.'); + const binding = option + ? { configId: option.id, value: toggleValue(option, selection.fastMode) } + : findFastModeBindingHint(capability, selection.fastMode); + if (!binding) { + throw new Error( + 'Fast mode cannot be encoded for the selected agent: it publishes no fast mode option and no known binding for it.' + ); } - configOptionValues[option.id] = toggleValue(option, selection.fastMode); - if (switchesModel) { - // Agents drop the fast toggle entirely for models that lack fast support, - // and no agent publishes which models those are. + configOptionValues[binding.configId] = binding.value; + if (!option || switchesModel) { unverifiedSelections.push(`fastMode=${selection.fastMode}`); - validatedConfigIds.push(option.id); } } @@ -356,89 +371,6 @@ export const resolveAgentRunConfigSelection = ( ...(modeId ? { modeId } : {}), ...(selection.modelId !== undefined ? { modelId: selection.modelId } : {}), ...(Object.keys(configOptionValues).length > 0 ? { configOptionValues } : {}), - ...(validatedConfigIds.length > 0 ? { validatedConfigIds } : {}), ...(unverifiedSelections.length > 0 ? { unverifiedSelections } : {}), }; }; - -/** - * Whether a bare config id names an option the agent rebuilds per MODEL. - * - * The snapshot is consulted for the option's CATEGORY, because an agent may - * publish effort under its own id, but an id the snapshot omits still matches: - * omission is exactly what a per-model option does for a model that lacks the - * control (Codex publishes `fast-mode` only while the current model has a fast - * speed tier), so it cannot be the reason to reject one. - */ -const isPerModelConfigOptionId = ( - configId: string, - capability: RunConfigCapabilitySource | undefined -): boolean => { - if (isAcpFastModeConfigId(configId)) { - return true; - } - const option = findConfigOption(capability, (candidate) => candidate.id === configId); - return isAcpThoughtLevelConfigOption(option ?? { id: configId }); -}; - -export type PerModelConfigOptionResolution = { - /** - * Ids the caller must exclude from its probed-snapshot check, because this - * module either validated them against the target model or established that - * the snapshot cannot judge them. - */ - validatedConfigIds: string[]; - /** Requested values that could not be verified offline. */ - unverifiedSelections: string[]; -}; - -/** - * The same per-model rule `resolveAgentRunConfigSelection` applies, for - * CONCRETE config option values that never passed through a semantic - * selection: an Agent Role's stored `runConfig`, an explicit `--config-option`, - * or a frozen Operation replayed after recovery. - * - * Only a turn that runs a model OTHER than the probed one is affected — when - * the turn runs the probed model the snapshot describes it exactly and stays - * authoritative. Effort is still validated strictly wherever the agent - * published its per-model breakdown; what is left is dispatched as requested - * and reported in `unverifiedSelections`, because a snapshot of a different - * model cannot prove the agent will reject it. - */ -export const resolvePerModelConfigOptionSelection = (args: { - configOptionValues: Record | undefined; - modelId: string | undefined; - capability: RunConfigCapabilitySource | undefined; -}): PerModelConfigOptionResolution => { - const entries = Object.entries(args.configOptionValues ?? {}); - const probedModelId = findCurrentModelId(args.capability); - const targetModelId = args.modelId ?? probedModelId; - if (entries.length === 0 || targetModelId === undefined || targetModelId === probedModelId) { - return { validatedConfigIds: [], unverifiedSelections: [] }; - } - - const validatedConfigIds: string[] = []; - const unverifiedSelections: string[] = []; - for (const [configId, value] of entries) { - if (!isPerModelConfigOptionId(configId, args.capability)) { - continue; - } - const targetModelEfforts = isAcpFastModeConfigId(configId) - ? undefined - : args.capability?.modelReasoningEfforts?.[targetModelId]; - if (targetModelEfforts) { - if (typeof value !== 'string' || !targetModelEfforts.includes(value)) { - throw new Error( - `Invalid reasoning effort for model ${targetModelId}: ${String(value)}. Allowed values: ${targetModelEfforts.join( - ', ' - )}.` - ); - } - validatedConfigIds.push(configId); - continue; - } - validatedConfigIds.push(configId); - unverifiedSelections.push(`${configId}=${String(value)}`); - } - return { validatedConfigIds, unverifiedSelections }; -}; diff --git a/packages/shared/tests/acp-run-config.test.ts b/packages/shared/tests/acp-run-config.test.ts index 2360114a1..2b3086693 100644 --- a/packages/shared/tests/acp-run-config.test.ts +++ b/packages/shared/tests/acp-run-config.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'; import { deriveModelReasoningEffortsFromLegacyModelIds, resolveAgentRunConfigSelection, - resolvePerModelConfigOptionSelection, summarizeAgentRunConfigCapabilities, type AcpCapabilityCacheEntry, } from '../src'; @@ -147,9 +146,8 @@ describe('agent run config selection', () => { collaboration_mode: 'plan', }, // This agent published no per-model breakdown, and the selection switches - // away from the probed model, so effort/fast cannot be checked offline: - // both are dispatched as requested and excluded from the snapshot check. - validatedConfigIds: ['reasoning_effort', 'fast-mode'], + // away from the probed model, so neither effort nor fast can be checked + // offline: both are dispatched as requested and reported as unverified. unverifiedSelections: ['reasoningEffort=high', 'fastMode=true'], }); }); @@ -202,22 +200,43 @@ describe('agent run config selection', () => { expect(resolveAgentRunConfigSelection({ planMode: false }, claudeCapability())).toEqual({}); }); - it('rejects controls the agent does not offer instead of running with other settings', () => { + it('dispatches controls the snapshot does not carry instead of rejecting them', () => { + // A snapshot captured under a model with no fast tier and no effort list + // looks exactly like this. It cannot decide anything for another model. const capability: AcpCapabilityCacheEntry = { ...codexCapability(), configOptions: [], }; - expect(() => resolveAgentRunConfigSelection({ reasoningEffort: 'high' }, capability)).toThrow( - /does not offer a reasoning effort option/ - ); - expect(() => resolveAgentRunConfigSelection({ fastMode: true }, capability)).toThrow( - /does not offer a fast mode option/ - ); + + expect(resolveAgentRunConfigSelection({ reasoningEffort: 'high' }, capability)).toEqual({ + configOptionValues: { reasoning_effort: 'high' }, + unverifiedSelections: ['reasoningEffort=high'], + }); + expect(resolveAgentRunConfigSelection({ fastMode: true }, capability)).toEqual({ + configOptionValues: { 'fast-mode': true }, + unverifiedSelections: ['fastMode=true'], + }); + + // Plan mode is the exception, and for a binding reason rather than a + // capability one: with neither a collaboration_mode option nor a plan + // permission mode there is no way to express the request at all. expect(() => resolveAgentRunConfigSelection({ planMode: true }, capability)).toThrow( /does not offer a plan mode/ ); }); + it('reports a missing wire binding as such, not as an unsupported control', () => { + // A third-party agent Lody has no fast-mode convention for. + const capability: AcpCapabilityCacheEntry = { + ...codexCapability(), + agentType: 'some-registry-agent', + configOptions: [], + }; + expect(() => resolveAgentRunConfigSelection({ fastMode: true }, capability)).toThrow( + /cannot be encoded/ + ); + }); + it('refuses to select anything when the agent has reported no capabilities', () => { expect(() => resolveAgentRunConfigSelection({ modelId: 'gpt-5.4-mini' }, undefined)).toThrow( /ACP capabilities are unavailable/ @@ -276,9 +295,9 @@ describe('agent run config selection', () => { }, }; - // `xhigh` is absent from the probed model's snapshot options but valid for - // the model being selected: it must be accepted and marked pre-validated so - // the caller's snapshot check does not reject it. + // `xhigh` is absent from the probed model's snapshot options but the agent + // published a breakdown saying the selected model takes it: confirmed, so + // nothing is reported as unverified. expect( resolveAgentRunConfigSelection( { modelId: 'gpt-5.6-sol', reasoningEffort: 'xhigh' }, @@ -287,16 +306,21 @@ describe('agent run config selection', () => { ).toEqual({ modelId: 'gpt-5.6-sol', configOptionValues: { reasoning_effort: 'xhigh' }, - validatedConfigIds: ['reasoning_effort'], }); - // Valid for the probed model, unsupported by the target model. - expect(() => + // Outside the target model's published list. That is real evidence, but it + // can be stale (the breakdown is per account and per catalog revision), so + // it dispatches and is reported rather than rejected. + expect( resolveAgentRunConfigSelection( { modelId: 'gpt-5.4-mini', reasoningEffort: 'high' }, capability ) - ).toThrow(/Invalid reasoning effort for model gpt-5\.4-mini.*Allowed values: low, medium/s); + ).toEqual({ + modelId: 'gpt-5.4-mini', + configOptionValues: { reasoning_effort: 'high' }, + unverifiedSelections: ['reasoningEffort=high'], + }); }); it('flags selections it cannot verify offline instead of pretending they hold', () => { @@ -355,87 +379,3 @@ describe('agent run config selection', () => { expect(resolveAgentRunConfigSelection({ planMode: true }, legacy)).toEqual({ modeId: 'plan' }); }); }); - -describe('per-model config options carried as concrete ids', () => { - /** Codex probed under a model without a fast speed tier: no `fast-mode` at all. */ - const withoutFastMode = (): AcpCapabilityCacheEntry => { - const capability = codexCapability(); - return { - ...capability, - configOptions: (capability.configOptions ?? []).filter((option) => option.id !== 'fast-mode'), - }; - }; - - it('keeps a stored fast toggle dispatchable when the turn runs another model', () => { - // An Agent Role's stored run config: concrete ids, no semantic selection. - expect( - resolvePerModelConfigOptionSelection({ - configOptionValues: { 'fast-mode': true, mode: 'agent' }, - modelId: 'gpt-5.4-mini', - capability: codexCapability(), - }) - ).toEqual({ validatedConfigIds: ['fast-mode'], unverifiedSelections: ['fast-mode=true'] }); - - // The probe never saw the option because its model had no fast tier. That - // says nothing about the model this turn selects. - expect( - resolvePerModelConfigOptionSelection({ - configOptionValues: { 'fast-mode': true }, - modelId: 'gpt-5.4-mini', - capability: withoutFastMode(), - }) - ).toEqual({ validatedConfigIds: ['fast-mode'], unverifiedSelections: ['fast-mode=true'] }); - }); - - it('leaves the snapshot authoritative for the model it was probed under', () => { - for (const modelId of ['gpt-5.6-sol', undefined]) { - expect( - resolvePerModelConfigOptionSelection({ - configOptionValues: { 'fast-mode': true }, - modelId, - capability: withoutFastMode(), - }) - ).toEqual({ validatedConfigIds: [], unverifiedSelections: [] }); - } - }); - - it('still validates a stored effort against the target model breakdown', () => { - const capability: AcpCapabilityCacheEntry = { - ...codexCapability(), - modelReasoningEfforts: { - 'gpt-5.6-sol': ['low', 'medium', 'high'], - 'gpt-5.4-mini': ['low', 'medium'], - }, - }; - - expect( - resolvePerModelConfigOptionSelection({ - configOptionValues: { reasoning_effort: 'medium' }, - modelId: 'gpt-5.4-mini', - capability, - }) - ).toEqual({ validatedConfigIds: ['reasoning_effort'], unverifiedSelections: [] }); - - expect(() => - resolvePerModelConfigOptionSelection({ - configOptionValues: { reasoning_effort: 'high' }, - modelId: 'gpt-5.4-mini', - capability, - }) - ).toThrow(/Invalid reasoning effort for model gpt-5\.4-mini.*Allowed values: low, medium/s); - }); - - it('recognizes an agent that publishes effort under its own id', () => { - // Claude: `effort` by category, `fast` as the toggle id. - expect( - resolvePerModelConfigOptionSelection({ - configOptionValues: { effort: 'high', fast: 'on', mode: 'auto' }, - modelId: 'opus', - capability: claudeCapability(), - }) - ).toEqual({ - validatedConfigIds: ['effort', 'fast'], - unverifiedSelections: ['effort=high', 'fast=on'], - }); - }); -}); From 47f3d837cdc42dd4a325431b54e11b493a80a0a0 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 15:07:37 +0800 Subject: [PATCH 04/24] fix(cli): apply permission-bearing config last and report the agent's own mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects, one visible only because of the other. The applier set the permission mode BEFORE the model. Claude rebuilds the available permission modes on every model switch and downgrades the current one to `default` when the new model does not support it, so a turn that asked for plan/read-only and also switched models ran with WIDER permissions than it requested. Model and ordinary options now go first and permission-bearing ones last, so the last word belongs to what was asked for. `applyPromptConfig` already runs before `prompt`, so the state read afterwards is still taken before the agent can act on it. That was invisible because a successful `session/set_mode` overwrote the agent's reported mode with the requested one in the runtime patch — the request echoed back as if it were the outcome. It now only FILLS a mode the agent's own state does not report, mirroring what the model branch already did. Without this, no mode divergence could ever be reported. The regression test fails when the ordering is reverted; the existing fixture that hid it now reports the mode it accepted, like a real agent. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- .../acp-session-config-applier.test.ts | 52 +++++- .../src/session/acp-session-config-applier.ts | 167 +++++++++--------- 2 files changed, 133 insertions(+), 86 deletions(-) diff --git a/apps/cli/src/session/acp-session-config-applier.test.ts b/apps/cli/src/session/acp-session-config-applier.test.ts index b236ef5fe..d6bcc180f 100644 --- a/apps/cli/src/session/acp-session-config-applier.test.ts +++ b/apps/cli/src/session/acp-session-config-applier.test.ts @@ -22,13 +22,18 @@ function createLogger(): Logger { describe('applyAcpSessionRunConfig', () => { it('applies mode, model, and remaining options to an established ACP session', async () => { - const setSessionMode = vi.fn(async () => undefined); + // A real agent reports back what it accepted, so the fake does too: the + // applier reads that state rather than echoing the request. + let currentMode = 'default'; + const setSessionMode = vi.fn(async (_sessionId: string, mode: string) => { + currentMode = mode; + }); const setSessionModel = vi.fn(async () => undefined); const setSessionConfigOption = vi.fn(async () => undefined); const agentClient = { isCreated: () => true, getConfigOptions: () => [ - { id: 'permission-mode', category: 'mode', type: 'select', currentValue: 'default' }, + { id: 'permission-mode', category: 'mode', type: 'select', currentValue: currentMode }, { id: 'engine', category: 'model', type: 'select', currentValue: 'model-a' }, { id: 'effort', category: 'thought_level', type: 'select', currentValue: 'high' }, ], @@ -109,6 +114,49 @@ describe('applyAcpSessionRunConfig', () => { expect(vi.mocked(logger.debug).mock.calls.flat().join('\n')).not.toContain('private-value'); }); + it('keeps the requested permission mode when the model switch would reset it', async () => { + // Claude rebuilds the available permission modes on a model switch and + // downgrades the current one to `default` when the new model does not + // support it. Applying the mode before the model therefore loses it — and + // loses it toward WIDER permissions than the turn asked for. + let currentMode = 'default'; + let currentModel = 'model-a'; + const agentClient = { + isCreated: () => true, + getConfigOptions: () => [ + { id: 'permission-mode', category: 'mode', type: 'select', currentValue: currentMode }, + { id: 'engine', category: 'model', type: 'select', currentValue: currentModel }, + ], + setSessionMode: vi.fn(async (_sessionId: string, mode: string) => { + currentMode = mode; + }), + unstable_setSessionModel: vi.fn(async (_sessionId: string, model: string) => { + currentModel = model; + currentMode = 'default'; + }), + setSessionConfigOption: vi.fn(async () => undefined), + } as unknown as AgentClient; + + const result = await applyAcpSessionRunConfig({ + session: { + sessionId: 'session-8' as SessionId, + acpSessionId: 'acp-8' as ACPSessionId, + agentClient, + }, + config: { + cliType: 'builtin', + agentType: 'claude', + modeId: 'plan', + modelId: 'model-b', + }, + logger: createLogger(), + }); + + expect(result.runtimeConfigPatch?.modelId).toBe('model-b'); + expect(result.runtimeConfigPatch?.modeId).toBe('plan'); + expect(result.warningSelections).toEqual([]); + }); + it('reports a selection the agent accepted but dropped from its own state', async () => { // The codex shape: `fast-mode` is accepted without error on a model with no // fast speed tier, and simply does not come back in the published state, so diff --git a/apps/cli/src/session/acp-session-config-applier.ts b/apps/cli/src/session/acp-session-config-applier.ts index de6126a34..cca1fa38c 100644 --- a/apps/cli/src/session/acp-session-config-applier.ts +++ b/apps/cli/src/session/acp-session-config-applier.ts @@ -2,6 +2,7 @@ import { ACP_CONFIG_OPTION_OFF_VALUE, ACP_CONFIG_OPTION_ON_VALUE, isAcpFastModeConfigId, + isAcpPlanModeConfigOption, isSensitiveAcpConfigOptionId, type ACPSessionId, type AcpConfigOptionValue, @@ -155,101 +156,36 @@ export async function applyAcpSessionRunConfig(args: { const targetModelId = config.modelId ?? (typeof configOptionModelId === 'string' ? configOptionModelId : undefined); - if (config.modeId) { - const label = `mode=${JSON.stringify(config.modeId)}`; + const applyMode = async (value: string, label: string): Promise => { let rejected = false; try { - await agentClient.setSessionMode?.(acpSessionId, config.modeId); - confirmedLegacyModeId = config.modeId; + await agentClient.setSessionMode?.(acpSessionId, value); + confirmedLegacyModeId = value; } catch (error) { rejected = true; rejectedSelections.push(label); - logger.debug( - `[${sessionId}] Failed to set ACP mode ${JSON.stringify(config.modeId)}: ${String(error)}` - ); + logger.debug(`[${sessionId}] Failed to set ACP mode ${label}: ${String(error)}`); } - appliedSelections.push({ - label, - requested: config.modeId, - source: { kind: 'mode' }, - rejected, - }); - } - if (config.modelId) { - const label = `model=${JSON.stringify(config.modelId)}`; + appliedSelections.push({ label, requested: value, source: { kind: 'mode' }, rejected }); + }; + + const applyModel = async (value: string, label: string): Promise => { let rejected = false; try { - await agentClient.unstable_setSessionModel?.(acpSessionId, config.modelId); - confirmedLegacyModelId = config.modelId; + await agentClient.unstable_setSessionModel?.(acpSessionId, value); + confirmedLegacyModelId = value; } catch (error) { rejected = true; rejectedSelections.push(label); - logger.debug( - `[${sessionId}] Failed to set ACP model ${JSON.stringify(config.modelId)}: ${String(error)}` - ); + logger.debug(`[${sessionId}] Failed to set ACP model ${label}: ${String(error)}`); } - appliedSelections.push({ - label, - requested: config.modelId, - source: { kind: 'model' }, - rejected, - }); - } + appliedSelections.push({ label, requested: value, source: { kind: 'model' }, rejected }); + }; - for (const [configId, value] of configOptionEntries) { - if (configId === modeConfigId) { - // An explicit `config.modeId` outranks this duplicate, and is judged in - // its place: losing a precedence contest is not the agent disagreeing. - if (!config.modeId && typeof value === 'string') { - let rejected = false; - try { - await agentClient.setSessionMode?.(acpSessionId, value); - confirmedLegacyModeId = value; - } catch (error) { - rejected = true; - logger.debug( - `[${sessionId}] Failed to set ACP mode option ${configId}=${formatAcpConfigValueForLog( - configId, - value - )}: ${String(error)}` - ); - } - appliedSelections.push({ - label: `${configId}=${formatAcpConfigValueForLog(configId, value)}`, - requested: value, - source: { kind: 'mode' }, - rejected, - }); - } - continue; - } - if (configId === modelConfigId) { - if (!config.modelId && typeof value === 'string') { - let rejected = false; - try { - await agentClient.unstable_setSessionModel?.(acpSessionId, value); - confirmedLegacyModelId = value; - } catch (error) { - rejected = true; - logger.debug( - `[${sessionId}] Failed to set ACP model option ${configId}=${formatAcpConfigValueForLog( - configId, - value - )}: ${String(error)}` - ); - } - appliedSelections.push({ - label: `${configId}=${formatAcpConfigValueForLog(configId, value)}`, - requested: value, - source: { kind: 'model' }, - rejected, - }); - } - continue; - } - if (shouldSkipFableFastModeDisable({ modelId: targetModelId, configId, value })) { - continue; - } + const applyConfigOption = async ( + configId: string, + value: AcpConfigOptionValue + ): Promise => { const label = `${configId}=${formatAcpConfigValueForLog(configId, value)}`; let rejected = false; try { @@ -265,6 +201,62 @@ export async function applyAcpSessionRunConfig(args: { source: { kind: 'configOption', configId }, rejected, }); + }; + + /** + * Permission-bearing controls go LAST, and that ordering is load-bearing. + * + * Claude rebuilds the available permission modes on every model switch and + * downgrades the current one to `default` when the new model does not support + * it — so a mode applied before the model is silently widened by the model + * that follows it. Applying the model and the ordinary options first, then the + * permission-bearing ones, means the last word belongs to what the user asked + * for. `applyPromptConfig` runs before `prompt`, so the state read below is + * still taken before the agent can act on it. + */ + const isPermissionBearing = (configId: string): boolean => + configId === modeConfigId || isAcpPlanModeConfigOption({ id: configId }); + const configOptionEntryFor = (configId: string): AcpConfigOptionValue | undefined => + configOptionEntries.find(([id]) => id === configId)?.[1]; + + const duplicateModelValue = configOptionEntryFor(modelConfigId); + if (config.modelId) { + await applyModel(config.modelId, `model=${JSON.stringify(config.modelId)}`); + } else if (typeof duplicateModelValue === 'string') { + await applyModel( + duplicateModelValue, + `${modelConfigId}=${formatAcpConfigValueForLog(modelConfigId, duplicateModelValue)}` + ); + } + + for (const [configId, value] of configOptionEntries) { + if (configId === modeConfigId || configId === modelConfigId || isPermissionBearing(configId)) { + continue; + } + if (shouldSkipFableFastModeDisable({ modelId: targetModelId, configId, value })) { + continue; + } + await applyConfigOption(configId, value); + } + + for (const [configId, value] of configOptionEntries) { + if (configId === modeConfigId || !isPermissionBearing(configId)) { + continue; + } + await applyConfigOption(configId, value); + } + + // An explicit `config.modeId` outranks the duplicate config-option entry, and + // is judged in its place: losing a precedence contest is not the agent + // disagreeing. + const duplicateModeValue = configOptionEntryFor(modeConfigId); + if (config.modeId) { + await applyMode(config.modeId, `mode=${JSON.stringify(config.modeId)}`); + } else if (typeof duplicateModeValue === 'string') { + await applyMode( + duplicateModeValue, + `${modeConfigId}=${formatAcpConfigValueForLog(modeConfigId, duplicateModeValue)}` + ); } logger.debug(`[${sessionId}] applyAcpSessionRunConfig completed`); @@ -272,11 +264,18 @@ export async function applyAcpSessionRunConfig(args: { acpSessionId, agentClient.getConfigOptions() ); - if (confirmedLegacyModeId) { + // A `session/set_mode` that did not throw is an acknowledgement, not proof of + // the resulting state: the agent may change the mode again while applying the + // rest of the turn (Claude downgrades it on an unsupported model switch). So + // it only FILLS a mode the agent's own state does not report — never + // overwrites one, which would report the request back as if it were the + // outcome and leave every mode divergence invisible. + if (confirmedLegacyModeId && runtimeConfigPatch.modeId === undefined) { runtimeConfigPatch.modeId = confirmedLegacyModeId; if ( !isSensitiveAcpConfigOptionId(modeConfigId) && - agentConfigOptions.some((option) => option.id === modeConfigId) + agentConfigOptions.some((option) => option.id === modeConfigId) && + runtimeConfigPatch.configOptionValues?.[modeConfigId] === undefined ) { runtimeConfigPatch.configOptionValues = { ...runtimeConfigPatch.configOptionValues, From 58fbed013a4e12d5faace1daf30700878c911c3f Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 15:11:44 +0800 Subject: [PATCH 05/24] fix(components): keep stored run-config values the capability catalog omits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways a guess became a promise, and a promise became invisible. `applyAgentRoleRunConfigDefaults` seeded a new Role from `provisional` capabilities — the built-in static tables, a hand-maintained copy of a catalog the agent fetches per account. A Role is a durable promise about how a Session runs, so seeding it from a guess persisted that guess as the commitment. It is how a Role came to promise Fast mode for an agent whose probe never published the option. Only `authoritative` capabilities seed now; the rest stay unset for the user to choose. The composer then deleted what it could not find. Its authoritative branch walked the selector catalog and dropped every key outside it — but that catalog comes from a snapshot of ONE model, so a Role pinning Fast on a fast-capable model had its own value removed from the composer meant to show what the Role will do, and removed again from the dispatch table. A stored key with no selector is now kept while no runtime table exists, and `filterAcpSessionConfigOptionValues` drops only values a selector it HAS rejects. A present runtime table still owns the whole key set: that one is the agent's live state, not a snapshot of some other model, so an omission there is an answer. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/acp-session-config-selection.ts | 31 +++++++++++++++++++ .../components/src/lib/agent-role-form.ts | 10 +++++- .../acp-session-config-selection.test.ts | 14 ++++++--- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/components/src/lib/acp-session-config-selection.ts b/packages/components/src/lib/acp-session-config-selection.ts index 2a265aed1..b380e1ba3 100644 --- a/packages/components/src/lib/acp-session-config-selection.ts +++ b/packages/components/src/lib/acp-session-config-selection.ts @@ -302,16 +302,47 @@ export const resolveAcpSessionConfigSelection = ( ? preferredValue : selector.currentValue; } + + /* A stored value the catalog does not cover is KEPT while no runtime table + exists. The catalog describes the model the capability probe happened to + run, so a value it omits may belong to the model this turn selects — an + Agent Role pinning Fast on a fast-capable model is exactly that. Dropping + it made the Role's own value invisible in the composer that is supposed to + show what the Role will do. A present runtime table still owns the whole + key set: that one is the agent's live state, not a snapshot of another + model, so an omission there is an answer. */ + if (!runtimeTable) { + const cataloged = new Set(selectors.map((selector) => selector.configId)); + for (const [configId, value] of Object.entries({ + ...preferences.configOptionValues, + ...edits.configOptions, + })) { + if (!cataloged.has(configId)) { + configOptionValues[configId] = value; + } + } + } } return { selectedModeId, selectedModelId, configOptionValues }; }; +/** + * Drops values a KNOWN selector rejects. A value with no selector at all is + * kept: the selector catalog comes from a capability snapshot of one model, so + * its silence is not a verdict, and the runtime reports any divergence. + */ export const filterAcpSessionConfigOptionValues = ( values: Record | undefined, selectors: readonly AcpConfigOptionSelector[] ): Record => { const filtered: Record = {}; + const cataloged = new Set(selectors.map((selector) => selector.configId)); + for (const [configId, value] of Object.entries(values ?? {})) { + if (!cataloged.has(configId)) { + filtered[configId] = value; + } + } for (const selector of selectors) { const value = values?.[selector.configId]; if (isConfigOptionValueValid(selector, value)) { diff --git a/packages/components/src/lib/agent-role-form.ts b/packages/components/src/lib/agent-role-form.ts index ebe51a4e4..148068789 100644 --- a/packages/components/src/lib/agent-role-form.ts +++ b/packages/components/src/lib/agent-role-form.ts @@ -196,12 +196,20 @@ export const buildAgentRoleFromForm = ( * Only unset fields are filled: a saved Role keeps its stored selection even * when it differs from the agent's current default, which is what makes an * incompatible value visible instead of silently replaced. + * + * A Role is a durable promise about how a Session will run, so it may only be + * seeded from capabilities the agent itself reported. `provisional` ones are + * the built-in static tables — a hand-maintained copy of a catalog the agent + * fetches per account — and writing those in would persist a GUESS as the + * Role's commitment. That is how a Role came to promise Fast mode for an agent + * whose probe never published the option. Unseeded fields stay unset and the + * user chooses them. */ export const applyAgentRoleRunConfigDefaults = ( value: AgentRoleFormValue, selectorOptions: AcpSelectorOptions | null ): AgentRoleFormValue => { - if (!selectorOptions || selectorOptions.capabilityAuthority === 'unavailable') return value; + if (!selectorOptions || selectorOptions.capabilityAuthority !== 'authoritative') return value; const modelId = value.modelId ?? diff --git a/packages/components/tests/acp-session-config-selection.test.ts b/packages/components/tests/acp-session-config-selection.test.ts index 057519e12..509c0e108 100644 --- a/packages/components/tests/acp-session-config-selection.test.ts +++ b/packages/components/tests/acp-session-config-selection.test.ts @@ -82,7 +82,7 @@ describe('ACP session config derivation', () => { ).toBe('gpt-5.6-sol'); }); - it('keeps unknown config keys provisionally and removes them authoritatively', () => { + it('keeps a stored key the catalog does not cover, whatever the authority', () => { const selectors = [ { configId: 'fast-mode', @@ -107,13 +107,17 @@ describe('ACP session config derivation', () => { configOptionSelectors: selectors, }).configOptionValues ).toEqual({ future_option: 'enabled', 'fast-mode': 'future-value' }); + // Authoritative only means the catalog is the agent's own — of ONE model. + // A value it rejects for a selector it HAS falls back ('future-value' is + // not a `fast-mode` value); a key it never mentions is kept, because the + // model this turn selects may well have it. expect( resolveAcpSessionConfigSelection(inputs, { ...baseOptions, capabilityAuthority: 'authoritative', configOptionSelectors: selectors, }).configOptionValues - ).toEqual({ 'fast-mode': 'off' }); + ).toEqual({ 'fast-mode': 'off', future_option: 'enabled' }); }); it('applies the runtime baseline over non-user fields', () => { @@ -320,18 +324,20 @@ describe('ACP session config derivation', () => { ], }, ]; + // Uncataloged keys survive; a cataloged one with a value the selector + // rejects does not. expect( filterAcpSessionConfigOptionValues( { 'plan-mode': 'on', collaboration_mode: 'plan', future_option: 'enabled' }, selectors ) - ).toEqual({ collaboration_mode: 'plan' }); + ).toEqual({ 'plan-mode': 'on', collaboration_mode: 'plan', future_option: 'enabled' }); expect( filterAcpSessionConfigOptionValues( { 'plan-mode': 'on', collaboration_mode: 'invalid' }, selectors ) - ).toEqual({}); + ).toEqual({ 'plan-mode': 'on' }); }); }); From 6f9a5cd4a126d038677a74e18395ba7f3d412ede Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 15:13:22 +0800 Subject: [PATCH 06/24] docs(cli): record that snapshots report, and permission config applies last MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the per-model invariant around what the change actually established: a capability snapshot is evidence about one model and never rejects a run config, the only loud failure left is a missing wire binding, and Roles are seeded from authoritative capabilities alone. Adds the ordering invariant — permission-bearing config last, a set_mode acknowledgement is not the state, divergence is reported rather than blocked, and an upgrade must never turn a Role that used to run into one that fails. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/AGENTS.md | 61 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index fe9763d4e..35c817de6 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -252,29 +252,39 @@ Two things the dev build does deliberately, both load-bearing: mapping onto each agent's advertised option ids (also the source of truth for the web selectors), `applyAgentRunConfigSelection` in `src/commands/session.ts` applies it once the target agent's cached capabilities are read, and - `validateSessionCreateOptions({ dispatchConfig })` rejects unsupported selections - before the Operation is accepted. `lody_session_create_options` publishes the valid + `validateSessionCreateOptions({ dispatchConfig })` resolves the effective dispatch + config before the Operation is accepted (it no longer rejects on capability + evidence — see the snapshot invariant below). `lody_session_create_options` publishes the valid values per agent config as `runConfig`. Its default response is sparse: online Machines, one default/current agent config, the current local project, and no GitHub repository fetch. Agent configs/local projects/repos expand only through their query inputs. Durable create acceptance stores each target's resolved effective dispatch config; recovery must use it instead of inheriting again from mutable requester history. -- INVARIANT: reasoning effort and fast mode are per MODEL. An ACP probe's - `configOptions` only describe the model that was current at probe time — agents - rebuild those options on every model switch and then REJECT a value the new model - does not support. `acp-capability-normalization.ts` recovers the model-independent - view into `AcpCapabilityCacheEntry.modelReasoningEfforts` from agents that also - publish the legacy `model[effort]` list (Codex); effort is validated against the - TARGET model and the ids so validated come back as `validatedConfigIds`, which - `validateTurnConfigOptionValues(..., skipIds)` must skip (the probed model's list - would wrongly reject them). What cannot be checked offline is dispatched as - requested. The rule follows the VALUES, not the caller: concrete ids that never - passed through a semantic selection — an Agent Role's stored `runConfig`, an - explicit `--config-option`, a frozen Operation replayed after recovery — go - through `resolvePerModelConfigOptionSelection` for the same exemption, because a - per-model option's ABSENCE from the snapshot is exactly what the probed model's - lack of the control looks like and can never be the reason to reject another - model's turn. Runtime rejections remain in debug diagnostics; what becomes a visible +- INVARIANT: a capability snapshot never rejects a run config. `configOptions` + describes the model that was current when it was captured — and every created + session rewrites it (`scheduleCreatedSessionCapabilityUpdate`), so it describes + whichever model ran last. Neither a missing option, nor a value outside the list + it recorded, nor an unseen model/mode id is evidence about the model a turn + selects. `validateTurnConfigOptionValues` therefore rejects only what no model + could carry: a value the option's own declared TYPE forbids. Everything else is + dispatched and reconciled against the state the agent publishes. + `findUnverifiedTurnSelectors` and `unverifiedSelections` record what could not be + confirmed; neither blocks. Do not reintroduce a `validatedConfigIds`-style + exemption set: it only made sense while the snapshot could reject, and with + rejection gone there is nothing to exempt. + The one thing that still fails loudly is a missing wire BINDING — no snapshot + option and no agent convention for how to spell a control, so there is no + request to send and an invented id would be a silent no-op. That is a different + statement from "unsupported" and must be worded as such. + `acp-capability-normalization.ts` still recovers `modelReasoningEfforts` from the + legacy `model[effort]` list (Codex): a published per-model breakdown CONFIRMS a + value for the selected model, which is the only thing that keeps it out of + `unverifiedSelections`. It never rejects one. + Client side: a Role may be seeded only from `authoritative` capabilities — + `provisional` means the built-in static tables, and seeding from those persists a + guess as a durable promise. The composer keeps stored keys its selector catalog + does not cover; only a present runtime table (the agent's live state) owns the + whole key set. Runtime rejections remain in debug diagnostics; what becomes a visible `agent_warning` is DIVERGENCE — the state the agent publishes after applying the turn's config contradicts what was requested. A rejection is not that signal in either direction: Codex accepts `fast-mode` on a model with no fast speed tier and @@ -287,6 +297,21 @@ Two things the dev build does deliberately, both load-bearing: Claude Fable models omit the Fast mode option, so an explicit `fast=false` is skipped as an already-effective no-op and is judged for neither; `fast=true` must still be dispatched and retained in debug diagnostics if rejected. +- INVARIANT: permission-bearing config (the mode option, plan/collaboration mode) + is applied LAST, after the model and the ordinary options. Claude rebuilds the + available permission modes on a model switch and downgrades the current one to + `default` when the new model lacks it, so a mode set before the model is + silently widened by the model that follows. `applyPromptConfig` runs before + `prompt`, so the state read after applying is still taken before the agent acts. + A successful `session/set_mode` is an acknowledgement, not proof of the resulting + state: it may only FILL a mode the agent's own state does not report, never + overwrite one — echoing the request back as the outcome makes every mode + divergence invisible. Divergence is reported, not blocked: no run-config + mismatch may prevent Session creation or prompt submission, and Agent Roles and + frozen Operations behave exactly like ordinary preferences at run time, + differing only in warning wording and follow-up marking. A Role that would run + diverged is surfaced, not refused — an upgrade must never turn a Role that used + to run into one that fails. - MCP `session_list` defaults to 20 (maximum 100), and `session_history` defaults to 10 (maximum 50 and 128 KiB). Keep the MCP surface bounded even though the human CLI retains `session history --all`. `session_list` and `session_status_many` derive busy/idle from From 6d9217976cb1bb460aebf1f51786df8e830fac06 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 17:10:39 +0800 Subject: [PATCH 07/24] fix: bind per-model controls per agent and keep only per-model unknowns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, all in the same seam. The fallback wire id for reasoning effort was Codex's `reasoning_effort` for every agent. Claude spells it `effort`, so a snapshot captured without the option — exactly the case the fallback exists for — sent Claude an id it has never heard of, and the turn ran at the default effort. Both fast mode and effort now resolve through one per-agent binding table, and an agent with no known binding reports that the control cannot be encoded rather than borrowing another agent's spelling. Preserving stored config values outside the selector catalog was too broad: a removed or renamed option has no surface left to clear it, so every new Session would resend and re-warn about it forever. Only ids Lody knows name a per-model control keep that exemption — the case it was for, a Role pinning Fast on a fast-capable model the probe never ran. Anything else uncataloged is stale and goes. The Role form still drew run-config controls from `provisional` capabilities while seeding refused to fill them, so a boolean rendered Off and saved as unset: the Role promised a configuration nobody chose. Those controls now appear only for capabilities the agent itself reported, with the existing "open the agent once, then edit this role" message otherwise. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- .../components/settings/agent-role-form.tsx | 10 ++- .../src/lib/acp-session-config-selection.ts | 29 +++++--- .../acp-session-config-selection.test.ts | 45 +++++++----- packages/shared/src/acp-run-config.ts | 73 ++++++++++++------- packages/shared/tests/acp-run-config.test.ts | 23 ++++++ 5 files changed, 122 insertions(+), 58 deletions(-) diff --git a/packages/components/src/components/settings/agent-role-form.tsx b/packages/components/src/components/settings/agent-role-form.tsx index 400ee6c44..48a2c4980 100644 --- a/packages/components/src/components/settings/agent-role-form.tsx +++ b/packages/components/src/components/settings/agent-role-form.tsx @@ -100,7 +100,13 @@ export function AgentRoleForm({ const configOptionSelectors = selectorOptions ? selectAuthorableAgentRoleConfigOptions(selectorOptions.configOptionSelectors) : []; - const capabilitiesUnavailable = selectorOptions?.capabilityAuthority === 'unavailable'; + /* Run-config controls are shown only for capabilities the AGENT reported. + `provisional` ones come from the built-in static tables, and a control drawn + from those reads as a choice while `applyAgentRoleRunConfigDefaults` refuses + to seed it — a boolean would render Off and save as unset, so the Role would + promise a configuration the user never picked. The existing copy already + says what to do: open the agent once, then edit the Role. */ + const capabilitiesUnreported = selectorOptions?.capabilityAuthority !== 'authoritative'; const hasError = (code: AgentRoleFormError) => errors.includes(code); return ( @@ -223,7 +229,7 @@ export function AgentRoleForm({ title={t('settings.agentRoles.form.sectionRunConfig')} hint={t('settings.agentRoles.form.sectionRunConfigHint')} > - {capabilitiesUnavailable || !selectorOptions ? ( + {capabilitiesUnreported || !selectorOptions ? ( {t('settings.agentRoles.form.capabilitiesUnavailable')} diff --git a/packages/components/src/lib/acp-session-config-selection.ts b/packages/components/src/lib/acp-session-config-selection.ts index b380e1ba3..35e629be9 100644 --- a/packages/components/src/lib/acp-session-config-selection.ts +++ b/packages/components/src/lib/acp-session-config-selection.ts @@ -1,3 +1,4 @@ +import { isAcpPerModelConfigId } from '@lody/shared'; import type { AcpCapabilityAuthority, AcpConfigOptionValue } from '@lody/shared'; import { isConfigOptionValueValid, @@ -303,21 +304,24 @@ export const resolveAcpSessionConfigSelection = ( : selector.currentValue; } - /* A stored value the catalog does not cover is KEPT while no runtime table + /* A stored value for a PER-MODEL control is kept while no runtime table exists. The catalog describes the model the capability probe happened to - run, so a value it omits may belong to the model this turn selects — an - Agent Role pinning Fast on a fast-capable model is exactly that. Dropping - it made the Role's own value invisible in the composer that is supposed to - show what the Role will do. A present runtime table still owns the whole - key set: that one is the agent's live state, not a snapshot of another - model, so an omission there is an answer. */ + run, so it drops those controls for any other model — an Agent Role + pinning Fast on a fast-capable model is exactly that, and dropping it made + the Role's own value invisible in the composer meant to show what the Role + will do. The exemption is limited to ids Lody knows name a per-model + control: any other uncataloged key has no such excuse, and blanket-keeping + it would resend a removed or renamed option forever with no surface left + to clear it. A present runtime table still owns the whole key set: that + one is the agent's live state, not another model's snapshot, so an + omission there is an answer. */ if (!runtimeTable) { const cataloged = new Set(selectors.map((selector) => selector.configId)); for (const [configId, value] of Object.entries({ ...preferences.configOptionValues, ...edits.configOptions, })) { - if (!cataloged.has(configId)) { + if (!cataloged.has(configId) && isAcpPerModelConfigId(configId)) { configOptionValues[configId] = value; } } @@ -328,9 +332,10 @@ export const resolveAcpSessionConfigSelection = ( }; /** - * Drops values a KNOWN selector rejects. A value with no selector at all is - * kept: the selector catalog comes from a capability snapshot of one model, so - * its silence is not a verdict, and the runtime reports any divergence. + * Drops values a KNOWN selector rejects, plus uncataloged keys that are not + * per-model controls. A per-model id missing from the catalog only means the + * probed model lacked it, so its stored value still reaches dispatch and the + * runtime reports any divergence; anything else uncataloged is stale. */ export const filterAcpSessionConfigOptionValues = ( values: Record | undefined, @@ -339,7 +344,7 @@ export const filterAcpSessionConfigOptionValues = ( const filtered: Record = {}; const cataloged = new Set(selectors.map((selector) => selector.configId)); for (const [configId, value] of Object.entries(values ?? {})) { - if (!cataloged.has(configId)) { + if (!cataloged.has(configId) && isAcpPerModelConfigId(configId)) { filtered[configId] = value; } } diff --git a/packages/components/tests/acp-session-config-selection.test.ts b/packages/components/tests/acp-session-config-selection.test.ts index 509c0e108..a4361fa92 100644 --- a/packages/components/tests/acp-session-config-selection.test.ts +++ b/packages/components/tests/acp-session-config-selection.test.ts @@ -82,42 +82,51 @@ describe('ACP session config derivation', () => { ).toBe('gpt-5.6-sol'); }); - it('keeps a stored key the catalog does not cover, whatever the authority', () => { + it('keeps a stored per-model value the catalog omits, and drops other unknown keys', () => { + // A catalog captured under a model with no fast tier: it carries the + // ordinary option but not `fast-mode`. const selectors = [ { - configId: 'fast-mode', - label: 'Fast mode', + configId: 'approval_policy', + label: 'Approval policy', type: 'select' as const, - currentValue: 'off', + currentValue: 'on-request', options: [ - { value: 'off', label: 'Off' }, - { value: 'on', label: 'On' }, + { value: 'on-request', label: 'On request' }, + { value: 'never', label: 'Never' }, ], }, ]; const inputs = { edits: emptyEdits, preferences: { - configOptionValues: { future_option: 'enabled', 'fast-mode': 'future-value' }, + configOptionValues: { + approval_policy: 'never', + 'fast-mode': true, + future_option: 'enabled', + }, }, }; + + // Non-authoritative keeps stored values verbatim, as before. expect( resolveAcpSessionConfigSelection(inputs, { ...baseOptions, configOptionSelectors: selectors, }).configOptionValues - ).toEqual({ future_option: 'enabled', 'fast-mode': 'future-value' }); - // Authoritative only means the catalog is the agent's own — of ONE model. - // A value it rejects for a selector it HAS falls back ('future-value' is - // not a `fast-mode` value); a key it never mentions is kept, because the - // model this turn selects may well have it. + ).toEqual({ approval_policy: 'never', 'fast-mode': true, future_option: 'enabled' }); + + // Authoritative means the catalog is the agent's own — of ONE model. It + // omits `fast-mode` because the probed model had no fast tier, so an Agent + // Role pinning Fast keeps its value; `future_option` is simply stale and + // has no per-model excuse, so it goes. expect( resolveAcpSessionConfigSelection(inputs, { ...baseOptions, capabilityAuthority: 'authoritative', configOptionSelectors: selectors, }).configOptionValues - ).toEqual({ 'fast-mode': 'off', future_option: 'enabled' }); + ).toEqual({ approval_policy: 'never', 'fast-mode': true }); }); it('applies the runtime baseline over non-user fields', () => { @@ -324,20 +333,20 @@ describe('ACP session config derivation', () => { ], }, ]; - // Uncataloged keys survive; a cataloged one with a value the selector - // rejects does not. + // An uncataloged PER-MODEL id survives to dispatch; other uncataloged keys + // do not, and a cataloged one whose value the selector rejects does not. expect( filterAcpSessionConfigOptionValues( - { 'plan-mode': 'on', collaboration_mode: 'plan', future_option: 'enabled' }, + { 'fast-mode': true, collaboration_mode: 'plan', future_option: 'enabled' }, selectors ) - ).toEqual({ 'plan-mode': 'on', collaboration_mode: 'plan', future_option: 'enabled' }); + ).toEqual({ 'fast-mode': true, collaboration_mode: 'plan' }); expect( filterAcpSessionConfigOptionValues( { 'plan-mode': 'on', collaboration_mode: 'invalid' }, selectors ) - ).toEqual({ 'plan-mode': 'on' }); + ).toEqual({}); }); }); diff --git a/packages/shared/src/acp-run-config.ts b/packages/shared/src/acp-run-config.ts index 817b38a09..ad1f03256 100644 --- a/packages/shared/src/acp-run-config.ts +++ b/packages/shared/src/acp-run-config.ts @@ -169,29 +169,42 @@ const findFastModeOption = ( ); /** - * Wire binding for the fast toggle when the snapshot does not carry the option. + * Wire spelling per agent for the controls a snapshot can legitimately omit. * - * A binding hint answers "how would this agent spell it", never "does this - * model support it". Both builtin agents publish the toggle as a boolean while - * the client advertises boolean config options — which Lody always does — so - * the shape is known even when the captured model had no fast tier. An agent we - * have no convention for gets no guess: dispatching an invented id would be a - * silent no-op, which is worse than saying we cannot encode the request. + * A binding answers "how would THIS agent spell it", never "does this model + * support it". It is needed exactly when the captured model lacked the control, + * because then the snapshot carries no option to read the id off. There is no + * default: the ids differ per agent (Codex `reasoning_effort` / `fast-mode`, + * Claude `effort` / `fast`), so falling back to one agent's spelling would send + * another agent an id it has never heard of — a silent no-op, which is worse + * than saying the request cannot be encoded. */ -const findFastModeBindingHint = ( - capability: RunConfigCapabilitySource | undefined, - enabled: boolean -): { configId: string; value: AcpConfigOptionValue } | undefined => { - switch (capability?.agentType?.toLowerCase()) { - case 'codex': - return { configId: 'fast-mode', value: enabled }; - case 'claude': - return { configId: 'fast', value: enabled }; - default: - return undefined; - } +const AGENT_PER_MODEL_BINDINGS: Record< + string, + { fastModeConfigId?: string; reasoningEffortConfigId?: string } +> = { + codex: { fastModeConfigId: 'fast-mode', reasoningEffortConfigId: ACP_REASONING_EFFORT_CONFIG_ID }, + claude: { fastModeConfigId: 'fast', reasoningEffortConfigId: 'effort' }, + grok: { reasoningEffortConfigId: ACP_REASONING_EFFORT_CONFIG_ID }, }; +const findAgentPerModelBinding = (capability: RunConfigCapabilitySource | undefined) => + capability?.agentType ? AGENT_PER_MODEL_BINDINGS[capability.agentType.toLowerCase()] : undefined; + +/** + * Ids Lody knows name a PER-MODEL control for some agent. Such an id missing + * from a capability snapshot means the captured model lacked the control, not + * that the option is gone — so a stored value for one must survive a snapshot + * that does not list it. Any other unknown id has no such excuse. + */ +export const isAcpPerModelConfigId = (configId: string): boolean => + isAcpFastModeConfigId(configId) || + configId === ACP_REASONING_EFFORT_CONFIG_ID || + Object.values(AGENT_PER_MODEL_BINDINGS).some( + (binding) => + binding.fastModeConfigId === configId || binding.reasoningEffortConfigId === configId + ); + const findReasoningEffortOption = ( capability: RunConfigCapabilitySource | undefined ): AcpConfigOptionSummary | undefined => @@ -328,10 +341,16 @@ export const resolveAgentRunConfigSelection = ( ? targetModelEfforts.includes(selection.reasoningEffort) : !switchesModel && option?.options.some((value) => value.value === selection.reasoningEffort) === true; + const configId = option?.id ?? findAgentPerModelBinding(capability)?.reasoningEffortConfigId; + if (!configId) { + throw new Error( + 'Reasoning effort cannot be encoded for the selected agent: it publishes no reasoning effort option and Lody knows no binding for it.' + ); + } if (!confirmed) { unverifiedSelections.push(`reasoningEffort=${selection.reasoningEffort}`); } - configOptionValues[option?.id ?? ACP_REASONING_EFFORT_CONFIG_ID] = selection.reasoningEffort; + configOptionValues[configId] = selection.reasoningEffort; } if (selection.fastMode !== undefined) { @@ -340,15 +359,17 @@ export const resolveAgentRunConfigSelection = ( // decide anything. What it CAN decide is the wire shape, and when it does // not know that either the agent's own convention does. const option = findFastModeOption(capability); - const binding = option - ? { configId: option.id, value: toggleValue(option, selection.fastMode) } - : findFastModeBindingHint(capability, selection.fastMode); - if (!binding) { + const configId = option?.id ?? findAgentPerModelBinding(capability)?.fastModeConfigId; + if (!configId) { throw new Error( - 'Fast mode cannot be encoded for the selected agent: it publishes no fast mode option and no known binding for it.' + 'Fast mode cannot be encoded for the selected agent: it publishes no fast mode option and Lody knows no binding for it.' ); } - configOptionValues[binding.configId] = binding.value; + // Both builtin agents publish the toggle as a boolean while the client + // advertises boolean config options, which Lody always does. + configOptionValues[configId] = option + ? toggleValue(option, selection.fastMode) + : selection.fastMode; if (!option || switchesModel) { unverifiedSelections.push(`fastMode=${selection.fastMode}`); } diff --git a/packages/shared/tests/acp-run-config.test.ts b/packages/shared/tests/acp-run-config.test.ts index 2b3086693..ba5374f15 100644 --- a/packages/shared/tests/acp-run-config.test.ts +++ b/packages/shared/tests/acp-run-config.test.ts @@ -225,6 +225,26 @@ describe('agent run config selection', () => { ); }); + it('spells a snapshot-less control the way the target agent spells it', () => { + // Probed under a model with neither control, for each builtin agent. + const bare = (agentType: string): AcpCapabilityCacheEntry => ({ + ...codexCapability(), + agentType, + configOptions: [], + }); + + expect( + resolveAgentRunConfigSelection({ reasoningEffort: 'high', fastMode: true }, bare('codex')) + .configOptionValues + ).toEqual({ reasoning_effort: 'high', 'fast-mode': true }); + + // Claude spells both differently; Codex's ids would be a silent no-op there. + expect( + resolveAgentRunConfigSelection({ reasoningEffort: 'high', fastMode: true }, bare('claude')) + .configOptionValues + ).toEqual({ effort: 'high', fast: true }); + }); + it('reports a missing wire binding as such, not as an unsupported control', () => { // A third-party agent Lody has no fast-mode convention for. const capability: AcpCapabilityCacheEntry = { @@ -235,6 +255,9 @@ describe('agent run config selection', () => { expect(() => resolveAgentRunConfigSelection({ fastMode: true }, capability)).toThrow( /cannot be encoded/ ); + expect(() => resolveAgentRunConfigSelection({ reasoningEffort: 'high' }, capability)).toThrow( + /cannot be encoded/ + ); }); it('refuses to select anything when the agent has reported no capabilities', () => { From 82c2fbb02c03554c599b392fe4c44930cba0c440 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 17:45:26 +0800 Subject: [PATCH 08/24] fix(cli): stop a turn the agent would run with wider permission than requested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single exception to "report, never block". Everything else — model, effort, fast — still runs and warns, because the worst case is a slower or costlier turn. Running with MORE permission than was asked for is not that: by the time a notice is readable the agent may already have edited files, and a session sitting in `auto` that refuses a `plan` request keeps approving without a human. It fires only on a contradiction the agent itself establishes. `runtimeConfigPatch.modeId` is the agent's published state — it is filled from a `set_mode` acknowledgement only when the agent reports no mode of its own, in which case the two are equal and nothing fires. `isAcpPermissionWiderThanRequested` then requires BOTH modes to be ranked among the builtin ones and the effective one to be strictly wider. A snapshot, a stale cache, an unranked third-party mode, an unconfirmed request, and a NARROWER outcome all answer false. The ranks come from the semantics the repo already classifies (read-only/plan < ask < model-reviewed < accept-edits < skip-approval), not from mode names. The way out is explicit and scoped to one turn: `SessionTurnInputConfig.acceptWiderPermission` is informed acceptance carried by a single resend — never inherited, never a default — and it stands the stop down while still reporting the mismatch. The failure keeps its own reason (`permission_not_applied`) so the client can name both modes and offer that resend instead of showing a generic pre-prompt error. Ablation: removing the detection fails the escalation test; ignoring the acceptance flag fails the stand-down test. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/AGENTS.md | 13 ++ apps/cli/src/lib/message-handler.ts | 25 +++- .../acp-session-config-applier.test.ts | 131 ++++++++++++++++++ .../src/session/acp-session-config-applier.ts | 52 +++++++ .../src/session/session-execution-service.ts | 6 + locales/en.json | 1 + locales/zh_CN.json | 1 + .../components/src/components/ai-gui/view.tsx | 5 + packages/shared/src/acp-run-config.ts | 50 +++++++ packages/shared/src/ai.ts | 7 + packages/shared/src/message-schemas.ts | 3 + packages/shared/src/session-input.ts | 3 + packages/shared/tests/acp-run-config.test.ts | 23 +++ 13 files changed, 315 insertions(+), 5 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 35c817de6..575a6a294 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -312,6 +312,19 @@ Two things the dev build does deliberately, both load-bearing: differing only in warning wording and follow-up marking. A Role that would run diverged is surfaced, not refused — an upgrade must never turn a Role that used to run into one that fails. + EXCEPTION, and the only one: when the agent's own reported final state says the + turn would run with MORE permission than it asked for, the turn fails before + `prompt` (`AcpPermissionNotAppliedError` → `permission_not_applied`). By the + time a warning about that is readable the agent may already have edited files, + so this is the one divergence a notice cannot cover. It fires only on a live + contradiction: `isAcpPermissionWiderThanRequested` requires BOTH modes to be + ranked among the builtin ones and the effective one to be strictly wider, and + the effective mode is read from the agent's published state — a snapshot, a + stale cache, an unranked third-party mode, an unconfirmed request, or a + NARROWER outcome must never stop a turn. The way out is explicit and + per-turn: `SessionTurnInputConfig.acceptWiderPermission` is informed + acceptance carried by one resend, never inherited and never a default, and it + suppresses the stop while still reporting the mismatch. - MCP `session_list` defaults to 20 (maximum 100), and `session_history` defaults to 10 (maximum 50 and 128 KiB). Keep the MCP surface bounded even though the human CLI retains `session history --all`. `session_list` and `session_status_many` derive busy/idle from diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index b1f7915c4..29a30bf65 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -318,6 +318,7 @@ import { AutoPromptRunner } from '@/session/auto-prompt-runner'; import { TurnPostProcessingService } from '@/session/turn-post-processing-service'; import { applyAcpSessionRunConfig, + AcpPermissionNotAppliedError, type AcpSessionRunConfig, } from '@/session/acp-session-config-applier'; import { @@ -1721,11 +1722,12 @@ export class MessageHandler { basedOnUserTurnId?: string; } ): Promise { - const { runtimeConfigPatch, warningSelections } = await applyAcpSessionRunConfig({ - session, - config, - logger: this.logger, - }); + const { runtimeConfigPatch, warningSelections, permissionEscalation } = + await applyAcpSessionRunConfig({ + session, + config, + logger: this.logger, + }); if (runtimeConfigPatch && context.basedOnUserTurnId) { const basedOnUserTurnId = context.basedOnUserTurnId; @@ -1740,6 +1742,19 @@ export class MessageHandler { }); } + /* The one divergence that stops a turn. Everything else — model, effort, + fast — runs and reports, because the worst case is a slower or costlier + turn. Running with MORE permission than the user asked for is not that: + by the time a warning is readable the agent may already have edited + files. The turn fails here, before `prompt`, and the user re-sends with + an explicit one-time acceptance if they want it anyway. */ + if (permissionEscalation) { + throw new AcpPermissionNotAppliedError( + permissionEscalation.requestedModeId, + permissionEscalation.effectiveModeId + ); + } + if (warningSelections.length > 0) { // Not awaited: this is reporting, and the prompt hot path must not block // on a history write. diff --git a/apps/cli/src/session/acp-session-config-applier.test.ts b/apps/cli/src/session/acp-session-config-applier.test.ts index d6bcc180f..47abbc5bc 100644 --- a/apps/cli/src/session/acp-session-config-applier.test.ts +++ b/apps/cli/src/session/acp-session-config-applier.test.ts @@ -157,6 +157,137 @@ describe('applyAcpSessionRunConfig', () => { expect(result.warningSelections).toEqual([]); }); + describe('permission not applied', () => { + /** An agent whose model switch resets the permission mode to `auto`. */ + const wideningAgent = () => { + let currentMode = 'auto'; + let currentModel = 'model-a'; + return { + isCreated: () => true, + getConfigOptions: () => [ + { id: 'permission-mode', category: 'mode', type: 'select', currentValue: currentMode }, + { id: 'engine', category: 'model', type: 'select', currentValue: currentModel }, + ], + setSessionMode: vi.fn(async (_sessionId: string, mode: string) => { + currentMode = mode; + }), + unstable_setSessionModel: vi.fn(async (_sessionId: string, model: string) => { + currentModel = model; + currentMode = 'auto'; + }), + setSessionConfigOption: vi.fn(async () => undefined), + } as unknown as AgentClient; + }; + + const apply = async ( + config: Parameters[0]['config'], + agentClient: AgentClient + ) => + await applyAcpSessionRunConfig({ + session: { + sessionId: 'session-9' as SessionId, + acpSessionId: 'acp-9' as ACPSessionId, + agentClient, + }, + config, + logger: createLogger(), + }); + + it('reports the escalation when the agent ends up wider than requested', async () => { + // The agent refuses to stay in plan: `setSessionMode` "succeeds" but its + // own state says `auto`, which approves without asking a human. + const agentClient = { + isCreated: () => true, + getConfigOptions: () => [ + { id: 'permission-mode', category: 'mode', type: 'select', currentValue: 'auto' }, + ], + setSessionMode: vi.fn(async () => undefined), + setSessionConfigOption: vi.fn(async () => undefined), + } as unknown as AgentClient; + + const result = await apply( + { cliType: 'builtin', agentType: 'claude', modeId: 'plan' }, + agentClient + ); + + expect(result.permissionEscalation).toEqual({ + requestedModeId: 'plan', + effectiveModeId: 'auto', + }); + }); + + it('does not report one when the agent honors the request', async () => { + // Same widening agent, but the ordering fix means plan is applied after + // the model that would have reset it. + const result = await apply( + { cliType: 'builtin', agentType: 'claude', modeId: 'plan', modelId: 'model-b' }, + wideningAgent() + ); + + expect(result.runtimeConfigPatch?.modeId).toBe('plan'); + expect(result.permissionEscalation).toBeUndefined(); + }); + + it('does not report one on an unconfirmed, narrower, or unranked outcome', async () => { + // Nothing to compare against: the agent publishes no mode of its own, so + // the patch carries only our own acknowledgement. + const silent = { + isCreated: () => true, + getConfigOptions: () => [], + setSessionMode: vi.fn(async () => undefined), + } as unknown as AgentClient; + expect( + (await apply({ cliType: 'builtin', agentType: 'claude', modeId: 'plan' }, silent)) + .permissionEscalation + ).toBeUndefined(); + + // Narrower than requested is a functional mismatch, not an escalation. + const narrower = { + isCreated: () => true, + getConfigOptions: () => [ + { id: 'permission-mode', category: 'mode', type: 'select', currentValue: 'plan' }, + ], + setSessionMode: vi.fn(async () => undefined), + } as unknown as AgentClient; + expect( + (await apply({ cliType: 'builtin', agentType: 'claude', modeId: 'auto' }, narrower)) + .permissionEscalation + ).toBeUndefined(); + + // A third-party mode Lody does not rank can never be judged wider. + const unranked = { + isCreated: () => true, + getConfigOptions: () => [ + { id: 'permission-mode', category: 'mode', type: 'select', currentValue: 'vendor-mode' }, + ], + setSessionMode: vi.fn(async () => undefined), + } as unknown as AgentClient; + expect( + (await apply({ cliType: 'registry', agentType: 'other', modeId: 'plan' }, unranked)) + .permissionEscalation + ).toBeUndefined(); + }); + + it('stands down for a turn that carries the informed acceptance', async () => { + const agentClient = { + isCreated: () => true, + getConfigOptions: () => [ + { id: 'permission-mode', category: 'mode', type: 'select', currentValue: 'auto' }, + ], + setSessionMode: vi.fn(async () => undefined), + } as unknown as AgentClient; + + const result = await apply( + { cliType: 'builtin', agentType: 'claude', modeId: 'plan', acceptWiderPermission: true }, + agentClient + ); + + expect(result.permissionEscalation).toBeUndefined(); + // Still reported: accepting the run does not make the mismatch invisible. + expect(result.warningSelections).toEqual(['mode="plan"']); + }); + }); + it('reports a selection the agent accepted but dropped from its own state', async () => { // The codex shape: `fast-mode` is accepted without error on a model with no // fast speed tier, and simply does not come back in the published state, so diff --git a/apps/cli/src/session/acp-session-config-applier.ts b/apps/cli/src/session/acp-session-config-applier.ts index cca1fa38c..1d0d1dda4 100644 --- a/apps/cli/src/session/acp-session-config-applier.ts +++ b/apps/cli/src/session/acp-session-config-applier.ts @@ -2,6 +2,7 @@ import { ACP_CONFIG_OPTION_OFF_VALUE, ACP_CONFIG_OPTION_ON_VALUE, isAcpFastModeConfigId, + isAcpPermissionWiderThanRequested, isAcpPlanModeConfigOption, isSensitiveAcpConfigOptionId, type ACPSessionId, @@ -55,6 +56,8 @@ export type AcpSessionRunConfig = { modeId?: string; modelId?: string; configOptionValues?: Record; + /** One-time informed acceptance carried by this turn. */ + acceptWiderPermission?: boolean; }; type AcpSessionRunConfigApplyResult = { @@ -64,6 +67,13 @@ type AcpSessionRunConfigApplyResult = { warningSelections: string[]; /** Agent-confirmed state after applying the requested selections. */ runtimeConfigPatch: SessionAcpRuntimeConfigPatch | null; + /** + * The agent's own reported state says this turn would run with MORE + * permission than it asked for. Present only on that contradiction — never + * from a snapshot, never when either mode is unranked, never when the agent + * reported nothing to compare. + */ + permissionEscalation?: { requestedModeId: string; effectiveModeId: string }; }; /** A boolean toggle and an `on`/`off` select express the same choice. */ @@ -313,9 +323,51 @@ export async function applyAcpSessionRunConfig(args: { }) .map((selection) => selection.label); + /* The permission the turn asked for, against the one the agent reports after + everything has been applied. `runtimeConfigPatch.modeId` is the agent's own + state — it is only filled from a `set_mode` acknowledgement when the agent + reports no mode of its own, in which case the two are equal and nothing + fires here. So this cannot be triggered by a snapshot, by a stale cache, or + by an unconfirmed request. */ + const requestedModeId = + config.modeId ?? + (typeof configOptionEntryFor(modeConfigId) === 'string' + ? (configOptionEntryFor(modeConfigId) as string) + : undefined); + const permissionEscalation = + requestedModeId !== undefined && + !config.acceptWiderPermission && + isAcpPermissionWiderThanRequested(requestedModeId, runtimeConfigPatch.modeId) + ? { requestedModeId, effectiveModeId: runtimeConfigPatch.modeId as string } + : undefined; + if (permissionEscalation) { + logger.debug( + `[${sessionId}] Permission not applied: requested ${permissionEscalation.requestedModeId}, effective ${permissionEscalation.effectiveModeId}` + ); + } + return { rejectedSelections, warningSelections, runtimeConfigPatch, + ...(permissionEscalation ? { permissionEscalation } : {}), }; } + +/** + * The agent's own state reports a wider permission than the turn requested. + * + * Thrown before `prompt`, so the turn never runs. Carries both mode ids so the + * failure notice can name them and offer the one-time informed downgrade. + */ +export class AcpPermissionNotAppliedError extends Error { + constructor( + readonly requestedModeId: string, + readonly effectiveModeId: string + ) { + super( + `The agent did not apply the requested permission mode "${requestedModeId}" and would run with "${effectiveModeId}", which allows more than was asked for.` + ); + this.name = 'AcpPermissionNotAppliedError'; + } +} diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index c2327af57..eebb35fb0 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -88,6 +88,7 @@ import { } from '@/agent/managed-agent-runtime'; import type { FetchAcpCapabilitiesOptions } from '@/agent/acp-capabilities'; import { AcpAuthenticationRequiredError, AgentSteerNotDeliveredError } from '@/agent/agent-client'; +import { AcpPermissionNotAppliedError } from '@/session/acp-session-config-applier'; import { AcpAuthenticationManager, type AcpAuthenticationProgressEvent, @@ -1873,6 +1874,11 @@ export class SessionExecutionService { // of a generic "failed before the agent could start". if (error instanceof AcpAuthenticationRequiredError) { await this.deps.recordChatFailure(sessionDoc, 'acp_auth_required', message); + } else if (error instanceof AcpPermissionNotAppliedError) { + // Keep the specific reason: it is what lets the client name the two modes + // and offer to run once with the permission the agent actually has, + // instead of a generic "failed before the agent could start". + await this.deps.recordChatFailure(sessionDoc, 'permission_not_applied', message); } else if (isGitExecutableNotFoundError(error)) { await this.deps.recordChatFailure( sessionDoc, diff --git a/locales/en.json b/locales/en.json index 73bc12f40..75223d2e9 100644 --- a/locales/en.json +++ b/locales/en.json @@ -2118,6 +2118,7 @@ "sessions.systemNotices.chatFailed.sessionNotFound": "Session not found", "sessions.systemNotices.chatFailed.sessionRestoreFailed": "Failed to restore session", "sessions.systemNotices.chatFailed.turnPrePromptFailed": "Failed before the agent could start", + "sessions.systemNotices.chatFailed.permissionNotApplied": "The agent did not apply the requested permission mode, so the turn was stopped before it ran — resend to run with the permission the agent has", "sessions.systemNotices.chatFailed.messageDeliveryFailed": "Message delivery failed - please resend after sync recovers", "sessions.systemNotices.chatFailed.machineAccessDenied": "Machine access denied", "sessions.systemNotices.chatFailed.memoryPressure": "The machine is low on memory - free some memory and retry", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 8eb5cc241..c9b9765f1 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -2118,6 +2118,7 @@ "sessions.systemNotices.chatFailed.sessionNotFound": "会话不存在", "sessions.systemNotices.chatFailed.sessionRestoreFailed": "会话恢复失败", "sessions.systemNotices.chatFailed.turnPrePromptFailed": "Agent 启动前失败", + "sessions.systemNotices.chatFailed.permissionNotApplied": "该 Agent 未应用请求的权限模式,本轮已在执行前停止——重新发送即可按该 Agent 实际拥有的权限运行", "sessions.systemNotices.chatFailed.messageDeliveryFailed": "消息送达失败 - 请在同步恢复后重发", "sessions.systemNotices.chatFailed.machineAccessDenied": "机器访问被拒绝", "sessions.systemNotices.chatFailed.memoryPressure": "机器内存不足 - 请释放内存后重试", diff --git a/packages/components/src/components/ai-gui/view.tsx b/packages/components/src/components/ai-gui/view.tsx index 795dac302..c2a143a48 100644 --- a/packages/components/src/components/ai-gui/view.tsx +++ b/packages/components/src/components/ai-gui/view.tsx @@ -2292,6 +2292,11 @@ const ChatFailedNoticeView = ({ 'sessions.systemNotices.chatFailed.turnPrePromptFailed', 'Failed before the agent could start' ); + case 'permission_not_applied': + return t( + 'sessions.systemNotices.chatFailed.permissionNotApplied', + 'The agent did not apply the requested permission mode, so the turn was stopped before it ran — resend to run with the permission the agent has' + ); case 'message_delivery_failed': return t( 'sessions.systemNotices.chatFailed.messageDeliveryFailed', diff --git a/packages/shared/src/acp-run-config.ts b/packages/shared/src/acp-run-config.ts index ad1f03256..404304ada 100644 --- a/packages/shared/src/acp-run-config.ts +++ b/packages/shared/src/acp-run-config.ts @@ -53,6 +53,56 @@ export const isAcpPlanModeConfigOption = (option: ConfigOptionIdentity): boolean option.id === ACP_COLLABORATION_MODE_CONFIG_ID || option.category === ACP_COLLABORATION_MODE_CONFIG_ID; +/** + * How much a permission mode lets the agent do without asking a human, for the + * builtin modes Lody adapts. Higher is wider. + * + * Deliberately partial: an id not listed here — a third-party or newly added + * mode — has NO rank, and an unranked mode can never be judged wider than + * another. Blocking a turn on a guess about an unknown mode would be the same + * mistake as blocking it on a stale snapshot. + */ +const ACP_PERMISSION_MODE_RANKS: Record = { + // Cannot modify anything. + 'read-only': 0, + plan: 0, + // Asks a human before acting. + agent: 1, + default: 1, + // Routes approval to a reviewing model instead of a human. + 'agent-auto-review': 2, + auto: 2, + // Auto-approves edits. + acceptEdits: 3, + // Skips approval entirely. + dontAsk: 4, + bypassPermissions: 4, + 'agent-full-access': 4, + 'danger-full-access': 4, + yolo: 4, + 'always-approve': 4, +}; + +export const findAcpPermissionModeRank = (modeId: string | null | undefined): number | undefined => + typeof modeId === 'string' ? ACP_PERMISSION_MODE_RANKS[modeId] : undefined; + +/** + * Whether the agent ended up with MORE permission than the turn asked for. + * + * Both sides must be ranked and the effective one must be strictly wider. Equal, + * narrower, unranked, or unknown all answer `false`: this decides whether to + * stop a turn before it runs, so it may only fire on a contradiction the agent's + * own reported state establishes. + */ +export const isAcpPermissionWiderThanRequested = ( + requestedModeId: string | null | undefined, + effectiveModeId: string | null | undefined +): boolean => { + const requested = findAcpPermissionModeRank(requestedModeId); + const effective = findAcpPermissionModeRank(effectiveModeId); + return requested !== undefined && effective !== undefined && effective > requested; +}; + /** Semantic run-config selection, independent of any agent's option ids. */ export type AgentRunConfigSelection = { modelId?: string; diff --git a/packages/shared/src/ai.ts b/packages/shared/src/ai.ts index 960b9bf4b..c2336f4ab 100644 --- a/packages/shared/src/ai.ts +++ b/packages/shared/src/ai.ts @@ -1117,6 +1117,7 @@ export type ChatFailedReason = // HTTP 400), which would otherwise be recorded as an ordinary completion. | 'agent_no_output' | 'turn_pre_prompt_failed' + | 'permission_not_applied' | 'message_delivery_failed' | 'machine_access_denied' // requester is not authorized to use this machine (definitive backend deny) // ACP RPC errors (from @agentclientprotocol/sdk) @@ -1594,6 +1595,12 @@ export type ACPSessionConfig = { mcpServerIds?: McpServerId[]; /** Whether the built-in Lody Task MCP tools are available to this Turn's Agent session. */ taskToolsEnabled?: boolean; + /** + * One-time informed acceptance: the user was told the agent would run with a + * wider permission than requested and chose to run anyway. Scoped to the turn + * that carries it — never inherited, never a default. + */ + acceptWiderPermission?: boolean; /** * Agent Role identity selected in the composer for this Turn. Null is an * explicit None selection; absence is legacy/unknown. This is provenance for diff --git a/packages/shared/src/message-schemas.ts b/packages/shared/src/message-schemas.ts index 78e4b1b13..c9b88f921 100644 --- a/packages/shared/src/message-schemas.ts +++ b/packages/shared/src/message-schemas.ts @@ -367,6 +367,7 @@ export const ACPSessionConfigSchema = z configOptionValues: AcpConfigOptionValuesSchema.optional(), mcpServerIds: z.array(z.string()).optional(), taskToolsEnabled: z.boolean().optional(), + acceptWiderPermission: z.boolean().optional(), agentRoleId: z.string().trim().min(1).nullable().optional(), agentRoleRevision: z.number().int().nonnegative().optional(), issuePRMentions: z.array(IssuePRMentionSchema).optional(), @@ -388,6 +389,7 @@ const LooseSessionTurnInputConfigSchema = z configOptionValues: AcpConfigOptionValuesSchema.optional(), mcpServerIds: z.array(z.string()).optional(), taskToolsEnabled: z.boolean().optional(), + acceptWiderPermission: z.boolean().optional(), agentRoleId: z.string().trim().min(1).nullable().optional(), agentRoleRevision: z.number().int().nonnegative().optional(), issuePRMentions: z.array(IssuePRMentionSchema).optional(), @@ -3070,6 +3072,7 @@ export const ChatFailedReasonSchema = z.enum([ 'agent_disconnected', 'agent_no_output', 'turn_pre_prompt_failed', + 'permission_not_applied', 'message_delivery_failed', 'machine_access_denied', 'acp_auth_required', diff --git a/packages/shared/src/session-input.ts b/packages/shared/src/session-input.ts index e98d9abc5..1054eec8c 100644 --- a/packages/shared/src/session-input.ts +++ b/packages/shared/src/session-input.ts @@ -628,6 +628,8 @@ export const buildSessionTurnInputConfig = (args: { configOptionValues?: Record | null; mcpServerIds?: readonly McpServerId[] | null; taskToolsEnabled?: boolean; + /** One-time informed acceptance of a wider permission, for THIS turn only. */ + acceptWiderPermission?: boolean; agentRoleId?: AgentRoleId | null; agentRoleRevision?: number; issuePRMentions?: IssuePRMention[]; @@ -648,6 +650,7 @@ export const buildSessionTurnInputConfig = (args: { ? args.configOptionValues : undefined, mcpServerIds: args.mcpServerIds ? [...args.mcpServerIds] : undefined, + ...(args.acceptWiderPermission === true ? { acceptWiderPermission: true } : {}), ...(args.taskToolsEnabled !== undefined ? { taskToolsEnabled: args.taskToolsEnabled === true } : {}), diff --git a/packages/shared/tests/acp-run-config.test.ts b/packages/shared/tests/acp-run-config.test.ts index ba5374f15..71bef8208 100644 --- a/packages/shared/tests/acp-run-config.test.ts +++ b/packages/shared/tests/acp-run-config.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { deriveModelReasoningEffortsFromLegacyModelIds, + isAcpPermissionWiderThanRequested, resolveAgentRunConfigSelection, summarizeAgentRunConfigCapabilities, type AcpCapabilityCacheEntry, @@ -402,3 +403,25 @@ describe('agent run config selection', () => { expect(resolveAgentRunConfigSelection({ planMode: true }, legacy)).toEqual({ modeId: 'plan' }); }); }); + +describe('permission width', () => { + it('answers only on a ranked, strictly wider outcome', () => { + // Wider: the effective mode acts with less human involvement. + expect(isAcpPermissionWiderThanRequested('plan', 'auto')).toBe(true); + expect(isAcpPermissionWiderThanRequested('plan', 'default')).toBe(true); + expect(isAcpPermissionWiderThanRequested('default', 'bypassPermissions')).toBe(true); + expect(isAcpPermissionWiderThanRequested('read-only', 'agent-full-access')).toBe(true); + + // Equal or narrower is a functional mismatch, not an escalation. + expect(isAcpPermissionWiderThanRequested('plan', 'plan')).toBe(false); + expect(isAcpPermissionWiderThanRequested('auto', 'plan')).toBe(false); + expect(isAcpPermissionWiderThanRequested('agent-full-access', 'agent')).toBe(false); + + // Unranked or absent on either side answers false: a turn may not be + // stopped on a guess about a mode Lody does not adapt. + expect(isAcpPermissionWiderThanRequested('plan', 'vendor-mode')).toBe(false); + expect(isAcpPermissionWiderThanRequested('vendor-mode', 'agent-full-access')).toBe(false); + expect(isAcpPermissionWiderThanRequested('plan', undefined)).toBe(false); + expect(isAcpPermissionWiderThanRequested(undefined, 'agent-full-access')).toBe(false); + }); +}); From 9f0f61a3227051a628d5aec248d19e346e879f39 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 17:46:37 +0800 Subject: [PATCH 09/24] fix(components): do not promise a resend path that does not exist yet The permission_not_applied notice told the user to resend to run with the agent Permission it has, but a plain resend carries no acceptWiderPermission flag and fails again. State what happened; the affordance that sets the flag is a follow-up. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- locales/en.json | 2 +- locales/zh_CN.json | 2 +- packages/components/src/components/ai-gui/view.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/locales/en.json b/locales/en.json index 75223d2e9..1f38abdd2 100644 --- a/locales/en.json +++ b/locales/en.json @@ -2118,7 +2118,7 @@ "sessions.systemNotices.chatFailed.sessionNotFound": "Session not found", "sessions.systemNotices.chatFailed.sessionRestoreFailed": "Failed to restore session", "sessions.systemNotices.chatFailed.turnPrePromptFailed": "Failed before the agent could start", - "sessions.systemNotices.chatFailed.permissionNotApplied": "The agent did not apply the requested permission mode, so the turn was stopped before it ran — resend to run with the permission the agent has", + "sessions.systemNotices.chatFailed.permissionNotApplied": "The agent did not apply the requested permission mode, so the turn was stopped before it ran", "sessions.systemNotices.chatFailed.messageDeliveryFailed": "Message delivery failed - please resend after sync recovers", "sessions.systemNotices.chatFailed.machineAccessDenied": "Machine access denied", "sessions.systemNotices.chatFailed.memoryPressure": "The machine is low on memory - free some memory and retry", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index c9b9765f1..a212765b9 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -2118,7 +2118,7 @@ "sessions.systemNotices.chatFailed.sessionNotFound": "会话不存在", "sessions.systemNotices.chatFailed.sessionRestoreFailed": "会话恢复失败", "sessions.systemNotices.chatFailed.turnPrePromptFailed": "Agent 启动前失败", - "sessions.systemNotices.chatFailed.permissionNotApplied": "该 Agent 未应用请求的权限模式,本轮已在执行前停止——重新发送即可按该 Agent 实际拥有的权限运行", + "sessions.systemNotices.chatFailed.permissionNotApplied": "该 Agent 未应用请求的权限模式,本轮已在执行前停止", "sessions.systemNotices.chatFailed.messageDeliveryFailed": "消息送达失败 - 请在同步恢复后重发", "sessions.systemNotices.chatFailed.machineAccessDenied": "机器访问被拒绝", "sessions.systemNotices.chatFailed.memoryPressure": "机器内存不足 - 请释放内存后重试", diff --git a/packages/components/src/components/ai-gui/view.tsx b/packages/components/src/components/ai-gui/view.tsx index c2a143a48..c37ec84b4 100644 --- a/packages/components/src/components/ai-gui/view.tsx +++ b/packages/components/src/components/ai-gui/view.tsx @@ -2295,7 +2295,7 @@ const ChatFailedNoticeView = ({ case 'permission_not_applied': return t( 'sessions.systemNotices.chatFailed.permissionNotApplied', - 'The agent did not apply the requested permission mode, so the turn was stopped before it ran — resend to run with the permission the agent has' + 'The agent did not apply the requested permission mode, so the turn was stopped before it ran' ); case 'message_delivery_failed': return t( From 7475c1aeac08587e4759fe77479d8a0c75684493 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 18:18:45 +0800 Subject: [PATCH 10/24] feat(components): offer to run a stopped turn with the permission the agent has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stop that PR #333 added had no way out from the UI: the notice said the turn was stopped and a plain resend would be stopped again, because it carries no acceptance. The failure notice now offers the decision the stop exists to ask for. The action replays THAT turn — the prompt, mode, model, config option values and Role frozen in its own `inputConfig`, never the composer's current selection, which would pair the old prompt with settings the user has since changed. `acceptWiderPermission` rides only this dispatch: it is written into the new turn's input config and into nothing that outlives it, so the next ordinary send asks for the original permission again. The mismatch is still reported on the turn that runs. Both permissions are named, in the notice and on the button. "Run anyway" would hide which one the turn is about to run with, and that is the only thing the user is being asked to decide. `useOneShotAction` closes the double-click window before the first await — `pending` drives the disabled attribute, but React has not re-rendered when the second click lands — and always clears, so a failed attempt leaves the action usable rather than dead on the one turn being recovered. A newer user message supersedes the offer entirely: replaying then would inject the old turn behind whatever was just sent. Ablation: removing the in-flight guard fails the double-click test, removing the supersede rule fails the newer-message test, and defaulting the flag on fails the ordinary-send test. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/src/lib/message-handler.ts | 9 +- .../src/session/session-execution-service.ts | 15 +- locales/en.json | 4 + locales/zh_CN.json | 4 + .../src/components/ai-gui/index.tsx | 8 + .../components/src/components/ai-gui/view.tsx | 59 +++++- .../sessions/session-chat-interface.tsx | 108 ++++++++++- .../src/lib/permission-not-applied-retry.ts | 175 +++++++++++++++++ .../permission-not-applied-retry.test.tsx | 178 ++++++++++++++++++ packages/shared/src/ai.ts | 6 + packages/shared/src/message-schemas.ts | 6 + packages/shared/tests/session-input.test.ts | 24 +++ 12 files changed, 580 insertions(+), 16 deletions(-) create mode 100644 packages/components/src/lib/permission-not-applied-retry.ts create mode 100644 packages/components/tests/permission-not-applied-retry.test.tsx diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index 29a30bf65..9fee14978 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -63,6 +63,7 @@ import { type SessionLegacyMetaFields, PERMISSION_REQUEST_TIMEOUT_MS, type ChatFailedCode, + type ChatFailedMeta, type ChatFailedReason, type ProjectRef, type SessionPreparationCancelSpec, @@ -1680,7 +1681,8 @@ export class MessageHandler { sessionDoc: SessionDocument, reason: ChatFailedReason, message?: string, - code?: ChatFailedCode + code?: ChatFailedCode, + permission?: ChatFailedMeta['permission'] ): Promise { // Failure notices append to the history list; order them after the user // turn entry for RPC fast-path turns (no-op when no gate is pending). @@ -1693,6 +1695,7 @@ export class MessageHandler { meta: { reason, ...(code ? { code } : {}), + ...(permission ? { permission } : {}), message, }, }; @@ -3134,8 +3137,8 @@ export class MessageHandler { notifySessionCompleted: async (sessionId, userId, occurrenceId) => await this.notifySessionCompleted(sessionId, userId, occurrenceId), }, - recordChatFailure: async (sessionDoc, reason, message, code) => - await this.recordChatFailure(sessionDoc, reason, message, code), + recordChatFailure: async (sessionDoc, reason, message, code, permission) => + await this.recordChatFailure(sessionDoc, reason, message, code, permission), maybeGenerateAndStoreSessionTitle: async ( sessionId, cliType, diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index eebb35fb0..30a715b4b 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -4,6 +4,7 @@ import { type AgentConfigCliType, type AgentConfigMeta, type ChatFailedCode, + type ChatFailedMeta, type ChatFailedReason, type IssuePRMention, type LocalProjectId, @@ -456,7 +457,8 @@ export type SessionExecutionServiceDeps = { sessionDoc: SessionDocument, reason: ChatFailedReason, message?: string, - code?: ChatFailedCode + code?: ChatFailedCode, + permission?: ChatFailedMeta['permission'] ) => Promise; maybeGenerateAndStoreSessionTitle: ( sessionId: SessionId, @@ -1875,10 +1877,13 @@ export class SessionExecutionService { if (error instanceof AcpAuthenticationRequiredError) { await this.deps.recordChatFailure(sessionDoc, 'acp_auth_required', message); } else if (error instanceof AcpPermissionNotAppliedError) { - // Keep the specific reason: it is what lets the client name the two modes - // and offer to run once with the permission the agent actually has, - // instead of a generic "failed before the agent could start". - await this.deps.recordChatFailure(sessionDoc, 'permission_not_applied', message); + // Keep the specific reason AND both mode ids: they are what let the client + // name the two permissions and offer to run this exact turn once with the + // one the agent actually has, instead of a generic pre-prompt error. + await this.deps.recordChatFailure(sessionDoc, 'permission_not_applied', message, undefined, { + requestedModeId: error.requestedModeId, + effectiveModeId: error.effectiveModeId, + }); } else if (isGitExecutableNotFoundError(error)) { await this.deps.recordChatFailure( sessionDoc, diff --git a/locales/en.json b/locales/en.json index 1f38abdd2..ee25e6cff 100644 --- a/locales/en.json +++ b/locales/en.json @@ -2119,6 +2119,10 @@ "sessions.systemNotices.chatFailed.sessionRestoreFailed": "Failed to restore session", "sessions.systemNotices.chatFailed.turnPrePromptFailed": "Failed before the agent could start", "sessions.systemNotices.chatFailed.permissionNotApplied": "The agent did not apply the requested permission mode, so the turn was stopped before it ran", + "sessions.systemNotices.chatFailed.permissionNotAppliedDetail": "Stopped before running: this turn asked for \"{{requested}}\" but the agent reported \"{{effective}}\", which allows more", + "sessions.systemNotices.chatFailed.permissionRunFailed": "Failed to start the turn", + "sessions.systemNotices.chatFailed.permissionRunOnce": "Run once with \"{{effective}}\"", + "sessions.systemNotices.chatFailed.permissionRunning": "Starting…", "sessions.systemNotices.chatFailed.messageDeliveryFailed": "Message delivery failed - please resend after sync recovers", "sessions.systemNotices.chatFailed.machineAccessDenied": "Machine access denied", "sessions.systemNotices.chatFailed.memoryPressure": "The machine is low on memory - free some memory and retry", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index a212765b9..5e944b48a 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -2119,6 +2119,10 @@ "sessions.systemNotices.chatFailed.sessionRestoreFailed": "会话恢复失败", "sessions.systemNotices.chatFailed.turnPrePromptFailed": "Agent 启动前失败", "sessions.systemNotices.chatFailed.permissionNotApplied": "该 Agent 未应用请求的权限模式,本轮已在执行前停止", + "sessions.systemNotices.chatFailed.permissionNotAppliedDetail": "已在执行前停止:本轮请求 \"{{requested}}\",但该 Agent 报告的是 \"{{effective}}\",权限更宽", + "sessions.systemNotices.chatFailed.permissionRunFailed": "无法启动本轮", + "sessions.systemNotices.chatFailed.permissionRunOnce": "以 \"{{effective}}\" 运行一次", + "sessions.systemNotices.chatFailed.permissionRunning": "正在启动…", "sessions.systemNotices.chatFailed.messageDeliveryFailed": "消息送达失败 - 请在同步恢复后重发", "sessions.systemNotices.chatFailed.machineAccessDenied": "机器访问被拒绝", "sessions.systemNotices.chatFailed.memoryPressure": "机器内存不足 - 请释放内存后重试", diff --git a/packages/components/src/components/ai-gui/index.tsx b/packages/components/src/components/ai-gui/index.tsx index 1cdf37e9c..89e0b75d2 100644 --- a/packages/components/src/components/ai-gui/index.tsx +++ b/packages/components/src/components/ai-gui/index.tsx @@ -1,3 +1,4 @@ +import type { PermissionRetryControl } from '@/lib/permission-not-applied-retry'; import { forwardRef, memo, @@ -99,6 +100,7 @@ export interface SessionChatStreamProps { /** Resends an undelivered (missing-history-acked) user turn's content as a * NEW message; the row's "Not delivered" label opens the confirmation dialog. */ onResendUndelivered?: (userTurnId: string, inputBlocks: SessionInputBlock[]) => Promise; + permissionRetry?: PermissionRetryControl; /** Bounded continuation control for the latest provider-capacity failure. */ capacityRetry?: CapacityRetryControl; forkingAssistantMessageId?: string | null; @@ -120,6 +122,7 @@ const MessageRowConnected = memo(function MessageRowConnected({ onNavigateSession, onEditLastUser, onResendUndelivered, + permissionRetry, capacityRetry, conversationFontSize, }: { @@ -131,6 +134,7 @@ const MessageRowConnected = memo(function MessageRowConnected({ /** Resends an undelivered (missing-history-acked) user turn's content as a * NEW message; the row's "Not delivered" label opens the confirmation dialog. */ onResendUndelivered?: (userTurnId: string, inputBlocks: SessionInputBlock[]) => Promise; + permissionRetry?: PermissionRetryControl; capacityRetry?: CapacityRetryControl; conversationFontSize: ConversationFontSize; }) { @@ -147,6 +151,7 @@ const MessageRowConnected = memo(function MessageRowConnected({ onNavigateSession={onNavigateSession} onEdit={onEditLastUser} onResendUndelivered={onResendUndelivered} + permissionRetry={permissionRetry} capacityRetry={capacityRetry} conversationFontSize={conversationFontSize} /> @@ -181,6 +186,7 @@ const SessionChatStreamImpl = forwardRef ); @@ -259,6 +266,7 @@ const SessionChatStreamImpl = forwardRef Promise; onResendUndelivered?: (userTurnId: string, inputBlocks: SessionInputBlock[]) => Promise; capacityRetry?: CapacityRetryControl; + permissionRetry?: PermissionRetryControl; user?: SessionChatUser; conversationFontSize?: ConversationFontSize; }) { @@ -1843,6 +1846,7 @@ export const MessageRowView = memo(function MessageRowView({ sessionId={sessionId} onNavigateSession={onNavigateSession} capacityRetry={capacityRetry} + permissionRetry={permissionRetry} /> ); } @@ -1876,11 +1880,13 @@ const SystemMessageRowView = ({ sessionId, onNavigateSession, capacityRetry, + permissionRetry, }: { message: SessionHistoryParsed; sessionId: SessionId; onNavigateSession?: (target: SessionNavigationTarget) => void; capacityRetry?: CapacityRetryControl; + permissionRetry?: PermissionRetryControl; }) => { const tasksEnabled = useAtomValue(tasksFeatureEnabledAtom); const systemItems = message.items.flatMap((item, itemIndex) => @@ -1909,6 +1915,7 @@ const SystemMessageRowView = ({ sessionId={sessionId} onNavigateSession={onNavigateSession} capacityRetry={capacityRetry} + permissionRetry={permissionRetry} /> ) : item.type === 'worktree_script' ? ( ; sessionId: SessionId; onNavigateSession?: (target: SessionNavigationTarget) => void; capacityRetry?: CapacityRetryControl; + permissionRetry?: PermissionRetryControl; }) => { const { t } = useTranslation(); switch (notice.name) { case 'chat_failed': return ( - + ); case 'agent_warning': return ; @@ -2213,10 +2227,12 @@ const ChatFailedNoticeView = ({ notice, sessionId, capacityRetry, + permissionRetry, }: { notice: Extract; sessionId: SessionId; capacityRetry?: CapacityRetryControl; + permissionRetry?: PermissionRetryControl; }) => { const { t } = useTranslation(); const sessionMeta = useAtomValue(sessionMetaAtomFamily(getSessionRoomId(sessionId))); @@ -2292,11 +2308,21 @@ const ChatFailedNoticeView = ({ 'sessions.systemNotices.chatFailed.turnPrePromptFailed', 'Failed before the agent could start' ); - case 'permission_not_applied': - return t( - 'sessions.systemNotices.chatFailed.permissionNotApplied', - 'The agent did not apply the requested permission mode, so the turn was stopped before it ran' - ); + case 'permission_not_applied': { + const permission = ( + notice.meta as { permission?: { requestedModeId?: string; effectiveModeId?: string } } + )?.permission; + return permission?.requestedModeId && permission.effectiveModeId + ? t( + 'sessions.systemNotices.chatFailed.permissionNotAppliedDetail', + 'Stopped before running: this turn asked for "{{requested}}" but the agent reported "{{effective}}", which allows more', + { requested: permission.requestedModeId, effective: permission.effectiveModeId } + ) + : t( + 'sessions.systemNotices.chatFailed.permissionNotApplied', + 'The agent did not apply the requested permission mode, so the turn was stopped before it ran' + ); + } case 'message_delivery_failed': return t( 'sessions.systemNotices.chatFailed.messageDeliveryFailed', @@ -2464,6 +2490,26 @@ const ChatFailedNoticeView = ({ ) : null; + /* Naming both permissions is the point: "run anyway" would hide which one the + turn is about to run with. The ids are what the agent and the composer both + use, so they are what the user can match against. */ + const permissionAction = permissionRetry ? ( + + ) : null; + return (
{/* Tapping the notice opens a modal instead of a hover tooltip: a tooltip @@ -2483,6 +2529,7 @@ const ChatFailedNoticeView = ({
{noticeBody}
)} {retryAction} + {permissionAction}
{hasDetail ? ( ; /** Role identity frozen beside this Turn's run config; null is explicit None. */ agentRole?: SessionTurnAgentRoleSelection; + /** + * Informed acceptance of a permission the agent reported as wider than the + * one this turn asks for. Rides the single turn it is passed with: it is + * written into that turn's input config and nowhere else — not the composer, + * not the Session, not a Role, not a user default. + */ + acceptWiderPermission?: boolean; }; function buildEditedMessageQueueItem( @@ -3535,6 +3547,8 @@ export const SessionChatInterface = memo( modelIdOverride?: string | null; configOptionValuesOverride?: Record; agentRole?: SessionTurnAgentRoleSelection; + /** One-time informed acceptance, written into this turn only. */ + acceptWiderPermission?: boolean; } ): Promise => { try { @@ -3561,6 +3575,7 @@ export const SessionChatInterface = memo( agentRoleId: options?.agentRole?.agentRoleId ?? (options?.agentRole === null ? null : undefined), agentRoleRevision: options?.agentRole?.agentRoleRevision, + ...(options?.acceptWiderPermission === true ? { acceptWiderPermission: true } : {}), resume: session.acpSessionId ?? undefined, }); @@ -3675,7 +3690,11 @@ export const SessionChatInterface = memo( inputBlocks: SessionInputBlock[], options?: Pick< DispatchInputBlocksOptions, - 'modeIdOverride' | 'modelIdOverride' | 'configOptionValuesOverride' | 'agentRole' + | 'modeIdOverride' + | 'modelIdOverride' + | 'configOptionValuesOverride' + | 'agentRole' + | 'acceptWiderPermission' > ): Promise => { try { @@ -3772,7 +3791,11 @@ export const SessionChatInterface = memo( inputBlocks: SessionInputBlock[], options?: Pick< DispatchInputBlocksOptions, - 'modeIdOverride' | 'modelIdOverride' | 'configOptionValuesOverride' | 'agentRole' + | 'modeIdOverride' + | 'modelIdOverride' + | 'configOptionValuesOverride' + | 'agentRole' + | 'acceptWiderPermission' > ): Promise => { const turnConfigOptionValues = options?.configOptionValuesOverride ?? configOptionValues; @@ -3783,6 +3806,7 @@ export const SessionChatInterface = memo( modelIdOverride: options?.modelIdOverride, configOptionValuesOverride: turnConfigOptionValues, agentRole: options?.agentRole, + ...(options?.acceptWiderPermission === true ? { acceptWiderPermission: true } : {}), }); }, [configOptionValues, enqueueInputBlocks] @@ -3869,6 +3893,7 @@ export const SessionChatInterface = memo( modelIdOverride: turnModelId, configOptionValuesOverride: turnConfigOptionValues, agentRole: options?.agentRole, + ...(options?.acceptWiderPermission === true ? { acceptWiderPermission: true } : {}), }); captureSessionEvent( accepted ? 'session/message_guide_requested' : 'session/message_submit_failed', @@ -3981,6 +4006,84 @@ export const SessionChatInterface = memo( ), }); + /* The daemon stopped a turn because the agent reported a wider permission + than it asked for. The offer replays THAT turn — its frozen prompt, mode, + model, config values and Role — with a one-time acceptance, never the + composer's current selection. The flag rides this dispatch only: it is + written into the new turn's input config and into nothing that outlives + it, so the next ordinary send carries no acceptance at all. */ + const permissionRetryTarget = useMemo( + () => findPermissionNotAppliedRetryTarget(sessionDoc?.history), + [sessionDoc?.history] + ); + const handleRunWithAgentPermission = useCallback(async () => { + const target = permissionRetryTarget; + if (!target) { + return; + } + try { + const accepted = await dispatchInputBlocks(target.inputBlocks, { + modeIdOverride: target.modeId ?? null, + modelIdOverride: target.modelId ?? null, + configOptionValuesOverride: target.configOptionValues ?? {}, + agentRole: + typeof target.agentRoleId === 'string' && target.agentRoleRevision !== undefined + ? { agentRoleId: target.agentRoleId, agentRoleRevision: target.agentRoleRevision } + : target.agentRoleId === null + ? null + : undefined, + acceptWiderPermission: true, + }); + if (!accepted) { + toast.error( + t('sessions.systemNotices.chatFailed.permissionRunFailed', 'Failed to start the turn') + ); + } + } catch (error) { + console.warn('Failed to rerun the turn with the agent permission', { + sessionId: session.id, + userTurnId: target.userTurnId, + error, + }); + toast.error( + t('sessions.systemNotices.chatFailed.permissionRunFailed', 'Failed to start the turn') + ); + } + }, [dispatchInputBlocks, permissionRetryTarget, session.id, t]); + + const { pending: permissionRetryPending, run: runWithAgentPermission } = useOneShotAction( + handleRunWithAgentPermission + ); + + const permissionRetry: PermissionRetryControl | null = useMemo( + () => + permissionRetryTarget + ? { + noticeId: permissionRetryTarget.noticeId, + requestedModeId: permissionRetryTarget.requestedModeId, + effectiveModeId: permissionRetryTarget.effectiveModeId, + pending: permissionRetryPending, + canRetry: + sessionDocReady && + !isAgentBusy && + !isMachineRemoved && + !isArchivedSession && + !isExternalHistoryRefreshing, + retry: runWithAgentPermission, + } + : null, + [ + runWithAgentPermission, + isAgentBusy, + isArchivedSession, + isExternalHistoryRefreshing, + isMachineRemoved, + permissionRetryPending, + permissionRetryTarget, + sessionDocReady, + ] + ); + // Resend a user turn the missing-history recovery negatively acknowledged: // the row's "Not delivered" label opens a confirmation dialog that calls // this with the turn's exact content. It rides the ordinary send path as a @@ -5856,6 +5959,7 @@ export const SessionChatInterface = memo( } onResendUndelivered={handleResendUndelivered} capacityRetry={capacityRetry ?? undefined} + permissionRetry={permissionRetry ?? undefined} forkingAssistantMessageId={forkingAssistantMessageId} onNavigateSession={onNavigateSession} onLastCompletedAssistantMessageIdChange={ diff --git a/packages/components/src/lib/permission-not-applied-retry.ts b/packages/components/src/lib/permission-not-applied-retry.ts new file mode 100644 index 000000000..6df152604 --- /dev/null +++ b/packages/components/src/lib/permission-not-applied-retry.ts @@ -0,0 +1,175 @@ +import { useCallback, useRef, useState } from 'react'; +import type { AcpConfigOptionValue, AgentRoleId, SessionInputBlock } from '@lody/shared'; + +/** + * Recovering a turn the daemon stopped because the agent reported a permission + * wider than the turn asked for. + * + * The offer must replay THAT turn: its own prompt, mode, model and config + * option values, frozen in its `inputConfig`. Reading the composer instead + * would pair the old prompt with whatever the user has since selected, which is + * a different run than the one that was stopped — and the whole point of the + * stop was that the user gets to decide about this exact one. + */ +export type PermissionNotAppliedRetryTarget = { + /** History entry id of the failure notice, for matching the render site. */ + noticeId: string; + /** The permission the stopped turn asked for. */ + requestedModeId: string; + /** The wider one the agent reported. */ + effectiveModeId: string; + userTurnId: string; + inputBlocks: SessionInputBlock[]; + modeId?: string; + modelId?: string; + configOptionValues?: Record; + agentRoleId?: AgentRoleId | null; + agentRoleRevision?: number; +}; + +type RetryHistoryItem = { type?: string; name?: string; meta?: unknown } | null | undefined; + +type RetryHistoryEntry = { + id: string; + role?: string; + items?: readonly RetryHistoryItem[]; + inputConfig?: unknown; +}; + +const readPermissionMeta = ( + item: RetryHistoryItem +): { requestedModeId: string; effectiveModeId: string } | null => { + if (item?.type !== 'system_notice' || item.name !== 'chat_failed') { + return null; + } + const meta = item.meta as + | { reason?: unknown; permission?: { requestedModeId?: unknown; effectiveModeId?: unknown } } + | undefined; + if (meta?.reason !== 'permission_not_applied') { + return null; + } + const requestedModeId = meta.permission?.requestedModeId; + const effectiveModeId = meta.permission?.effectiveModeId; + return typeof requestedModeId === 'string' && typeof effectiveModeId === 'string' + ? { requestedModeId, effectiveModeId } + : null; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * The newest stopped turn still awaiting a decision, or null. + * + * A user entry newer than the notice supersedes it: the user moved on, and + * replaying the old turn would inject it out of order behind whatever they + * sent. The same rule the capacity retry uses. + */ +export const findPermissionNotAppliedRetryTarget = ( + history: readonly RetryHistoryEntry[] | null | undefined +): PermissionNotAppliedRetryTarget | null => { + if (!history) { + return null; + } + let noticeIndex = -1; + let permission: { requestedModeId: string; effectiveModeId: string } | null = null; + for (let index = history.length - 1; index >= 0; index -= 1) { + const entry = history[index]; + if (!entry) continue; + if (entry.role === 'user') { + return null; + } + const found = entry.items?.map(readPermissionMeta).find((value) => value !== null) ?? null; + if (found) { + noticeIndex = index; + permission = found; + break; + } + } + if (noticeIndex < 0 || !permission) { + return null; + } + + for (let index = noticeIndex - 1; index >= 0; index -= 1) { + const entry = history[index]; + if (!entry || entry.role !== 'user') continue; + const inputConfig = isRecord(entry.inputConfig) ? entry.inputConfig : undefined; + const inputBlocks = Array.isArray(inputConfig?.inputBlocks) + ? (inputConfig.inputBlocks as SessionInputBlock[]) + : []; + if (inputBlocks.length === 0) { + // Without the frozen blocks there is nothing to replay faithfully, and + // reconstructing from the rendered items would risk sending something + // other than what that turn ran. + return null; + } + return { + noticeId: history[noticeIndex]?.id ?? '', + ...permission, + userTurnId: entry.id, + inputBlocks, + ...(typeof inputConfig?.modeId === 'string' ? { modeId: inputConfig.modeId } : {}), + ...(typeof inputConfig?.modelId === 'string' ? { modelId: inputConfig.modelId } : {}), + ...(isRecord(inputConfig?.configOptionValues) + ? { + configOptionValues: inputConfig.configOptionValues as Record< + string, + AcpConfigOptionValue + >, + } + : {}), + ...(typeof inputConfig?.agentRoleId === 'string' || inputConfig?.agentRoleId === null + ? { agentRoleId: inputConfig.agentRoleId as AgentRoleId | null } + : {}), + ...(typeof inputConfig?.agentRoleRevision === 'number' + ? { agentRoleRevision: inputConfig.agentRoleRevision } + : {}), + }; + } + return null; +}; + +/** What the failure notice needs to render its one-time acceptance action. */ +export type PermissionRetryControl = { + noticeId: string; + requestedModeId: string; + effectiveModeId: string; + pending: boolean; + canRetry: boolean; + retry: () => void; +}; + +/** + * Runs an action at most once at a time. + * + * The ref closes the double-click window before the first `await`; `pending` + * only drives the button's disabled state, and React would not have re-rendered + * in time to stop the second click. The flag is always cleared, so a failed + * attempt leaves the action usable — the alternative is a permanently dead + * button on the one turn the user is trying to recover. + */ +export const useOneShotAction = ( + action: () => Promise +): { pending: boolean; run: () => void } => { + const inFlightRef = useRef(false); + const [pending, setPending] = useState(false); + const actionRef = useRef(action); + actionRef.current = action; + + const run = useCallback(() => { + if (inFlightRef.current) { + return; + } + inFlightRef.current = true; + setPending(true); + void actionRef + .current() + .catch(() => undefined) + .finally(() => { + inFlightRef.current = false; + setPending(false); + }); + }, []); + + return { pending, run }; +}; diff --git a/packages/components/tests/permission-not-applied-retry.test.tsx b/packages/components/tests/permission-not-applied-retry.test.tsx new file mode 100644 index 000000000..aac764633 --- /dev/null +++ b/packages/components/tests/permission-not-applied-retry.test.tsx @@ -0,0 +1,178 @@ +/** @vitest-environment jsdom */ + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + findPermissionNotAppliedRetryTarget, + useOneShotAction, +} from '../src/lib/permission-not-applied-retry'; + +const stoppedTurn = (overrides?: Record) => ({ + id: 'user-1', + role: 'user', + inputConfig: { + inputBlocks: [{ type: 'text', text: 'ship it' }], + modeId: 'plan', + modelId: 'model-b', + configOptionValues: { reasoning_effort: 'high' }, + agentRoleId: 'role-1', + agentRoleRevision: 3, + ...overrides, + }, +}); + +const failureNotice = (permission?: { requestedModeId: string; effectiveModeId: string }) => ({ + id: 'notice-1', + role: 'system', + items: [ + { + type: 'system_notice', + name: 'chat_failed', + meta: { + reason: 'permission_not_applied', + ...(permission ? { permission } : {}), + }, + }, + ], +}); + +describe('findPermissionNotAppliedRetryTarget', () => { + it('replays the stopped turn, not the composer', () => { + const target = findPermissionNotAppliedRetryTarget([ + { + id: 'user-0', + role: 'user', + inputConfig: { inputBlocks: [{ type: 'text', text: 'older' }] }, + }, + { id: 'assistant-0', role: 'assistant' }, + stoppedTurn(), + failureNotice({ requestedModeId: 'plan', effectiveModeId: 'auto' }), + ]); + + expect(target).toEqual({ + noticeId: 'notice-1', + requestedModeId: 'plan', + effectiveModeId: 'auto', + userTurnId: 'user-1', + inputBlocks: [{ type: 'text', text: 'ship it' }], + modeId: 'plan', + modelId: 'model-b', + configOptionValues: { reasoning_effort: 'high' }, + agentRoleId: 'role-1', + agentRoleRevision: 3, + }); + }); + + it('stands down once the user has sent something newer', () => { + // Replaying now would inject the old turn behind whatever they just sent. + expect( + findPermissionNotAppliedRetryTarget([ + stoppedTurn(), + failureNotice({ requestedModeId: 'plan', effectiveModeId: 'auto' }), + { + id: 'user-2', + role: 'user', + inputConfig: { inputBlocks: [{ type: 'text', text: 'next' }] }, + }, + ]) + ).toBeNull(); + }); + + it('ignores failures that are not this one, and notices without both modes', () => { + expect( + findPermissionNotAppliedRetryTarget([ + stoppedTurn(), + { + id: 'notice-other', + role: 'system', + items: [ + { type: 'system_notice', name: 'chat_failed', meta: { reason: 'agent_disconnected' } }, + ], + }, + ]) + ).toBeNull(); + + // A notice from a machine that predates the structured meta cannot name the + // permissions, so it gets no action rather than a vague one. + expect(findPermissionNotAppliedRetryTarget([stoppedTurn(), failureNotice()])).toBeNull(); + }); + + it('declines when the stopped turn kept no frozen blocks to replay', () => { + expect( + findPermissionNotAppliedRetryTarget([ + { id: 'user-1', role: 'user', inputConfig: { modeId: 'plan' } }, + failureNotice({ requestedModeId: 'plan', effectiveModeId: 'auto' }), + ]) + ).toBeNull(); + }); +}); + +describe('useOneShotAction', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + const mount = (action: () => Promise) => { + const seen: { pending: boolean; run: () => void }[] = []; + const Probe = () => { + seen.push(useOneShotAction(action)); + return null; + }; + act(() => root.render()); + return { latest: () => seen[seen.length - 1]! }; + }; + + it('runs once while a run is open, however many times it is invoked', async () => { + let resolve: (() => void) | undefined; + const action = vi.fn( + async () => + await new Promise((r) => { + resolve = r; + }) + ); + const probe = mount(action); + + act(() => { + probe.latest().run(); + // The second click lands before React re-renders with `pending`, so the + // disabled attribute cannot be what stops it. + probe.latest().run(); + probe.latest().run(); + }); + expect(action).toHaveBeenCalledTimes(1); + expect(probe.latest().pending).toBe(true); + + await act(async () => { + resolve?.(); + }); + expect(probe.latest().pending).toBe(false); + }); + + it('stays usable after a failed attempt', async () => { + const action = vi.fn(async () => { + throw new Error('dispatch refused'); + }); + const probe = mount(action); + + await act(async () => { + probe.latest().run(); + }); + expect(probe.latest().pending).toBe(false); + + await act(async () => { + probe.latest().run(); + }); + expect(action).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/shared/src/ai.ts b/packages/shared/src/ai.ts index c2336f4ab..7032e17b3 100644 --- a/packages/shared/src/ai.ts +++ b/packages/shared/src/ai.ts @@ -1146,6 +1146,12 @@ export type ChatFailedMeta = { code?: ChatFailedCode; /** Human-readable error message */ message?: string; + /** + * `permission_not_applied` only: the mode the turn asked for and the wider one + * the agent reported. Structured so the notice can name both instead of the + * client parsing them back out of `message`. + */ + permission?: { requestedModeId: string; effectiveModeId: string }; }; /** diff --git a/packages/shared/src/message-schemas.ts b/packages/shared/src/message-schemas.ts index c9b88f921..ccdf8a226 100644 --- a/packages/shared/src/message-schemas.ts +++ b/packages/shared/src/message-schemas.ts @@ -3096,6 +3096,12 @@ export const ChatFailedMetaSchema = z.object({ reason: ChatFailedReasonSchema, code: ChatFailedCodeSchema.optional(), message: z.string().optional(), + /** + * `permission_not_applied` only: the mode the turn asked for and the wider one + * the agent reported. Structured so the notice can name both instead of the + * client parsing them back out of `message`. + */ + permission: z.object({ requestedModeId: z.string(), effectiveModeId: z.string() }).optional(), }); // Non-system notice MessageContent discriminated union diff --git a/packages/shared/tests/session-input.test.ts b/packages/shared/tests/session-input.test.ts index 5f1ba03ea..98ac80650 100644 --- a/packages/shared/tests/session-input.test.ts +++ b/packages/shared/tests/session-input.test.ts @@ -939,3 +939,27 @@ describe('session-input helpers', () => { } }); }); + +describe('one-time acceptance of a wider permission', () => { + const base = { + inputBlocks: [{ type: 'text' as const, text: 'ship it' }], + cliType: 'builtin' as const, + agentType: 'claude', + modeId: 'plan', + }; + + it('writes the flag only into the turn that carries it', () => { + expect( + buildSessionTurnInputConfig({ ...base, acceptWiderPermission: true }).acceptWiderPermission + ).toBe(true); + }); + + it('leaves an ordinary send — including a plain resend — without it', () => { + // Nothing outside the one dispatch may set it: an omitted, false, or + // undefined flag must never become an acceptance the next turn inherits. + expect(buildSessionTurnInputConfig(base).acceptWiderPermission).toBeUndefined(); + expect( + buildSessionTurnInputConfig({ ...base, acceptWiderPermission: false }).acceptWiderPermission + ).toBeUndefined(); + }); +}); From e507a471f23390d061a2611980172bbbe8587946 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 18:41:38 +0800 Subject: [PATCH 11/24] fix: cover every permission shape and replay the stopped turn exactly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings, all cases where the stop or the replay was narrower than the thing it claims to protect. The live permission check only looked at the MODE. Grok's real permission control is a `category: '_permission'` config option (`permission_mode`, `ask`/`auto`/`always-approve`), so a turn asking for `ask` that the agent reported as `always-approve` produced a warning and ran — precisely the escalation the stop exists for. Permission is now recognised in all three shapes it arrives in — the legacy `set_mode` selector, a `mode` config option, and an explicit `_permission` one — each requested value is compared against the agent's reported value for THAT control, and all of them are applied after the model so a model switch cannot overwrite them. The rank table gains the values it was missing: Grok's `ask`, and DeepSeek Harness's `workspace-write`, without which `read-only → workspace-write` was silent too. "Run once with the agent's permission" replayed the stopped turn's prompt and run config but let the composer supply `mcpServerIds`, `taskToolsEnabled` and `issuePRMentions` — so changing an unsent MCP selection and then accepting would pair the old prompt with tool reach that turn never had, and an explicit `mcpServerIds: []` was lost entirely. All of them now come from the frozen `inputConfig`; `acceptWiderPermission` remains the only thing the new turn adds. Kimi publishes reasoning as `thinking`, which the per-agent binding table did not know: a snapshot-less encode reported "cannot be encoded" and a saved Kimi Role value was filtered out as an unknown key. Hiding the Role form's run-config controls for unreported capabilities stopped a guess being seeded, but left the Role saveable with nothing pinned — a Role IS its run config and pins the permission mode. `validateAgentRoleForm` now refuses that combination; an existing Role that already carries values stays editable while its agent is unreachable. Ablation: each fix has a test that fails when it is reverted — `_permission` recognition, the two rank additions, the Kimi binding, the frozen tool reach, and the Role save gate. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/AGENTS.md | 27 ++- .../acp-session-config-applier.test.ts | 155 ++++++++++++++++++ .../src/session/acp-session-config-applier.ts | 68 ++++++-- .../sessions/session-chat-interface.tsx | 56 ++++++- .../settings/agent-role-editor-dialog.tsx | 8 +- .../components/settings/agent-role-form.tsx | 2 +- .../components/src/lib/agent-role-form.ts | 31 +++- .../src/lib/permission-not-applied-retry.ts | 18 ++ .../agent-role-form-run-config-gate.test.tsx | 131 +++++++++++++++ .../permission-not-applied-retry.test.tsx | 13 ++ packages/shared/src/acp-run-config.ts | 5 +- packages/shared/tests/acp-run-config.test.ts | 24 +++ 12 files changed, 507 insertions(+), 31 deletions(-) create mode 100644 packages/components/tests/agent-role-form-run-config-gate.test.tsx diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 575a6a294..78d8c9fe4 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -282,7 +282,10 @@ Two things the dev build does deliberately, both load-bearing: `unverifiedSelections`. It never rejects one. Client side: a Role may be seeded only from `authoritative` capabilities — `provisional` means the built-in static tables, and seeding from those persists a - guess as a durable promise. The composer keeps stored keys its selector catalog + guess as a durable promise. Nor may it be SAVED without one: a Role is its run + config and pins the permission mode, so `validateAgentRoleForm` refuses a new + Role that pins nothing while capabilities are unreported (`run_config_unavailable`). + An existing Role that already carries values stays editable offline. The composer keeps stored keys its selector catalog does not cover; only a present runtime table (the agent's live state) owns the whole key set. Runtime rejections remain in debug diagnostics; what becomes a visible `agent_warning` is DIVERGENCE — the state the agent publishes after applying the @@ -317,14 +320,28 @@ Two things the dev build does deliberately, both load-bearing: `prompt` (`AcpPermissionNotAppliedError` → `permission_not_applied`). By the time a warning about that is readable the agent may already have edited files, so this is the one divergence a notice cannot cover. It fires only on a live - contradiction: `isAcpPermissionWiderThanRequested` requires BOTH modes to be + contradiction. Permission arrives in THREE shapes and all three are checked: + the legacy `session/set_mode` selector, a `category: 'mode'` config option, and + an explicit `category: '_permission'` one (Grok's `permission_mode`, values + `ask`/`auto`/`always-approve`). Matching only the first two let a requested + `ask` run as `always-approve` with nothing but a warning. Each requested + permission-bearing value is compared against the agent's reported value for + THAT control; `isAcpPermissionWiderThanRequested` requires BOTH values to be ranked among the builtin ones and the effective one to be strictly wider, and - the effective mode is read from the agent's published state — a snapshot, a + the effective value is read from the agent's published state — a snapshot, a stale cache, an unranked third-party mode, an unconfirmed request, or a - NARROWER outcome must never stop a turn. The way out is explicit and + NARROWER outcome must never stop a turn. The rank table covers only values the + repo adapts (Codex, Claude, Grok `ask`/`auto`/`always-approve`, DeepSeek + Harness `read-only`/`workspace-write`/`danger-full-access`); adding an agent + means adding its values there, or its escalations go unseen. The way out is explicit and per-turn: `SessionTurnInputConfig.acceptWiderPermission` is informed acceptance carried by one resend, never inherited and never a default, and it - suppresses the stop while still reporting the mismatch. + suppresses the stop while still reporting the mismatch. That resend replays the + STOPPED turn: prompt, mode, model, config values, Role, `mcpServerIds` + (including an explicit empty selection), `taskToolsEnabled` and + `issuePRMentions` all come from its frozen `inputConfig`, never from the + composer — pairing an old prompt with tool reach the user has since changed + would hand it permissions that turn never had. - MCP `session_list` defaults to 20 (maximum 100), and `session_history` defaults to 10 (maximum 50 and 128 KiB). Keep the MCP surface bounded even though the human CLI retains `session history --all`. `session_list` and `session_status_many` derive busy/idle from diff --git a/apps/cli/src/session/acp-session-config-applier.test.ts b/apps/cli/src/session/acp-session-config-applier.test.ts index 47abbc5bc..38de61d68 100644 --- a/apps/cli/src/session/acp-session-config-applier.test.ts +++ b/apps/cli/src/session/acp-session-config-applier.test.ts @@ -268,6 +268,161 @@ describe('applyAcpSessionRunConfig', () => { ).toBeUndefined(); }); + it('catches a widened explicit _permission selector, not just the mode', async () => { + // Grok's real permission control is a `_permission` config option, not a + // mode: `ask` requested, `always-approve` reported. + const agentClient = { + isCreated: () => true, + getConfigOptions: () => [ + { + id: 'permission_mode', + category: '_permission', + type: 'select', + currentValue: 'always-approve', + }, + ], + setSessionConfigOption: vi.fn(async () => undefined), + } as unknown as AgentClient; + + const result = await apply( + { + cliType: 'builtin', + agentType: 'grok', + configOptionValues: { permission_mode: 'ask' }, + }, + agentClient + ); + + expect(result.permissionEscalation).toEqual({ + requestedModeId: 'ask', + effectiveModeId: 'always-approve', + }); + }); + + it('ranks the remaining builtin permission values', async () => { + const permissionAgent = (currentValue: string) => + ({ + isCreated: () => true, + getConfigOptions: () => [ + { id: 'permission_mode', category: '_permission', type: 'select', currentValue }, + ], + setSessionConfigOption: vi.fn(async () => undefined), + }) as unknown as AgentClient; + + // Grok `ask` → `auto`: approval moves from a human to the model. + expect( + ( + await apply( + { + cliType: 'builtin', + agentType: 'grok', + configOptionValues: { permission_mode: 'ask' }, + }, + permissionAgent('auto') + ) + ).permissionEscalation + ).toEqual({ requestedModeId: 'ask', effectiveModeId: 'auto' }); + + // DeepSeek Harness `read-only` → `workspace-write`. + expect( + ( + await apply( + { + cliType: 'builtin', + agentType: 'deepseek', + configOptionValues: { permission_mode: 'read-only' }, + }, + permissionAgent('workspace-write') + ) + ).permissionEscalation + ).toEqual({ requestedModeId: 'read-only', effectiveModeId: 'workspace-write' }); + + // The other direction is a functional mismatch, not an escalation. + expect( + ( + await apply( + { + cliType: 'builtin', + agentType: 'grok', + configOptionValues: { permission_mode: 'always-approve' }, + }, + permissionAgent('ask') + ) + ).permissionEscalation + ).toBeUndefined(); + }); + + it('applies an explicit _permission selector after the model', async () => { + // Same reset hazard as the mode: a model switch must not be able to + // overwrite the permission the turn asked for. + let currentPermission = 'always-approve'; + let currentModel = 'model-a'; + const agentClient = { + isCreated: () => true, + getConfigOptions: () => [ + { + id: 'permission_mode', + category: '_permission', + type: 'select', + currentValue: currentPermission, + }, + { id: 'engine', category: 'model', type: 'select', currentValue: currentModel }, + ], + unstable_setSessionModel: vi.fn(async (_sessionId: string, model: string) => { + currentModel = model; + currentPermission = 'always-approve'; + }), + setSessionConfigOption: vi.fn( + async (_sessionId: string, configId: string, value: unknown) => { + if (configId === 'permission_mode' && typeof value === 'string') { + currentPermission = value; + } + } + ), + } as unknown as AgentClient; + + const result = await apply( + { + cliType: 'builtin', + agentType: 'grok', + modelId: 'model-b', + configOptionValues: { permission_mode: 'ask' }, + }, + agentClient + ); + + expect(result.permissionEscalation).toBeUndefined(); + expect(result.runtimeConfigPatch?.configOptionValues?.['permission_mode']).toBe('ask'); + }); + + it('stands down for a _permission escalation the turn accepted', async () => { + const agentClient = { + isCreated: () => true, + getConfigOptions: () => [ + { + id: 'permission_mode', + category: '_permission', + type: 'select', + currentValue: 'always-approve', + }, + ], + setSessionConfigOption: vi.fn(async () => undefined), + } as unknown as AgentClient; + + const result = await apply( + { + cliType: 'builtin', + agentType: 'grok', + configOptionValues: { permission_mode: 'ask' }, + acceptWiderPermission: true, + }, + agentClient + ); + + expect(result.permissionEscalation).toBeUndefined(); + expect(result.warningSelections).toEqual(['permission_mode="ask"']); + }); + it('stands down for a turn that carries the informed acceptance', async () => { const agentClient = { isCreated: () => true, diff --git a/apps/cli/src/session/acp-session-config-applier.ts b/apps/cli/src/session/acp-session-config-applier.ts index 1d0d1dda4..60d22e0f0 100644 --- a/apps/cli/src/session/acp-session-config-applier.ts +++ b/apps/cli/src/session/acp-session-config-applier.ts @@ -213,6 +213,17 @@ export async function applyAcpSessionRunConfig(args: { }); }; + /* Every shape a permission control arrives in. Agents publish it three ways: + the legacy `session/set_mode` selector, a `category: 'mode'` config option, + and an explicit `category: '_permission'` one (Grok's `permission_mode`). + Matching only the first two let a requested `ask` run as `always-approve` + with nothing but a warning. */ + const permissionConfigIds = new Set( + agentConfigOptions + .filter((option) => option.category === 'mode' || option.category === '_permission') + .map((option) => option.id) + ); + /** * Permission-bearing controls go LAST, and that ordering is load-bearing. * @@ -225,7 +236,9 @@ export async function applyAcpSessionRunConfig(args: { * still taken before the agent can act on it. */ const isPermissionBearing = (configId: string): boolean => - configId === modeConfigId || isAcpPlanModeConfigOption({ id: configId }); + configId === modeConfigId || + permissionConfigIds.has(configId) || + isAcpPlanModeConfigOption({ id: configId }); const configOptionEntryFor = (configId: string): AcpConfigOptionValue | undefined => configOptionEntries.find(([id]) => id === configId)?.[1]; @@ -323,23 +336,42 @@ export async function applyAcpSessionRunConfig(args: { }) .map((selection) => selection.label); - /* The permission the turn asked for, against the one the agent reports after - everything has been applied. `runtimeConfigPatch.modeId` is the agent's own - state — it is only filled from a `set_mode` acknowledgement when the agent - reports no mode of its own, in which case the two are equal and nothing - fires here. So this cannot be triggered by a snapshot, by a stale cache, or - by an unconfirmed request. */ - const requestedModeId = - config.modeId ?? - (typeof configOptionEntryFor(modeConfigId) === 'string' - ? (configOptionEntryFor(modeConfigId) as string) - : undefined); - const permissionEscalation = - requestedModeId !== undefined && - !config.acceptWiderPermission && - isAcpPermissionWiderThanRequested(requestedModeId, runtimeConfigPatch.modeId) - ? { requestedModeId, effectiveModeId: runtimeConfigPatch.modeId as string } - : undefined; + /* Every permission-bearing selection this turn made, against the value the + agent reports for it after everything has been applied. The effective side + is the agent's own state: `runtimeConfigPatch.modeId` is only filled from a + `set_mode` acknowledgement when the agent reports no mode of its own (in + which case the two are equal and nothing fires), and the config table comes + straight from what the agent published. So this cannot be triggered by a + snapshot, by a stale cache, or by an unconfirmed request. */ + const findPermissionEscalation = (): + | { requestedModeId: string; effectiveModeId: string } + | undefined => { + if (config.acceptWiderPermission) { + return undefined; + } + for (const selection of appliedSelections) { + if ( + selection.source.kind === 'model' || + (selection.source.kind === 'configOption' && + !isPermissionBearing(selection.source.configId)) + ) { + continue; + } + const effective = + selection.source.kind === 'mode' + ? runtimeConfigPatch.modeId + : effectiveConfigOptionValues[selection.source.configId]; + if ( + typeof selection.requested === 'string' && + typeof effective === 'string' && + isAcpPermissionWiderThanRequested(selection.requested, effective) + ) { + return { requestedModeId: selection.requested, effectiveModeId: effective }; + } + } + return undefined; + }; + const permissionEscalation = findPermissionEscalation(); if (permissionEscalation) { logger.debug( `[${sessionId}] Permission not applied: requested ${permissionEscalation.requestedModeId}, effective ${permissionEscalation.effectiveModeId}` diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index dca18caf7..cec3ce6b3 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -87,6 +87,8 @@ import { buildConversationMarkdown, buildPendingUserHistoryEntry, buildSessionTurnInputConfig, + type IssuePRMention, + type McpServerId, countBillableSessionTurns, deriveSessionPullRequestReadiness, evaluateBillingQuota, @@ -1864,6 +1866,10 @@ export type DispatchInputBlocksOptions = { * not the Session, not a Role, not a user default. */ acceptWiderPermission?: boolean; + /** Turn-scoped execution fields replayed verbatim from a frozen turn. */ + mcpServerIdsOverride?: readonly McpServerId[]; + taskToolsEnabledOverride?: boolean; + issuePRMentionsOverride?: IssuePRMention[]; }; function buildEditedMessageQueueItem( @@ -3549,6 +3555,14 @@ export const SessionChatInterface = memo( agentRole?: SessionTurnAgentRoleSelection; /** One-time informed acceptance, written into this turn only. */ acceptWiderPermission?: boolean; + /** + * Turn-scoped execution fields replayed verbatim from a frozen turn. + * An explicit empty `mcpServerIds` is a real selection, so these are + * applied when PRESENT rather than when truthy. + */ + mcpServerIdsOverride?: readonly McpServerId[]; + taskToolsEnabledOverride?: boolean; + issuePRMentionsOverride?: IssuePRMention[]; } ): Promise => { try { @@ -3569,9 +3583,9 @@ export const SessionChatInterface = memo( modeId: turnModeId, modelId: turnModelId, configOptionValues: turnConfigOptionValues, - issuePRMentions, - mcpServerIds: mcpSelection.selectedIds, - taskToolsEnabled: tasksEnabled, + issuePRMentions: options?.issuePRMentionsOverride ?? issuePRMentions, + mcpServerIds: options?.mcpServerIdsOverride ?? mcpSelection.selectedIds, + taskToolsEnabled: options?.taskToolsEnabledOverride ?? tasksEnabled, agentRoleId: options?.agentRole?.agentRoleId ?? (options?.agentRole === null ? null : undefined), agentRoleRevision: options?.agentRole?.agentRoleRevision, @@ -3695,6 +3709,9 @@ export const SessionChatInterface = memo( | 'configOptionValuesOverride' | 'agentRole' | 'acceptWiderPermission' + | 'mcpServerIdsOverride' + | 'taskToolsEnabledOverride' + | 'issuePRMentionsOverride' > ): Promise => { try { @@ -3796,6 +3813,9 @@ export const SessionChatInterface = memo( | 'configOptionValuesOverride' | 'agentRole' | 'acceptWiderPermission' + | 'mcpServerIdsOverride' + | 'taskToolsEnabledOverride' + | 'issuePRMentionsOverride' > ): Promise => { const turnConfigOptionValues = options?.configOptionValuesOverride ?? configOptionValues; @@ -3807,6 +3827,15 @@ export const SessionChatInterface = memo( configOptionValuesOverride: turnConfigOptionValues, agentRole: options?.agentRole, ...(options?.acceptWiderPermission === true ? { acceptWiderPermission: true } : {}), + ...(options?.mcpServerIdsOverride !== undefined + ? { mcpServerIdsOverride: options.mcpServerIdsOverride } + : {}), + ...(options?.taskToolsEnabledOverride !== undefined + ? { taskToolsEnabledOverride: options.taskToolsEnabledOverride } + : {}), + ...(options?.issuePRMentionsOverride !== undefined + ? { issuePRMentionsOverride: options.issuePRMentionsOverride } + : {}), }); }, [configOptionValues, enqueueInputBlocks] @@ -3894,6 +3923,15 @@ export const SessionChatInterface = memo( configOptionValuesOverride: turnConfigOptionValues, agentRole: options?.agentRole, ...(options?.acceptWiderPermission === true ? { acceptWiderPermission: true } : {}), + ...(options?.mcpServerIdsOverride !== undefined + ? { mcpServerIdsOverride: options.mcpServerIdsOverride } + : {}), + ...(options?.taskToolsEnabledOverride !== undefined + ? { taskToolsEnabledOverride: options.taskToolsEnabledOverride } + : {}), + ...(options?.issuePRMentionsOverride !== undefined + ? { issuePRMentionsOverride: options.issuePRMentionsOverride } + : {}), }); captureSessionEvent( accepted ? 'session/message_guide_requested' : 'session/message_submit_failed', @@ -4033,6 +4071,18 @@ export const SessionChatInterface = memo( ? null : undefined, acceptWiderPermission: true, + // Tool reach belonged to that turn too: replaying it with whatever the + // composer holds now would pair the old prompt with new permissions, + // and would silently drop an explicit empty MCP selection. + ...(target.mcpServerIds !== undefined + ? { mcpServerIdsOverride: target.mcpServerIds as McpServerId[] } + : {}), + ...(target.taskToolsEnabled !== undefined + ? { taskToolsEnabledOverride: target.taskToolsEnabled } + : {}), + ...(target.issuePRMentions !== undefined + ? { issuePRMentionsOverride: target.issuePRMentions as IssuePRMention[] } + : {}), }); if (!accepted) { toast.error( diff --git a/packages/components/src/components/settings/agent-role-editor-dialog.tsx b/packages/components/src/components/settings/agent-role-editor-dialog.tsx index 116dc359d..f236a3829 100644 --- a/packages/components/src/components/settings/agent-role-editor-dialog.tsx +++ b/packages/components/src/components/settings/agent-role-editor-dialog.tsx @@ -149,9 +149,15 @@ export function AgentRoleEditorDialog({ ? editor.role.id : editor.roleId : null, + // Only once an agent config is picked does its authority mean + // anything; before that the run-config section is not the thing + // blocking the form. + ...(selectedAgentConfig && selectorOptions + ? { capabilityAuthority: selectorOptions.capabilityAuthority } + : {}), }) : [], - [accessibleRoles, editor, editorValue] + [accessibleRoles, editor, editorValue, selectedAgentConfig, selectorOptions] ); const runConfigIssues = useMemo( () => diff --git a/packages/components/src/components/settings/agent-role-form.tsx b/packages/components/src/components/settings/agent-role-form.tsx index 48a2c4980..2f349e3a5 100644 --- a/packages/components/src/components/settings/agent-role-form.tsx +++ b/packages/components/src/components/settings/agent-role-form.tsx @@ -230,7 +230,7 @@ export function AgentRoleForm({ hint={t('settings.agentRoles.form.sectionRunConfigHint')} > {capabilitiesUnreported || !selectorOptions ? ( - + {t('settings.agentRoles.form.capabilitiesUnavailable')} ) : ( diff --git a/packages/components/src/lib/agent-role-form.ts b/packages/components/src/lib/agent-role-form.ts index 148068789..040d0b278 100644 --- a/packages/components/src/lib/agent-role-form.ts +++ b/packages/components/src/lib/agent-role-form.ts @@ -1,5 +1,6 @@ import { AGENT_ROLE_VERSION, + type AcpCapabilityAuthority, ACP_CONFIG_OPTION_OFF_VALUE, getAgentRoleMentionSlug, isAcpThoughtLevelConfigOption, @@ -98,7 +99,9 @@ export type AgentRoleFormError = | 'name_required' | 'name_taken' | 'machine_required' - | 'agent_config_required'; + | 'agent_config_required' + /** The agent has not reported capabilities, so nothing can be pinned yet. */ + | 'run_config_unavailable'; /** * The name is the only authored label, so it carries both jobs: it is what the @@ -110,7 +113,12 @@ export type AgentRoleFormError = */ export const validateAgentRoleForm = ( value: AgentRoleFormValue, - options: { accessibleRoles: readonly AgentRole[]; editingRoleId?: AgentRoleId | null } + options: { + accessibleRoles: readonly AgentRole[]; + editingRoleId?: AgentRoleId | null; + /** How the selected agent's capabilities were obtained, when known. */ + capabilityAuthority?: AcpCapabilityAuthority; + } ): AgentRoleFormError[] => { const errors: AgentRoleFormError[] = []; const slug = normalizeAgentRoleMentionSlug(value.name); @@ -128,6 +136,25 @@ export const validateAgentRoleForm = ( if (!value.machineId) errors.push('machine_required'); if (!value.agentConfigId) errors.push('agent_config_required'); + + /* A Role IS its run config — it pins the permission mode, and the surfaces + that hide the permission control rely on that. Non-authoritative + capabilities are the built-in static tables, which must not be seeded into + a Role, so the form has nothing real to offer and the user has chosen + nothing: saving here would create a "complete configuration" that pins no + permission at all. An existing Role that already carries values keeps its + own and stays editable offline. */ + const hasPinnedRunConfig = + Boolean(value.modeId) || + Boolean(value.modelId) || + Object.keys(value.configOptionValues).length > 0; + if ( + options.capabilityAuthority !== undefined && + options.capabilityAuthority !== 'authoritative' && + !hasPinnedRunConfig + ) { + errors.push('run_config_unavailable'); + } return errors; }; diff --git a/packages/components/src/lib/permission-not-applied-retry.ts b/packages/components/src/lib/permission-not-applied-retry.ts index 6df152604..6d0f3923a 100644 --- a/packages/components/src/lib/permission-not-applied-retry.ts +++ b/packages/components/src/lib/permission-not-applied-retry.ts @@ -25,6 +25,15 @@ export type PermissionNotAppliedRetryTarget = { configOptionValues?: Record; agentRoleId?: AgentRoleId | null; agentRoleRevision?: number; + /** + * Tool reach is part of what that turn was, not of what the composer holds + * now. An explicit empty selection is a selection — `mcpServerIds: []` means + * "no servers", which is why it travels as `undefined`-vs-array rather than + * being collapsed to falsy. + */ + mcpServerIds?: string[]; + taskToolsEnabled?: boolean; + issuePRMentions?: unknown[]; }; type RetryHistoryItem = { type?: string; name?: string; meta?: unknown } | null | undefined; @@ -124,6 +133,15 @@ export const findPermissionNotAppliedRetryTarget = ( ...(typeof inputConfig?.agentRoleRevision === 'number' ? { agentRoleRevision: inputConfig.agentRoleRevision } : {}), + ...(Array.isArray(inputConfig?.mcpServerIds) + ? { mcpServerIds: inputConfig.mcpServerIds as string[] } + : {}), + ...(typeof inputConfig?.taskToolsEnabled === 'boolean' + ? { taskToolsEnabled: inputConfig.taskToolsEnabled } + : {}), + ...(Array.isArray(inputConfig?.issuePRMentions) + ? { issuePRMentions: inputConfig.issuePRMentions as unknown[] } + : {}), }; } return null; diff --git a/packages/components/tests/agent-role-form-run-config-gate.test.tsx b/packages/components/tests/agent-role-form-run-config-gate.test.tsx new file mode 100644 index 000000000..98b6c102c --- /dev/null +++ b/packages/components/tests/agent-role-form-run-config-gate.test.tsx @@ -0,0 +1,131 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import type { AgentConfigId, MachineId } from '@lody/shared'; + +import { AgentRoleForm } from '../src/components/settings/agent-role-form'; +import { + EMPTY_AGENT_ROLE_FORM_VALUE, + validateAgentRoleForm, + type AgentRoleFormValue, +} from '../src/lib/agent-role-form'; +import type { AcpSelectorOptions } from '../src/components/shared/acp-selector-options'; +import { initI18n } from '../src/i18n'; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const namedValue = (overrides: Partial = {}): AgentRoleFormValue => ({ + ...EMPTY_AGENT_ROLE_FORM_VALUE, + name: 'Reviewer', + machineId: 'machine-1' as MachineId, + agentConfigId: 'config-1' as AgentConfigId, + ...overrides, +}); + +const selectorOptions = (authority: AcpSelectorOptions['capabilityAuthority']) => + ({ + capabilityAuthority: authority, + modeOptions: [{ value: 'plan', label: 'Plan' }], + modelOptions: [{ value: 'model-a', label: 'Model A' }], + defaultModeId: 'plan', + defaultModelId: 'model-a', + configOptionSelectors: [], + }) as unknown as AcpSelectorOptions; + +describe('Agent Role run-config gate', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(async () => { + await initI18n('en'); + vi.stubGlobal( + 'ResizeObserver', + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } + ); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + const render = (props: Parameters[0]) => + act(() => root.render(createElement(AgentRoleForm, props))); + + const submitButton = () => + Array.from(container.querySelectorAll('button')).find( + (button) => button.getAttribute('type') === 'submit' + ); + + it('refuses to save a Role that pins nothing because capabilities are unreported', () => { + // The agent has not reported; the form shows no run-config controls, so the + // user has chosen nothing. Saving would create a "complete configuration" + // that pins no permission at all. + const value = namedValue(); + const errors = validateAgentRoleForm(value, { + accessibleRoles: [], + capabilityAuthority: 'provisional', + }); + expect(errors).toEqual(['run_config_unavailable']); + + render({ + value, + onChange: () => undefined, + machines: [{ machineId: 'machine-1' as MachineId, label: 'Mac' }], + agentConfigs: [{ agentConfigId: 'config-1' as AgentConfigId, label: 'Codex' }], + selectorOptions: selectorOptions('provisional'), + issues: [], + errors, + onSubmit: () => undefined, + onCancel: () => undefined, + }); + + expect(submitButton()?.disabled).toBe(true); + // And the controls really are absent, so nothing renders as chosen. + expect(container.textContent).toContain('has not reported its capabilities'); + }); + + it('saves once the agent has reported', () => { + const value = namedValue({ modeId: 'plan', modelId: 'model-a' }); + const errors = validateAgentRoleForm(value, { + accessibleRoles: [], + capabilityAuthority: 'authoritative', + }); + expect(errors).toEqual([]); + + render({ + value, + onChange: () => undefined, + machines: [{ machineId: 'machine-1' as MachineId, label: 'Mac' }], + agentConfigs: [{ agentConfigId: 'config-1' as AgentConfigId, label: 'Codex' }], + selectorOptions: selectorOptions('authoritative'), + issues: [], + errors, + onSubmit: () => undefined, + onCancel: () => undefined, + }); + + expect(submitButton()?.disabled).toBe(false); + }); + + it('keeps an existing Role editable while its agent is unreachable', () => { + // It already carries what it pins, so nothing is being invented here. + expect( + validateAgentRoleForm(namedValue({ modeId: 'plan', modelId: 'model-a' }), { + accessibleRoles: [], + capabilityAuthority: 'unavailable', + }) + ).toEqual([]); + }); +}); diff --git a/packages/components/tests/permission-not-applied-retry.test.tsx b/packages/components/tests/permission-not-applied-retry.test.tsx index aac764633..2d6d059fe 100644 --- a/packages/components/tests/permission-not-applied-retry.test.tsx +++ b/packages/components/tests/permission-not-applied-retry.test.tsx @@ -64,6 +64,19 @@ describe('findPermissionNotAppliedRetryTarget', () => { }); }); + it("keeps the stopped turn's tool reach, including an explicit empty selection", () => { + // The composer may hold a different MCP selection by now; replaying with it + // would pair the old prompt with tool permissions that turn never had. + const target = findPermissionNotAppliedRetryTarget([ + stoppedTurn({ mcpServerIds: [], taskToolsEnabled: false, issuePRMentions: [{ number: 7 }] }), + failureNotice({ requestedModeId: 'plan', effectiveModeId: 'auto' }), + ]); + + expect(target?.mcpServerIds).toEqual([]); + expect(target?.taskToolsEnabled).toBe(false); + expect(target?.issuePRMentions).toEqual([{ number: 7 }]); + }); + it('stands down once the user has sent something newer', () => { // Replaying now would inject the old turn behind whatever they just sent. expect( diff --git a/packages/shared/src/acp-run-config.ts b/packages/shared/src/acp-run-config.ts index 404304ada..11885a85b 100644 --- a/packages/shared/src/acp-run-config.ts +++ b/packages/shared/src/acp-run-config.ts @@ -69,11 +69,13 @@ const ACP_PERMISSION_MODE_RANKS: Record = { // Asks a human before acting. agent: 1, default: 1, + ask: 1, // Routes approval to a reviewing model instead of a human. 'agent-auto-review': 2, auto: 2, - // Auto-approves edits. + // Auto-approves edits inside the workspace. acceptEdits: 3, + 'workspace-write': 3, // Skips approval entirely. dontAsk: 4, bypassPermissions: 4, @@ -236,6 +238,7 @@ const AGENT_PER_MODEL_BINDINGS: Record< codex: { fastModeConfigId: 'fast-mode', reasoningEffortConfigId: ACP_REASONING_EFFORT_CONFIG_ID }, claude: { fastModeConfigId: 'fast', reasoningEffortConfigId: 'effort' }, grok: { reasoningEffortConfigId: ACP_REASONING_EFFORT_CONFIG_ID }, + kimi: { reasoningEffortConfigId: 'thinking' }, }; const findAgentPerModelBinding = (capability: RunConfigCapabilitySource | undefined) => diff --git a/packages/shared/tests/acp-run-config.test.ts b/packages/shared/tests/acp-run-config.test.ts index 71bef8208..867032340 100644 --- a/packages/shared/tests/acp-run-config.test.ts +++ b/packages/shared/tests/acp-run-config.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { deriveModelReasoningEffortsFromLegacyModelIds, + isAcpPerModelConfigId, isAcpPermissionWiderThanRequested, resolveAgentRunConfigSelection, summarizeAgentRunConfigCapabilities, @@ -244,6 +245,14 @@ describe('agent run config selection', () => { resolveAgentRunConfigSelection({ reasoningEffort: 'high', fastMode: true }, bare('claude')) .configOptionValues ).toEqual({ effort: 'high', fast: true }); + + // Kimi publishes thinking as `thinking`, and offers no fast toggle at all. + expect( + resolveAgentRunConfigSelection({ reasoningEffort: 'high' }, bare('kimi')).configOptionValues + ).toEqual({ thinking: 'high' }); + expect(() => resolveAgentRunConfigSelection({ fastMode: true }, bare('kimi'))).toThrow( + /cannot be encoded/ + ); }); it('reports a missing wire binding as such, not as an unsupported control', () => { @@ -404,6 +413,16 @@ describe('agent run config selection', () => { }); }); +describe('per-model config ids', () => { + it('recognizes every builtin spelling, so a stored value survives a snapshot without it', () => { + for (const configId of ['fast-mode', 'fast', 'reasoning_effort', 'effort', 'thinking']) { + expect(isAcpPerModelConfigId(configId)).toBe(true); + } + // A key with no per-model excuse is not preserved on the strength of this. + expect(isAcpPerModelConfigId('approval_policy')).toBe(false); + }); +}); + describe('permission width', () => { it('answers only on a ranked, strictly wider outcome', () => { // Wider: the effective mode acts with less human involvement. @@ -411,6 +430,11 @@ describe('permission width', () => { expect(isAcpPermissionWiderThanRequested('plan', 'default')).toBe(true); expect(isAcpPermissionWiderThanRequested('default', 'bypassPermissions')).toBe(true); expect(isAcpPermissionWiderThanRequested('read-only', 'agent-full-access')).toBe(true); + // Grok's `_permission` values and DeepSeek Harness's workspace tier. + expect(isAcpPermissionWiderThanRequested('ask', 'auto')).toBe(true); + expect(isAcpPermissionWiderThanRequested('ask', 'always-approve')).toBe(true); + expect(isAcpPermissionWiderThanRequested('read-only', 'workspace-write')).toBe(true); + expect(isAcpPermissionWiderThanRequested('workspace-write', 'danger-full-access')).toBe(true); // Equal or narrower is a functional mismatch, not an escalation. expect(isAcpPermissionWiderThanRequested('plan', 'plan')).toBe(false); From 156318a0cd939cd1b71f8b93883cb428ed4648e4 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 18:59:44 +0800 Subject: [PATCH 12/24] fix(components): carry the replay's turn config through every send route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings, both places where a narrower path defeated the guard above it. The permission retry handed `acceptWiderPermission` and the frozen tool config to `dispatchInputBlocks`, which forwarded only mode/model/config/Role to the direct and queue hops. The offer is shown while the agent is idle, so the direct hop is the one it takes: the new turn reached the daemon with no acceptance and was stopped again, while its MCP, task-tool and issue-mention values came from the composer rather than from the turn being replayed. They now travel as one `TurnScopedOverrides` carrier that the inner send takes as a REQUIRED field, so a route that forgets it fails to compile rather than silently producing a turn the daemon will stop. Every route — direct, queue, guide — forwards it, `applyTurnScopedOverrides` is the single last hop before `buildSessionTurnInputConfig`, the queued config reads the built config instead of re-reading the composer, and the button asks for `forceDirect` so the decision the user just made is not parked behind a queue. The Role create gate keyed on the run config being empty, which the composer defeats: chat landing and the input area create a Role from what they currently show, and under `provisional` capabilities that is the static tables' own defaults resolved into a non-empty value nobody chose. Emptiness cannot tell that from a real selection, so the rule is now the creation itself — `isEditingExistingRole` comes from `editor.mode`, never inferred from the value. Editing an existing Role stays open offline. Ablation: neutering the carrier at a hop, dropping the frozen MCP selection at the last hop, and reverting the gate to the emptiness check each fail their test. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/AGENTS.md | 15 +- .../sessions/session-chat-interface.tsx | 153 ++++++++---------- .../settings/agent-role-editor-dialog.tsx | 1 + .../components/src/lib/agent-role-form.ts | 28 ++-- .../src/lib/turn-scoped-overrides.ts | 90 +++++++++++ .../agent-role-form-run-config-gate.test.tsx | 20 ++- .../tests/turn-scoped-overrides.test.ts | 92 +++++++++++ 7 files changed, 299 insertions(+), 100 deletions(-) create mode 100644 packages/components/src/lib/turn-scoped-overrides.ts create mode 100644 packages/components/tests/turn-scoped-overrides.test.ts diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 78d8c9fe4..fdbf16c05 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -284,8 +284,12 @@ Two things the dev build does deliberately, both load-bearing: `provisional` means the built-in static tables, and seeding from those persists a guess as a durable promise. Nor may it be SAVED without one: a Role is its run config and pins the permission mode, so `validateAgentRoleForm` refuses a new - Role that pins nothing while capabilities are unreported (`run_config_unavailable`). - An existing Role that already carries values stays editable offline. The composer keeps stored keys its selector catalog + Role while capabilities are unreported (`run_config_unavailable`), keyed on + CREATION rather than on the value being empty: the chat landing and the input + area both create a Role from what the composer currently shows, and under + `provisional` capabilities those are the static tables' own defaults — a + non-empty run config nobody chose. Editing an existing Role stays open, so its + owner can rename it while the machine is offline. The composer keeps stored keys its selector catalog does not cover; only a present runtime table (the agent's live state) owns the whole key set. Runtime rejections remain in debug diagnostics; what becomes a visible `agent_warning` is DIVERGENCE — the state the agent publishes after applying the @@ -341,7 +345,12 @@ Two things the dev build does deliberately, both load-bearing: (including an explicit empty selection), `taskToolsEnabled` and `issuePRMentions` all come from its frozen `inputConfig`, never from the composer — pairing an old prompt with tool reach the user has since changed - would hand it permissions that turn never had. + would hand it permissions that turn never had. Those fields travel as ONE + required `TurnScopedOverrides` carrier through every send route (direct, + queue, guide), because each route rebuilds the turn config from composer + state: a route that forwarded three of four silently produced a turn with no + acceptance, which the daemon stopped again. Required, not optional, so a new + route fails to compile rather than dropping it. - MCP `session_list` defaults to 20 (maximum 100), and `session_history` defaults to 10 (maximum 50 and 128 KiB). Keep the MCP surface bounded even though the human CLI retains `session history --all`. `session_list` and `session_status_many` derive busy/idle from diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index cec3ce6b3..8d70d4dec 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -87,8 +87,6 @@ import { buildConversationMarkdown, buildPendingUserHistoryEntry, buildSessionTurnInputConfig, - type IssuePRMention, - type McpServerId, countBillableSessionTurns, deriveSessionPullRequestReadiness, evaluateBillingQuota, @@ -217,6 +215,12 @@ import { useOneShotAction, type PermissionRetryControl, } from '@/lib/permission-not-applied-retry'; +import { + applyTurnScopedOverrides, + buildPermissionRetryOverrides, + pickTurnScopedOverrides, + type TurnScopedOverrides, +} from '@/lib/turn-scoped-overrides'; import { buildFixCiErrorsPrompt, buildResolvePrConflictsPrompt } from './session-pr-prompts'; import { resolveConflictsActionAtomFamily } from './session-pr-agent-action'; import { setPreferredPrMergeMethod, usePreferredPrMergeMethod } from './pr-merge-method'; @@ -1859,18 +1863,7 @@ export type DispatchInputBlocksOptions = { configOptionValuesOverride?: Record; /** Role identity frozen beside this Turn's run config; null is explicit None. */ agentRole?: SessionTurnAgentRoleSelection; - /** - * Informed acceptance of a permission the agent reported as wider than the - * one this turn asks for. Rides the single turn it is passed with: it is - * written into that turn's input config and nowhere else — not the composer, - * not the Session, not a Role, not a user default. - */ - acceptWiderPermission?: boolean; - /** Turn-scoped execution fields replayed verbatim from a frozen turn. */ - mcpServerIdsOverride?: readonly McpServerId[]; - taskToolsEnabledOverride?: boolean; - issuePRMentionsOverride?: IssuePRMention[]; -}; +} & TurnScopedOverrides; function buildEditedMessageQueueItem( item: MessageQueueItem, @@ -3544,7 +3537,7 @@ export const SessionChatInterface = memo( const enqueueInputBlocks = useCallback( async ( inputBlocks: SessionInputBlock[], - options?: { + options: { createHistory?: boolean; existingUserTurnId?: string; requestDispatch?: boolean; @@ -3553,16 +3546,13 @@ export const SessionChatInterface = memo( modelIdOverride?: string | null; configOptionValuesOverride?: Record; agentRole?: SessionTurnAgentRoleSelection; - /** One-time informed acceptance, written into this turn only. */ - acceptWiderPermission?: boolean; /** - * Turn-scoped execution fields replayed verbatim from a frozen turn. - * An explicit empty `mcpServerIds` is a real selection, so these are - * applied when PRESENT rather than when truthy. + * Required, not optional: every send route rebuilds the turn config + * from composer state, and a replay must override it. Making the + * carrier mandatory is what turns "this route forgot the acceptance" + * into a compile error instead of a turn that is stopped again. */ - mcpServerIdsOverride?: readonly McpServerId[]; - taskToolsEnabledOverride?: boolean; - issuePRMentionsOverride?: IssuePRMention[]; + turnScoped: TurnScopedOverrides; } ): Promise => { try { @@ -3576,22 +3566,27 @@ export const SessionChatInterface = memo( const issuePRMentions = prompt ? extractIssuePRMentionsFromText(prompt, knownIssuePrItems, repoFullName) : undefined; - const inputConfig = buildSessionTurnInputConfig({ - inputBlocks, - cliType: session.cliType, - agentType: session.agentType, - modeId: turnModeId, - modelId: turnModelId, - configOptionValues: turnConfigOptionValues, - issuePRMentions: options?.issuePRMentionsOverride ?? issuePRMentions, - mcpServerIds: options?.mcpServerIdsOverride ?? mcpSelection.selectedIds, - taskToolsEnabled: options?.taskToolsEnabledOverride ?? tasksEnabled, - agentRoleId: - options?.agentRole?.agentRoleId ?? (options?.agentRole === null ? null : undefined), - agentRoleRevision: options?.agentRole?.agentRoleRevision, - ...(options?.acceptWiderPermission === true ? { acceptWiderPermission: true } : {}), - resume: session.acpSessionId ?? undefined, - }); + const inputConfig = buildSessionTurnInputConfig( + applyTurnScopedOverrides( + { + inputBlocks, + cliType: session.cliType, + agentType: session.agentType, + modeId: turnModeId, + modelId: turnModelId, + configOptionValues: turnConfigOptionValues, + issuePRMentions, + mcpServerIds: mcpSelection.selectedIds, + taskToolsEnabled: tasksEnabled, + agentRoleId: + options?.agentRole?.agentRoleId ?? + (options?.agentRole === null ? null : undefined), + agentRoleRevision: options?.agentRole?.agentRoleRevision, + resume: session.acpSessionId ?? undefined, + }, + options.turnScoped + ) + ); let userTurnId = options?.existingUserTurnId?.trim() || null; if (!userTurnId && options?.createHistory) { @@ -3725,21 +3720,27 @@ export const SessionChatInterface = memo( const issuePRMentions = prompt ? extractIssuePRMentionsFromText(prompt, knownIssuePrItems, repoFullName) : undefined; - const inputConfig = buildSessionTurnInputConfig({ - inputBlocks, - cliType: session.cliType, - agentType: session.agentType, - modeId: turnModeId, - modelId: turnModelId, - configOptionValues: turnConfigOptionValues, - issuePRMentions, - mcpServerIds: mcpSelection.selectedIds, - taskToolsEnabled: tasksEnabled, - agentRoleId: - options?.agentRole?.agentRoleId ?? (options?.agentRole === null ? null : undefined), - agentRoleRevision: options?.agentRole?.agentRoleRevision, - resume: session.acpSessionId ?? undefined, - }); + const inputConfig = buildSessionTurnInputConfig( + applyTurnScopedOverrides( + { + inputBlocks, + cliType: session.cliType, + agentType: session.agentType, + modeId: turnModeId, + modelId: turnModelId, + configOptionValues: turnConfigOptionValues, + issuePRMentions, + mcpServerIds: mcpSelection.selectedIds, + taskToolsEnabled: tasksEnabled, + agentRoleId: + options?.agentRole?.agentRoleId ?? + (options?.agentRole === null ? null : undefined), + agentRoleRevision: options?.agentRole?.agentRoleRevision, + resume: session.acpSessionId ?? undefined, + }, + pickTurnScopedOverrides(options) + ) + ); const queuedInputConfig: MessageQueueItemInput['acpSessionConfig'] = { prompt: inputConfig.prompt, inputBlocks, @@ -3749,8 +3750,11 @@ export const SessionChatInterface = memo( modelId: inputConfig.modelId ?? undefined, configOptionValues: inputConfig.configOptionValues ?? undefined, issuePRMentions: inputConfig.issuePRMentions ?? undefined, - mcpServerIds: [...mcpSelection.selectedIds], + // From the built config, not the composer: a queued replay must + // keep the stopped turn's tool reach and its acceptance. + mcpServerIds: [...(inputConfig.mcpServerIds ?? mcpSelection.selectedIds)], taskToolsEnabled: inputConfig.taskToolsEnabled, + ...(inputConfig.acceptWiderPermission === true ? { acceptWiderPermission: true } : {}), agentRoleId: inputConfig.agentRoleId, agentRoleRevision: inputConfig.agentRoleRevision, resume: inputConfig.resume ?? undefined, @@ -3826,16 +3830,7 @@ export const SessionChatInterface = memo( modelIdOverride: options?.modelIdOverride, configOptionValuesOverride: turnConfigOptionValues, agentRole: options?.agentRole, - ...(options?.acceptWiderPermission === true ? { acceptWiderPermission: true } : {}), - ...(options?.mcpServerIdsOverride !== undefined - ? { mcpServerIdsOverride: options.mcpServerIdsOverride } - : {}), - ...(options?.taskToolsEnabledOverride !== undefined - ? { taskToolsEnabledOverride: options.taskToolsEnabledOverride } - : {}), - ...(options?.issuePRMentionsOverride !== undefined - ? { issuePRMentionsOverride: options.issuePRMentionsOverride } - : {}), + turnScoped: pickTurnScopedOverrides(options), }); }, [configOptionValues, enqueueInputBlocks] @@ -3901,6 +3896,7 @@ export const SessionChatInterface = memo( modelIdOverride: turnModelId, configOptionValuesOverride: turnConfigOptionValues, agentRole: options?.agentRole, + ...pickTurnScopedOverrides(options), }); captureSessionEvent( accepted ? 'session/message_queued' : 'session/message_submit_failed', @@ -3922,16 +3918,7 @@ export const SessionChatInterface = memo( modelIdOverride: turnModelId, configOptionValuesOverride: turnConfigOptionValues, agentRole: options?.agentRole, - ...(options?.acceptWiderPermission === true ? { acceptWiderPermission: true } : {}), - ...(options?.mcpServerIdsOverride !== undefined - ? { mcpServerIdsOverride: options.mcpServerIdsOverride } - : {}), - ...(options?.taskToolsEnabledOverride !== undefined - ? { taskToolsEnabledOverride: options.taskToolsEnabledOverride } - : {}), - ...(options?.issuePRMentionsOverride !== undefined - ? { issuePRMentionsOverride: options.issuePRMentionsOverride } - : {}), + turnScoped: pickTurnScopedOverrides(options), }); captureSessionEvent( accepted ? 'session/message_guide_requested' : 'session/message_submit_failed', @@ -3961,6 +3948,7 @@ export const SessionChatInterface = memo( modelIdOverride: turnModelId, configOptionValuesOverride: turnConfigOptionValues, agentRole: options?.agentRole, + ...pickTurnScopedOverrides(options), }); if (!accepted) { captureSessionEvent('session/message_submit_failed', { @@ -4070,19 +4058,14 @@ export const SessionChatInterface = memo( : target.agentRoleId === null ? null : undefined, - acceptWiderPermission: true, + // The offer is only shown while the agent is idle, so this is the + // route it takes; asking for it explicitly keeps the decision the + // user just made from being parked behind a queue. + forceDirect: true, // Tool reach belonged to that turn too: replaying it with whatever the // composer holds now would pair the old prompt with new permissions, // and would silently drop an explicit empty MCP selection. - ...(target.mcpServerIds !== undefined - ? { mcpServerIdsOverride: target.mcpServerIds as McpServerId[] } - : {}), - ...(target.taskToolsEnabled !== undefined - ? { taskToolsEnabledOverride: target.taskToolsEnabled } - : {}), - ...(target.issuePRMentions !== undefined - ? { issuePRMentionsOverride: target.issuePRMentions as IssuePRMention[] } - : {}), + ...buildPermissionRetryOverrides(target), }); if (!accepted) { toast.error( diff --git a/packages/components/src/components/settings/agent-role-editor-dialog.tsx b/packages/components/src/components/settings/agent-role-editor-dialog.tsx index f236a3829..8e7835e20 100644 --- a/packages/components/src/components/settings/agent-role-editor-dialog.tsx +++ b/packages/components/src/components/settings/agent-role-editor-dialog.tsx @@ -155,6 +155,7 @@ export function AgentRoleEditorDialog({ ...(selectedAgentConfig && selectorOptions ? { capabilityAuthority: selectorOptions.capabilityAuthority } : {}), + isEditingExistingRole: editor?.mode === 'edit', }) : [], [accessibleRoles, editor, editorValue, selectedAgentConfig, selectorOptions] diff --git a/packages/components/src/lib/agent-role-form.ts b/packages/components/src/lib/agent-role-form.ts index 040d0b278..4e6638a84 100644 --- a/packages/components/src/lib/agent-role-form.ts +++ b/packages/components/src/lib/agent-role-form.ts @@ -118,6 +118,13 @@ export const validateAgentRoleForm = ( editingRoleId?: AgentRoleId | null; /** How the selected agent's capabilities were obtained, when known. */ capabilityAuthority?: AcpCapabilityAuthority; + /** + * True only while editing a Role that already exists. It cannot be inferred + * from the value: the composer creates a Role pre-filled from whatever it + * currently shows, and under `provisional` capabilities those are the static + * tables' own defaults — a non-empty run config that nobody chose. + */ + isEditingExistingRole?: boolean; } ): AgentRoleFormError[] => { const errors: AgentRoleFormError[] = []; @@ -138,20 +145,19 @@ export const validateAgentRoleForm = ( if (!value.agentConfigId) errors.push('agent_config_required'); /* A Role IS its run config — it pins the permission mode, and the surfaces - that hide the permission control rely on that. Non-authoritative - capabilities are the built-in static tables, which must not be seeded into - a Role, so the form has nothing real to offer and the user has chosen - nothing: saving here would create a "complete configuration" that pins no - permission at all. An existing Role that already carries values keeps its - own and stays editable offline. */ - const hasPinnedRunConfig = - Boolean(value.modeId) || - Boolean(value.modelId) || - Object.keys(value.configOptionValues).length > 0; + that hide the permission control rely on that. Under non-authoritative + capabilities the only values available are the built-in static tables', so + a NEW Role created now would persist a guess as a durable promise: either + seeded here, or carried in from a composer that resolved those same static + defaults (chat landing and the input area both create a Role from what they + currently show). Emptiness cannot tell those apart, so the rule is the + creation itself, not the value. Editing an existing Role stays open — it + already carries what it pins, and its owner must be able to rename it while + the machine is offline. */ if ( options.capabilityAuthority !== undefined && options.capabilityAuthority !== 'authoritative' && - !hasPinnedRunConfig + !options.isEditingExistingRole ) { errors.push('run_config_unavailable'); } diff --git a/packages/components/src/lib/turn-scoped-overrides.ts b/packages/components/src/lib/turn-scoped-overrides.ts new file mode 100644 index 000000000..288816df7 --- /dev/null +++ b/packages/components/src/lib/turn-scoped-overrides.ts @@ -0,0 +1,90 @@ +import type { AcpConfigOptionValue, IssuePRMention, McpServerId } from '@lody/shared'; +import type { PermissionNotAppliedRetryTarget } from '@/lib/permission-not-applied-retry'; + +/** + * The fields a REPLAYED turn carries that the composer must not supply. + * + * A send has several routes — direct dispatch, queue, guide — and each one + * rebuilds the turn's input config from the composer's current state. That is + * right for an ordinary send and wrong for a replay: the composer holds what + * the user has since selected, not what the stopped turn ran with. These travel + * together as one object so a route either forwards the whole set or fails to + * compile, rather than forwarding three of four and losing the acceptance that + * made the replay possible at all. + * + * `undefined` means "use the composer's value"; a PRESENT value wins, including + * an empty `mcpServerIds` array, which is an explicit "no servers" selection. + */ +export type TurnScopedOverrides = { + /** One-time informed acceptance of a wider permission, for this turn only. */ + acceptWiderPermission?: boolean; + mcpServerIdsOverride?: readonly McpServerId[]; + taskToolsEnabledOverride?: boolean; + issuePRMentionsOverride?: IssuePRMention[]; +}; + +export const EMPTY_TURN_SCOPED_OVERRIDES: TurnScopedOverrides = {}; + +/** Narrows a wider options object to exactly the turn-scoped set. */ +export const pickTurnScopedOverrides = ( + options: TurnScopedOverrides | undefined +): TurnScopedOverrides => ({ + ...(options?.acceptWiderPermission === true ? { acceptWiderPermission: true } : {}), + ...(options?.mcpServerIdsOverride !== undefined + ? { mcpServerIdsOverride: options.mcpServerIdsOverride } + : {}), + ...(options?.taskToolsEnabledOverride !== undefined + ? { taskToolsEnabledOverride: options.taskToolsEnabledOverride } + : {}), + ...(options?.issuePRMentionsOverride !== undefined + ? { issuePRMentionsOverride: options.issuePRMentionsOverride } + : {}), +}); + +/** What the stopped turn ran with, as dispatch overrides. */ +export const buildPermissionRetryOverrides = ( + target: Pick< + PermissionNotAppliedRetryTarget, + 'mcpServerIds' | 'taskToolsEnabled' | 'issuePRMentions' + > +): TurnScopedOverrides => ({ + acceptWiderPermission: true, + ...(target.mcpServerIds !== undefined + ? { mcpServerIdsOverride: target.mcpServerIds as McpServerId[] } + : {}), + ...(target.taskToolsEnabled !== undefined + ? { taskToolsEnabledOverride: target.taskToolsEnabled } + : {}), + ...(target.issuePRMentions !== undefined + ? { issuePRMentionsOverride: target.issuePRMentions as IssuePRMention[] } + : {}), +}); + +type TurnInputConfigFields = { + mcpServerIds?: readonly McpServerId[] | null; + taskToolsEnabled?: boolean; + issuePRMentions?: IssuePRMention[]; + configOptionValues?: Record | null; +}; + +/** + * The last hop: applies the overrides over whatever the composer produced, just + * before `buildSessionTurnInputConfig`. Every send route goes through here, so + * a replay reaches the wire with the turn's own values. + */ +export const applyTurnScopedOverrides = ( + args: T, + overrides: TurnScopedOverrides +): T & { acceptWiderPermission?: boolean } => ({ + ...args, + ...(overrides.mcpServerIdsOverride !== undefined + ? { mcpServerIds: overrides.mcpServerIdsOverride } + : {}), + ...(overrides.taskToolsEnabledOverride !== undefined + ? { taskToolsEnabled: overrides.taskToolsEnabledOverride } + : {}), + ...(overrides.issuePRMentionsOverride !== undefined + ? { issuePRMentions: overrides.issuePRMentionsOverride } + : {}), + ...(overrides.acceptWiderPermission === true ? { acceptWiderPermission: true } : {}), +}); diff --git a/packages/components/tests/agent-role-form-run-config-gate.test.tsx b/packages/components/tests/agent-role-form-run-config-gate.test.tsx index 98b6c102c..9a952019c 100644 --- a/packages/components/tests/agent-role-form-run-config-gate.test.tsx +++ b/packages/components/tests/agent-role-form-run-config-gate.test.tsx @@ -68,7 +68,7 @@ describe('Agent Role run-config gate', () => { (button) => button.getAttribute('type') === 'submit' ); - it('refuses to save a Role that pins nothing because capabilities are unreported', () => { + it('refuses to create a Role while capabilities are unreported', () => { // The agent has not reported; the form shows no run-config controls, so the // user has chosen nothing. Saving would create a "complete configuration" // that pins no permission at all. @@ -119,12 +119,30 @@ describe('Agent Role run-config gate', () => { expect(submitButton()?.disabled).toBe(false); }); + it('refuses a composer-seeded create too, non-empty though it is', () => { + // Chat landing and the input area create a Role from what they currently + // show, and under `provisional` capabilities those values are the static + // tables' own defaults — nobody chose them. Emptiness cannot tell that from + // a real selection, so creation itself is what is refused. + expect( + validateAgentRoleForm( + namedValue({ + modeId: 'plan', + modelId: 'model-a', + configOptionValues: { 'fast-mode': true }, + }), + { accessibleRoles: [], capabilityAuthority: 'provisional' } + ) + ).toEqual(['run_config_unavailable']); + }); + it('keeps an existing Role editable while its agent is unreachable', () => { // It already carries what it pins, so nothing is being invented here. expect( validateAgentRoleForm(namedValue({ modeId: 'plan', modelId: 'model-a' }), { accessibleRoles: [], capabilityAuthority: 'unavailable', + isEditingExistingRole: true, }) ).toEqual([]); }); diff --git a/packages/components/tests/turn-scoped-overrides.test.ts b/packages/components/tests/turn-scoped-overrides.test.ts new file mode 100644 index 000000000..39b829f1b --- /dev/null +++ b/packages/components/tests/turn-scoped-overrides.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { buildSessionTurnInputConfig, type McpServerId } from '@lody/shared'; + +import { + applyTurnScopedOverrides, + buildPermissionRetryOverrides, + pickTurnScopedOverrides, +} from '../src/lib/turn-scoped-overrides'; +import { findPermissionNotAppliedRetryTarget } from '../src/lib/permission-not-applied-retry'; + +/** What the composer would contribute if nothing overrode it. */ +const composerArgs = { + inputBlocks: [{ type: 'text' as const, text: 'ship it' }], + cliType: 'builtin' as const, + agentType: 'claude', + modeId: 'plan', + mcpServerIds: ['server-the-user-picked-later'] as McpServerId[], + taskToolsEnabled: true, + issuePRMentions: [{ number: 99 }] as never[], +}; + +describe('turn-scoped overrides reach the wire', () => { + const stoppedHistory = [ + { + id: 'user-1', + role: 'user', + inputConfig: { + inputBlocks: [{ type: 'text', text: 'ship it' }], + modeId: 'plan', + modelId: 'model-b', + configOptionValues: { reasoning_effort: 'high' }, + mcpServerIds: [], + taskToolsEnabled: false, + issuePRMentions: [{ number: 7 }], + }, + }, + { + id: 'notice-1', + role: 'system', + items: [ + { + type: 'system_notice', + name: 'chat_failed', + meta: { + reason: 'permission_not_applied', + permission: { requestedModeId: 'plan', effectiveModeId: 'auto' }, + }, + }, + ], + }, + ]; + + it('carries the acceptance and the frozen turn config through every send route', () => { + const target = findPermissionNotAppliedRetryTarget(stoppedHistory); + expect(target).not.toBeNull(); + + // The route the button takes: dispatch options → each hop's narrowing → + // the args `buildSessionTurnInputConfig` is finally called with. + const dispatchOptions = { ...buildPermissionRetryOverrides(target!) }; + const afterHops = pickTurnScopedOverrides(pickTurnScopedOverrides(dispatchOptions)); + const config = buildSessionTurnInputConfig(applyTurnScopedOverrides(composerArgs, afterHops)); + + expect(config.acceptWiderPermission).toBe(true); + // The composer's own values lost to the stopped turn's, including the + // explicit empty MCP selection and `taskToolsEnabled: false`. + expect(config.mcpServerIds).toEqual([]); + expect(config.taskToolsEnabled).toBe(false); + expect(config.issuePRMentions).toEqual([{ number: 7 }]); + }); + + it('leaves an ordinary send entirely to the composer', () => { + const config = buildSessionTurnInputConfig( + applyTurnScopedOverrides(composerArgs, pickTurnScopedOverrides(undefined)) + ); + + expect(config.acceptWiderPermission).toBeUndefined(); + expect(config.mcpServerIds).toEqual(['server-the-user-picked-later']); + expect(config.taskToolsEnabled).toBe(true); + }); + + it('keeps a stopped turn that pinned nothing extra from inventing values', () => { + // No MCP/task/mention fields on the frozen config: the composer's stay. + const overrides = buildPermissionRetryOverrides({}); + const config = buildSessionTurnInputConfig( + applyTurnScopedOverrides(composerArgs, pickTurnScopedOverrides(overrides)) + ); + + expect(config.acceptWiderPermission).toBe(true); + expect(config.mcpServerIds).toEqual(['server-the-user-picked-later']); + expect(config.taskToolsEnabled).toBe(true); + }); +}); From 09d9fc90706b1257d0d5dc27ef771defb9d946a0 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 19:11:57 +0800 Subject: [PATCH 13/24] fix(shared): keep the one-time permission acceptance across every rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag was in the schemas but not in `normalizeSessionTurnInputConfig`, which rebuilds the turn config field by field — and every transport runs the config through it: direct RPC, `session/dispatch-turn`, steer, the Loro history readback, queue promotion. So the acceptance the client set was dropped before the daemon could read it, and the accepted turn was stopped again. Two more hand-written rebuilds had the same hole: the dispatch watcher's `acpSessionConfig` constructions (the doc-driven path, which is the one the desktop actually takes) and queue promotion's own `buildSessionTurnInputConfig` call. All four now copy ONLY an explicit `true`. `false` and absent are the same answer — not accepted — and neither may be written back as something a later turn could read as an acceptance. The queued config's strict schema admits the field too, or a queued replay would be rejected whole. `buildCliHistoryInputConfig` deliberately does not copy it: CLI and MCP turns never carry an acceptance, and it must not become inheritable there. Ablation: removing the normalizer branch fails the round trip; copying the value verbatim instead of gating on `true` fails it too. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/AGENTS.md | 12 ++++++++- .../src/session/session-dispatch-watcher.ts | 15 +++++++++++ packages/shared/src/message-schemas.ts | 12 +++++++++ packages/shared/tests/session-input.test.ts | 26 +++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index fdbf16c05..b98f87626 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -350,7 +350,17 @@ Two things the dev build does deliberately, both load-bearing: queue, guide), because each route rebuilds the turn config from composer state: a route that forwarded three of four silently produced a turn with no acceptance, which the daemon stopped again. Required, not optional, so a new - route fails to compile rather than dropping it. + route fails to compile rather than dropping it. The same field must survive + every REBUILD on the way to the daemon, and there are three hand-written ones: + `normalizeSessionTurnInputConfig` (which direct RPC, `session/dispatch-turn`, + steer, the Loro history readback and queue promotion all run the config + through), the dispatch watcher's two `acpSessionConfig` constructions, and + queue promotion's `buildSessionTurnInputConfig` call. A rebuild that omits it + stops the very turn the user just accepted. Each copies ONLY an explicit + `true`: `false` and absent are the same answer, and neither may be written + back as something a later turn could read. `buildCliHistoryInputConfig` is the + deliberate exception — CLI and MCP turns never carry an acceptance, and it + must not become inheritable there. - MCP `session_list` defaults to 20 (maximum 100), and `session_history` defaults to 10 (maximum 50 and 128 KiB). Keep the MCP surface bounded even though the human CLI retains `session history --all`. `session_list` and `session_status_many` derive busy/idle from diff --git a/apps/cli/src/session/session-dispatch-watcher.ts b/apps/cli/src/session/session-dispatch-watcher.ts index 764f5ceda..c84c86bbc 100644 --- a/apps/cli/src/session/session-dispatch-watcher.ts +++ b/apps/cli/src/session/session-dispatch-watcher.ts @@ -1972,6 +1972,13 @@ export class SessionDispatchWatcher { agentRoleId: entry.inputConfig?.agentRoleId, agentRoleRevision: entry.inputConfig?.agentRoleRevision, issuePRMentions: entry.inputConfig?.issuePRMentions, + // Only an explicit `true` travels, and only with the turn that carries + // it: this is one-time informed acceptance of a wider permission, so a + // rebuild that dropped it would stop the very turn the user just + // accepted, and one that defaulted it would accept for every turn. + ...(entry.inputConfig?.acceptWiderPermission === true + ? { acceptWiderPermission: true } + : {}), resume: entry.inputConfig?.resume ?? resolveDispatchAcpSessionId(meta), }, userTurnId: entry.id, @@ -2016,6 +2023,9 @@ export class SessionDispatchWatcher { agentRoleId: entry.inputConfig?.agentRoleId, agentRoleRevision: entry.inputConfig?.agentRoleRevision, issuePRMentions: entry.inputConfig?.issuePRMentions, + ...(entry.inputConfig?.acceptWiderPermission === true + ? { acceptWiderPermission: true } + : {}), resume: entry.inputConfig?.resume, }, worktreeSetup: launchConfig?.worktreeSetup, @@ -2115,6 +2125,11 @@ export class SessionDispatchWatcher { agentRoleId: queuedItem.acpSessionConfig?.agentRoleId, agentRoleRevision: queuedItem.acpSessionConfig?.agentRoleRevision, issuePRMentions: queuedItem.acpSessionConfig?.issuePRMentions, + // A queued turn keeps the acceptance it was queued with; promotion must + // not quietly turn it back into a turn that will be stopped. + ...(queuedItem.acpSessionConfig?.acceptWiderPermission === true + ? { acceptWiderPermission: true } + : {}), resume: resolveResumableAcpSessionId(meta), }); const pendingEntry = buildPendingUserHistoryEntry({ diff --git a/packages/shared/src/message-schemas.ts b/packages/shared/src/message-schemas.ts index ccdf8a226..74d6f0f3e 100644 --- a/packages/shared/src/message-schemas.ts +++ b/packages/shared/src/message-schemas.ts @@ -489,6 +489,17 @@ export const normalizeSessionTurnInputConfig = ( normalized.taskToolsEnabled = taskToolsEnabled; } + /* Only an explicit `true` survives. This is one-time informed acceptance of a + permission the agent reported as wider than the turn asked for, so `false` + and absent are the same thing — "not accepted" — and neither may be written + back as a value that another turn could read. Every transport normalizes + through here (direct RPC, the dispatch-turn and steer entries, the Loro + history readback, queue promotion), so a field this rebuild does not copy + never reaches the daemon at all. */ + if (maybeParseField(z.boolean(), record.acceptWiderPermission) === true) { + normalized.acceptWiderPermission = true; + } + if (record.agentRoleId === null) { normalized.agentRoleId = null; } else { @@ -925,6 +936,7 @@ export const SessionPreparationRunConfigSchema = z .transform((ids) => normalizeMcpServerIdSelection(ids) ?? []) .optional(), taskToolsEnabled: z.boolean().optional(), + acceptWiderPermission: z.boolean().optional(), }) .strict(); diff --git a/packages/shared/tests/session-input.test.ts b/packages/shared/tests/session-input.test.ts index 98ac80650..9d7955ba1 100644 --- a/packages/shared/tests/session-input.test.ts +++ b/packages/shared/tests/session-input.test.ts @@ -962,4 +962,30 @@ describe('one-time acceptance of a wider permission', () => { buildSessionTurnInputConfig({ ...base, acceptWiderPermission: false }).acceptWiderPermission ).toBeUndefined(); }); + + it('survives the normalizer every transport runs it through', () => { + // Direct RPC, `session/dispatch-turn`, steer, the Loro history readback and + // queue promotion all rebuild the config through this one function, so a + // field it does not copy never reaches the daemon — however carefully the + // client set it. + expect( + normalizeSessionTurnInputConfig( + buildSessionTurnInputConfig({ ...base, acceptWiderPermission: true }) + )?.acceptWiderPermission + ).toBe(true); + + // One-time semantics survive the round trip too: only an explicit `true` is + // carried, so nothing can read an acceptance out of a turn that made none. + expect( + normalizeSessionTurnInputConfig(buildSessionTurnInputConfig(base))?.acceptWiderPermission + ).toBeUndefined(); + expect( + normalizeSessionTurnInputConfig({ ...base, acceptWiderPermission: false }) + ?.acceptWiderPermission + ).toBeUndefined(); + expect( + normalizeSessionTurnInputConfig({ ...base, acceptWiderPermission: 'yes' }) + ?.acceptWiderPermission + ).toBeUndefined(); + }); }); From 4e6c99b7ddadc6c44e68a527fde0104aaab79b34 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 19:18:52 +0800 Subject: [PATCH 14/24] fix: never copy a one-time permission acceptance onto a different prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Making the acceptance survive every rebuild also made it copyable, and edit-and-resend copies: it normalizes the original turn's config, spreads it, and mints a new `userTurnId` with a new prompt. The consent the user gave for ONE prompt would have ridden along to a different one, and that turn would sail past the stop that exists to ask — a permission bypass assembled out of a spread. `deriveTurnInputConfigForNewTurn` is now the only way to build a turn config from another turn's, and it drops the acceptance. Edit-and-resend and the history replay import (which also rewrites the prompt) go through it. Same-turn rewrites deliberately do not: marking a turn `processing`, tagging `_lodyDeliveryKind`, or retrying its transport is still the turn the user accepted, so those keep it. `use-task-comment-dispatch` builds a fresh config rather than deriving one, so it never carried it in the first place. Ablation: making the derivation spread the config unchanged fails the new test; removing the normalizer branch fails both it and the round trip, so the two halves cannot drift apart unnoticed. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/AGENTS.md | 7 +++++ .../sessions/session-chat-interface.tsx | 20 ++++++++----- .../shared/src/acp/history-replay-import.ts | 8 +++-- packages/shared/src/message-schemas.ts | 23 ++++++++++++++ packages/shared/tests/session-input.test.ts | 30 ++++++++++++++++++- 5 files changed, 76 insertions(+), 12 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index b98f87626..b6e28c532 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -361,6 +361,13 @@ Two things the dev build does deliberately, both load-bearing: back as something a later turn could read. `buildCliHistoryInputConfig` is the deliberate exception — CLI and MCP turns never carry an acceptance, and it must not become inheritable there. + The mirror image is just as load-bearing: making the field survive rebuilds + also made it copyable. Any derivation that mints a new `userTurnId` or changes + the prompt/input blocks — edit-and-resend, history replay import — must go + through `deriveTurnInputConfigForNewTurn`, which drops it. The acceptance was + given for ONE prompt; carrying it onto another is a permission bypass built + out of a spread. Same-turn rewrites (status, `_lodyDeliveryKind`, transport + retry) are not copies and keep it. - MCP `session_list` defaults to 20 (maximum 100), and `session_history` defaults to 10 (maximum 50 and 128 KiB). Keep the MCP surface bounded even though the human CLI retains `session history --all`. `session_list` and `session_status_many` derive busy/idle from diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index 8d70d4dec..03e657a57 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -105,6 +105,7 @@ import { isSessionGoalCleared, isSessionGoalActive, normalizeSessionInputBlocks, + deriveTurnInputConfigForNewTurn, normalizeSessionTurnInputConfig, resolveSessionAcpRuntimeConfig, resolveSessionConversationConfig, @@ -2957,14 +2958,17 @@ export const SessionChatInterface = memo( inputBlocks.push({ type: 'text', text: nextText }); } - const originalConfig = normalizeSessionTurnInputConfig(message.inputConfig) ?? {}; - const inputConfig: SessionTurnInputConfig = { - ...originalConfig, - prompt: extractPromptPreviewFromInputBlocks(inputBlocks), - inputBlocks, - cliType: session.cliType, - agentType: session.agentType, - }; + // A new turn id and a changed prompt: the one-time permission acceptance + // the user gave the ORIGINAL prompt does not travel with it. + const inputConfig: SessionTurnInputConfig = deriveTurnInputConfigForNewTurn( + message.inputConfig, + { + prompt: extractPromptPreviewFromInputBlocks(inputBlocks), + inputBlocks, + cliType: session.cliType, + agentType: session.agentType, + } + ); const replacementUserTurnId = uuidv4(); try { const response = await runtime.requestSessionEditAndResend( diff --git a/packages/shared/src/acp/history-replay-import.ts b/packages/shared/src/acp/history-replay-import.ts index fa629dc44..6a97f541e 100644 --- a/packages/shared/src/acp/history-replay-import.ts +++ b/packages/shared/src/acp/history-replay-import.ts @@ -1,3 +1,4 @@ +import { deriveTurnInputConfigForNewTurn } from '../message-schemas'; import type { ACPSessionId, MessageContent } from '../ai'; import type { LocalProjectHistoryProvider } from '../project'; import type { SessionHistoryInput } from '../schema'; @@ -52,10 +53,11 @@ function appendUserText(entry: SessionHistoryInput, text: string): SessionHistor ...entry, items: items as unknown as SessionHistoryInput['items'], inputConfig: entry.inputConfig - ? { - ...entry.inputConfig, + ? // The prompt changes here, so this is a new turn's config: a one-time + // permission acceptance must not ride along with it. + deriveTurnInputConfigForNewTurn(entry.inputConfig, { prompt: `${entry.inputConfig.prompt ?? ''}${text}`, - } + }) : entry.inputConfig, }; } diff --git a/packages/shared/src/message-schemas.ts b/packages/shared/src/message-schemas.ts index 74d6f0f3e..8e93546cf 100644 --- a/packages/shared/src/message-schemas.ts +++ b/packages/shared/src/message-schemas.ts @@ -543,6 +543,29 @@ export const normalizeSessionTurnInputConfig = ( return Object.keys(normalized).length > 0 ? normalized : undefined; }; +/** + * A turn config derived from ANOTHER turn's, for a new turn. + * + * `acceptWiderPermission` is informed acceptance of a permission the agent + * reported as wider than what ONE prompt asked for. Copying it onto a different + * prompt would carry that consent somewhere the user never gave it, and the new + * turn would sail past the stop that exists to ask — a permission bypass built + * out of a spread. So any copy that mints a new `userTurnId`, or changes the + * prompt or input blocks, must go through here. + * + * Same-turn rewrites are NOT copies: marking a turn `processing`, tagging its + * delivery kind, or retrying its transport keeps the acceptance, because it is + * still the turn the user accepted. + */ +export const deriveTurnInputConfigForNewTurn = ( + original: unknown, + overrides: Partial = {} +): SessionTurnInputConfig => { + const { acceptWiderPermission: _dropped, ...carried } = + normalizeSessionTurnInputConfig(original) ?? {}; + return { ...carried, ...overrides }; +}; + export const ProjectRefSchema = z.discriminatedUnion('kind', [ z .object({ diff --git a/packages/shared/tests/session-input.test.ts b/packages/shared/tests/session-input.test.ts index 9d7955ba1..d5680ec31 100644 --- a/packages/shared/tests/session-input.test.ts +++ b/packages/shared/tests/session-input.test.ts @@ -15,7 +15,11 @@ import { resolveSessionConversationSourceFence, resolveSessionTaskToolsEnabled, } from '../src/session-input'; -import { normalizeSessionTurnInputConfig, SessionFileBlockSchema } from '../src/message-schemas'; +import { + deriveTurnInputConfigForNewTurn, + normalizeSessionTurnInputConfig, + SessionFileBlockSchema, +} from '../src/message-schemas'; import { sessionDocSchema } from '../src/schema'; import type { CommentReferencePayload, @@ -963,6 +967,30 @@ describe('one-time acceptance of a wider permission', () => { ).toBeUndefined(); }); + it('does not travel to a turn derived from another one', () => { + // Edit-and-resend mints a new userTurnId and a new prompt. The acceptance + // was given for the ORIGINAL prompt, so carrying it would let the edited + // one past the stop without ever asking — a bypass built out of a spread. + const accepted = buildSessionTurnInputConfig({ ...base, acceptWiderPermission: true }); + expect(accepted.acceptWiderPermission).toBe(true); + + const edited = deriveTurnInputConfigForNewTurn(accepted, { + prompt: 'ship something else', + inputBlocks: [{ type: 'text', text: 'ship something else' }], + }); + expect(edited.acceptWiderPermission).toBeUndefined(); + // Everything else the original turn ran with is still carried. + expect(edited.modeId).toBe('plan'); + expect(edited.prompt).toBe('ship something else'); + + // The same-turn path is untouched: a status rewrite or a transport retry is + // still the turn the user accepted. + expect( + normalizeSessionTurnInputConfig({ ...accepted, _lodyDeliveryKind: 'steer' }) + ?.acceptWiderPermission + ).toBe(true); + }); + it('survives the normalizer every transport runs it through', () => { // Direct RPC, `session/dispatch-turn`, steer, the Loro history readback and // queue promotion all rebuild the config through this one function, so a From 92e039ad2743da99183a6ab4105e1c2824716b66 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 19:32:19 +0800 Subject: [PATCH 15/24] fix: bind the permission acceptance to the difference the user was shown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `acceptWiderPermission: true` suppressed EVERY escalation on the turn that carried it, while the button that sets it only ever discloses one — the pair from the last failure notice. Two ways that hands out consent nobody gave: the agent may have moved further by the time the turn re-runs (`plan → auto` accepted, `plan → always-approve` live), and a second permission control may have widened alongside the one in the notice, which Grok can do because it carries both a mode selector and an explicit `_permission` one. The acceptance is now the triple it was always meant to be — `{ controlId, requestedModeId, effectiveModeId }` — written from the notice's own meta, so the daemon can tell the disclosed difference from one that appeared afterwards. The applier skips only an exact match and CONTINUES scanning the remaining permission selections; anything else stops the turn again and produces its own accurate notice. Matching is on the whole triple, never on the values alone, so consent cannot transfer between permission dimensions that happen to share them. A malformed or partial acceptance reads as none. Ablation, all three now caught: suppressing everything when accepted, ending the scan at the accepted item instead of continuing, and dropping `controlId` from the match. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/AGENTS.md | 13 +- apps/cli/src/lib/message-handler.ts | 1 + .../acp-session-config-applier.test.ts | 183 +++++++++++++++++- .../src/session/acp-session-config-applier.ts | 44 +++-- .../src/session/session-dispatch-watcher.ts | 20 +- .../src/session/session-execution-service.ts | 1 + .../components/src/components/ai-gui/view.tsx | 2 +- .../sessions/session-chat-interface.tsx | 7 +- .../src/lib/permission-not-applied-retry.ts | 40 ++-- .../src/lib/turn-scoped-overrides.ts | 27 ++- .../permission-not-applied-retry.test.tsx | 37 +++- .../tests/turn-scoped-overrides.test.ts | 26 ++- packages/shared/src/ai.ts | 23 ++- packages/shared/src/message-schemas.ts | 23 ++- packages/shared/src/session-input.ts | 5 +- packages/shared/tests/session-input.test.ts | 40 ++-- 16 files changed, 400 insertions(+), 92 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index b6e28c532..efcade22d 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -340,7 +340,18 @@ Two things the dev build does deliberately, both load-bearing: means adding its values there, or its escalations go unseen. The way out is explicit and per-turn: `SessionTurnInputConfig.acceptWiderPermission` is informed acceptance carried by one resend, never inherited and never a default, and it - suppresses the stop while still reporting the mismatch. That resend replays the + suppresses the stop while still reporting the mismatch. It NAMES the exact + difference that was disclosed — `{ controlId, requestedModeId, + effectiveModeId }`, written from the failure notice's own meta — because a + bare boolean also accepts differences the user never saw: the agent may have + moved further by the time the turn re-runs (`plan → auto` accepted, `plan → + always-approve` live), and a second permission control may have widened + alongside the one in the notice (Grok carries both a mode selector and an + explicit `_permission` one). The applier skips ONLY an exact triple match and + keeps scanning the remaining permission selections, so anything undisclosed + stops the turn again with its own accurate notice. Do not match on the values + alone, and do not treat a rank ceiling as equivalent across different + permission controls. That resend replays the STOPPED turn: prompt, mode, model, config values, Role, `mcpServerIds` (including an explicit empty selection), `taskToolsEnabled` and `issuePRMentions` all come from its frozen `inputConfig`, never from the diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index 9fee14978..39a23f05a 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -1753,6 +1753,7 @@ export class MessageHandler { an explicit one-time acceptance if they want it anyway. */ if (permissionEscalation) { throw new AcpPermissionNotAppliedError( + permissionEscalation.controlId, permissionEscalation.requestedModeId, permissionEscalation.effectiveModeId ); diff --git a/apps/cli/src/session/acp-session-config-applier.test.ts b/apps/cli/src/session/acp-session-config-applier.test.ts index 38de61d68..4939a96f2 100644 --- a/apps/cli/src/session/acp-session-config-applier.test.ts +++ b/apps/cli/src/session/acp-session-config-applier.test.ts @@ -211,6 +211,7 @@ describe('applyAcpSessionRunConfig', () => { ); expect(result.permissionEscalation).toEqual({ + controlId: 'permission-mode', requestedModeId: 'plan', effectiveModeId: 'auto', }); @@ -294,6 +295,7 @@ describe('applyAcpSessionRunConfig', () => { ); expect(result.permissionEscalation).toEqual({ + controlId: 'permission_mode', requestedModeId: 'ask', effectiveModeId: 'always-approve', }); @@ -321,7 +323,7 @@ describe('applyAcpSessionRunConfig', () => { permissionAgent('auto') ) ).permissionEscalation - ).toEqual({ requestedModeId: 'ask', effectiveModeId: 'auto' }); + ).toEqual({ controlId: 'permission_mode', requestedModeId: 'ask', effectiveModeId: 'auto' }); // DeepSeek Harness `read-only` → `workspace-write`. expect( @@ -335,7 +337,11 @@ describe('applyAcpSessionRunConfig', () => { permissionAgent('workspace-write') ) ).permissionEscalation - ).toEqual({ requestedModeId: 'read-only', effectiveModeId: 'workspace-write' }); + ).toEqual({ + controlId: 'permission_mode', + requestedModeId: 'read-only', + effectiveModeId: 'workspace-write', + }); // The other direction is a functional mismatch, not an escalation. expect( @@ -414,7 +420,11 @@ describe('applyAcpSessionRunConfig', () => { cliType: 'builtin', agentType: 'grok', configOptionValues: { permission_mode: 'ask' }, - acceptWiderPermission: true, + acceptWiderPermission: { + controlId: 'permission_mode', + requestedModeId: 'ask', + effectiveModeId: 'always-approve', + }, }, agentClient ); @@ -423,6 +433,162 @@ describe('applyAcpSessionRunConfig', () => { expect(result.warningSelections).toEqual(['permission_mode="ask"']); }); + it('still stops when the agent moved further than what was accepted', async () => { + // Accepted `plan → auto` after the first stop; by the time the turn + // re-runs the agent reports `always-approve`. That is a difference the + // user was never shown, so the acceptance does not cover it. + const agentClient = { + isCreated: () => true, + getConfigOptions: () => [ + { + id: 'permission-mode', + category: 'mode', + type: 'select', + currentValue: 'always-approve', + }, + ], + setSessionMode: vi.fn(async () => undefined), + setSessionConfigOption: vi.fn(async () => undefined), + } as unknown as AgentClient; + + const result = await apply( + { + cliType: 'builtin', + agentType: 'claude', + modeId: 'plan', + acceptWiderPermission: { + controlId: 'permission-mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }, + }, + agentClient + ); + + expect(result.permissionEscalation).toEqual({ + controlId: 'permission-mode', + requestedModeId: 'plan', + effectiveModeId: 'always-approve', + }); + }); + + it('still stops on a second permission control the user never saw', async () => { + // Grok carries both a mode selector and an explicit `_permission` one. + // Accepting the disclosed one must not wave the other one through. + const agentClient = { + isCreated: () => true, + getConfigOptions: () => [ + { id: 'interaction_mode', category: 'mode', type: 'select', currentValue: 'auto' }, + { + id: 'permission_mode', + category: '_permission', + type: 'select', + currentValue: 'always-approve', + }, + ], + setSessionMode: vi.fn(async () => undefined), + setSessionConfigOption: vi.fn(async () => undefined), + } as unknown as AgentClient; + + const result = await apply( + { + cliType: 'builtin', + agentType: 'grok', + modeId: 'plan', + configOptionValues: { permission_mode: 'ask' }, + acceptWiderPermission: { + controlId: 'interaction_mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }, + }, + agentClient + ); + + expect(result.permissionEscalation).toEqual({ + controlId: 'permission_mode', + requestedModeId: 'ask', + effectiveModeId: 'always-approve', + }); + }); + + it('keeps scanning past the accepted item to the one still undisclosed', async () => { + // The accepted control is reached FIRST in the apply order, so a skip that + // ended the scan would let the later mode escalation through. + const agentClient = { + isCreated: () => true, + getConfigOptions: () => [ + { id: 'interaction_mode', category: 'mode', type: 'select', currentValue: 'auto' }, + { + id: 'permission_mode', + category: '_permission', + type: 'select', + currentValue: 'always-approve', + }, + ], + setSessionMode: vi.fn(async () => undefined), + setSessionConfigOption: vi.fn(async () => undefined), + } as unknown as AgentClient; + + const result = await apply( + { + cliType: 'builtin', + agentType: 'grok', + modeId: 'plan', + configOptionValues: { permission_mode: 'ask' }, + acceptWiderPermission: { + controlId: 'permission_mode', + requestedModeId: 'ask', + effectiveModeId: 'always-approve', + }, + }, + agentClient + ); + + expect(result.permissionEscalation).toEqual({ + controlId: 'interaction_mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }); + }); + + it('does not let an acceptance for one control cover the same values on another', async () => { + // Same `plan → auto` values, different control. Matching on the values + // alone would silently transfer consent between permission dimensions. + const agentClient = { + isCreated: () => true, + getConfigOptions: () => [ + { + id: 'permission_mode', + category: '_permission', + type: 'select', + currentValue: 'auto', + }, + ], + setSessionConfigOption: vi.fn(async () => undefined), + } as unknown as AgentClient; + + const result = await apply( + { + cliType: 'builtin', + agentType: 'grok', + configOptionValues: { permission_mode: 'plan' }, + acceptWiderPermission: { + controlId: 'interaction_mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }, + }, + agentClient + ); + + expect(result.permissionEscalation).toEqual({ + controlId: 'permission_mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }); + }); + it('stands down for a turn that carries the informed acceptance', async () => { const agentClient = { isCreated: () => true, @@ -433,7 +599,16 @@ describe('applyAcpSessionRunConfig', () => { } as unknown as AgentClient; const result = await apply( - { cliType: 'builtin', agentType: 'claude', modeId: 'plan', acceptWiderPermission: true }, + { + cliType: 'builtin', + agentType: 'claude', + modeId: 'plan', + acceptWiderPermission: { + controlId: 'permission-mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }, + }, agentClient ); diff --git a/apps/cli/src/session/acp-session-config-applier.ts b/apps/cli/src/session/acp-session-config-applier.ts index 60d22e0f0..04c6982fe 100644 --- a/apps/cli/src/session/acp-session-config-applier.ts +++ b/apps/cli/src/session/acp-session-config-applier.ts @@ -3,6 +3,7 @@ import { ACP_CONFIG_OPTION_ON_VALUE, isAcpFastModeConfigId, isAcpPermissionWiderThanRequested, + type AcceptedWiderPermission, isAcpPlanModeConfigOption, isSensitiveAcpConfigOptionId, type ACPSessionId, @@ -56,8 +57,8 @@ export type AcpSessionRunConfig = { modeId?: string; modelId?: string; configOptionValues?: Record; - /** One-time informed acceptance carried by this turn. */ - acceptWiderPermission?: boolean; + /** One-time informed acceptance carried by this turn, for one exact difference. */ + acceptWiderPermission?: AcceptedWiderPermission; }; type AcpSessionRunConfigApplyResult = { @@ -73,7 +74,7 @@ type AcpSessionRunConfigApplyResult = { * from a snapshot, never when either mode is unranked, never when the agent * reported nothing to compare. */ - permissionEscalation?: { requestedModeId: string; effectiveModeId: string }; + permissionEscalation?: AcceptedWiderPermission; }; /** A boolean toggle and an `on`/`off` select express the same choice. */ @@ -343,12 +344,8 @@ export async function applyAcpSessionRunConfig(args: { which case the two are equal and nothing fires), and the config table comes straight from what the agent published. So this cannot be triggered by a snapshot, by a stale cache, or by an unconfirmed request. */ - const findPermissionEscalation = (): - | { requestedModeId: string; effectiveModeId: string } - | undefined => { - if (config.acceptWiderPermission) { - return undefined; - } + const accepted = config.acceptWiderPermission; + const findPermissionEscalation = (): AcceptedWiderPermission | undefined => { for (const selection of appliedSelections) { if ( selection.source.kind === 'model' || @@ -357,24 +354,40 @@ export async function applyAcpSessionRunConfig(args: { ) { continue; } + const controlId = selection.source.kind === 'mode' ? modeConfigId : selection.source.configId; const effective = selection.source.kind === 'mode' ? runtimeConfigPatch.modeId - : effectiveConfigOptionValues[selection.source.configId]; + : effectiveConfigOptionValues[controlId]; + if ( + typeof selection.requested !== 'string' || + typeof effective !== 'string' || + !isAcpPermissionWiderThanRequested(selection.requested, effective) + ) { + continue; + } + /* Only the exact difference the user was shown is skipped, and the scan + CONTINUES. A bare "accepted" would also wave through a difference they + never saw: the agent may have moved further still by the time the turn + re-runs (`plan → auto` accepted, `plan → always-approve` live), and a + second permission control may have widened alongside the one in the + notice. Either way this is a new, undisclosed escalation, and it gets + its own accurate stop. */ if ( - typeof selection.requested === 'string' && - typeof effective === 'string' && - isAcpPermissionWiderThanRequested(selection.requested, effective) + accepted?.controlId === controlId && + accepted.requestedModeId === selection.requested && + accepted.effectiveModeId === effective ) { - return { requestedModeId: selection.requested, effectiveModeId: effective }; + continue; } + return { controlId, requestedModeId: selection.requested, effectiveModeId: effective }; } return undefined; }; const permissionEscalation = findPermissionEscalation(); if (permissionEscalation) { logger.debug( - `[${sessionId}] Permission not applied: requested ${permissionEscalation.requestedModeId}, effective ${permissionEscalation.effectiveModeId}` + `[${sessionId}] Permission not applied for ${permissionEscalation.controlId}: requested ${permissionEscalation.requestedModeId}, effective ${permissionEscalation.effectiveModeId}` ); } @@ -394,6 +407,7 @@ export async function applyAcpSessionRunConfig(args: { */ export class AcpPermissionNotAppliedError extends Error { constructor( + readonly controlId: string, readonly requestedModeId: string, readonly effectiveModeId: string ) { diff --git a/apps/cli/src/session/session-dispatch-watcher.ts b/apps/cli/src/session/session-dispatch-watcher.ts index c84c86bbc..d0a15d62d 100644 --- a/apps/cli/src/session/session-dispatch-watcher.ts +++ b/apps/cli/src/session/session-dispatch-watcher.ts @@ -1,6 +1,7 @@ import type { RepoTransportRoomStatus, RepoWatchHandle } from 'loro-repo'; import { Effect, Fiber } from 'effect'; import { + AcceptedWiderPermissionSchema, buildMissingEmail, buildPendingUserHistoryEntry, buildSessionTurnInputConfig, @@ -1976,8 +1977,8 @@ export class SessionDispatchWatcher { // it: this is one-time informed acceptance of a wider permission, so a // rebuild that dropped it would stop the very turn the user just // accepted, and one that defaulted it would accept for every turn. - ...(entry.inputConfig?.acceptWiderPermission === true - ? { acceptWiderPermission: true } + ...(entry.inputConfig?.acceptWiderPermission + ? { acceptWiderPermission: entry.inputConfig.acceptWiderPermission } : {}), resume: entry.inputConfig?.resume ?? resolveDispatchAcpSessionId(meta), }, @@ -2023,8 +2024,8 @@ export class SessionDispatchWatcher { agentRoleId: entry.inputConfig?.agentRoleId, agentRoleRevision: entry.inputConfig?.agentRoleRevision, issuePRMentions: entry.inputConfig?.issuePRMentions, - ...(entry.inputConfig?.acceptWiderPermission === true - ? { acceptWiderPermission: true } + ...(entry.inputConfig?.acceptWiderPermission + ? { acceptWiderPermission: entry.inputConfig.acceptWiderPermission } : {}), resume: entry.inputConfig?.resume, }, @@ -2127,9 +2128,14 @@ export class SessionDispatchWatcher { issuePRMentions: queuedItem.acpSessionConfig?.issuePRMentions, // A queued turn keeps the acceptance it was queued with; promotion must // not quietly turn it back into a turn that will be stopped. - ...(queuedItem.acpSessionConfig?.acceptWiderPermission === true - ? { acceptWiderPermission: true } - : {}), + // The queued value crosses a CRDT, so it is re-validated rather than + // trusted: a malformed acceptance must read as no acceptance. + ...(() => { + const parsed = AcceptedWiderPermissionSchema.safeParse( + queuedItem.acpSessionConfig?.acceptWiderPermission + ); + return parsed.success ? { acceptWiderPermission: parsed.data } : {}; + })(), resume: resolveResumableAcpSessionId(meta), }); const pendingEntry = buildPendingUserHistoryEntry({ diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 30a715b4b..59d9f54dd 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -1881,6 +1881,7 @@ export class SessionExecutionService { // name the two permissions and offer to run this exact turn once with the // one the agent actually has, instead of a generic pre-prompt error. await this.deps.recordChatFailure(sessionDoc, 'permission_not_applied', message, undefined, { + controlId: error.controlId, requestedModeId: error.requestedModeId, effectiveModeId: error.effectiveModeId, }); diff --git a/packages/components/src/components/ai-gui/view.tsx b/packages/components/src/components/ai-gui/view.tsx index d0d0f708e..b0c87d20e 100644 --- a/packages/components/src/components/ai-gui/view.tsx +++ b/packages/components/src/components/ai-gui/view.tsx @@ -2505,7 +2505,7 @@ const ChatFailedNoticeView = ({ : t( 'sessions.systemNotices.chatFailed.permissionRunOnce', 'Run once with "{{effective}}"', - { effective: permissionRetry.effectiveModeId } + { effective: permissionRetry.disclosed.effectiveModeId } )} ) : null; diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index 03e657a57..b03b5018d 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -3758,7 +3758,9 @@ export const SessionChatInterface = memo( // keep the stopped turn's tool reach and its acceptance. mcpServerIds: [...(inputConfig.mcpServerIds ?? mcpSelection.selectedIds)], taskToolsEnabled: inputConfig.taskToolsEnabled, - ...(inputConfig.acceptWiderPermission === true ? { acceptWiderPermission: true } : {}), + ...(inputConfig.acceptWiderPermission + ? { acceptWiderPermission: inputConfig.acceptWiderPermission } + : {}), agentRoleId: inputConfig.agentRoleId, agentRoleRevision: inputConfig.agentRoleRevision, resume: inputConfig.resume ?? undefined, @@ -4097,8 +4099,7 @@ export const SessionChatInterface = memo( permissionRetryTarget ? { noticeId: permissionRetryTarget.noticeId, - requestedModeId: permissionRetryTarget.requestedModeId, - effectiveModeId: permissionRetryTarget.effectiveModeId, + disclosed: permissionRetryTarget.disclosed, pending: permissionRetryPending, canRetry: sessionDocReady && diff --git a/packages/components/src/lib/permission-not-applied-retry.ts b/packages/components/src/lib/permission-not-applied-retry.ts index 6d0f3923a..235fb1158 100644 --- a/packages/components/src/lib/permission-not-applied-retry.ts +++ b/packages/components/src/lib/permission-not-applied-retry.ts @@ -1,5 +1,10 @@ import { useCallback, useRef, useState } from 'react'; -import type { AcpConfigOptionValue, AgentRoleId, SessionInputBlock } from '@lody/shared'; +import type { + AcceptedWiderPermission, + AcpConfigOptionValue, + AgentRoleId, + SessionInputBlock, +} from '@lody/shared'; /** * Recovering a turn the daemon stopped because the agent reported a permission @@ -14,10 +19,8 @@ import type { AcpConfigOptionValue, AgentRoleId, SessionInputBlock } from '@lody export type PermissionNotAppliedRetryTarget = { /** History entry id of the failure notice, for matching the render site. */ noticeId: string; - /** The permission the stopped turn asked for. */ - requestedModeId: string; - /** The wider one the agent reported. */ - effectiveModeId: string; + /** The exact difference that was disclosed, as the notice reported it. */ + disclosed: AcceptedWiderPermission; userTurnId: string; inputBlocks: SessionInputBlock[]; modeId?: string; @@ -45,22 +48,26 @@ type RetryHistoryEntry = { inputConfig?: unknown; }; -const readPermissionMeta = ( - item: RetryHistoryItem -): { requestedModeId: string; effectiveModeId: string } | null => { +const readPermissionMeta = (item: RetryHistoryItem): AcceptedWiderPermission | null => { if (item?.type !== 'system_notice' || item.name !== 'chat_failed') { return null; } const meta = item.meta as - | { reason?: unknown; permission?: { requestedModeId?: unknown; effectiveModeId?: unknown } } + | { + reason?: unknown; + permission?: { controlId?: unknown; requestedModeId?: unknown; effectiveModeId?: unknown }; + } | undefined; if (meta?.reason !== 'permission_not_applied') { return null; } - const requestedModeId = meta.permission?.requestedModeId; - const effectiveModeId = meta.permission?.effectiveModeId; - return typeof requestedModeId === 'string' && typeof effectiveModeId === 'string' - ? { requestedModeId, effectiveModeId } + const { controlId, requestedModeId, effectiveModeId } = meta.permission ?? {}; + // All three or nothing: an acceptance that cannot name the control it is for + // would be a blanket one. + return typeof controlId === 'string' && + typeof requestedModeId === 'string' && + typeof effectiveModeId === 'string' + ? { controlId, requestedModeId, effectiveModeId } : null; }; @@ -81,7 +88,7 @@ export const findPermissionNotAppliedRetryTarget = ( return null; } let noticeIndex = -1; - let permission: { requestedModeId: string; effectiveModeId: string } | null = null; + let permission: AcceptedWiderPermission | null = null; for (let index = history.length - 1; index >= 0; index -= 1) { const entry = history[index]; if (!entry) continue; @@ -114,7 +121,7 @@ export const findPermissionNotAppliedRetryTarget = ( } return { noticeId: history[noticeIndex]?.id ?? '', - ...permission, + disclosed: permission, userTurnId: entry.id, inputBlocks, ...(typeof inputConfig?.modeId === 'string' ? { modeId: inputConfig.modeId } : {}), @@ -150,8 +157,7 @@ export const findPermissionNotAppliedRetryTarget = ( /** What the failure notice needs to render its one-time acceptance action. */ export type PermissionRetryControl = { noticeId: string; - requestedModeId: string; - effectiveModeId: string; + disclosed: AcceptedWiderPermission; pending: boolean; canRetry: boolean; retry: () => void; diff --git a/packages/components/src/lib/turn-scoped-overrides.ts b/packages/components/src/lib/turn-scoped-overrides.ts index 288816df7..736e4dfdc 100644 --- a/packages/components/src/lib/turn-scoped-overrides.ts +++ b/packages/components/src/lib/turn-scoped-overrides.ts @@ -1,4 +1,9 @@ -import type { AcpConfigOptionValue, IssuePRMention, McpServerId } from '@lody/shared'; +import type { + AcceptedWiderPermission, + AcpConfigOptionValue, + IssuePRMention, + McpServerId, +} from '@lody/shared'; import type { PermissionNotAppliedRetryTarget } from '@/lib/permission-not-applied-retry'; /** @@ -16,8 +21,8 @@ import type { PermissionNotAppliedRetryTarget } from '@/lib/permission-not-appli * an empty `mcpServerIds` array, which is an explicit "no servers" selection. */ export type TurnScopedOverrides = { - /** One-time informed acceptance of a wider permission, for this turn only. */ - acceptWiderPermission?: boolean; + /** One-time informed acceptance of ONE disclosed difference, this turn only. */ + acceptWiderPermission?: AcceptedWiderPermission; mcpServerIdsOverride?: readonly McpServerId[]; taskToolsEnabledOverride?: boolean; issuePRMentionsOverride?: IssuePRMention[]; @@ -29,7 +34,9 @@ export const EMPTY_TURN_SCOPED_OVERRIDES: TurnScopedOverrides = {}; export const pickTurnScopedOverrides = ( options: TurnScopedOverrides | undefined ): TurnScopedOverrides => ({ - ...(options?.acceptWiderPermission === true ? { acceptWiderPermission: true } : {}), + ...(options?.acceptWiderPermission + ? { acceptWiderPermission: options.acceptWiderPermission } + : {}), ...(options?.mcpServerIdsOverride !== undefined ? { mcpServerIdsOverride: options.mcpServerIdsOverride } : {}), @@ -45,10 +52,12 @@ export const pickTurnScopedOverrides = ( export const buildPermissionRetryOverrides = ( target: Pick< PermissionNotAppliedRetryTarget, - 'mcpServerIds' | 'taskToolsEnabled' | 'issuePRMentions' + 'disclosed' | 'mcpServerIds' | 'taskToolsEnabled' | 'issuePRMentions' > ): TurnScopedOverrides => ({ - acceptWiderPermission: true, + // The acceptance names what the notice showed, so the daemon can tell it from + // a difference that appeared afterwards. + acceptWiderPermission: target.disclosed, ...(target.mcpServerIds !== undefined ? { mcpServerIdsOverride: target.mcpServerIds as McpServerId[] } : {}), @@ -75,7 +84,7 @@ type TurnInputConfigFields = { export const applyTurnScopedOverrides = ( args: T, overrides: TurnScopedOverrides -): T & { acceptWiderPermission?: boolean } => ({ +): T & { acceptWiderPermission?: AcceptedWiderPermission } => ({ ...args, ...(overrides.mcpServerIdsOverride !== undefined ? { mcpServerIds: overrides.mcpServerIdsOverride } @@ -86,5 +95,7 @@ export const applyTurnScopedOverrides = ( ...(overrides.issuePRMentionsOverride !== undefined ? { issuePRMentions: overrides.issuePRMentionsOverride } : {}), - ...(overrides.acceptWiderPermission === true ? { acceptWiderPermission: true } : {}), + ...(overrides.acceptWiderPermission + ? { acceptWiderPermission: overrides.acceptWiderPermission } + : {}), }); diff --git a/packages/components/tests/permission-not-applied-retry.test.tsx b/packages/components/tests/permission-not-applied-retry.test.tsx index 2d6d059fe..eaef1e835 100644 --- a/packages/components/tests/permission-not-applied-retry.test.tsx +++ b/packages/components/tests/permission-not-applied-retry.test.tsx @@ -22,7 +22,11 @@ const stoppedTurn = (overrides?: Record) => ({ }, }); -const failureNotice = (permission?: { requestedModeId: string; effectiveModeId: string }) => ({ +const failureNotice = (permission?: { + controlId: string; + requestedModeId: string; + effectiveModeId: string; +}) => ({ id: 'notice-1', role: 'system', items: [ @@ -47,13 +51,20 @@ describe('findPermissionNotAppliedRetryTarget', () => { }, { id: 'assistant-0', role: 'assistant' }, stoppedTurn(), - failureNotice({ requestedModeId: 'plan', effectiveModeId: 'auto' }), + failureNotice({ + controlId: 'permission-mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }), ]); expect(target).toEqual({ noticeId: 'notice-1', - requestedModeId: 'plan', - effectiveModeId: 'auto', + disclosed: { + controlId: 'permission-mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }, userTurnId: 'user-1', inputBlocks: [{ type: 'text', text: 'ship it' }], modeId: 'plan', @@ -69,7 +80,11 @@ describe('findPermissionNotAppliedRetryTarget', () => { // would pair the old prompt with tool permissions that turn never had. const target = findPermissionNotAppliedRetryTarget([ stoppedTurn({ mcpServerIds: [], taskToolsEnabled: false, issuePRMentions: [{ number: 7 }] }), - failureNotice({ requestedModeId: 'plan', effectiveModeId: 'auto' }), + failureNotice({ + controlId: 'permission-mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }), ]); expect(target?.mcpServerIds).toEqual([]); @@ -82,7 +97,11 @@ describe('findPermissionNotAppliedRetryTarget', () => { expect( findPermissionNotAppliedRetryTarget([ stoppedTurn(), - failureNotice({ requestedModeId: 'plan', effectiveModeId: 'auto' }), + failureNotice({ + controlId: 'permission-mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }), { id: 'user-2', role: 'user', @@ -115,7 +134,11 @@ describe('findPermissionNotAppliedRetryTarget', () => { expect( findPermissionNotAppliedRetryTarget([ { id: 'user-1', role: 'user', inputConfig: { modeId: 'plan' } }, - failureNotice({ requestedModeId: 'plan', effectiveModeId: 'auto' }), + failureNotice({ + controlId: 'permission-mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }), ]) ).toBeNull(); }); diff --git a/packages/components/tests/turn-scoped-overrides.test.ts b/packages/components/tests/turn-scoped-overrides.test.ts index 39b829f1b..b5d00a329 100644 --- a/packages/components/tests/turn-scoped-overrides.test.ts +++ b/packages/components/tests/turn-scoped-overrides.test.ts @@ -43,7 +43,11 @@ describe('turn-scoped overrides reach the wire', () => { name: 'chat_failed', meta: { reason: 'permission_not_applied', - permission: { requestedModeId: 'plan', effectiveModeId: 'auto' }, + permission: { + controlId: 'permission-mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }, }, }, ], @@ -60,7 +64,11 @@ describe('turn-scoped overrides reach the wire', () => { const afterHops = pickTurnScopedOverrides(pickTurnScopedOverrides(dispatchOptions)); const config = buildSessionTurnInputConfig(applyTurnScopedOverrides(composerArgs, afterHops)); - expect(config.acceptWiderPermission).toBe(true); + expect(config.acceptWiderPermission).toEqual({ + controlId: 'permission-mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }); // The composer's own values lost to the stopped turn's, including the // explicit empty MCP selection and `taskToolsEnabled: false`. expect(config.mcpServerIds).toEqual([]); @@ -80,12 +88,22 @@ describe('turn-scoped overrides reach the wire', () => { it('keeps a stopped turn that pinned nothing extra from inventing values', () => { // No MCP/task/mention fields on the frozen config: the composer's stay. - const overrides = buildPermissionRetryOverrides({}); + const overrides = buildPermissionRetryOverrides({ + disclosed: { + controlId: 'permission-mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }, + }); const config = buildSessionTurnInputConfig( applyTurnScopedOverrides(composerArgs, pickTurnScopedOverrides(overrides)) ); - expect(config.acceptWiderPermission).toBe(true); + expect(config.acceptWiderPermission).toEqual({ + controlId: 'permission-mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }); expect(config.mcpServerIds).toEqual(['server-the-user-picked-later']); expect(config.taskToolsEnabled).toBe(true); }); diff --git a/packages/shared/src/ai.ts b/packages/shared/src/ai.ts index 7032e17b3..7559e5b85 100644 --- a/packages/shared/src/ai.ts +++ b/packages/shared/src/ai.ts @@ -1151,7 +1151,7 @@ export type ChatFailedMeta = { * the agent reported. Structured so the notice can name both instead of the * client parsing them back out of `message`. */ - permission?: { requestedModeId: string; effectiveModeId: string }; + permission?: AcceptedWiderPermission; }; /** @@ -1577,6 +1577,14 @@ export type ToolCallContent = export type ACPSessionId = string & { __brand: 'ACPSessionId' }; +/** The one permission difference a user was shown and chose to run with. */ +export type AcceptedWiderPermission = { + /** The control it was disclosed for: a config option id, or the mode selector. */ + controlId: string; + requestedModeId: string; + effectiveModeId: string; +}; + export type IssuePRMention = { type: 'issue' | 'pr'; title: string; @@ -1602,11 +1610,16 @@ export type ACPSessionConfig = { /** Whether the built-in Lody Task MCP tools are available to this Turn's Agent session. */ taskToolsEnabled?: boolean; /** - * One-time informed acceptance: the user was told the agent would run with a - * wider permission than requested and chose to run anyway. Scoped to the turn - * that carries it — never inherited, never a default. + * One-time informed acceptance, scoped to the turn that carries it — never + * inherited, never a default. + * + * It names the exact difference the user was shown, because a bare "yes" + * would also accept differences they never saw: the agent may have moved + * further by the time the turn re-runs, and a second permission control may + * have widened alongside the one in the notice. Anything that does not match + * this triple stops the turn again with its own accurate notice. */ - acceptWiderPermission?: boolean; + acceptWiderPermission?: AcceptedWiderPermission; /** * Agent Role identity selected in the composer for this Turn. Null is an * explicit None selection; absence is legacy/unknown. This is provenance for diff --git a/packages/shared/src/message-schemas.ts b/packages/shared/src/message-schemas.ts index 8e93546cf..36bfe326e 100644 --- a/packages/shared/src/message-schemas.ts +++ b/packages/shared/src/message-schemas.ts @@ -354,6 +354,15 @@ export const SessionInputBlocksSchema = z } }); +/** The one permission difference a user was shown and chose to run with. */ +export const AcceptedWiderPermissionSchema = z + .object({ + controlId: z.string().trim().min(1), + requestedModeId: z.string().trim().min(1), + effectiveModeId: z.string().trim().min(1), + }) + .strict(); + export const ACPSessionConfigSchema = z .object({ prompt: z.string(), @@ -367,7 +376,7 @@ export const ACPSessionConfigSchema = z configOptionValues: AcpConfigOptionValuesSchema.optional(), mcpServerIds: z.array(z.string()).optional(), taskToolsEnabled: z.boolean().optional(), - acceptWiderPermission: z.boolean().optional(), + acceptWiderPermission: AcceptedWiderPermissionSchema.optional(), agentRoleId: z.string().trim().min(1).nullable().optional(), agentRoleRevision: z.number().int().nonnegative().optional(), issuePRMentions: z.array(IssuePRMentionSchema).optional(), @@ -389,7 +398,7 @@ const LooseSessionTurnInputConfigSchema = z configOptionValues: AcpConfigOptionValuesSchema.optional(), mcpServerIds: z.array(z.string()).optional(), taskToolsEnabled: z.boolean().optional(), - acceptWiderPermission: z.boolean().optional(), + acceptWiderPermission: AcceptedWiderPermissionSchema.optional(), agentRoleId: z.string().trim().min(1).nullable().optional(), agentRoleRevision: z.number().int().nonnegative().optional(), issuePRMentions: z.array(IssuePRMentionSchema).optional(), @@ -496,8 +505,12 @@ export const normalizeSessionTurnInputConfig = ( through here (direct RPC, the dispatch-turn and steer entries, the Loro history readback, queue promotion), so a field this rebuild does not copy never reaches the daemon at all. */ - if (maybeParseField(z.boolean(), record.acceptWiderPermission) === true) { - normalized.acceptWiderPermission = true; + const acceptWiderPermission = maybeParseField( + AcceptedWiderPermissionSchema, + record.acceptWiderPermission + ); + if (acceptWiderPermission) { + normalized.acceptWiderPermission = acceptWiderPermission; } if (record.agentRoleId === null) { @@ -959,7 +972,7 @@ export const SessionPreparationRunConfigSchema = z .transform((ids) => normalizeMcpServerIdSelection(ids) ?? []) .optional(), taskToolsEnabled: z.boolean().optional(), - acceptWiderPermission: z.boolean().optional(), + acceptWiderPermission: AcceptedWiderPermissionSchema.optional(), }) .strict(); diff --git a/packages/shared/src/session-input.ts b/packages/shared/src/session-input.ts index 1054eec8c..116f2e15f 100644 --- a/packages/shared/src/session-input.ts +++ b/packages/shared/src/session-input.ts @@ -1,3 +1,4 @@ +import type { AcceptedWiderPermission } from './ai'; import type { ACPSessionConfig, AcpConfigOptionValue, @@ -629,7 +630,7 @@ export const buildSessionTurnInputConfig = (args: { mcpServerIds?: readonly McpServerId[] | null; taskToolsEnabled?: boolean; /** One-time informed acceptance of a wider permission, for THIS turn only. */ - acceptWiderPermission?: boolean; + acceptWiderPermission?: AcceptedWiderPermission; agentRoleId?: AgentRoleId | null; agentRoleRevision?: number; issuePRMentions?: IssuePRMention[]; @@ -650,7 +651,7 @@ export const buildSessionTurnInputConfig = (args: { ? args.configOptionValues : undefined, mcpServerIds: args.mcpServerIds ? [...args.mcpServerIds] : undefined, - ...(args.acceptWiderPermission === true ? { acceptWiderPermission: true } : {}), + ...(args.acceptWiderPermission ? { acceptWiderPermission: args.acceptWiderPermission } : {}), ...(args.taskToolsEnabled !== undefined ? { taskToolsEnabled: args.taskToolsEnabled === true } : {}), diff --git a/packages/shared/tests/session-input.test.ts b/packages/shared/tests/session-input.test.ts index d5680ec31..2b2305d4a 100644 --- a/packages/shared/tests/session-input.test.ts +++ b/packages/shared/tests/session-input.test.ts @@ -951,11 +951,17 @@ describe('one-time acceptance of a wider permission', () => { agentType: 'claude', modeId: 'plan', }; + const accepted = { + controlId: 'permission-mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }; it('writes the flag only into the turn that carries it', () => { expect( - buildSessionTurnInputConfig({ ...base, acceptWiderPermission: true }).acceptWiderPermission - ).toBe(true); + buildSessionTurnInputConfig({ ...base, acceptWiderPermission: accepted }) + .acceptWiderPermission + ).toEqual(accepted); }); it('leaves an ordinary send — including a plain resend — without it', () => { @@ -963,7 +969,8 @@ describe('one-time acceptance of a wider permission', () => { // undefined flag must never become an acceptance the next turn inherits. expect(buildSessionTurnInputConfig(base).acceptWiderPermission).toBeUndefined(); expect( - buildSessionTurnInputConfig({ ...base, acceptWiderPermission: false }).acceptWiderPermission + buildSessionTurnInputConfig({ ...base, acceptWiderPermission: undefined }) + .acceptWiderPermission ).toBeUndefined(); }); @@ -971,10 +978,13 @@ describe('one-time acceptance of a wider permission', () => { // Edit-and-resend mints a new userTurnId and a new prompt. The acceptance // was given for the ORIGINAL prompt, so carrying it would let the edited // one past the stop without ever asking — a bypass built out of a spread. - const accepted = buildSessionTurnInputConfig({ ...base, acceptWiderPermission: true }); - expect(accepted.acceptWiderPermission).toBe(true); + const acceptedTurn = buildSessionTurnInputConfig({ + ...base, + acceptWiderPermission: accepted, + }); + expect(acceptedTurn.acceptWiderPermission).toEqual(accepted); - const edited = deriveTurnInputConfigForNewTurn(accepted, { + const edited = deriveTurnInputConfigForNewTurn(acceptedTurn, { prompt: 'ship something else', inputBlocks: [{ type: 'text', text: 'ship something else' }], }); @@ -986,9 +996,9 @@ describe('one-time acceptance of a wider permission', () => { // The same-turn path is untouched: a status rewrite or a transport retry is // still the turn the user accepted. expect( - normalizeSessionTurnInputConfig({ ...accepted, _lodyDeliveryKind: 'steer' }) + normalizeSessionTurnInputConfig({ ...acceptedTurn, _lodyDeliveryKind: 'steer' }) ?.acceptWiderPermission - ).toBe(true); + ).toEqual(accepted); }); it('survives the normalizer every transport runs it through', () => { @@ -998,22 +1008,26 @@ describe('one-time acceptance of a wider permission', () => { // client set it. expect( normalizeSessionTurnInputConfig( - buildSessionTurnInputConfig({ ...base, acceptWiderPermission: true }) + buildSessionTurnInputConfig({ ...base, acceptWiderPermission: accepted }) )?.acceptWiderPermission - ).toBe(true); + ).toEqual(accepted); // One-time semantics survive the round trip too: only an explicit `true` is // carried, so nothing can read an acceptance out of a turn that made none. expect( normalizeSessionTurnInputConfig(buildSessionTurnInputConfig(base))?.acceptWiderPermission ).toBeUndefined(); + // A malformed or partial acceptance reads as no acceptance: one that cannot + // name the control and both values would be a blanket one. expect( - normalizeSessionTurnInputConfig({ ...base, acceptWiderPermission: false }) + normalizeSessionTurnInputConfig({ ...base, acceptWiderPermission: true }) ?.acceptWiderPermission ).toBeUndefined(); expect( - normalizeSessionTurnInputConfig({ ...base, acceptWiderPermission: 'yes' }) - ?.acceptWiderPermission + normalizeSessionTurnInputConfig({ + ...base, + acceptWiderPermission: { requestedModeId: 'plan', effectiveModeId: 'auto' }, + })?.acceptWiderPermission ).toBeUndefined(); }); }); From 1a82d970f57b351fd144ece1983bb9570088ea1a Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 19:48:46 +0800 Subject: [PATCH 16/24] fix(shared): stop the notice schema from stripping the permission control id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ChatFailedMetaSchema` still declared its own two-field `permission` shape while everything else had moved to the triple, and Zod strips undeclared keys: `controlId` was removed on every history read. The client requires all three to build an acceptance, so a user saw the failure but no "run once" action — the way out of a stop the daemon had reported correctly was unreachable through the real read path. The meta and the acceptance are the same thing — the client reads one and writes the other back — so they now share `AcceptedWiderPermissionSchema` rather than keeping a second shape that can drift. A malformed or pre-triple `permission` degrades to absent instead of failing: rejecting it would make the whole history item unparseable rather than merely actionless, and an acceptance that cannot name its control would be a blanket one, so dropping it is the safe direction. Ablation: the old two-field shape fails both new tests; making the triple strict without the degrade fails the legacy-notice one. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/AGENTS.md | 8 ++- packages/shared/src/message-schemas.ts | 21 ++++++-- packages/shared/tests/message-schemas.test.ts | 50 +++++++++++++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index efcade22d..23800e14b 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -351,7 +351,13 @@ Two things the dev build does deliberately, both load-bearing: keeps scanning the remaining permission selections, so anything undisclosed stops the turn again with its own accurate notice. Do not match on the values alone, and do not treat a rank ceiling as equivalent across different - permission controls. That resend replays the + permission controls. The notice meta and the acceptance share ONE schema + (`AcceptedWiderPermissionSchema`): the client reads the meta and writes it + straight back, and Zod strips undeclared keys, so a second declaration drops a + field on every history read — the action then disappears from a failure the + daemon reported correctly. A malformed or pre-triple `permission` degrades to + absent rather than failing the parse, so the notice still renders and simply + offers no acceptance. That resend replays the STOPPED turn: prompt, mode, model, config values, Role, `mcpServerIds` (including an explicit empty selection), `taskToolsEnabled` and `issuePRMentions` all come from its frozen `inputConfig`, never from the diff --git a/packages/shared/src/message-schemas.ts b/packages/shared/src/message-schemas.ts index 36bfe326e..b7a23caad 100644 --- a/packages/shared/src/message-schemas.ts +++ b/packages/shared/src/message-schemas.ts @@ -3145,11 +3145,24 @@ export const ChatFailedMetaSchema = z.object({ code: ChatFailedCodeSchema.optional(), message: z.string().optional(), /** - * `permission_not_applied` only: the mode the turn asked for and the wider one - * the agent reported. Structured so the notice can name both instead of the - * client parsing them back out of `message`. + * `permission_not_applied` only: the exact difference that was disclosed. + * + * The SAME shape the acceptance uses, deliberately — the client reads this + * meta and writes it straight back as `acceptWiderPermission`, so a second + * declaration here would let the two drift and, since Zod strips undeclared + * keys, silently drop a field on every history read. That is what happened: + * a `controlId` this schema did not know about was removed on parse, the + * client could no longer build an acceptance from the notice, and the "run + * once" action vanished from a failure the daemon had reported correctly. + * + * Absent-tolerant by DEGRADING, not by failing: a notice written before the + * triple existed carries only two of the three fields, and rejecting it would + * make the whole history item unparseable rather than merely actionless. It + * drops to `undefined`, so the failure still renders and simply offers no + * acceptance — which is the safe direction, since an acceptance that cannot + * name its control would be a blanket one. */ - permission: z.object({ requestedModeId: z.string(), effectiveModeId: z.string() }).optional(), + permission: AcceptedWiderPermissionSchema.optional().catch(undefined), }); // Non-system notice MessageContent discriminated union diff --git a/packages/shared/tests/message-schemas.test.ts b/packages/shared/tests/message-schemas.test.ts index 9e94a0e77..9dff1473d 100644 --- a/packages/shared/tests/message-schemas.test.ts +++ b/packages/shared/tests/message-schemas.test.ts @@ -60,6 +60,56 @@ describe('message-schemas system_notice', () => { expect(result.success).toBe(true); }); + it('keeps the whole disclosed permission triple through a notice parse', () => { + // Zod strips undeclared keys, so a field this schema does not know about is + // removed on every history read. The client builds its one-time acceptance + // from exactly this meta, so a dropped `controlId` silently removes the + // "run once" action from a failure the daemon reported correctly. + const result = MessageContentSchema.safeParse({ + type: 'system_notice', + name: 'chat_failed', + meta: { + reason: 'permission_not_applied', + permission: { + controlId: 'permission_mode', + requestedModeId: 'ask', + effectiveModeId: 'always-approve', + }, + }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.meta).toEqual({ + reason: 'permission_not_applied', + permission: { + controlId: 'permission_mode', + requestedModeId: 'ask', + effectiveModeId: 'always-approve', + }, + }); + } + }); + + it('still renders a notice written before the triple existed', () => { + // Readable as a failure; it just cannot name a control, so no acceptance + // can be built from it and no action is offered. + const result = MessageContentSchema.safeParse({ + type: 'system_notice', + name: 'chat_failed', + meta: { + reason: 'permission_not_applied', + permission: { requestedModeId: 'plan', effectiveModeId: 'auto' }, + }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect((result.data.meta as { permission?: unknown }).permission).toBeUndefined(); + expect((result.data.meta as { reason?: string }).reason).toBe('permission_not_applied'); + } + }); + it('accepts chat_failed notice with reason meta', () => { const result = MessageContentSchema.safeParse({ type: 'system_notice', From 74e063a88525a200945f3e6942f6beb723b39b30 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 19:58:56 +0800 Subject: [PATCH 17/24] fix: let per-control permission acceptances accumulate for one turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matching each acceptance exactly stopped the bypass but made the way out unreachable when two controls widen at once, which real Grok does: it carries both a mode selector and an explicit `_permission` one. The first stop disclosed one, the retry carried that acceptance alone, the applier correctly stopped on the second — and the next retry replaced the first acceptance with the second, landing back on the first stop. The user alternates between two notices forever. `acceptWiderPermissions` is now the list it needed to be: strictly validated, deduplicated and bounded. A retry inherits the acceptances already on the turn it replays — re-validated from that turn's own frozen config, so nothing but a previously accepted exact triple accumulates — and appends the current notice's triple. This grants nothing extra: the applier still matches every entry exactly and keeps scanning, so a difference nobody was shown stops the turn as before. The clearing rules are unchanged and now cover the whole set: an ordinary send, edit-and-resend and any prompt-derived turn carry none of it; a same-turn transport retry keeps it. A legacy boolean or a bare object reads as no acceptance rather than a partial one. Ablation: a retry that keeps only the current disclosure fails the accumulation test; an applier that matches only the first accepted entry fails the full loop. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/AGENTS.md | 13 ++- .../acp-session-config-applier.test.ts | 109 +++++++++++++----- .../src/session/acp-session-config-applier.ts | 19 +-- .../src/session/session-dispatch-watcher.ts | 16 +-- .../sessions/session-chat-interface.tsx | 8 +- .../src/lib/permission-not-applied-retry.ts | 29 ++++- .../src/lib/turn-scoped-overrides.ts | 38 ++++-- .../permission-not-applied-retry.test.tsx | 1 + .../tests/turn-scoped-overrides.test.ts | 51 ++++++-- packages/shared/src/ai.ts | 18 ++- packages/shared/src/message-schemas.ts | 42 +++++-- packages/shared/src/session-input.ts | 8 +- packages/shared/tests/session-input.test.ts | 40 +++---- 13 files changed, 277 insertions(+), 115 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 23800e14b..568725e31 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -347,9 +347,18 @@ Two things the dev build does deliberately, both load-bearing: moved further by the time the turn re-runs (`plan → auto` accepted, `plan → always-approve` live), and a second permission control may have widened alongside the one in the notice (Grok carries both a mode selector and an - explicit `_permission` one). The applier skips ONLY an exact triple match and + explicit `_permission` one). The applier skips ONLY exact triple matches and keeps scanning the remaining permission selections, so anything undisclosed - stops the turn again with its own accurate notice. Do not match on the values + stops the turn again with its own accurate notice. + It is a LIST (`acceptWiderPermissions`) because those differences are + disclosed one stop at a time: two controls widening at once produce two + notices, and a replay carrying only the newest acceptance would drop the + previous one and land back on the first — the user alternates between two + notices with no way through. A retry therefore inherits the acceptances + already on the turn it is replaying, re-validated, and appends the current + notice's triple. Accumulating grants nothing extra, because every entry is + still matched exactly. An ordinary send, edit-and-resend and any prompt-derived + turn clear the whole set; a same-turn transport retry keeps it. Do not match on the values alone, and do not treat a rank ceiling as equivalent across different permission controls. The notice meta and the acceptance share ONE schema (`AcceptedWiderPermissionSchema`): the client reads the meta and writes it diff --git a/apps/cli/src/session/acp-session-config-applier.test.ts b/apps/cli/src/session/acp-session-config-applier.test.ts index 4939a96f2..ddcfd0b41 100644 --- a/apps/cli/src/session/acp-session-config-applier.test.ts +++ b/apps/cli/src/session/acp-session-config-applier.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { ACPSessionId, SessionId } from '@lody/shared'; +import type { AcceptedWiderPermission } from '@lody/shared'; import type { AgentClient } from '@/agent/agent-client'; import type { Logger } from '@/utils/logger'; import { applyAcpSessionRunConfig } from './acp-session-config-applier'; @@ -420,11 +421,13 @@ describe('applyAcpSessionRunConfig', () => { cliType: 'builtin', agentType: 'grok', configOptionValues: { permission_mode: 'ask' }, - acceptWiderPermission: { - controlId: 'permission_mode', - requestedModeId: 'ask', - effectiveModeId: 'always-approve', - }, + acceptWiderPermissions: [ + { + controlId: 'permission_mode', + requestedModeId: 'ask', + effectiveModeId: 'always-approve', + }, + ], }, agentClient ); @@ -456,11 +459,9 @@ describe('applyAcpSessionRunConfig', () => { cliType: 'builtin', agentType: 'claude', modeId: 'plan', - acceptWiderPermission: { - controlId: 'permission-mode', - requestedModeId: 'plan', - effectiveModeId: 'auto', - }, + acceptWiderPermissions: [ + { controlId: 'permission-mode', requestedModeId: 'plan', effectiveModeId: 'auto' }, + ], }, agentClient ); @@ -496,11 +497,9 @@ describe('applyAcpSessionRunConfig', () => { agentType: 'grok', modeId: 'plan', configOptionValues: { permission_mode: 'ask' }, - acceptWiderPermission: { - controlId: 'interaction_mode', - requestedModeId: 'plan', - effectiveModeId: 'auto', - }, + acceptWiderPermissions: [ + { controlId: 'interaction_mode', requestedModeId: 'plan', effectiveModeId: 'auto' }, + ], }, agentClient ); @@ -536,11 +535,13 @@ describe('applyAcpSessionRunConfig', () => { agentType: 'grok', modeId: 'plan', configOptionValues: { permission_mode: 'ask' }, - acceptWiderPermission: { - controlId: 'permission_mode', - requestedModeId: 'ask', - effectiveModeId: 'always-approve', - }, + acceptWiderPermissions: [ + { + controlId: 'permission_mode', + requestedModeId: 'ask', + effectiveModeId: 'always-approve', + }, + ], }, agentClient ); @@ -573,11 +574,9 @@ describe('applyAcpSessionRunConfig', () => { cliType: 'builtin', agentType: 'grok', configOptionValues: { permission_mode: 'plan' }, - acceptWiderPermission: { - controlId: 'interaction_mode', - requestedModeId: 'plan', - effectiveModeId: 'auto', - }, + acceptWiderPermissions: [ + { controlId: 'interaction_mode', requestedModeId: 'plan', effectiveModeId: 'auto' }, + ], }, agentClient ); @@ -589,6 +588,58 @@ describe('applyAcpSessionRunConfig', () => { }); }); + it('reaches a runnable turn after both disclosures are accepted', async () => { + // The whole loop for two controls widening at once: stop on one, accept + // it, stop on the other, accept both, run. Without accumulation the + // second acceptance would replace the first and the user would alternate + // between the two notices with no way through. + const grokAgent = () => + ({ + isCreated: () => true, + getConfigOptions: () => [ + { id: 'interaction_mode', category: 'mode', type: 'select', currentValue: 'auto' }, + { + id: 'permission_mode', + category: '_permission', + type: 'select', + currentValue: 'always-approve', + }, + ], + setSessionMode: vi.fn(async () => undefined), + setSessionConfigOption: vi.fn(async () => undefined), + }) as unknown as AgentClient; + const turn = (acceptWiderPermissions: AcceptedWiderPermission[]) => ({ + cliType: 'builtin' as const, + agentType: 'grok', + modeId: 'plan', + configOptionValues: { permission_mode: 'ask' }, + ...(acceptWiderPermissions.length > 0 ? { acceptWiderPermissions } : {}), + }); + + const first = await apply(turn([]), grokAgent()); + const firstEscalation = first.permissionEscalation; + expect(firstEscalation).toEqual({ + controlId: 'permission_mode', + requestedModeId: 'ask', + effectiveModeId: 'always-approve', + }); + + // Accepting only the first still stops, on the one never disclosed. + const second = await apply(turn([firstEscalation!]), grokAgent()); + const secondEscalation = second.permissionEscalation; + expect(secondEscalation).toEqual({ + controlId: 'interaction_mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }); + + // Carrying BOTH — what the retry builder produces from the stopped turn — + // finally runs, and both mismatches are still reported. + const third = await apply(turn([firstEscalation!, secondEscalation!]), grokAgent()); + expect(third.permissionEscalation).toBeUndefined(); + expect(third.warningSelections).toEqual(['permission_mode="ask"', 'mode="plan"']); + }); + it('stands down for a turn that carries the informed acceptance', async () => { const agentClient = { isCreated: () => true, @@ -603,11 +654,9 @@ describe('applyAcpSessionRunConfig', () => { cliType: 'builtin', agentType: 'claude', modeId: 'plan', - acceptWiderPermission: { - controlId: 'permission-mode', - requestedModeId: 'plan', - effectiveModeId: 'auto', - }, + acceptWiderPermissions: [ + { controlId: 'permission-mode', requestedModeId: 'plan', effectiveModeId: 'auto' }, + ], }, agentClient ); diff --git a/apps/cli/src/session/acp-session-config-applier.ts b/apps/cli/src/session/acp-session-config-applier.ts index 04c6982fe..89142485c 100644 --- a/apps/cli/src/session/acp-session-config-applier.ts +++ b/apps/cli/src/session/acp-session-config-applier.ts @@ -57,8 +57,8 @@ export type AcpSessionRunConfig = { modeId?: string; modelId?: string; configOptionValues?: Record; - /** One-time informed acceptance carried by this turn, for one exact difference. */ - acceptWiderPermission?: AcceptedWiderPermission; + /** Differences disclosed and accepted for this turn, each matched exactly. */ + acceptWiderPermissions?: AcceptedWiderPermission[]; }; type AcpSessionRunConfigApplyResult = { @@ -344,7 +344,7 @@ export async function applyAcpSessionRunConfig(args: { which case the two are equal and nothing fires), and the config table comes straight from what the agent published. So this cannot be triggered by a snapshot, by a stale cache, or by an unconfirmed request. */ - const accepted = config.acceptWiderPermission; + const accepted = config.acceptWiderPermissions ?? []; const findPermissionEscalation = (): AcceptedWiderPermission | undefined => { for (const selection of appliedSelections) { if ( @@ -366,17 +366,20 @@ export async function applyAcpSessionRunConfig(args: { ) { continue; } - /* Only the exact difference the user was shown is skipped, and the scan - CONTINUES. A bare "accepted" would also wave through a difference they + /* Only differences the user was actually shown are skipped, each matched + exactly, and the scan CONTINUES. A bare "accepted" would also wave through a difference they never saw: the agent may have moved further still by the time the turn re-runs (`plan → auto` accepted, `plan → always-approve` live), and a second permission control may have widened alongside the one in the notice. Either way this is a new, undisclosed escalation, and it gets its own accurate stop. */ if ( - accepted?.controlId === controlId && - accepted.requestedModeId === selection.requested && - accepted.effectiveModeId === effective + accepted.some( + (entry) => + entry.controlId === controlId && + entry.requestedModeId === selection.requested && + entry.effectiveModeId === effective + ) ) { continue; } diff --git a/apps/cli/src/session/session-dispatch-watcher.ts b/apps/cli/src/session/session-dispatch-watcher.ts index d0a15d62d..d5c711419 100644 --- a/apps/cli/src/session/session-dispatch-watcher.ts +++ b/apps/cli/src/session/session-dispatch-watcher.ts @@ -1,7 +1,7 @@ import type { RepoTransportRoomStatus, RepoWatchHandle } from 'loro-repo'; import { Effect, Fiber } from 'effect'; import { - AcceptedWiderPermissionSchema, + AcceptedWiderPermissionsSchema, buildMissingEmail, buildPendingUserHistoryEntry, buildSessionTurnInputConfig, @@ -1977,8 +1977,8 @@ export class SessionDispatchWatcher { // it: this is one-time informed acceptance of a wider permission, so a // rebuild that dropped it would stop the very turn the user just // accepted, and one that defaulted it would accept for every turn. - ...(entry.inputConfig?.acceptWiderPermission - ? { acceptWiderPermission: entry.inputConfig.acceptWiderPermission } + ...(entry.inputConfig?.acceptWiderPermissions?.length + ? { acceptWiderPermissions: entry.inputConfig.acceptWiderPermissions } : {}), resume: entry.inputConfig?.resume ?? resolveDispatchAcpSessionId(meta), }, @@ -2024,8 +2024,8 @@ export class SessionDispatchWatcher { agentRoleId: entry.inputConfig?.agentRoleId, agentRoleRevision: entry.inputConfig?.agentRoleRevision, issuePRMentions: entry.inputConfig?.issuePRMentions, - ...(entry.inputConfig?.acceptWiderPermission - ? { acceptWiderPermission: entry.inputConfig.acceptWiderPermission } + ...(entry.inputConfig?.acceptWiderPermissions?.length + ? { acceptWiderPermissions: entry.inputConfig.acceptWiderPermissions } : {}), resume: entry.inputConfig?.resume, }, @@ -2131,10 +2131,10 @@ export class SessionDispatchWatcher { // The queued value crosses a CRDT, so it is re-validated rather than // trusted: a malformed acceptance must read as no acceptance. ...(() => { - const parsed = AcceptedWiderPermissionSchema.safeParse( - queuedItem.acpSessionConfig?.acceptWiderPermission + const parsed = AcceptedWiderPermissionsSchema.safeParse( + queuedItem.acpSessionConfig?.acceptWiderPermissions ); - return parsed.success ? { acceptWiderPermission: parsed.data } : {}; + return parsed.success ? { acceptWiderPermissions: parsed.data } : {}; })(), resume: resolveResumableAcpSessionId(meta), }); diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index b03b5018d..219ff1090 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -3707,7 +3707,7 @@ export const SessionChatInterface = memo( | 'modelIdOverride' | 'configOptionValuesOverride' | 'agentRole' - | 'acceptWiderPermission' + | 'acceptWiderPermissions' | 'mcpServerIdsOverride' | 'taskToolsEnabledOverride' | 'issuePRMentionsOverride' @@ -3758,8 +3758,8 @@ export const SessionChatInterface = memo( // keep the stopped turn's tool reach and its acceptance. mcpServerIds: [...(inputConfig.mcpServerIds ?? mcpSelection.selectedIds)], taskToolsEnabled: inputConfig.taskToolsEnabled, - ...(inputConfig.acceptWiderPermission - ? { acceptWiderPermission: inputConfig.acceptWiderPermission } + ...(inputConfig.acceptWiderPermissions?.length + ? { acceptWiderPermissions: inputConfig.acceptWiderPermissions } : {}), agentRoleId: inputConfig.agentRoleId, agentRoleRevision: inputConfig.agentRoleRevision, @@ -3822,7 +3822,7 @@ export const SessionChatInterface = memo( | 'modelIdOverride' | 'configOptionValuesOverride' | 'agentRole' - | 'acceptWiderPermission' + | 'acceptWiderPermissions' | 'mcpServerIdsOverride' | 'taskToolsEnabledOverride' | 'issuePRMentionsOverride' diff --git a/packages/components/src/lib/permission-not-applied-retry.ts b/packages/components/src/lib/permission-not-applied-retry.ts index 235fb1158..7a54cdfc0 100644 --- a/packages/components/src/lib/permission-not-applied-retry.ts +++ b/packages/components/src/lib/permission-not-applied-retry.ts @@ -19,8 +19,18 @@ import type { export type PermissionNotAppliedRetryTarget = { /** History entry id of the failure notice, for matching the render site. */ noticeId: string; - /** The exact difference that was disclosed, as the notice reported it. */ + /** The exact difference this notice disclosed. */ disclosed: AcceptedWiderPermission; + /** + * Differences already disclosed and accepted on the turn that was stopped. + * + * Two controls widening at once are disclosed one stop at a time, so a replay + * carrying only the newest acceptance would drop the previous one and land + * back on the first stop — the user would alternate between two notices with + * no way through. Read from the stopped turn's own frozen config and + * re-validated, so nothing but a previously accepted exact triple accumulates. + */ + previouslyAccepted: AcceptedWiderPermission[]; userTurnId: string; inputBlocks: SessionInputBlock[]; modeId?: string; @@ -74,6 +84,22 @@ const readPermissionMeta = (item: RetryHistoryItem): AcceptedWiderPermission | n const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +/** Re-validates what a stopped turn claims it already accepted. */ +const readAcceptedWiderPermissions = (value: unknown): AcceptedWiderPermission[] => { + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((entry) => { + if (!isRecord(entry)) return []; + const { controlId, requestedModeId, effectiveModeId } = entry; + return typeof controlId === 'string' && + typeof requestedModeId === 'string' && + typeof effectiveModeId === 'string' + ? [{ controlId, requestedModeId, effectiveModeId }] + : []; + }); +}; + /** * The newest stopped turn still awaiting a decision, or null. * @@ -122,6 +148,7 @@ export const findPermissionNotAppliedRetryTarget = ( return { noticeId: history[noticeIndex]?.id ?? '', disclosed: permission, + previouslyAccepted: readAcceptedWiderPermissions(inputConfig?.acceptWiderPermissions), userTurnId: entry.id, inputBlocks, ...(typeof inputConfig?.modeId === 'string' ? { modeId: inputConfig.modeId } : {}), diff --git a/packages/components/src/lib/turn-scoped-overrides.ts b/packages/components/src/lib/turn-scoped-overrides.ts index 736e4dfdc..d442b5df4 100644 --- a/packages/components/src/lib/turn-scoped-overrides.ts +++ b/packages/components/src/lib/turn-scoped-overrides.ts @@ -21,8 +21,8 @@ import type { PermissionNotAppliedRetryTarget } from '@/lib/permission-not-appli * an empty `mcpServerIds` array, which is an explicit "no servers" selection. */ export type TurnScopedOverrides = { - /** One-time informed acceptance of ONE disclosed difference, this turn only. */ - acceptWiderPermission?: AcceptedWiderPermission; + /** Differences disclosed and accepted for this turn only. */ + acceptWiderPermissions?: AcceptedWiderPermission[]; mcpServerIdsOverride?: readonly McpServerId[]; taskToolsEnabledOverride?: boolean; issuePRMentionsOverride?: IssuePRMention[]; @@ -30,12 +30,24 @@ export type TurnScopedOverrides = { export const EMPTY_TURN_SCOPED_OVERRIDES: TurnScopedOverrides = {}; +const dedupeAcceptedWiderPermissions = ( + entries: readonly AcceptedWiderPermission[] +): AcceptedWiderPermission[] => { + const seen = new Set(); + return entries.filter((entry) => { + const key = `${entry.controlId}\u0000${entry.requestedModeId}\u0000${entry.effectiveModeId}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +}; + /** Narrows a wider options object to exactly the turn-scoped set. */ export const pickTurnScopedOverrides = ( options: TurnScopedOverrides | undefined ): TurnScopedOverrides => ({ - ...(options?.acceptWiderPermission - ? { acceptWiderPermission: options.acceptWiderPermission } + ...(options?.acceptWiderPermissions?.length + ? { acceptWiderPermissions: options.acceptWiderPermissions } : {}), ...(options?.mcpServerIdsOverride !== undefined ? { mcpServerIdsOverride: options.mcpServerIdsOverride } @@ -52,12 +64,16 @@ export const pickTurnScopedOverrides = ( export const buildPermissionRetryOverrides = ( target: Pick< PermissionNotAppliedRetryTarget, - 'disclosed' | 'mcpServerIds' | 'taskToolsEnabled' | 'issuePRMentions' + 'disclosed' | 'previouslyAccepted' | 'mcpServerIds' | 'taskToolsEnabled' | 'issuePRMentions' > ): TurnScopedOverrides => ({ - // The acceptance names what the notice showed, so the daemon can tell it from - // a difference that appeared afterwards. - acceptWiderPermission: target.disclosed, + // What this notice showed, ON TOP of what the stopped turn had already been + // accepted for. Each entry is still matched exactly by the daemon, so the set + // grants nothing beyond the differences shown one stop at a time. + acceptWiderPermissions: dedupeAcceptedWiderPermissions([ + ...target.previouslyAccepted, + target.disclosed, + ]), ...(target.mcpServerIds !== undefined ? { mcpServerIdsOverride: target.mcpServerIds as McpServerId[] } : {}), @@ -84,7 +100,7 @@ type TurnInputConfigFields = { export const applyTurnScopedOverrides = ( args: T, overrides: TurnScopedOverrides -): T & { acceptWiderPermission?: AcceptedWiderPermission } => ({ +): T & { acceptWiderPermissions?: AcceptedWiderPermission[] } => ({ ...args, ...(overrides.mcpServerIdsOverride !== undefined ? { mcpServerIds: overrides.mcpServerIdsOverride } @@ -95,7 +111,7 @@ export const applyTurnScopedOverrides = ( ...(overrides.issuePRMentionsOverride !== undefined ? { issuePRMentions: overrides.issuePRMentionsOverride } : {}), - ...(overrides.acceptWiderPermission - ? { acceptWiderPermission: overrides.acceptWiderPermission } + ...(overrides.acceptWiderPermissions?.length + ? { acceptWiderPermissions: overrides.acceptWiderPermissions } : {}), }); diff --git a/packages/components/tests/permission-not-applied-retry.test.tsx b/packages/components/tests/permission-not-applied-retry.test.tsx index eaef1e835..9495dfdbd 100644 --- a/packages/components/tests/permission-not-applied-retry.test.tsx +++ b/packages/components/tests/permission-not-applied-retry.test.tsx @@ -65,6 +65,7 @@ describe('findPermissionNotAppliedRetryTarget', () => { requestedModeId: 'plan', effectiveModeId: 'auto', }, + previouslyAccepted: [], userTurnId: 'user-1', inputBlocks: [{ type: 'text', text: 'ship it' }], modeId: 'plan', diff --git a/packages/components/tests/turn-scoped-overrides.test.ts b/packages/components/tests/turn-scoped-overrides.test.ts index b5d00a329..39b9f18f9 100644 --- a/packages/components/tests/turn-scoped-overrides.test.ts +++ b/packages/components/tests/turn-scoped-overrides.test.ts @@ -64,11 +64,9 @@ describe('turn-scoped overrides reach the wire', () => { const afterHops = pickTurnScopedOverrides(pickTurnScopedOverrides(dispatchOptions)); const config = buildSessionTurnInputConfig(applyTurnScopedOverrides(composerArgs, afterHops)); - expect(config.acceptWiderPermission).toEqual({ - controlId: 'permission-mode', - requestedModeId: 'plan', - effectiveModeId: 'auto', - }); + expect(config.acceptWiderPermissions).toEqual([ + { controlId: 'permission-mode', requestedModeId: 'plan', effectiveModeId: 'auto' }, + ]); // The composer's own values lost to the stopped turn's, including the // explicit empty MCP selection and `taskToolsEnabled: false`. expect(config.mcpServerIds).toEqual([]); @@ -81,7 +79,7 @@ describe('turn-scoped overrides reach the wire', () => { applyTurnScopedOverrides(composerArgs, pickTurnScopedOverrides(undefined)) ); - expect(config.acceptWiderPermission).toBeUndefined(); + expect(config.acceptWiderPermissions).toBeUndefined(); expect(config.mcpServerIds).toEqual(['server-the-user-picked-later']); expect(config.taskToolsEnabled).toBe(true); }); @@ -94,17 +92,48 @@ describe('turn-scoped overrides reach the wire', () => { requestedModeId: 'plan', effectiveModeId: 'auto', }, + previouslyAccepted: [], }); const config = buildSessionTurnInputConfig( applyTurnScopedOverrides(composerArgs, pickTurnScopedOverrides(overrides)) ); - expect(config.acceptWiderPermission).toEqual({ - controlId: 'permission-mode', - requestedModeId: 'plan', - effectiveModeId: 'auto', - }); + expect(config.acceptWiderPermissions).toEqual([ + { controlId: 'permission-mode', requestedModeId: 'plan', effectiveModeId: 'auto' }, + ]); expect(config.mcpServerIds).toEqual(['server-the-user-picked-later']); expect(config.taskToolsEnabled).toBe(true); }); + + it('accumulates the disclosures already accepted on the stopped turn', () => { + // Two controls widened at once, so they are disclosed one stop at a time. + // A replay carrying only the newest acceptance drops the previous one and + // lands back on the first stop — the user would alternate forever. + const first = { controlId: 'permission_mode', requestedModeId: 'ask', effectiveModeId: 'auto' }; + const second = { + controlId: 'interaction_mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }; + + const config = buildSessionTurnInputConfig( + applyTurnScopedOverrides( + composerArgs, + pickTurnScopedOverrides( + buildPermissionRetryOverrides({ disclosed: second, previouslyAccepted: [first] }) + ) + ) + ); + + expect(config.acceptWiderPermissions).toEqual([first, second]); + }); + + it('does not duplicate a disclosure the stopped turn already accepted', () => { + const only = { controlId: 'permission_mode', requestedModeId: 'ask', effectiveModeId: 'auto' }; + + expect( + buildPermissionRetryOverrides({ disclosed: only, previouslyAccepted: [only] }) + .acceptWiderPermissions + ).toEqual([only]); + }); }); diff --git a/packages/shared/src/ai.ts b/packages/shared/src/ai.ts index 7559e5b85..15b7cfdd6 100644 --- a/packages/shared/src/ai.ts +++ b/packages/shared/src/ai.ts @@ -1613,13 +1613,19 @@ export type ACPSessionConfig = { * One-time informed acceptance, scoped to the turn that carries it — never * inherited, never a default. * - * It names the exact difference the user was shown, because a bare "yes" - * would also accept differences they never saw: the agent may have moved - * further by the time the turn re-runs, and a second permission control may - * have widened alongside the one in the notice. Anything that does not match - * this triple stops the turn again with its own accurate notice. + * Each entry names one exact difference the user was shown, because a bare + * "yes" would also accept differences they never saw: the agent may have + * moved further by the time the turn re-runs, and a second permission control + * may have widened alongside the one in the notice. + * + * It is a LIST because those differences are disclosed one at a time. Two + * controls widening at once produce two stops, and a replay that carried only + * the newest acceptance would drop the previous one and land back on the + * first stop — the user would alternate between two notices forever with no + * way through. Each entry is still matched exactly, so accumulating them + * grants nothing beyond what was individually shown and accepted. */ - acceptWiderPermission?: AcceptedWiderPermission; + acceptWiderPermissions?: AcceptedWiderPermission[]; /** * Agent Role identity selected in the composer for this Turn. Null is an * explicit None selection; absence is legacy/unknown. This is provenance for diff --git a/packages/shared/src/message-schemas.ts b/packages/shared/src/message-schemas.ts index b7a23caad..f89aadf31 100644 --- a/packages/shared/src/message-schemas.ts +++ b/packages/shared/src/message-schemas.ts @@ -354,7 +354,7 @@ export const SessionInputBlocksSchema = z } }); -/** The one permission difference a user was shown and chose to run with. */ +/** One permission difference a user was shown and chose to run with. */ export const AcceptedWiderPermissionSchema = z .object({ controlId: z.string().trim().min(1), @@ -363,6 +363,28 @@ export const AcceptedWiderPermissionSchema = z }) .strict(); +/** + * Every difference disclosed and accepted for ONE turn, deduplicated. + * + * Bounded because it grows only by one control per stop and an agent publishes + * a handful; a longer list is not a real disclosure history. Malformed input — + * including the boolean this field used to be, or a single bare object — is a + * whole-list rejection, so it reads as no acceptance rather than a partial one. + */ +export const AcceptedWiderPermissionsSchema = z + .array(AcceptedWiderPermissionSchema) + .min(1) + .max(8) + .transform((entries) => { + const seen = new Set(); + return entries.filter((entry) => { + const key = `${entry.controlId}\u0000${entry.requestedModeId}\u0000${entry.effectiveModeId}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }); + export const ACPSessionConfigSchema = z .object({ prompt: z.string(), @@ -376,7 +398,7 @@ export const ACPSessionConfigSchema = z configOptionValues: AcpConfigOptionValuesSchema.optional(), mcpServerIds: z.array(z.string()).optional(), taskToolsEnabled: z.boolean().optional(), - acceptWiderPermission: AcceptedWiderPermissionSchema.optional(), + acceptWiderPermissions: AcceptedWiderPermissionsSchema.optional(), agentRoleId: z.string().trim().min(1).nullable().optional(), agentRoleRevision: z.number().int().nonnegative().optional(), issuePRMentions: z.array(IssuePRMentionSchema).optional(), @@ -398,7 +420,7 @@ const LooseSessionTurnInputConfigSchema = z configOptionValues: AcpConfigOptionValuesSchema.optional(), mcpServerIds: z.array(z.string()).optional(), taskToolsEnabled: z.boolean().optional(), - acceptWiderPermission: AcceptedWiderPermissionSchema.optional(), + acceptWiderPermissions: AcceptedWiderPermissionsSchema.optional(), agentRoleId: z.string().trim().min(1).nullable().optional(), agentRoleRevision: z.number().int().nonnegative().optional(), issuePRMentions: z.array(IssuePRMentionSchema).optional(), @@ -505,12 +527,12 @@ export const normalizeSessionTurnInputConfig = ( through here (direct RPC, the dispatch-turn and steer entries, the Loro history readback, queue promotion), so a field this rebuild does not copy never reaches the daemon at all. */ - const acceptWiderPermission = maybeParseField( - AcceptedWiderPermissionSchema, - record.acceptWiderPermission + const acceptWiderPermissions = maybeParseField( + AcceptedWiderPermissionsSchema, + record.acceptWiderPermissions ); - if (acceptWiderPermission) { - normalized.acceptWiderPermission = acceptWiderPermission; + if (acceptWiderPermissions?.length) { + normalized.acceptWiderPermissions = acceptWiderPermissions; } if (record.agentRoleId === null) { @@ -574,7 +596,7 @@ export const deriveTurnInputConfigForNewTurn = ( original: unknown, overrides: Partial = {} ): SessionTurnInputConfig => { - const { acceptWiderPermission: _dropped, ...carried } = + const { acceptWiderPermissions: _dropped, ...carried } = normalizeSessionTurnInputConfig(original) ?? {}; return { ...carried, ...overrides }; }; @@ -972,7 +994,7 @@ export const SessionPreparationRunConfigSchema = z .transform((ids) => normalizeMcpServerIdSelection(ids) ?? []) .optional(), taskToolsEnabled: z.boolean().optional(), - acceptWiderPermission: AcceptedWiderPermissionSchema.optional(), + acceptWiderPermissions: AcceptedWiderPermissionsSchema.optional(), }) .strict(); diff --git a/packages/shared/src/session-input.ts b/packages/shared/src/session-input.ts index 116f2e15f..7b360c971 100644 --- a/packages/shared/src/session-input.ts +++ b/packages/shared/src/session-input.ts @@ -629,8 +629,8 @@ export const buildSessionTurnInputConfig = (args: { configOptionValues?: Record | null; mcpServerIds?: readonly McpServerId[] | null; taskToolsEnabled?: boolean; - /** One-time informed acceptance of a wider permission, for THIS turn only. */ - acceptWiderPermission?: AcceptedWiderPermission; + /** Differences disclosed and accepted for THIS turn only. */ + acceptWiderPermissions?: AcceptedWiderPermission[]; agentRoleId?: AgentRoleId | null; agentRoleRevision?: number; issuePRMentions?: IssuePRMention[]; @@ -651,7 +651,9 @@ export const buildSessionTurnInputConfig = (args: { ? args.configOptionValues : undefined, mcpServerIds: args.mcpServerIds ? [...args.mcpServerIds] : undefined, - ...(args.acceptWiderPermission ? { acceptWiderPermission: args.acceptWiderPermission } : {}), + ...(args.acceptWiderPermissions?.length + ? { acceptWiderPermissions: args.acceptWiderPermissions } + : {}), ...(args.taskToolsEnabled !== undefined ? { taskToolsEnabled: args.taskToolsEnabled === true } : {}), diff --git a/packages/shared/tests/session-input.test.ts b/packages/shared/tests/session-input.test.ts index 2b2305d4a..f5905997f 100644 --- a/packages/shared/tests/session-input.test.ts +++ b/packages/shared/tests/session-input.test.ts @@ -951,26 +951,24 @@ describe('one-time acceptance of a wider permission', () => { agentType: 'claude', modeId: 'plan', }; - const accepted = { - controlId: 'permission-mode', - requestedModeId: 'plan', - effectiveModeId: 'auto', - }; + const accepted = [ + { controlId: 'permission-mode', requestedModeId: 'plan', effectiveModeId: 'auto' }, + ]; it('writes the flag only into the turn that carries it', () => { expect( - buildSessionTurnInputConfig({ ...base, acceptWiderPermission: accepted }) - .acceptWiderPermission + buildSessionTurnInputConfig({ ...base, acceptWiderPermissions: accepted }) + .acceptWiderPermissions ).toEqual(accepted); }); it('leaves an ordinary send — including a plain resend — without it', () => { // Nothing outside the one dispatch may set it: an omitted, false, or // undefined flag must never become an acceptance the next turn inherits. - expect(buildSessionTurnInputConfig(base).acceptWiderPermission).toBeUndefined(); + expect(buildSessionTurnInputConfig(base).acceptWiderPermissions).toBeUndefined(); expect( - buildSessionTurnInputConfig({ ...base, acceptWiderPermission: undefined }) - .acceptWiderPermission + buildSessionTurnInputConfig({ ...base, acceptWiderPermissions: undefined }) + .acceptWiderPermissions ).toBeUndefined(); }); @@ -980,15 +978,15 @@ describe('one-time acceptance of a wider permission', () => { // one past the stop without ever asking — a bypass built out of a spread. const acceptedTurn = buildSessionTurnInputConfig({ ...base, - acceptWiderPermission: accepted, + acceptWiderPermissions: accepted, }); - expect(acceptedTurn.acceptWiderPermission).toEqual(accepted); + expect(acceptedTurn.acceptWiderPermissions).toEqual(accepted); const edited = deriveTurnInputConfigForNewTurn(acceptedTurn, { prompt: 'ship something else', inputBlocks: [{ type: 'text', text: 'ship something else' }], }); - expect(edited.acceptWiderPermission).toBeUndefined(); + expect(edited.acceptWiderPermissions).toBeUndefined(); // Everything else the original turn ran with is still carried. expect(edited.modeId).toBe('plan'); expect(edited.prompt).toBe('ship something else'); @@ -997,7 +995,7 @@ describe('one-time acceptance of a wider permission', () => { // still the turn the user accepted. expect( normalizeSessionTurnInputConfig({ ...acceptedTurn, _lodyDeliveryKind: 'steer' }) - ?.acceptWiderPermission + ?.acceptWiderPermissions ).toEqual(accepted); }); @@ -1008,26 +1006,26 @@ describe('one-time acceptance of a wider permission', () => { // client set it. expect( normalizeSessionTurnInputConfig( - buildSessionTurnInputConfig({ ...base, acceptWiderPermission: accepted }) - )?.acceptWiderPermission + buildSessionTurnInputConfig({ ...base, acceptWiderPermissions: accepted }) + )?.acceptWiderPermissions ).toEqual(accepted); // One-time semantics survive the round trip too: only an explicit `true` is // carried, so nothing can read an acceptance out of a turn that made none. expect( - normalizeSessionTurnInputConfig(buildSessionTurnInputConfig(base))?.acceptWiderPermission + normalizeSessionTurnInputConfig(buildSessionTurnInputConfig(base))?.acceptWiderPermissions ).toBeUndefined(); // A malformed or partial acceptance reads as no acceptance: one that cannot // name the control and both values would be a blanket one. expect( - normalizeSessionTurnInputConfig({ ...base, acceptWiderPermission: true }) - ?.acceptWiderPermission + normalizeSessionTurnInputConfig({ ...base, acceptWiderPermissions: true }) + ?.acceptWiderPermissions ).toBeUndefined(); expect( normalizeSessionTurnInputConfig({ ...base, - acceptWiderPermission: { requestedModeId: 'plan', effectiveModeId: 'auto' }, - })?.acceptWiderPermission + acceptWiderPermissions: [{ requestedModeId: 'plan', effectiveModeId: 'auto' }], + })?.acceptWiderPermissions ).toBeUndefined(); }); }); From e7d5323a2c695ba838a3e443a547093a2a3da1b1 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 20:05:08 +0800 Subject: [PATCH 18/24] fix(cli): stop inheriting config options the capability catalog never knew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inheritance kept every uncataloged key, so a removed or renamed option rode the Session lineage forever: a new MCP `session_create` inherits it from the opener's last turn, dispatches it, the agent rejects or warns, and that config becomes the next Session's inheritance source — with no surface anywhere to clear it. The components side was narrowed to exactly this rule in 6d92179; the CLI kept the hole, and a test even pinned it. Inheritance is not a request. Nobody asked for these values on this turn, so an id the catalog does not know gets no benefit of the doubt — `filterInheritedTurnConfigOptionValues` keeps it only when `isAcpPerModelConfigId` recognises it, because those are absent from a snapshot whenever the captured model lacked them, which says nothing about the model a new Session runs. A cataloged option whose value the type cannot carry is still dropped. Explicit requests are untouched: `--config-option` and a frozen Operation request are asked for, and a snapshot of one model still does not get to refuse them. A test now pins that half too, so the split cannot quietly collapse in either direction. With no capability at all the same rule applies. An explicit request is always available, so a missing snapshot must not become a licence to carry every historical key forward — that is how the accumulation starts. Ablation: keeping every uncataloged key fails the new test, dropping the per-model ones fails it too, and rejecting unknown ids on the explicit path fails three. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/AGENTS.md | 11 ++++++ apps/cli/src/commands/session.test.ts | 52 ++++++++++++++++++++++----- apps/cli/src/commands/session.ts | 32 +++++++++++++---- 3 files changed, 81 insertions(+), 14 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 568725e31..8a60e1f70 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -272,6 +272,17 @@ Two things the dev build does deliberately, both load-bearing: confirmed; neither blocks. Do not reintroduce a `validatedConfigIds`-style exemption set: it only made sense while the snapshot could reject, and with rejection gone there is nothing to exempt. + INHERITANCE is the one place that still drops: + `filterInheritedTurnConfigOptionValues` keeps an uncataloged key only when + `isAcpPerModelConfigId` recognises it. A value carried forward from an older + turn is not a request — nobody asked for it on this turn — so a removed or + renamed option would otherwise ride the Session lineage forever: every new + Session inherits it, the agent rejects it, and that config becomes the next + inheritance source, with no surface anywhere to clear it. The same rule applies + when there is no capability at all; an explicit request is always available, so + a missing snapshot must not become a licence to carry every historical key. + Explicit `--config-option` and frozen Operation requests keep going out + unchanged — that split is the whole point. The one thing that still fails loudly is a missing wire BINDING — no snapshot option and no agent convention for how to spell a control, so there is no request to send and an invented id would be a silent no-op. That is a different diff --git a/apps/cli/src/commands/session.test.ts b/apps/cli/src/commands/session.test.ts index 66d510edb..5f23f3b04 100644 --- a/apps/cli/src/commands/session.test.ts +++ b/apps/cli/src/commands/session.test.ts @@ -31,7 +31,7 @@ import { filterAuthorizedMachineMetas, filterAuthorizedLocalProjectCandidates, filterCompatibleInheritedTurnConfig, - filterCompatibleTurnConfigOptionValues, + filterInheritedTurnConfigOptionValues, filterSessionMetas, hasNonPositionalPromptSource, listChildSessionIds, @@ -518,16 +518,52 @@ describe('session command helpers', () => { ).not.toThrow(); }); - it('drops inherited ACP config options only when the type cannot carry them', () => { + it('inherits only what the catalog knows, plus the per-model controls it cannot', () => { expect( - filterCompatibleTurnConfigOptionValues( - { approval_policy: 'never', web_search: 'true', removed: false }, + filterInheritedTurnConfigOptionValues( + { + approval_policy: 'never', + web_search: 'true', + removed_option: false, + 'fast-mode': true, + reasoning_effort: 'high', + thinking: 'on', + }, createAcpCapability() ) - // `web_search` is a boolean option handed a string: undispatchable. - // `removed` is simply absent from this snapshot, which the model this - // turn runs may well still have. - ).toEqual({ approval_policy: 'never', removed: false }); + ).toEqual({ + approval_policy: 'never', + // Absent from the snapshot because the captured model lacked them, which + // says nothing about the model a new Session runs. + 'fast-mode': true, + reasoning_effort: 'high', + thinking: 'on', + }); + // `web_search` is a boolean option handed a string: undispatchable under any + // model. `removed_option` is an id nobody asked for on this turn and the + // catalog has never heard of — inheriting it forever is how a deleted + // option keeps being resent down the Session lineage. + + // With no capability at all the same rule applies: inheritance is a + // convenience, and an explicit request is always available. + expect( + filterInheritedTurnConfigOptionValues({ removed_option: false, 'fast-mode': true }, undefined) + ).toEqual({ 'fast-mode': true }); + }); + + it('still dispatches an explicitly requested option the catalog does not know', () => { + // The other half of the rule: an explicit `--config-option` or a frozen + // Operation request is a request, and a snapshot of one model does not get + // to refuse it. + const requested = applyAgentRunConfigSelection( + { configOptionValues: { removed_option: false } }, + createAcpCapability() + ); + + expect(requested.config.configOptionValues).toEqual({ removed_option: false }); + expect(() => + validateTurnConfigOptionValues(requested.config.configOptionValues, createAcpCapability()) + ).not.toThrow(); }); it('reports mode and model selectors the snapshot cannot confirm, without rejecting them', () => { diff --git a/apps/cli/src/commands/session.ts b/apps/cli/src/commands/session.ts index 03a130d58..56ff84edc 100644 --- a/apps/cli/src/commands/session.ts +++ b/apps/cli/src/commands/session.ts @@ -49,6 +49,7 @@ import { isMachineDocRoomId, isSessionDocRoomId, hasAgentRunConfigSelection, + isAcpPerModelConfigId, resolveAgentRunConfigSelection, resolveBaseBranchPreference, resolveProjectGitHubRepo, @@ -1513,11 +1514,28 @@ export function findUnverifiedTurnSelectors( } /** - * Drops only what cannot be dispatched. An id the snapshot does not carry is - * kept: the snapshot describes one model, and an inherited value may well - * belong to another one. + * What a NEW Session may inherit from an older turn's config. + * + * Inheritance is not a request. Nobody asked for these values on this turn — + * they are carried forward as a convenience — so an id the capability catalog + * does not know gets no benefit of the doubt here, unlike an explicit + * `--config-option` or a frozen Operation request, which are dispatched as + * asked and reconciled at runtime. Without that distinction a removed or + * renamed option rides the Session lineage forever: every new Session inherits + * it, the agent rejects or warns about it, and the resulting config becomes the + * next inheritance source, with no surface anywhere to clear it. + * + * The one exception is an id Lody knows names a PER-MODEL control: those are + * absent from a snapshot whenever the captured model lacked them, which says + * nothing about the model a new Session will run. This mirrors what the + * composer keeps client-side. + * + * With no capability at all nothing can be cataloged, so the same rule applies + * and only per-model ids survive. Inheritance is a convenience and an explicit + * request is always available; carrying every historical key forward on a + * missing snapshot is how the accumulation starts. */ -export function filterCompatibleTurnConfigOptionValues( +export function filterInheritedTurnConfigOptionValues( values: Record | undefined, capability: AcpCapabilityCacheEntry | undefined ): Record | undefined { @@ -1530,7 +1548,9 @@ export function filterCompatibleTurnConfigOptionValues( const compatible = Object.fromEntries( Object.entries(values).filter(([id, value]) => { const option = optionsById.get(id); - return option === undefined || validateConfigOptionShape(option, value) === undefined; + return option === undefined + ? isAcpPerModelConfigId(id) + : validateConfigOptionShape(option, value) === undefined; }) ); return Object.keys(compatible).length > 0 ? compatible : undefined; @@ -1565,7 +1585,7 @@ export function filterCompatibleInheritedTurnConfig( if (!config) { return undefined; } - const configOptionValues = filterCompatibleTurnConfigOptionValues( + const configOptionValues = filterInheritedTurnConfigOptionValues( config.configOptionValues, capability ); From 768456b1485ab53490f9f367fee315c27906d013 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 05:48:30 +0800 Subject: [PATCH 19/24] refactor: delete the run-config reporting nobody reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ablation on this branch's own additions: removing the wiring that merges `findUnverifiedTurnSelectors` into `applyAgentRunConfigSelection` broke no test, because the value it produces is read nowhere. It was computed at three levels — the shared resolver, the CLI merge, the returned object — and consumed at zero. That is the same dead apparatus this branch set out to fix in the first place, rebuilt. The runtime divergence comparison is the report. An offline classification of what "could not be confirmed" only made sense while the snapshot could reject; with rejection gone it tells nobody anything. Deleted end to end, along with the effort/fast confirmation bookkeeping that existed solely to feed it, the now-unused selector-id helper, and the tests that pinned the value rather than any behaviour. `modelReasoningEfforts` keeps its real consumer — the MCP create-options summary. Two dedupe implementations of the accepted-permission triple became one, exported from shared: the client assembling a retry and the schema validating it on the way in now agree by construction, and the schema's bound is counted in the same entries. Ablation also found `.max(8)` and the schema dedupe passing silently — no test noticed their removal. Both are security-relevant (an unbounded durable list from a client; a bound that duplicates could exhaust), so they earned coverage rather than deletion: a 9-entry list now reads as no acceptance, and a repeated disclosure does not consume the budget. Also dropped `EMPTY_TURN_SCOPED_OVERRIDES` (no consumers) and unexported `findAcpPermissionModeRank` (used only inside its own module). Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/AGENTS.md | 9 +-- apps/cli/src/commands/session.test.ts | 72 ++++--------------- apps/cli/src/commands/session.ts | 63 ++-------------- .../src/lib/turn-scoped-overrides.ts | 15 +--- packages/shared/src/acp-run-config.ts | 63 ++++++++-------- packages/shared/src/message-schemas.ts | 11 +-- packages/shared/tests/acp-run-config.test.ts | 63 ---------------- packages/shared/tests/session-input.test.ts | 21 ++++++ 8 files changed, 81 insertions(+), 236 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 8a60e1f70..efa7a403e 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -268,10 +268,11 @@ Two things the dev build does deliberately, both load-bearing: selects. `validateTurnConfigOptionValues` therefore rejects only what no model could carry: a value the option's own declared TYPE forbids. Everything else is dispatched and reconciled against the state the agent publishes. - `findUnverifiedTurnSelectors` and `unverifiedSelections` record what could not be - confirmed; neither blocks. Do not reintroduce a `validatedConfigIds`-style - exemption set: it only made sense while the snapshot could reject, and with - rejection gone there is nothing to exempt. + Do not reintroduce a `validatedConfigIds`-style exemption set, nor an offline + classification of what "could not be confirmed": both only made sense while the + snapshot could reject. With rejection gone there is nothing to exempt, and a + classification nobody reads is not a diagnostic — the runtime divergence + comparison is the report. INHERITANCE is the one place that still drops: `filterInheritedTurnConfigOptionValues` keeps an uncataloged key only when `isAcpPerModelConfigId` recognises it. A value carried forward from an older diff --git a/apps/cli/src/commands/session.test.ts b/apps/cli/src/commands/session.test.ts index 5f23f3b04..9d3480a8f 100644 --- a/apps/cli/src/commands/session.test.ts +++ b/apps/cli/src/commands/session.test.ts @@ -66,7 +66,6 @@ import { updateSessionActivityTimestamps, updateSessionActivityTimestampsBestEffort, validateTurnConfigOptionValues, - findUnverifiedTurnSelectors, withBuiltinDefaultTurnMode, } from './session'; @@ -413,22 +412,22 @@ describe('session command helpers', () => { runConfig: { modelId: 'model-a', reasoningEffort: 'high' }, }, capability - ).config + ) ).toEqual({ modelId: 'model-a', configOptionValues: { approval_policy: 'never', reasoning_effort: 'high' }, }); // No selection: the config passes through untouched, capability or not. - expect(applyAgentRunConfigSelection({ modeId: 'default' }, undefined).config).toEqual({ + expect(applyAgentRunConfigSelection({ modeId: 'default' }, undefined)).toEqual({ modeId: 'default', }); // The snapshot carries no fast toggle, which says nothing about the model - // this turn runs: it is dispatched on the agent's own binding and reported. - const fast = applyAgentRunConfigSelection({ runConfig: { fastMode: true } }, capability); - expect(fast.config.configOptionValues).toEqual({ 'fast-mode': true }); - expect(fast.unverifiedSelections).toEqual(['fastMode=true']); + // this turn runs: it is dispatched on the agent's own binding. + expect( + applyAgentRunConfigSelection({ runConfig: { fastMode: true } }, capability).configOptionValues + ).toEqual({ 'fast-mode': true }); }); it('validates effort against the selected model and skips the probed-model snapshot check', () => { @@ -465,13 +464,11 @@ describe('session command helpers', () => { capability ); - expect(requested.config.configOptionValues).toEqual({ reasoning_effort: 'xhigh' }); - // Confirmed against the breakdown for model-b, so nothing to report… - expect(requested.unverifiedSelections).toEqual([]); - // …and `xhigh` being absent from the probed model's option list is not a - // reason to reject a value the agent published for the model being run. + expect(requested.configOptionValues).toEqual({ reasoning_effort: 'xhigh' }); + // `xhigh` being absent from the probed model's option list is not a reason + // to reject a value the agent published for the model being run. expect(() => - validateTurnConfigOptionValues(requested.config.configOptionValues, capability) + validateTurnConfigOptionValues(requested.configOptionValues, capability) ).not.toThrow(); }); @@ -502,7 +499,7 @@ describe('session command helpers', () => { const requested = applyAgentRunConfigSelection(roleRunConfig, capability); expect(() => - validateTurnConfigOptionValues(requested.config.configOptionValues, capability) + validateTurnConfigOptionValues(requested.configOptionValues, capability) ).not.toThrow(); // Same for the probed model: a snapshot that never carried the toggle is @@ -512,7 +509,7 @@ describe('session command helpers', () => { applyAgentRunConfigSelection( { modelId: 'model-a', configOptionValues: { 'fast-mode': true } }, capability - ).config.configOptionValues, + ).configOptionValues, capability ) ).not.toThrow(); @@ -560,24 +557,12 @@ describe('session command helpers', () => { createAcpCapability() ); - expect(requested.config.configOptionValues).toEqual({ removed_option: false }); + expect(requested.configOptionValues).toEqual({ removed_option: false }); expect(() => - validateTurnConfigOptionValues(requested.config.configOptionValues, createAcpCapability()) + validateTurnConfigOptionValues(requested.configOptionValues, createAcpCapability()) ).not.toThrow(); }); - it('reports mode and model selectors the snapshot cannot confirm, without rejecting them', () => { - const capability = createAcpCapability(); - expect( - findUnverifiedTurnSelectors({ modeId: 'default', modelId: 'model-a' }, capability) - ).toEqual([]); - expect(findUnverifiedTurnSelectors({ modeId: 'plan', modelId: 'model-b' }, capability)).toEqual( - ['mode=plan', 'model=model-b'] - ); - // No snapshot at all confirms nothing — and still blocks nothing. - expect(findUnverifiedTurnSelectors({ modeId: 'default' }, undefined)).toEqual(['mode=default']); - }); - it('keeps inherited mode and model selectors the snapshot does not list', () => { expect( filterCompatibleInheritedTurnConfig( @@ -595,35 +580,6 @@ describe('session command helpers', () => { }); }); - it('accepts mode and model selectors advertised as ACP config options', () => { - const capability: AcpCapabilityCacheEntry = { - ...createAcpCapability(), - modes: [], - models: [], - configOptions: [ - { - id: 'mode', - name: 'Mode', - category: 'mode', - type: 'select', - currentValue: 'plan', - options: [{ value: 'plan', name: 'Plan' }], - }, - { - id: 'model', - name: 'Model', - category: 'model', - type: 'select', - currentValue: 'model-b', - options: [{ value: 'model-b', name: 'Model B' }], - }, - ], - }; - expect(findUnverifiedTurnSelectors({ modeId: 'plan', modelId: 'model-b' }, capability)).toEqual( - [] - ); - }); - it('sorts sessions with invalid createdAt timestamps deterministically', () => { const sessions = [ createSessionMeta({ diff --git a/apps/cli/src/commands/session.ts b/apps/cli/src/commands/session.ts index 56ff84edc..25fa80cb7 100644 --- a/apps/cli/src/commands/session.ts +++ b/apps/cli/src/commands/session.ts @@ -1347,17 +1347,13 @@ export type ResolvedTurnDispatchConfig = { * option values the target agent advertises. Explicit ids on the config win over * the semantic selection only where the selection produced nothing. * - * Also returns every selection that could not be confirmed offline, for the - * dispatch record. None of them blocks the turn: the snapshot describes the - * model it was captured under, and the runtime is what settles the rest. + * Nothing here blocks a turn: the snapshot describes the model it was captured + * under, and the runtime is what settles the rest. */ export function applyAgentRunConfigSelection( config: ResolvedTurnDispatchConfig, capability: AcpCapabilityCacheEntry | undefined -): { - config: ResolvedTurnDispatchConfig; - unverifiedSelections: readonly string[]; -} { +): ResolvedTurnDispatchConfig { const { runConfig, ...rest } = config; const resolved = hasAgentRunConfigSelection(runConfig) ? resolveAgentRunConfigSelection(runConfig, capability) @@ -1368,19 +1364,12 @@ export function applyAgentRunConfigSelection( }; const modeId = resolved.modeId ?? rest.modeId; const modelId = resolved.modelId ?? rest.modelId; - const config_ = { + return { ...(rest.taskToolsEnabled !== undefined ? { taskToolsEnabled: rest.taskToolsEnabled } : {}), ...(modeId ? { modeId } : {}), ...(modelId ? { modelId } : {}), ...(Object.keys(configOptionValues).length > 0 ? { configOptionValues } : {}), }; - return { - config: config_, - unverifiedSelections: [ - ...(resolved.unverifiedSelections ?? []), - ...findUnverifiedTurnSelectors(config_, capability), - ], - }; } function resolveStructuredOutputTimeoutMs(timeoutSeconds: number | undefined): number { @@ -1495,24 +1484,6 @@ export function validateTurnConfigOptionValues( } } -/** - * Selections the snapshot cannot confirm, for the dispatch record. Never an - * error: the snapshot's mode/model lists belong to the probed session. - */ -export function findUnverifiedTurnSelectors( - config: Pick, - capability: AcpCapabilityCacheEntry | undefined -): string[] { - const unverified: string[] = []; - if (config.modeId && !getSupportedTurnSelectorIds(capability, 'mode').has(config.modeId)) { - unverified.push(`mode=${config.modeId}`); - } - if (config.modelId && !getSupportedTurnSelectorIds(capability, 'model').has(config.modelId)) { - unverified.push(`model=${config.modelId}`); - } - return unverified; -} - /** * What a NEW Session may inherit from an older turn's config. * @@ -1556,28 +1527,6 @@ export function filterInheritedTurnConfigOptionValues( return Object.keys(compatible).length > 0 ? compatible : undefined; } -const getSupportedTurnSelectorIds = ( - capability: AcpCapabilityCacheEntry | undefined, - category: 'mode' | 'model' -): Set => { - const ids = new Set( - category === 'mode' - ? (capability?.modes ?? []).map((mode) => mode.id) - : (capability?.models ?? []).map((model) => model.modelId) - ); - for (const option of capability?.configOptions ?? []) { - if (option.category !== category || option.type !== 'select') { - continue; - } - for (const candidate of option.options) { - if (typeof candidate.value === 'string') { - ids.add(candidate.value); - } - } - } - return ids; -}; - export function filterCompatibleInheritedTurnConfig( config: ResolvedTurnDispatchConfig | undefined, capability: AcpCapabilityCacheEntry | undefined @@ -2802,11 +2751,11 @@ async function resolveEffectiveSessionCreateDispatchConfig(args: { }) : undefined; const requested = applyAgentRunConfigSelection(dispatchConfig, capability); - validateTurnConfigOptionValues(requested.config.configOptionValues, capability); + validateTurnConfigOptionValues(requested.configOptionValues, capability); return { ...withBuiltinDefaultTurnMode( mergeTurnDispatchConfig( - requested.config, + requested, filterCompatibleInheritedTurnConfig(inheritedDispatchConfig, capability) ), args.agentConfig diff --git a/packages/components/src/lib/turn-scoped-overrides.ts b/packages/components/src/lib/turn-scoped-overrides.ts index d442b5df4..c7e4cb5bd 100644 --- a/packages/components/src/lib/turn-scoped-overrides.ts +++ b/packages/components/src/lib/turn-scoped-overrides.ts @@ -1,3 +1,4 @@ +import { dedupeAcceptedWiderPermissions } from '@lody/shared'; import type { AcceptedWiderPermission, AcpConfigOptionValue, @@ -28,20 +29,6 @@ export type TurnScopedOverrides = { issuePRMentionsOverride?: IssuePRMention[]; }; -export const EMPTY_TURN_SCOPED_OVERRIDES: TurnScopedOverrides = {}; - -const dedupeAcceptedWiderPermissions = ( - entries: readonly AcceptedWiderPermission[] -): AcceptedWiderPermission[] => { - const seen = new Set(); - return entries.filter((entry) => { - const key = `${entry.controlId}\u0000${entry.requestedModeId}\u0000${entry.effectiveModeId}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -}; - /** Narrows a wider options object to exactly the turn-scoped set. */ export const pickTurnScopedOverrides = ( options: TurnScopedOverrides | undefined diff --git a/packages/shared/src/acp-run-config.ts b/packages/shared/src/acp-run-config.ts index 11885a85b..7a38250c0 100644 --- a/packages/shared/src/acp-run-config.ts +++ b/packages/shared/src/acp-run-config.ts @@ -10,7 +10,12 @@ * actually advertises. */ -import type { AcpCapabilityCacheEntry, AcpConfigOptionSummary, AcpConfigOptionValue } from './ai'; +import type { + AcceptedWiderPermission, + AcpCapabilityCacheEntry, + AcpConfigOptionSummary, + AcpConfigOptionValue, +} from './ai'; /** * Config option ids that carry the agent's "fast mode" toggle: Codex publishes @@ -85,7 +90,7 @@ const ACP_PERMISSION_MODE_RANKS: Record = { 'always-approve': 4, }; -export const findAcpPermissionModeRank = (modeId: string | null | undefined): number | undefined => +const findAcpPermissionModeRank = (modeId: string | null | undefined): number | undefined => typeof modeId === 'string' ? ACP_PERMISSION_MODE_RANKS[modeId] : undefined; /** @@ -136,12 +141,6 @@ export type AgentRunConfigResolution = { modeId?: string; modelId?: string; configOptionValues?: Record; - /** - * Requested controls that could not be verified offline because the agent - * publishes no per-model breakdown for them. They are dispatched as - * requested; the runtime reports a visible warning if the agent rejects them. - */ - unverifiedSelections?: string[]; }; type RunConfigCapabilitySource = Pick< @@ -250,6 +249,27 @@ const findAgentPerModelBinding = (capability: RunConfigCapabilitySource | undefi * that the option is gone — so a stored value for one must survive a snapshot * that does not list it. Any other unknown id has no such excuse. */ +/** + * One entry per disclosed difference. Deduplicated by the WHOLE triple, since + * that is what the daemon matches on — two entries differing in any field are + * two different disclosures. + * + * Shared so the client that assembles a retry and the schema that validates it + * on the way in agree by construction; two copies of this key would drift, and + * the bound the schema enforces is counted in these entries. + */ +export const dedupeAcceptedWiderPermissions = ( + entries: readonly AcceptedWiderPermission[] +): AcceptedWiderPermission[] => { + const seen = new Set(); + return entries.filter((entry) => { + const key = `${entry.controlId}\u0000${entry.requestedModeId}\u0000${entry.effectiveModeId}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +}; + export const isAcpPerModelConfigId = (configId: string): boolean => isAcpFastModeConfigId(configId) || configId === ACP_REASONING_EFFORT_CONFIG_ID || @@ -354,9 +374,10 @@ export const summarizeAgentRunConfigCapabilities = ( * only ever describes the model that was current when it was captured — agents * rebuild those options on every model switch — so neither a missing option nor * a value outside its list says anything about the model this turn selects. - * Everything it cannot confirm is dispatched as requested and reported in - * `unverifiedSelections`; the runtime compares what the agent actually applied - * and surfaces a visible warning when they differ. + * Everything is dispatched as requested; the runtime compares what the agent + * actually applied and surfaces a visible warning when they differ. That + * comparison is the ONLY report — an offline classification of what "could not + * be confirmed" told nobody anything and is deliberately absent. * * The one thing that still throws is a missing BINDING: when neither the * snapshot nor the agent's own convention says how to spell a control on the @@ -377,32 +398,16 @@ export const resolveAgentRunConfigSelection = ( } const configOptionValues: Record = {}; - const unverifiedSelections: string[] = []; - const probedModelId = findCurrentModelId(capability); - const targetModelId = selection.modelId ?? probedModelId; - const switchesModel = targetModelId !== undefined && targetModelId !== probedModelId; let modeId: string | undefined; if (selection.reasoningEffort !== undefined) { const option = findReasoningEffortOption(capability); - const targetModelEfforts = targetModelId - ? capability.modelReasoningEfforts?.[targetModelId] - : undefined; - // The published per-model breakdown speaks for the selected model; the - // snapshot's own list speaks only for the model it was captured under. - const confirmed = targetModelEfforts - ? targetModelEfforts.includes(selection.reasoningEffort) - : !switchesModel && - option?.options.some((value) => value.value === selection.reasoningEffort) === true; const configId = option?.id ?? findAgentPerModelBinding(capability)?.reasoningEffortConfigId; if (!configId) { throw new Error( 'Reasoning effort cannot be encoded for the selected agent: it publishes no reasoning effort option and Lody knows no binding for it.' ); } - if (!confirmed) { - unverifiedSelections.push(`reasoningEffort=${selection.reasoningEffort}`); - } configOptionValues[configId] = selection.reasoningEffort; } @@ -423,9 +428,6 @@ export const resolveAgentRunConfigSelection = ( configOptionValues[configId] = option ? toggleValue(option, selection.fastMode) : selection.fastMode; - if (!option || switchesModel) { - unverifiedSelections.push(`fastMode=${selection.fastMode}`); - } } if (selection.planMode !== undefined) { @@ -445,6 +447,5 @@ export const resolveAgentRunConfigSelection = ( ...(modeId ? { modeId } : {}), ...(selection.modelId !== undefined ? { modelId: selection.modelId } : {}), ...(Object.keys(configOptionValues).length > 0 ? { configOptionValues } : {}), - ...(unverifiedSelections.length > 0 ? { unverifiedSelections } : {}), }; }; diff --git a/packages/shared/src/message-schemas.ts b/packages/shared/src/message-schemas.ts index f89aadf31..bf2b96fba 100644 --- a/packages/shared/src/message-schemas.ts +++ b/packages/shared/src/message-schemas.ts @@ -9,6 +9,7 @@ import { type ACPSessionId, type SessionTurnInputConfig, } from './ai'; +import { dedupeAcceptedWiderPermissions } from './acp-run-config'; import type { AgentRoleId, SessionId } from './ids'; import { MAX_MESSAGE_TEXT_SPAN_MARK_LENGTH, MESSAGE_TEXT_SPAN_KINDS } from './message-text-spans'; import { RpcSecretPublicKeySchema } from './rpc-secret'; @@ -375,15 +376,7 @@ export const AcceptedWiderPermissionsSchema = z .array(AcceptedWiderPermissionSchema) .min(1) .max(8) - .transform((entries) => { - const seen = new Set(); - return entries.filter((entry) => { - const key = `${entry.controlId}\u0000${entry.requestedModeId}\u0000${entry.effectiveModeId}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - }); + .transform(dedupeAcceptedWiderPermissions); export const ACPSessionConfigSchema = z .object({ diff --git a/packages/shared/tests/acp-run-config.test.ts b/packages/shared/tests/acp-run-config.test.ts index 867032340..8df75beef 100644 --- a/packages/shared/tests/acp-run-config.test.ts +++ b/packages/shared/tests/acp-run-config.test.ts @@ -147,10 +147,6 @@ describe('agent run config selection', () => { 'fast-mode': true, collaboration_mode: 'plan', }, - // This agent published no per-model breakdown, and the selection switches - // away from the probed model, so neither effort nor fast can be checked - // offline: both are dispatched as requested and reported as unverified. - unverifiedSelections: ['reasoningEffort=high', 'fastMode=true'], }); }); @@ -212,11 +208,9 @@ describe('agent run config selection', () => { expect(resolveAgentRunConfigSelection({ reasoningEffort: 'high' }, capability)).toEqual({ configOptionValues: { reasoning_effort: 'high' }, - unverifiedSelections: ['reasoningEffort=high'], }); expect(resolveAgentRunConfigSelection({ fastMode: true }, capability)).toEqual({ configOptionValues: { 'fast-mode': true }, - unverifiedSelections: ['fastMode=true'], }); // Plan mode is the exception, and for a binding reason rather than a @@ -319,63 +313,6 @@ describe('agent run config selection', () => { expect(summary.measuredForModelId).toBe('gpt-5.6-sol'); }); - it('validates effort against the model being selected, not the probed one', () => { - const capability: AcpCapabilityCacheEntry = { - ...codexCapability(), - modelReasoningEfforts: { - 'gpt-5.6-sol': ['low', 'medium', 'high', 'xhigh'], - 'gpt-5.4-mini': ['low', 'medium'], - }, - }; - - // `xhigh` is absent from the probed model's snapshot options but the agent - // published a breakdown saying the selected model takes it: confirmed, so - // nothing is reported as unverified. - expect( - resolveAgentRunConfigSelection( - { modelId: 'gpt-5.6-sol', reasoningEffort: 'xhigh' }, - capability - ) - ).toEqual({ - modelId: 'gpt-5.6-sol', - configOptionValues: { reasoning_effort: 'xhigh' }, - }); - - // Outside the target model's published list. That is real evidence, but it - // can be stale (the breakdown is per account and per catalog revision), so - // it dispatches and is reported rather than rejected. - expect( - resolveAgentRunConfigSelection( - { modelId: 'gpt-5.4-mini', reasoningEffort: 'high' }, - capability - ) - ).toEqual({ - modelId: 'gpt-5.4-mini', - configOptionValues: { reasoning_effort: 'high' }, - unverifiedSelections: ['reasoningEffort=high'], - }); - }); - - it('flags selections it cannot verify offline instead of pretending they hold', () => { - // No per-model breakdown: a model switch makes effort and fast unverifiable. - const resolved = resolveAgentRunConfigSelection( - { modelId: 'gpt-5.4-mini', reasoningEffort: 'high', fastMode: true }, - codexCapability() - ); - - expect(resolved.unverifiedSelections).toEqual(['reasoningEffort=high', 'fastMode=true']); - expect(resolved.configOptionValues).toEqual({ - reasoning_effort: 'high', - 'fast-mode': true, - }); - - // Staying on the probed model keeps the snapshot authoritative. - expect( - resolveAgentRunConfigSelection({ reasoningEffort: 'high', fastMode: true }, codexCapability()) - .unverifiedSelections - ).toBeUndefined(); - }); - it('recovers the per-model effort breakdown from a legacy model[effort] list', () => { expect( deriveModelReasoningEffortsFromLegacyModelIds([ diff --git a/packages/shared/tests/session-input.test.ts b/packages/shared/tests/session-input.test.ts index f5905997f..32995e2be 100644 --- a/packages/shared/tests/session-input.test.ts +++ b/packages/shared/tests/session-input.test.ts @@ -1015,6 +1015,27 @@ describe('one-time acceptance of a wider permission', () => { expect( normalizeSessionTurnInputConfig(buildSessionTurnInputConfig(base))?.acceptWiderPermissions ).toBeUndefined(); + // Bounded and deduplicated, both by the whole triple: a list that outgrows + // the bound reads as NO acceptance rather than a truncated one, and a + // repeated disclosure does not consume the budget. + const entry = (n: number) => ({ + controlId: `control-${n}`, + requestedModeId: 'plan', + effectiveModeId: 'auto', + }); + expect( + normalizeSessionTurnInputConfig({ + ...base, + acceptWiderPermissions: Array.from({ length: 9 }, (_unused, index) => entry(index)), + })?.acceptWiderPermissions + ).toBeUndefined(); + expect( + normalizeSessionTurnInputConfig({ + ...base, + acceptWiderPermissions: [entry(0), entry(0), entry(1)], + })?.acceptWiderPermissions + ).toEqual([entry(0), entry(1)]); + // A malformed or partial acceptance reads as no acceptance: one that cannot // name the control and both values would be a blanket one. expect( From 7058c9e4129b2c2e241f17d98329d9b7657622a6 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 06:05:15 +0800 Subject: [PATCH 20/24] fix: keep the permission acceptance with the turn it was given for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targeted review of the acceptance chain found four places where it leaks across turns. The client-side rule was right; the enforcement was in the wrong place. Edit-and-resend rebuilds the config in the DAEMON as `{...original.inputConfig, ...replacement}`. The client drops the acceptance, but a replacement that merely lacks the field does not overwrite a present one, so the CLI added it straight back: accept for prompt A, edit to prompt B, and B runs past the stop nobody accepted it for. The rebuild now goes through `deriveTurnInputConfigForNewTurn`, so a correct client is no longer what makes this safe. Editing a queued item and the Operation completion turn are the same derivation and now drop it too — the latter never dispatched it, but a nested Operation would freeze and copy that history forward. The notice also never said which turn it belonged to; the client took the nearest user entry above it. Another client's turn, or an edit-and-resend rewriting history between the failure and the notice landing, leaves a different prompt there — and if it makes the same permission selection, the acceptance runs it. The notice now carries `userTurnId` and the client matches by id, with adjacency kept only for notices written before the field. `deriveTurnInputConfigForNewTurn`'s overrides can no longer re-add the field, and the speculative-preparation schema no longer accepts it: a preparation is not a dispatched turn, so no difference was ever disclosed for it. Reviewed and found clean: `controlId` agrees between the notice and the applier for both the legacy `set_mode` selector and a `category:'mode'` option, and the rank table covers every builtin permission value at the pinned adapter SHAs. Ablation: reverting either P0 fails its regression test. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/AGENTS.md | 21 ++++++++-- .../orchestration/operation-coordinator.ts | 6 ++- .../session-edit-and-resend-service.test.ts | 17 +++++++++ .../session-edit-and-resend-service.ts | 8 +++- .../src/session/session-execution-service.ts | 4 ++ .../sessions/session-chat-interface.tsx | 7 +++- .../src/lib/permission-not-applied-retry.ts | 26 ++++++++++--- .../permission-not-applied-retry.test.tsx | 38 ++++++++++++++++++- packages/shared/src/ai.ts | 17 ++++++++- packages/shared/src/message-schemas.ts | 14 +++++-- 10 files changed, 140 insertions(+), 18 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index efa7a403e..493649b59 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -401,11 +401,24 @@ Two things the dev build does deliberately, both load-bearing: must not become inheritable there. The mirror image is just as load-bearing: making the field survive rebuilds also made it copyable. Any derivation that mints a new `userTurnId` or changes - the prompt/input blocks — edit-and-resend, history replay import — must go - through `deriveTurnInputConfigForNewTurn`, which drops it. The acceptance was + the prompt/input blocks — edit-and-resend, queue-item editing, the Operation + completion turn, history replay import — must drop it, through + `deriveTurnInputConfigForNewTurn` wherever the type allows. The acceptance was given for ONE prompt; carrying it onto another is a permission bypass built - out of a spread. Same-turn rewrites (status, `_lodyDeliveryKind`, transport - retry) are not copies and keep it. + out of a spread, and `{...original, ...replacement}` is that spread: a + replacement that merely LACKS the field does not overwrite a present one. The + DAEMON enforces this, not the client — edit-and-resend rebuilds server-side, + so a correct client is not what makes it safe. Same-turn rewrites (status, + `_lodyDeliveryKind`, transport retry) are not copies and keep it. The + `overrides` parameter cannot re-add the field, and speculative preparation + configs do not carry it at all. + The notice names the turn it stopped (`permission.userTurnId`) and the client + matches by that id. Reading "the nearest user entry above the notice" instead + attaches the acceptance to whatever landed last — another client's turn, or an + edit-and-resend that rewrote history between the failure and the notice — and + if that prompt makes the same permission selection it runs with an acceptance + nobody gave it. Adjacency remains only as the fallback for notices written + before the field existed. - MCP `session_list` defaults to 20 (maximum 100), and `session_history` defaults to 10 (maximum 50 and 128 KiB). Keep the MCP surface bounded even though the human CLI retains `session history --all`. `session_list` and `session_status_many` derive busy/idle from diff --git a/apps/cli/src/orchestration/operation-coordinator.ts b/apps/cli/src/orchestration/operation-coordinator.ts index f069af2ee..245efe601 100644 --- a/apps/cli/src/orchestration/operation-coordinator.ts +++ b/apps/cli/src/orchestration/operation-coordinator.ts @@ -6,6 +6,7 @@ import { performance } from 'node:perf_hooks'; import type { RepoWatchHandle } from 'loro-repo'; import { + deriveTurnInputConfigForNewTurn, buildMissingEmail, getServerNow, getSessionRoomId, @@ -1010,8 +1011,11 @@ export class LodyOperationCoordinator { items: [item], fileDiff: [], finished: true, + // New `systemTurnId`, new prompt: the frozen turn's one-time permission + // acceptance stays with the turn it was given for, even in history that + // never dispatches — a nested Operation would freeze and copy it again. inputConfig: { - ...operation.frozenContinuationConfig.inputConfig, + ...deriveTurnInputConfigForNewTurn(operation.frozenContinuationConfig.inputConfig), prompt: completionText(operation), chainDepth: operation.initiatorChainDepth + 1, }, diff --git a/apps/cli/src/session/session-edit-and-resend-service.test.ts b/apps/cli/src/session/session-edit-and-resend-service.test.ts index bfe90b775..fa909211d 100644 --- a/apps/cli/src/session/session-edit-and-resend-service.test.ts +++ b/apps/cli/src/session/session-edit-and-resend-service.test.ts @@ -51,6 +51,9 @@ const historyFixture = (): SessionHistoryInput[] => [ configOptionValues: { collaboration_mode: 'plan', }, + acceptWiderPermissions: [ + { controlId: 'mode', requestedModeId: 'plan', effectiveModeId: 'auto' }, + ], }, }, { @@ -227,6 +230,20 @@ describe('SessionEditAndResendService', () => { ); }); + it("does not carry the original turn's permission acceptance onto the new prompt", async () => { + // The client already drops it, but a rule the daemon does not enforce is a + // rule the next client forgets: the replacement config only LACKS the field, + // and a missing key does not overwrite a present one in a spread. + const harness = createHarness({ active: true }); + + await expect(harness.service.editAndResend(spec)).resolves.toMatchObject({ success: true }); + + const replacement = harness.getHistory().at(-1); + expect(replacement?.inputConfig).not.toHaveProperty('acceptWiderPermissions'); + // The rest of the original turn's config still carries over. + expect(replacement?.inputConfig).toMatchObject({ modelId: 'model-1' }); + }); + it('leaves the active turn untouched when provider fork fails', async () => { const harness = createHarness({ active: true, diff --git a/apps/cli/src/session/session-edit-and-resend-service.ts b/apps/cli/src/session/session-edit-and-resend-service.ts index dd50fb482..28e51cbc3 100644 --- a/apps/cli/src/session/session-edit-and-resend-service.ts +++ b/apps/cli/src/session/session-edit-and-resend-service.ts @@ -1,4 +1,5 @@ import { + deriveTurnInputConfigForNewTurn, buildPendingUserHistoryEntry, getServerNow, getSessionRoomId, @@ -484,9 +485,12 @@ export class SessionEditAndResendService { replacement: SessionTurnInputConfig, preparedSessionId: ACPSessionId ): SessionTurnInputConfig { + // A spread would carry the ORIGINAL turn's one-time permission acceptance + // onto this new prompt: `replacement` merely lacks the field, and a missing + // key does not overwrite a present one. The client already drops it, but a + // rule the daemon does not enforce is a rule the next client forgets. return { - ...original.inputConfig, - ...replacement, + ...deriveTurnInputConfigForNewTurn(original.inputConfig, replacement), cliType: meta.cliType, agentType: meta.agentType, resume: preparedSessionId, diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 59d9f54dd..2558d0046 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -1880,10 +1880,14 @@ export class SessionExecutionService { // Keep the specific reason AND both mode ids: they are what let the client // name the two permissions and offer to run this exact turn once with the // one the agent actually has, instead of a generic pre-prompt error. + // The turn id travels with the notice: the client must not have to guess + // which prompt this stop belongs to by looking at whatever user entry + // happens to sit above it. await this.deps.recordChatFailure(sessionDoc, 'permission_not_applied', message, undefined, { controlId: error.controlId, requestedModeId: error.requestedModeId, effectiveModeId: error.effectiveModeId, + ...(runtime.userTurnId ? { userTurnId: runtime.userTurnId } : {}), }); } else if (isGitExecutableNotFoundError(error)) { await this.deps.recordChatFailure( diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index 219ff1090..4fde1946c 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -1892,13 +1892,18 @@ function buildEditedMessageQueueItem( }; } + // Editing the text makes this a different prompt, so the one-time permission + // acceptance the queued turn carried does not follow it — the same rule + // `deriveTurnInputConfigForNewTurn` applies, written out here because the + // queued config is its own nominal type. + const { acceptWiderPermissions: _notCarried, ...carriedConfig } = item.acpSessionConfig; return { ...item, task: nextTask || imageOnlyLabel, isEditing: false, editingStartedAt: undefined, acpSessionConfig: { - ...item.acpSessionConfig, + ...carriedConfig, prompt: nextTask, inputBlocks: nextInputBlocks.length > 0 ? nextInputBlocks : undefined, }, diff --git a/packages/components/src/lib/permission-not-applied-retry.ts b/packages/components/src/lib/permission-not-applied-retry.ts index 7a54cdfc0..46a7b973d 100644 --- a/packages/components/src/lib/permission-not-applied-retry.ts +++ b/packages/components/src/lib/permission-not-applied-retry.ts @@ -1,6 +1,7 @@ import { useCallback, useRef, useState } from 'react'; import type { AcceptedWiderPermission, + PermissionNotAppliedNotice, AcpConfigOptionValue, AgentRoleId, SessionInputBlock, @@ -58,26 +59,36 @@ type RetryHistoryEntry = { inputConfig?: unknown; }; -const readPermissionMeta = (item: RetryHistoryItem): AcceptedWiderPermission | null => { +const readPermissionMeta = (item: RetryHistoryItem): PermissionNotAppliedNotice | null => { if (item?.type !== 'system_notice' || item.name !== 'chat_failed') { return null; } const meta = item.meta as | { reason?: unknown; - permission?: { controlId?: unknown; requestedModeId?: unknown; effectiveModeId?: unknown }; + permission?: { + controlId?: unknown; + requestedModeId?: unknown; + effectiveModeId?: unknown; + userTurnId?: unknown; + }; } | undefined; if (meta?.reason !== 'permission_not_applied') { return null; } - const { controlId, requestedModeId, effectiveModeId } = meta.permission ?? {}; + const { controlId, requestedModeId, effectiveModeId, userTurnId } = meta.permission ?? {}; // All three or nothing: an acceptance that cannot name the control it is for // would be a blanket one. return typeof controlId === 'string' && typeof requestedModeId === 'string' && typeof effectiveModeId === 'string' - ? { controlId, requestedModeId, effectiveModeId } + ? { + controlId, + requestedModeId, + effectiveModeId, + ...(typeof userTurnId === 'string' && userTurnId ? { userTurnId } : {}), + } : null; }; @@ -114,7 +125,7 @@ export const findPermissionNotAppliedRetryTarget = ( return null; } let noticeIndex = -1; - let permission: AcceptedWiderPermission | null = null; + let permission: PermissionNotAppliedNotice | null = null; for (let index = history.length - 1; index >= 0; index -= 1) { const entry = history[index]; if (!entry) continue; @@ -132,9 +143,14 @@ export const findPermissionNotAppliedRetryTarget = ( return null; } + /* The notice names its own turn, so it is found by id. Adjacency is the + fallback for notices written before that field existed: it attaches to + whatever user entry sits above, which is the right guess only when nothing + rewrote history between the failure and the notice landing. */ for (let index = noticeIndex - 1; index >= 0; index -= 1) { const entry = history[index]; if (!entry || entry.role !== 'user') continue; + if (permission.userTurnId !== undefined && entry.id !== permission.userTurnId) continue; const inputConfig = isRecord(entry.inputConfig) ? entry.inputConfig : undefined; const inputBlocks = Array.isArray(inputConfig?.inputBlocks) ? (inputConfig.inputBlocks as SessionInputBlock[]) diff --git a/packages/components/tests/permission-not-applied-retry.test.tsx b/packages/components/tests/permission-not-applied-retry.test.tsx index 9495dfdbd..634fff9e6 100644 --- a/packages/components/tests/permission-not-applied-retry.test.tsx +++ b/packages/components/tests/permission-not-applied-retry.test.tsx @@ -9,7 +9,7 @@ import { } from '../src/lib/permission-not-applied-retry'; const stoppedTurn = (overrides?: Record) => ({ - id: 'user-1', + id: typeof overrides?.id === 'string' ? overrides.id : 'user-1', role: 'user', inputConfig: { inputBlocks: [{ type: 'text', text: 'ship it' }], @@ -26,6 +26,7 @@ const failureNotice = (permission?: { controlId: string; requestedModeId: string; effectiveModeId: string; + userTurnId?: string; }) => ({ id: 'notice-1', role: 'system', @@ -93,6 +94,41 @@ describe('findPermissionNotAppliedRetryTarget', () => { expect(target?.issuePRMentions).toEqual([{ number: 7 }]); }); + it('binds the notice to the turn that produced it, not the nearest one above', () => { + // Another client's turn (or an edit-and-resend rewriting history between the + // failure and the notice landing) can leave a DIFFERENT user entry directly + // above the notice. Accepting there would run a prompt nobody accepted for. + const target = findPermissionNotAppliedRetryTarget([ + stoppedTurn({ + id: 'user-stopped', + inputBlocks: [{ type: 'text', text: 'the stopped prompt' }], + }), + stoppedTurn({ id: 'user-other', inputBlocks: [{ type: 'text', text: 'someone else' }] }), + failureNotice({ + controlId: 'permission-mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + userTurnId: 'user-stopped', + }), + ]); + + expect(target?.userTurnId).toBe('user-stopped'); + expect(target?.inputBlocks).toEqual([{ type: 'text', text: 'the stopped prompt' }]); + }); + + it('falls back to adjacency for a notice written before the turn id existed', () => { + const target = findPermissionNotAppliedRetryTarget([ + stoppedTurn({ id: 'user-1' }), + failureNotice({ + controlId: 'permission-mode', + requestedModeId: 'plan', + effectiveModeId: 'auto', + }), + ]); + + expect(target?.userTurnId).toBe('user-1'); + }); + it('stands down once the user has sent something newer', () => { // Replaying now would inject the old turn behind whatever they just sent. expect( diff --git a/packages/shared/src/ai.ts b/packages/shared/src/ai.ts index 15b7cfdd6..30c6cac19 100644 --- a/packages/shared/src/ai.ts +++ b/packages/shared/src/ai.ts @@ -1151,7 +1151,7 @@ export type ChatFailedMeta = { * the agent reported. Structured so the notice can name both instead of the * client parsing them back out of `message`. */ - permission?: AcceptedWiderPermission; + permission?: PermissionNotAppliedNotice; }; /** @@ -1577,6 +1577,21 @@ export type ToolCallContent = export type ACPSessionId = string & { __brand: 'ACPSessionId' }; +/** + * The stopped turn's identity alongside the difference that stopped it. + * + * The client builds its acceptance from this notice, so the notice has to say + * WHICH turn it belongs to. Reading "the nearest user entry above" instead + * attaches the acceptance to whatever landed last — another client's turn, or + * an edit-and-resend that rewrote history between the failure and the notice — + * and if that prompt happens to make the same permission selection it runs with + * an acceptance nobody gave it. + */ +export type PermissionNotAppliedNotice = AcceptedWiderPermission & { + /** The turn this stop belongs to. Absent on notices written before this field. */ + userTurnId?: string; +}; + /** The one permission difference a user was shown and chose to run with. */ export type AcceptedWiderPermission = { /** The control it was disclosed for: a config option id, or the mode selector. */ diff --git a/packages/shared/src/message-schemas.ts b/packages/shared/src/message-schemas.ts index bf2b96fba..03595e209 100644 --- a/packages/shared/src/message-schemas.ts +++ b/packages/shared/src/message-schemas.ts @@ -587,7 +587,10 @@ export const normalizeSessionTurnInputConfig = ( */ export const deriveTurnInputConfigForNewTurn = ( original: unknown, - overrides: Partial = {} + // The one field a derived turn may not carry is also the one field the + // overrides may not put back: a caller that could pass it would have a + // one-line way around the whole rule. + overrides: Omit, 'acceptWiderPermissions'> = {} ): SessionTurnInputConfig => { const { acceptWiderPermissions: _dropped, ...carried } = normalizeSessionTurnInputConfig(original) ?? {}; @@ -987,7 +990,8 @@ export const SessionPreparationRunConfigSchema = z .transform((ids) => normalizeMcpServerIdSelection(ids) ?? []) .optional(), taskToolsEnabled: z.boolean().optional(), - acceptWiderPermissions: AcceptedWiderPermissionsSchema.optional(), + // No acceptance here on purpose: a speculative preparation is not a + // dispatched turn, so there is no disclosed difference for it to accept. }) .strict(); @@ -3177,7 +3181,11 @@ export const ChatFailedMetaSchema = z.object({ * acceptance — which is the safe direction, since an acceptance that cannot * name its control would be a blanket one. */ - permission: AcceptedWiderPermissionSchema.optional().catch(undefined), + permission: AcceptedWiderPermissionSchema.extend({ + userTurnId: z.string().trim().min(1).optional(), + }) + .optional() + .catch(undefined), }); // Non-system notice MessageContent discriminated union From cb513fec0698909eb54c98dc0ac6077cfc57fa60 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 18:34:29 +0800 Subject: [PATCH 21/24] feat: let an agent declare what each of its models can do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A capability snapshot describes ONE model — the one that was current when `session/new` answered. Codex only lists `fast-mode` while that model has a fast speed tier, and both agents rebuild the effort list per model, so nothing in the snapshot answers "can Luna run Fast?" and ACP has no request that asks. Every surface was therefore reading a per-model fact off a model-scoped snapshot and getting the wrong answer for every other model. So the adapters now attach `_meta.lody.modelCapabilities` to `session/new`: `{ version: 1, models: { : { effortValues?, fastMode? } } }`, built from the same `Model`/`ModelInfo` objects the config options already come from. The CLI records it beside the snapshot along with `measuredForModelId`, the model the snapshot is actually about. It is advisory in one direction: it may describe a control the snapshot never carried, and it never grants permission, authorizes a value, or rejects one. A model it does not name is unknown, not unsupported — so the catalog is read whole or ignored whole, since half a catalog would answer "no fast mode" for models the agent could not fit under the bound. Freshness-gated by a TTL and by `sourceVersion`, because a declaration heard from one adapter build says nothing about the next. Two storage rules the tests pin, both easy to undo by accident: an ordinary refresh that heard no declaration must not clear the stored one, and the write-dedup key must include the declared content or the first declaration to arrive with an otherwise unchanged snapshot is silently dropped. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/AGENTS.md | 22 ++- .../src/agent/acp-capability-normalization.ts | 91 +++++++++++- .../acp-declared-model-capabilities.test.ts | 129 ++++++++++++++++++ apps/cli/src/lib/loro/doc.ts | 47 +++++-- .../machine-document-capabilities.test.ts | 66 +++++++++ .../src/session/session-execution-service.ts | 21 ++- .../tests/session-execution-service.test.ts | 5 +- packages/shared/src/acp-run-config.ts | 100 +++++++++++++- packages/shared/src/ai.ts | 46 ++++++- 9 files changed, 506 insertions(+), 21 deletions(-) create mode 100644 apps/cli/src/agent/acp-declared-model-capabilities.test.ts diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 493649b59..1ca847e0a 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -289,9 +289,25 @@ Two things the dev build does deliberately, both load-bearing: request to send and an invented id would be a silent no-op. That is a different statement from "unsupported" and must be worded as such. `acp-capability-normalization.ts` still recovers `modelReasoningEfforts` from the - legacy `model[effort]` list (Codex): a published per-model breakdown CONFIRMS a - value for the selected model, which is the only thing that keeps it out of - `unverifiedSelections`. It never rejects one. + legacy `model[effort]` list (Codex): a published per-model breakdown describes + models the snapshot itself does not. It never rejects one. + A DECLARED catalog is the same kind of evidence, said explicitly. An adapter may + attach `_meta.lody.modelCapabilities` (`{ version: 1, models: { : + { effortValues?, fastMode? } } }`) to its `session/new` response, which is what + lets a surface answer "does Luna support Fast?" while the probe ran on a model + that has no fast tier — `measuredForModelId` records which model the snapshot is + actually about. It is advisory in one direction only: it may report a control a + snapshot never carried, and it never grants permission, never authorizes a value, + and never rejects one. A model it does not name is UNKNOWN, not unsupported, so + the declaration is read whole or ignored whole (`readDeclaredModelCapabilities`): + half a catalog past the 64-model bound would answer "no fast mode" for models the + agent simply could not fit. It is freshness-gated — a TTL plus a `sourceVersion` + that must equal the entry's own, because a declaration heard from one adapter + build says nothing about the next one. Storage has two rules that are easy to + undo: a later probe that heard no declaration must NOT clear one already stored + for the same `sourceVersion` (an ordinary refresh does not re-elicit `_meta`), and + the write-dedup key must include the declared content, or the first declaration to + arrive alongside an otherwise unchanged snapshot is silently dropped. Client side: a Role may be seeded only from `authoritative` capabilities — `provisional` means the built-in static tables, and seeding from those persists a guess as a durable promise. Nor may it be SAVED without one: a Role is its run diff --git a/apps/cli/src/agent/acp-capability-normalization.ts b/apps/cli/src/agent/acp-capability-normalization.ts index 7809aac72..d1cd2d6f4 100644 --- a/apps/cli/src/agent/acp-capability-normalization.ts +++ b/apps/cli/src/agent/acp-capability-normalization.ts @@ -2,11 +2,80 @@ import { deriveModelReasoningEffortsFromLegacyModelIds, type AcpCommandSummary, type AcpConfigOptionSummary, + type DeclaredModelCapabilities, } from '@lody/shared'; import type { SessionConfigOption, SessionConfigSelectGroup } from '@agentclientprotocol/sdk'; import { z } from 'zod'; import { filterAcpConfigOptions } from '@/agent/acp-config-option-filter'; +/** + * Bounds for the agent's self-declared model catalog. + * + * `_meta` is whatever the other side put there, and this one gets persisted and + * fanned out to every client of the workspace, so it is bounded before it is + * believed. Numbers are generous for a real catalog and small for a payload: + * an agent publishing more models than this is not describing itself. + */ +const DECLARED_MODEL_LIMITS = { + models: 64, + modelIdLength: 128, + effortValues: 16, + effortValueLength: 64, +} as const; + +const zDeclaredModelCapabilities = z.object({ + _meta: z + .object({ + lody: z + .object({ + modelCapabilities: z + .object({ + version: z.literal(1), + producerRevision: z.string().trim().min(1).max(128).optional(), + models: z + .record( + z.string().trim().min(1).max(DECLARED_MODEL_LIMITS.modelIdLength), + z.object({ + effortValues: z + .array(z.string().trim().min(1).max(DECLARED_MODEL_LIMITS.effortValueLength)) + .max(DECLARED_MODEL_LIMITS.effortValues) + .optional(), + fastMode: z.boolean().optional(), + }) + ) + .refine((models) => Object.keys(models).length <= DECLARED_MODEL_LIMITS.models), + }) + .nullish(), + }) + .nullish(), + }) + .nullish(), +}); + +/** + * Reads the agent's own per-model statement, or nothing. + * + * An unknown `version`, a shape that does not parse, or a catalog past the + * bounds is ignored WHOLE rather than partially: half a catalog would answer + * "this model has no fast mode" for models the agent simply could not fit. + */ +export function readDeclaredModelCapabilities( + sessionResponse: unknown, + receivedAt: number +): DeclaredModelCapabilities | undefined { + const parsed = zDeclaredModelCapabilities.safeParse(sessionResponse); + const declared = parsed.success ? parsed.data._meta?.lody?.modelCapabilities : undefined; + if (!declared || Object.keys(declared.models).length === 0) { + return undefined; + } + return { + version: 1, + models: declared.models, + receivedAt, + ...(declared.producerRevision ? { producerRevision: declared.producerRevision } : {}), + }; +} + export type AcpCapabilitiesResult = { modes: Array<{ id: string; name: string; description?: string }>; models: Array<{ modelId: string; name?: string; description?: string }>; @@ -15,6 +84,8 @@ export type AcpCapabilitiesResult = { sessionFork: boolean; acknowledgedSteer: boolean; modelReasoningEfforts?: Record; + measuredForModelId?: string; + declaredModelCapabilities?: DeclaredModelCapabilities; }; function isSelectGroup(item: unknown): item is SessionConfigSelectGroup { @@ -151,7 +222,11 @@ type AcpSessionCapabilitiesResponse = { /** Extract cacheable capabilities from a real ACP new/load/resume session response. */ export function normalizeAcpSessionCapabilities( sessionResponse: AcpSessionCapabilitiesResponse, - lifecycleCapabilities: { sessionFork?: boolean; acknowledgedSteer?: boolean } = {} + lifecycleCapabilities: { + sessionFork?: boolean; + acknowledgedSteer?: boolean; + receivedAt?: number; + } = {} ): AcpCapabilitiesResult { const modes = (sessionResponse.modes?.availableModes ?? []).map((mode) => ({ id: mode.id, @@ -176,6 +251,18 @@ export function normalizeAcpSessionCapabilities( legacyModels.map((model) => model.modelId) ); + // What the snapshot is a snapshot OF, stored rather than left to each reader + // to infer from the model option's `currentValue`. + const modelOptionValue = modelOption?.currentValue; + const measuredForModelId = + typeof modelOptionValue === 'string' + ? modelOptionValue + : (readLegacySessionModelState(sessionResponse)?.currentModelId ?? undefined); + const declaredModelCapabilities = readDeclaredModelCapabilities( + sessionResponse, + lifecycleCapabilities.receivedAt ?? Date.now() + ); + return { modes, models, @@ -184,5 +271,7 @@ export function normalizeAcpSessionCapabilities( sessionFork: lifecycleCapabilities.sessionFork === true, acknowledgedSteer: lifecycleCapabilities.acknowledgedSteer === true, ...(modelReasoningEfforts ? { modelReasoningEfforts } : {}), + ...(measuredForModelId ? { measuredForModelId } : {}), + ...(declaredModelCapabilities ? { declaredModelCapabilities } : {}), }; } diff --git a/apps/cli/src/agent/acp-declared-model-capabilities.test.ts b/apps/cli/src/agent/acp-declared-model-capabilities.test.ts new file mode 100644 index 000000000..1cc1f2d31 --- /dev/null +++ b/apps/cli/src/agent/acp-declared-model-capabilities.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import { + findDeclaredEffortValues, + findDeclaredFastModeSupport, + summarizeAgentRunConfigCapabilities, + DECLARED_MODEL_CAPABILITIES_TTL_MS, + type AcpCapabilityCacheEntry, +} from '@lody/shared'; + +import { normalizeAcpSessionCapabilities } from './acp-capability-normalization'; + +/** A `session/new` response shaped like Codex's, carrying the Lody declaration. */ +const sessionResponse = (models: Record, extra: Record = {}) => ({ + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select' as const, + currentValue: 'gpt-5.2', + options: [ + { value: 'gpt-5.2', name: 'GPT-5.2' }, + { value: 'gpt-5.6-luna', name: 'Luna' }, + ], + }, + { + id: 'reasoning_effort', + name: 'Reasoning effort', + category: 'thought_level', + type: 'select' as const, + currentValue: 'medium', + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + ], + }, + ], + _meta: { lody: { modelCapabilities: { version: 1, models, ...extra } } }, +}); + +const entryOf = ( + response: ReturnType, + receivedAt = 1_000 +): AcpCapabilityCacheEntry => ({ + cliType: 'builtin', + agentType: 'codex', + modes: [], + models: [], + fetchedAt: receivedAt, + ...normalizeAcpSessionCapabilities(response, { receivedAt }), +}); + +describe('declared model capabilities', () => { + const declared = { + 'gpt-5.2': { effortValues: ['low', 'medium'], fastMode: false }, + 'gpt-5.6-luna': { effortValues: ['low', 'medium', 'high', 'xhigh'], fastMode: true }, + }; + + it('answers for a model the snapshot never described', () => { + // The probe ran on gpt-5.2, which has no fast tier, so `configOptions` + // carries no fast toggle at all. That is the exact case where the snapshot + // knows nothing and the declaration does. + const entry = entryOf(sessionResponse(declared)); + + expect(entry.measuredForModelId).toBe('gpt-5.2'); + expect(findDeclaredFastModeSupport(entry, 'gpt-5.6-luna', 1_000)).toBe(true); + expect(findDeclaredFastModeSupport(entry, 'gpt-5.2', 1_000)).toBe(false); + expect(findDeclaredEffortValues(entry, 'gpt-5.6-luna', 1_000)).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh', + ]); + }); + + it('says nothing about a model the agent did not name', () => { + const entry = entryOf(sessionResponse(declared)); + // Unknown, not unsupported: a declaration answers only for what it lists. + expect(findDeclaredFastModeSupport(entry, 'gpt-6-unreleased', 1_000)).toBeUndefined(); + }); + + it('stops speaking once stale or heard under another adapter version', () => { + const entry = entryOf(sessionResponse(declared)); + const stale = 1_000 + DECLARED_MODEL_CAPABILITIES_TTL_MS + 1; + expect(findDeclaredFastModeSupport(entry, 'gpt-5.6-luna', stale)).toBeUndefined(); + + // Same data, but the declaration was heard under a different adapter build. + const moved: AcpCapabilityCacheEntry = { + ...entry, + sourceVersion: 'codex@2', + declaredModelCapabilities: entry.declaredModelCapabilities + ? { ...entry.declaredModelCapabilities, sourceVersion: 'codex@1' } + : undefined, + }; + expect(findDeclaredFastModeSupport(moved, 'gpt-5.6-luna', 1_000)).toBeUndefined(); + }); + + it('reports fast mode for the agent once any model declares it', () => { + // The MCP create-options summary used to answer from the probed model's + // snapshot alone, so an agent whose default model lacks fast published + // `fastMode: false` for every model it has. + const entry = entryOf(sessionResponse(declared)); + const summary = summarizeAgentRunConfigCapabilities(entry, 1_000); + + expect(summary.fastMode).toBe(true); + expect(summary.measuredForModelId).toBe('gpt-5.2'); + expect( + summary.models.find((model) => model.id === 'gpt-5.6-luna')?.reasoningEffortValues + ).toEqual(['low', 'medium', 'high', 'xhigh']); + }); + + it('ignores a declaration it cannot trust, whole rather than in part', () => { + // Unknown version, and a catalog past the bound. Half a catalog would answer + // "no fast mode" for models the agent simply could not fit. + const wrongVersion = { + ...sessionResponse(declared), + _meta: { lody: { modelCapabilities: { version: 2, models: declared } } }, + }; + expect(entryOf(wrongVersion).declaredModelCapabilities).toBeUndefined(); + + const oversized = Object.fromEntries( + Array.from({ length: 65 }, (_unused, index) => [`model-${index}`, { fastMode: true }]) + ); + expect(entryOf(sessionResponse(oversized)).declaredModelCapabilities).toBeUndefined(); + + const noMeta = { ...sessionResponse(declared), _meta: undefined }; + expect(entryOf(noMeta).declaredModelCapabilities).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/lib/loro/doc.ts b/apps/cli/src/lib/loro/doc.ts index 8ad032ae6..7203936d9 100644 --- a/apps/cli/src/lib/loro/doc.ts +++ b/apps/cli/src/lib/loro/doc.ts @@ -1522,7 +1522,11 @@ export class LoroDocumentManager { sourceVersion: string, modelReasoningEfforts?: Record, acknowledgedSteer = false, - options: { signal?: AbortSignal } = {} + options: { + signal?: AbortSignal; + measuredForModelId?: string; + declaredModelCapabilities?: AcpCapabilityCacheEntry['declaredModelCapabilities']; + } = {} ): Promise { options.signal?.throwIfAborted(); if (!this.machine) { @@ -3119,6 +3123,18 @@ const serializeAcpCapabilityWithoutFetchTime = (entry: AcpCapabilityCacheEntry): modes: entry.modes, models: entry.models, configOptions: entry.configOptions, + modelReasoningEfforts: entry.modelReasoningEfforts, + measuredForModelId: entry.measuredForModelId, + // Content, not freshness: `receivedAt` is deliberately excluded so a + // re-probe that learns nothing new does not rewrite the row, while a + // catalog that actually changed does. + declaredModelCapabilities: entry.declaredModelCapabilities + ? { + version: entry.declaredModelCapabilities.version, + models: entry.declaredModelCapabilities.models, + producerRevision: entry.declaredModelCapabilities.producerRevision, + } + : undefined, availableCommands: entry.availableCommands, sessionFork: entry.sessionFork, acknowledgedSteer: entry.acknowledgedSteer, @@ -3207,7 +3223,11 @@ export class MachineDocument implements LoroDocument<{}, MachineMeta> { sourceVersion: string, modelReasoningEfforts?: Record, acknowledgedSteer = false, - options: { signal?: AbortSignal } = {} + options: { + signal?: AbortSignal; + measuredForModelId?: string; + declaredModelCapabilities?: AcpCapabilityCacheEntry['declaredModelCapabilities']; + } = {} ): Promise { options.signal?.throwIfAborted(); const normalizedModes = modes.map((mode) => ({ @@ -3220,6 +3240,14 @@ export class MachineDocument implements LoroDocument<{}, MachineMeta> { name: model.name ?? model.modelId, description: model.description ?? undefined, })); + const handle = await this.openMachineFlockDoc(); + options.signal?.throwIfAborted(); + const capabilityKey = getAcpCapabilityCacheKey(configId); + const existing = getMachineFlockAcpCapabilities( + readMachineFlockRowsFromFlock(handle.flock, { families: ['acpCapability'] }) + )[capabilityKey]; + const existingDeclared = + existing?.sourceVersion === sourceVersion ? existing.declaredModelCapabilities : undefined; const entry: AcpCapabilityCacheEntry = { cliType, agentType, @@ -3237,14 +3265,17 @@ export class MachineDocument implements LoroDocument<{}, MachineMeta> { modelReasoningEfforts && Object.keys(modelReasoningEfforts).length > 0 ? modelReasoningEfforts : undefined, + ...(options.measuredForModelId ? { measuredForModelId: options.measuredForModelId } : {}), + // A refresh that carries no declaration keeps the one already stored: the + // agent has not retracted it, this probe simply did not hear it (an older + // adapter, a failed catalog fetch). Only a NEW declaration replaces it. + ...((options.declaredModelCapabilities ?? existingDeclared) + ? { + declaredModelCapabilities: options.declaredModelCapabilities ?? existingDeclared, + } + : {}), fetchedAt: getServerNow(), }; - const handle = await this.openMachineFlockDoc(); - options.signal?.throwIfAborted(); - const capabilityKey = getAcpCapabilityCacheKey(configId); - const existing = getMachineFlockAcpCapabilities( - readMachineFlockRowsFromFlock(handle.flock, { families: ['acpCapability'] }) - )[capabilityKey]; if ( existing && serializeAcpCapabilityWithoutFetchTime(existing) === diff --git a/apps/cli/src/lib/loro/machine-document-capabilities.test.ts b/apps/cli/src/lib/loro/machine-document-capabilities.test.ts index 98351541c..ff8350f46 100644 --- a/apps/cli/src/lib/loro/machine-document-capabilities.test.ts +++ b/apps/cli/src/lib/loro/machine-document-capabilities.test.ts @@ -80,6 +80,72 @@ describe('MachineDocument ACP capabilities', () => { expect([...flock.rows.values()][0]?.value).toMatchObject({ acknowledgedSteer: true }); }); + it('keeps a declaration a later probe did not hear, and replaces one it did', async () => { + const flock = new FakeMachineFlock(); + const repo = { + openFlockDoc: vi.fn(async () => ({ flock, syncOnce: vi.fn(async () => undefined) })), + flush: vi.fn(async () => undefined), + } as unknown as LoroRepo; + const document = new MachineDocument( + repo, + 'workspace-1' as WorkspaceId, + 'machine-1' as MachineId, + vi.fn() + ); + const write = (options: Parameters[11]) => + document.updateAcpCapabilities( + 'config-1' as AgentConfigId, + 'builtin', + 'codex', + [{ id: 'agent', name: 'Agent' }], + [{ modelId: 'gpt-5', name: 'GPT-5' }], + undefined, + undefined, + false, + 'builtin:codex:test', + undefined, + false, + options + ); + const stored = () => + [...flock.rows.values()][0]?.value as { + declaredModelCapabilities?: { models: Record }; + }; + + await write({ + declaredModelCapabilities: { + version: 1, + models: { 'gpt-5.6-luna': { fastMode: true } }, + receivedAt: 1, + }, + }); + expect(stored()?.declaredModelCapabilities?.models).toEqual({ + 'gpt-5.6-luna': { fastMode: true }, + }); + + // A probe against an older adapter, or one whose catalog fetch failed, hears + // no declaration. That is not the agent retracting it. + await write({}); + expect(stored()?.declaredModelCapabilities?.models).toEqual({ + 'gpt-5.6-luna': { fastMode: true }, + }); + + // A new declaration is the agent speaking again, and replaces it whole — + // even when NOTHING else about the capabilities changed, which is the case + // the de-duplication key has to notice on its own. + await write({ + declaredModelCapabilities: { + version: 1, + models: { 'gpt-5.6-luna': { fastMode: true }, 'gpt-5.2': { fastMode: false } }, + receivedAt: 2, + }, + }); + expect(stored()?.declaredModelCapabilities?.models).toEqual({ + 'gpt-5.6-luna': { fastMode: true }, + 'gpt-5.2': { fastMode: false }, + }); + }); + it('does not write capabilities when cancelled while opening the Machine Flock', async () => { const flock = new FakeMachineFlock(); let markOpenStarted!: () => void; diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 2558d0046..0f5573ff6 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -89,6 +89,7 @@ import { } from '@/agent/managed-agent-runtime'; import type { FetchAcpCapabilitiesOptions } from '@/agent/acp-capabilities'; import { AcpAuthenticationRequiredError, AgentSteerNotDeliveredError } from '@/agent/agent-client'; +import type { DeclaredModelCapabilities } from '@lody/shared'; import { AcpPermissionNotAppliedError } from '@/session/acp-session-config-applier'; import { AcpAuthenticationManager, @@ -508,6 +509,8 @@ export type SessionExecutionServiceDeps = { sessionFork: boolean; acknowledgedSteer: boolean; modelReasoningEfforts?: Record; + measuredForModelId?: string; + declaredModelCapabilities?: DeclaredModelCapabilities; capabilitySourceVersion?: string; }>; /** Evict idle sessions if system memory is under pressure */ @@ -4879,7 +4882,15 @@ export class SessionExecutionService { capabilities.sessionFork, sourceVersion, capabilities.modelReasoningEfforts, - capabilities.acknowledgedSteer + capabilities.acknowledgedSteer, + { + ...(capabilities.measuredForModelId + ? { measuredForModelId: capabilities.measuredForModelId } + : {}), + ...(capabilities.declaredModelCapabilities + ? { declaredModelCapabilities: capabilities.declaredModelCapabilities } + : {}), + } ); })().catch((error: unknown) => { this.deps.logger.debug( @@ -5189,6 +5200,8 @@ export class SessionExecutionService { sessionFork, acknowledgedSteer, modelReasoningEfforts, + measuredForModelId, + declaredModelCapabilities, capabilitySourceVersion, } = await this.deps.fetchAcpCapabilities( message.cliType, @@ -5227,7 +5240,11 @@ export class SessionExecutionService { }), modelReasoningEfforts, acknowledgedSteer, - { signal: options.signal } + { + signal: options.signal, + ...(measuredForModelId ? { measuredForModelId } : {}), + ...(declaredModelCapabilities ? { declaredModelCapabilities } : {}), + } ); return { diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index aac6e1be1..b01cec1b2 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -2050,7 +2050,10 @@ describe('SessionExecutionService', () => { // Per-model reasoning efforts: absent for this agent, which publishes no // legacy `model[effort]` combination list. undefined, - true + true, + // The trailing options bag: this response names no current model and + // carries no `_meta` declaration, so there is nothing to record. + {} ) ); }); diff --git a/packages/shared/src/acp-run-config.ts b/packages/shared/src/acp-run-config.ts index 7a38250c0..f3e571d84 100644 --- a/packages/shared/src/acp-run-config.ts +++ b/packages/shared/src/acp-run-config.ts @@ -13,6 +13,7 @@ import type { AcceptedWiderPermission, AcpCapabilityCacheEntry, + DeclaredModelCapability, AcpConfigOptionSummary, AcpConfigOptionValue, } from './ai'; @@ -147,7 +148,12 @@ type RunConfigCapabilitySource = Pick< AcpCapabilityCacheEntry, 'modes' | 'models' | 'configOptions' | 'modelReasoningEfforts' > & - Partial>; + Partial< + Pick< + AcpCapabilityCacheEntry, + 'agentType' | 'sourceVersion' | 'measuredForModelId' | 'declaredModelCapabilities' + > + >; /** * Recovers the per-model effort breakdown from a legacy `model[effort]` model @@ -258,6 +264,49 @@ const findAgentPerModelBinding = (capability: RunConfigCapabilitySource | undefi * on the way in agree by construction; two copies of this key would drift, and * the bound the schema enforces is counted in these entries. */ +/** + * How long an agent's self-declaration keeps speaking for the account it was + * heard under. + * + * The underlying catalogs are fetched per account and change without telling us + * — Codex re-fetches its model list on a 300s TTL, Claude asks per model — so a + * declaration is a statement about a moment, not a fact. A day is long enough + * that a normal session never re-probes for this and short enough that a plan + * change is not still being quoted a week later. + */ +export const DECLARED_MODEL_CAPABILITIES_TTL_MS = 24 * 60 * 60 * 1000; + +/** + * The agent's own statement, if it is still allowed to speak. + * + * Returns nothing — meaning "we do not know", never "not supported" — when the + * declaration is stale or was heard under a different adapter/runtime identity. + * This is the ONLY reader; everywhere else takes the result, so a caller cannot + * skip the freshness question by reaching for the stored field. + * + * Not covered yet: the account itself changing under a stable `sourceVersion` + * (a re-login, a plan change). The TTL is what bounds that today. + */ +export const readDeclaredModelCapabilities = ( + capability: RunConfigCapabilitySource | undefined, + now: number +): Record | undefined => { + const declared = capability?.declaredModelCapabilities; + if (!declared || declared.version !== 1) { + return undefined; + } + if ( + declared.sourceVersion !== undefined && + declared.sourceVersion !== capability?.sourceVersion + ) { + return undefined; + } + if (now - declared.receivedAt > DECLARED_MODEL_CAPABILITIES_TTL_MS) { + return undefined; + } + return declared.models; +}; + export const dedupeAcceptedWiderPermissions = ( entries: readonly AcceptedWiderPermission[] ): AcceptedWiderPermission[] => { @@ -347,26 +396,67 @@ const listModels = ( * of guessing option ids. */ export const summarizeAgentRunConfigCapabilities = ( - capability: RunConfigCapabilitySource | undefined + capability: RunConfigCapabilitySource | undefined, + now: number = Date.now() ): AgentRunConfigCapabilities => { + /* Three sources, in descending order of what they actually know: + the agent's own per-model statement, the per-model effort breakdown + recovered from its legacy `model[effort]` list, and the snapshot — which + describes ONE model and is reported as such via `measuredForModelId`. */ + const declared = readDeclaredModelCapabilities(capability, now); const perModelEfforts = capability?.modelReasoningEfforts; - const measuredForModelId = findCurrentModelId(capability); + const measuredForModelId = capability?.measuredForModelId ?? findCurrentModelId(capability); return { models: listModels(capability).map((model) => { - const efforts = perModelEfforts?.[model.id]; + const efforts = declared?.[model.id]?.effortValues ?? perModelEfforts?.[model.id]; return { ...model, ...(efforts ? { reasoningEffortValues: efforts } : {}) }; }), reasoningEffortValues: (findReasoningEffortOption(capability)?.options ?? []).map( (value) => value.value ), ...(measuredForModelId ? { measuredForModelId } : {}), - fastMode: findFastModeOption(capability) !== undefined, + // The declaration answers for every model it names, so a snapshot captured + // under a model without the toggle stops being the whole story. + fastMode: declared + ? Object.values(declared).some((model) => model.fastMode === true) + : findFastModeOption(capability) !== undefined, planMode: findPlanModeOption(capability) !== undefined || findPlanPermissionModeId(capability) !== undefined, }; }; +/** + * Whether a model offers fast mode, when the agent has said so. + * + * `undefined` means unknown, never "no": only a fresh declaration can answer, + * and a snapshot that omits the toggle is not an answer about another model. + */ +export const findDeclaredFastModeSupport = ( + capability: RunConfigCapabilitySource | undefined, + modelId: string | undefined, + now: number = Date.now() +): boolean | undefined => { + if (!modelId) return undefined; + return readDeclaredModelCapabilities(capability, now)?.[modelId]?.fastMode; +}; + +/** + * Effort values a model accepts, when the agent has said so, else the legacy + * breakdown, else nothing. + */ +export const findDeclaredEffortValues = ( + capability: RunConfigCapabilitySource | undefined, + modelId: string | undefined, + now: number = Date.now() +): string[] | undefined => { + if (!modelId) return undefined; + return ( + readDeclaredModelCapabilities(capability, now)?.[modelId]?.effortValues ?? + capability?.modelReasoningEfforts?.[modelId] + ); +}; + /** * Maps a semantic selection onto the target agent's concrete ACP ids. * diff --git a/packages/shared/src/ai.ts b/packages/shared/src/ai.ts index 30c6cac19..673bffcfe 100644 --- a/packages/shared/src/ai.ts +++ b/packages/shared/src/ai.ts @@ -279,7 +279,41 @@ export type AcpCommandSummary = { }; // Bump when cached ACP probes need to be invalidated across clients. -export const ACP_CAPABILITY_CACHE_VERSION = 6; +export const ACP_CAPABILITY_CACHE_VERSION = 7; + +/** What an agent says one model can do, for controls it rebuilds per model. */ +export type DeclaredModelCapability = { + /** Reasoning-effort values this model accepts. */ + effortValues?: string[]; + /** Whether this model offers the fast toggle at all. */ + fastMode?: boolean; +}; + +/** + * An agent's own statement about its models, from the Lody `_meta` extension on + * `session/new`. + * + * This is the only evidence that can say a model does NOT have a control. A + * capability snapshot cannot: it describes whichever model was current when it + * was captured, so an option missing from it means nothing for another model. + * Both builtin agents already hold this data when they build that snapshot — + * Codex reads `additionalSpeedTiers` and `supportedReasoningEfforts` off its + * model catalog, Claude off `ModelInfo` — and until now dropped it. + * + * SELF-declared, deliberately: it is the agent describing itself, not Lody + * vouching for it. It may shape menus and offline answers; it may never grant + * permission, and a live disagreement still wins. + */ +export type DeclaredModelCapabilities = { + version: 1; + models: Record; + /** When Lody received it. A statement about a catalog is not timeless. */ + receivedAt: number; + /** Adapter/runtime identity it was received under. */ + sourceVersion?: string; + /** Producer's own catalog revision, for transport throttling. */ + producerRevision?: string; +}; export type AcpCapabilityAuthority = 'unavailable' | 'provisional' | 'authoritative'; @@ -307,6 +341,16 @@ export type AcpCapabilityCacheEntry = { * `configOptions` is a snapshot that only describes `currentValue`'s model. */ modelReasoningEfforts?: Record; + /** + * The model `configOptions` was captured under. + * + * Stored rather than recovered from the model option's `currentValue`: every + * reader needs it to know what the snapshot is a snapshot OF, and inferring + * it at each call site is how a snapshot comes to be read as a catalog. + */ + measuredForModelId?: string; + /** The agent's own per-model statement, when it publishes one. */ + declaredModelCapabilities?: DeclaredModelCapabilities; /** Available slash commands advertised by the agent. */ availableCommands?: AcpCommandSummary[]; /** True only when the runtime initialize response advertised `sessionCapabilities.fork`. */ From ca54a3893937416bf6e38d37267847ff1ff4abe5 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 18:37:02 +0800 Subject: [PATCH 22/24] feat: point the Codex and Claude adapters at their capability declaration The producer half of `_meta.lody.modelCapabilities` lives in the adapter repositories, so the consumer landed first and read an absent declaration as "nothing to say". These two pointers are what actually make it speak. Both are on `feat/declare-model-capabilities` in their own repositories; the pointers move again when those merge. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- packages/acp-extension-claude | 2 +- packages/acp-extension-codex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/acp-extension-claude b/packages/acp-extension-claude index 0164d9848..2ecc214b2 160000 --- a/packages/acp-extension-claude +++ b/packages/acp-extension-claude @@ -1 +1 @@ -Subproject commit 0164d9848631830a98f39f3ef69d9b2ea578145b +Subproject commit 2ecc214b2d1bda14d1a03eb7bc7a19a7a561fc04 diff --git a/packages/acp-extension-codex b/packages/acp-extension-codex index 0887c5620..acd4e9b0b 160000 --- a/packages/acp-extension-codex +++ b/packages/acp-extension-codex @@ -1 +1 @@ -Subproject commit 0887c5620b7b1773fa401e65a1009f10f80715a7 +Subproject commit acd4e9b0bac7131f57e9c6737453ce3cded6560d From da3ba265624f140c3836e902f042c6e9fa9e0c87 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 23:21:01 +0800 Subject: [PATCH 23/24] chore: rebase the Claude adapter pointer off an unmerged branch The declaration commit had been written on top of `fix: hide unknown context window usage`, which is what the submodule happened to be checked out at and is not on the adapter's `main`. Rebased onto `main` so the PR carries one independent commit, and the pointer follows. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- packages/acp-extension-claude | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/acp-extension-claude b/packages/acp-extension-claude index 2ecc214b2..ce7d65a11 160000 --- a/packages/acp-extension-claude +++ b/packages/acp-extension-claude @@ -1 +1 @@ -Subproject commit 2ecc214b2d1bda14d1a03eb7bc7a19a7a561fc04 +Subproject commit ce7d65a1171a3049a5a0f57dbc79f9fe7143efe1 From bf80d9a41871861b18bbeea6cdedd97dc92c6c8c Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 5 Sep 2026 17:54:44 +0800 Subject: [PATCH 24/24] fix: derive ACP controls per model and explain missing capabilities Use fresh model declarations, legacy per-model efforts, and matching snapshots to build reasoning and Fast controls. Distinguish unknown metadata from unsupported controls across desktop, mobile, and settings while preserving explicit requests and validating Role choices. Model: gpt-6-astra --- locales/en.json | 6 + locales/zh_CN.json | 6 + packages/components/src/AGENTS.md | 12 +- .../chat/chat-landing-selectors.tsx | 9 +- .../mobile/mobile-fast-plan-toggles.tsx | 8 + .../mobile/mobile-run-config-sheet.tsx | 15 +- .../sessions/desktop-run-config-menu.tsx | 15 +- .../settings/agent-config-dialog.tsx | 33 +- .../components/settings/agent-role-form.tsx | 6 +- .../shared/acp-control-availability.tsx | 35 ++ .../shared/acp-inline-selector-group.tsx | 56 ++-- .../components/shared/acp-selector-options.ts | 201 +++++++++--- packages/components/src/lib/AGENTS.md | 6 +- .../src/lib/acp-session-config-selection.ts | 39 +-- .../components/src/lib/agent-role-form.ts | 11 +- .../AcpControlAvailability.stories.tsx | 27 ++ .../tests/acp-control-availability.test.tsx | 30 ++ .../tests/acp-selector-options.test.ts | 310 +++++++++--------- .../acp-session-config-selection.test.ts | 53 ++- .../tests/agent-config-dialog.test.tsx | 73 +++-- .../components/tests/agent-role-form.test.ts | 29 ++ .../composer-run-config-role-face.test.tsx | 26 ++ .../tests/mobile-run-config-role-row.test.tsx | 26 ++ packages/shared/src/acp-run-config.ts | 44 +++ .../shared/tests/acp-model-controls.test.ts | 79 +++++ 25 files changed, 847 insertions(+), 308 deletions(-) create mode 100644 packages/components/src/components/shared/acp-control-availability.tsx create mode 100644 packages/components/src/stories/AcpControlAvailability.stories.tsx create mode 100644 packages/components/tests/acp-control-availability.test.tsx create mode 100644 packages/shared/tests/acp-model-controls.test.ts diff --git a/locales/en.json b/locales/en.json index b8db62f0f..65e32c100 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,4 +1,10 @@ { + "chat.fastCapabilitiesUnknown": "Fast mode not confirmed", + "chat.fastCapabilitiesUnsupported": "This model does not offer Fast mode", + "chat.reasoningCapabilitiesUnknown": "Reasoning levels not confirmed", + "chat.reasoningCapabilitiesUnsupported": "This model offers no selectable reasoning levels", + "chat.reasoningCapabilitiesRefreshHint": "Refresh this provider’s capabilities in Settings, or update Lody on the selected machine.", + "chat.retainedConfigValue": "Saved selection: {{value}}", "agents.acpCapabilities.refreshError": "Refresh failed", "agents.acpCapabilities.refreshModelsAndModes": "Refresh models and modes", "agents.acpCapabilities.refreshSuccess": "Capabilities refreshed: {{modelCount}} models, {{modeCount}} modes", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 6c4990609..6c6b7faa0 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -1,4 +1,10 @@ { + "chat.fastCapabilitiesUnknown": "Fast 模式未确认", + "chat.fastCapabilitiesUnsupported": "此模型不支持 Fast 模式", + "chat.reasoningCapabilitiesUnknown": "推理档位未确认", + "chat.reasoningCapabilitiesUnsupported": "此模型未提供可选推理档位", + "chat.reasoningCapabilitiesRefreshHint": "请在设置中刷新此 Provider 的能力,或更新所选机器上的 Lody。", + "chat.retainedConfigValue": "保留的选择:{{value}}", "agents.acpCapabilities.refreshError": "刷新失败", "agents.acpCapabilities.refreshModelsAndModes": "刷新模型和模式", "agents.acpCapabilities.refreshSuccess": "能力已刷新:{{modelCount}} 个模型,{{modeCount}} 个模式", diff --git a/packages/components/src/AGENTS.md b/packages/components/src/AGENTS.md index 2723a9d66..d042148aa 100644 --- a/packages/components/src/AGENTS.md +++ b/packages/components/src/AGENTS.md @@ -52,11 +52,13 @@ Parent `AGENTS.md` files also apply. ## ACP selectors -- Built-in Codex reasoning selectors normalize cached options against exact model support - in `components/shared/acp-selector-options.ts`: Astra, Sol, and Terra expose Max/Ultra; - Luna exposes Max only. Keep this aligned with the ACP model catalog; a model version - threshold cannot represent per-model differences, and cached efforts may belong to - a different selected model. +- Runtime reasoning/Fast menus use `resolveAcpModelControls` in shared, projected by + `components/shared/acp-selector-options.ts` for the SELECTED model. Fresh declarations + precede legacy per-model efforts; snapshot controls speak only for their measured model. + Never extend runtime catalogs with the static Codex model table. Missing evidence is + unknown, not unsupported: show `AcpControlAvailability` on desktop/mobile/inline surfaces. + A synthetic control has no default until the agent reports one; explicit model and + per-model requests survive catalog changes and remain subject to runtime reconciliation. ## ACP authentication diff --git a/packages/components/src/components/chat/chat-landing-selectors.tsx b/packages/components/src/components/chat/chat-landing-selectors.tsx index b9787d329..e8a65fcf9 100644 --- a/packages/components/src/components/chat/chat-landing-selectors.tsx +++ b/packages/components/src/components/chat/chat-landing-selectors.tsx @@ -1,3 +1,4 @@ +import { AcpControlAvailability } from '../shared/acp-control-availability'; import { useMemo } from 'react'; import type { ReactNode } from 'react'; import { @@ -170,7 +171,13 @@ export function ConfigOptionSelectors({ return ( <> {selectors.map((selector) => - selector.type === 'select' ? ( + selector.availability ? ( + + ) : selector.type === 'select' ? ( + ); return ( ) : null} - {thinkingSelector && thinkingOptions.length > 0 ? ( + {thinkingSelector?.availability ? ( + + ) : thinkingSelector && thinkingOptions.length > 0 ? ( id="run-config-reasoning" @@ -663,7 +669,12 @@ function MobileRunConfigSheetRows({ /> ) : null} - {fastSelector ? ( + {fastSelector?.availability ? ( + + ) : fastSelector ? (