diff --git a/.changeset/fix-acp-mode-state.md b/.changeset/fix-acp-mode-state.md new file mode 100644 index 00000000000..e3a2aad0541 --- /dev/null +++ b/.changeset/fix-acp-mode-state.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep ACP clients synchronized with the active permission or plan mode throughout a session. diff --git a/packages/acp-server/src/modes.ts b/packages/acp-server/src/modes.ts index a0ecdb7cd71..2b6acb6e62d 100644 --- a/packages/acp-server/src/modes.ts +++ b/packages/acp-server/src/modes.ts @@ -3,8 +3,8 @@ * * The 4 modes (`default`, `plan`, `auto`, `yolo`) are the locked decision. * Every `session/new` and `session/load` response advertises {@link ACP_MODES} - * as the mode picker plus {@link DEFAULT_MODE_ID} as `currentModeId`, so ACP - * clients render the dropdown from a single canonical source. + * as the mode picker plus the mode derived from the engine's current plan and + * permission state, so ACP clients render an accurate dropdown. * * `session/set_mode` and the `mode` arm of `session/set_config_option` consume * the same source of truth: {@link isAcpModeId} narrows the wire string, and @@ -42,7 +42,7 @@ export const ACP_MODES = [ }, ] as const satisfies readonly SessionMode[]; -/** Initial `currentModeId` for every freshly created ACP session. */ +/** Fallback mode until the engine state has been read. */ export const DEFAULT_MODE_ID = 'default' as const; /** The four wire-level mode ids understood by this host. */ @@ -85,3 +85,23 @@ export function acpModeToToggles(id: AcpModeId): AcpModeToggles { } } } + +/** Project the engine's plan and permission state into the ACP mode taxonomy. */ +export function acpModeFromEngineState( + permission: PermissionMode, + planActive: boolean, +): AcpModeId { + if (planActive) return 'plan'; + switch (permission) { + case 'manual': + return 'default'; + case 'auto': + return 'auto'; + case 'yolo': + return 'yolo'; + default: { + const _exhaustive: never = permission; + throw new Error(`Unhandled PermissionMode: ${String(_exhaustive)}`); + } + } +} diff --git a/packages/acp-server/src/session.ts b/packages/acp-server/src/session.ts index 66741fe42b3..f15b9b14d45 100644 --- a/packages/acp-server/src/session.ts +++ b/packages/acp-server/src/session.ts @@ -84,7 +84,13 @@ import { import { AcpInteractionBridge } from './interaction-bridge'; import { log } from './log'; import { projectModelCatalog } from './model-catalog'; -import { ACP_MODES, type AcpModeId, acpModeToToggles, DEFAULT_MODE_ID } from './modes'; +import { + ACP_MODES, + type AcpModeId, + acpModeFromEngineState, + acpModeToToggles, + DEFAULT_MODE_ID, +} from './modes'; import { projectHistoryToSessionUpdates } from './replay'; import { buildAcpSkillSlashCommands, detectSlashIntent } from './slash'; @@ -186,6 +192,16 @@ export class AcpSession { private currentThinkingLevel: string = 'off'; /** Current ACP mode. */ private currentModeId: AcpModeId = DEFAULT_MODE_ID; + /** Last mode included in a lifecycle response or pushed to the ACP client. */ + private reportedModeId: AcpModeId = DEFAULT_MODE_ID; + /** Coalesces permission/plan invalidations into one authoritative refresh loop. */ + private modeRefreshDirty = false; + private modeRefreshRevision = 0; + private modeRefreshRunner: Promise | undefined; + private modeStateInitialized = false; + /** Suppresses intermediate updates while `setMode` changes two engine toggles. */ + private modeMutationDepth = 0; + private disposed = false; /** * Cached session skill summaries — the backing data for slash-intent * detection and `availableCommands()`. Seeded in `init()` and refreshed on @@ -310,6 +326,14 @@ export class AcpSession { this.onTurnEnded(event); }); }), + events.on('permission.mode.changed', () => { + void this.requestModeRefresh(); + }), + events.on('agent.status.updated', (event) => { + if (typeof event.planMode === 'boolean') { + void this.requestModeRefresh(); + } + }), // Compaction runs as a background LLM task outside any turn, so these // are not turn-scoped; the subscription is already agent-grained (this // session's main agent), which keeps other sessions' events out. @@ -361,6 +385,83 @@ export class AcpSession { // Awaited: the post-`session/new` `available_commands_update` must already // carry the skills (see `activateSession`). await this.refreshSkills(); + await this.requestModeRefresh(); + this.reportedModeId = this.currentModeId; + this.modeStateInitialized = true; + } + + /** Mark the engine mode snapshot stale and join the current refresh, if any. */ + private requestModeRefresh(): Promise { + if (this.disposed) return Promise.resolve(); + this.modeRefreshDirty = true; + this.modeRefreshRevision += 1; + if (this.modeRefreshRunner !== undefined) return this.modeRefreshRunner; + const runner = this.runModeRefreshLoop(); + this.modeRefreshRunner = runner; + return runner; + } + + /** Re-read until no permission/plan invalidation arrived during the last read. */ + private async runModeRefreshLoop(): Promise { + try { + while (this.modeRefreshDirty && !this.disposed) { + this.modeRefreshDirty = false; + await this.refreshModeState(this.modeRefreshRevision); + } + } finally { + this.modeRefreshRunner = undefined; + } + } + + /** Reconcile the cached ACP projection from one permission/plan snapshot. */ + private async refreshModeState(revision: number): Promise { + let permission: Awaited>; + let plan: Awaited>; + try { + [permission, plan] = await Promise.all([ + this.agent.getPermission(), + this.agent.getPlan(), + ]); + } catch (error) { + log.warn('acp: could not refresh permission/plan state', { + sessionId: this.sessionId, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + // A later invalidation owns the authoritative snapshot. Skip this result + // entirely so a trailing refresh cannot be preceded by a stale update. + if ( + this.disposed || + revision !== this.modeRefreshRevision || + this.modeMutationDepth > 0 + ) { + return; + } + + const nextModeId = acpModeFromEngineState(permission, plan !== null); + this.currentModeId = nextModeId; + if (!this.modeStateInitialized || nextModeId === this.reportedModeId) { + return; + } + + this.reportedModeId = nextModeId; + await this.emitModeStateUpdate(nextModeId); + } + + /** Push both ACP representations from the same reconciled mode snapshot. */ + private async emitModeStateUpdate(modeId: AcpModeId): Promise { + if (this.disposed) return; + try { + await this.conn.sessionUpdate(currentModeUpdateNotification(this.sessionId, modeId)); + } catch (error) { + log.warn('acp: failed to push current_mode_update', { + sessionId: this.sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + if (this.disposed) return; + await this.emitConfigOptionUpdate(modeId); } /** Refresh the skill cache from the session catalog (best-effort). */ @@ -381,6 +482,9 @@ export class AcpSession { * and detaches the event subscriptions. Idempotent. */ dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.modeRefreshDirty = false; this.cancel(); const driver = this.driver; if (driver !== undefined) { @@ -1015,13 +1119,15 @@ export class AcpSession { * thinking-capable (see `buildSessionConfigOptions`). */ async configOptions(): Promise { + return this.configOptionsForMode(this.currentModeId); + } + + /** Build config options with a captured mode so paired updates cannot diverge. */ + private async configOptionsForMode(modeId: AcpModeId): Promise { + const modelId = this.currentModelId; + const thinkingLevel = this.currentThinkingLevel; const models = projectModelCatalog(await this.klient.global.kosong.listModels()); - return buildSessionConfigOptions( - models, - this.currentModelId, - this.currentThinkingLevel, - this.currentModeId, - ); + return buildSessionConfigOptions(models, modelId, thinkingLevel, modeId); } /** @@ -1102,30 +1208,32 @@ export class AcpSession { /** Switch the ACP mode (plan mode + permission mode). */ async setMode(id: AcpModeId): Promise { const { plan, permission } = acpModeToToggles(id); - if (plan) { - await this.agent.enterPlan(); - } else { - // KLIENT-GAP(plan): `exitPlan` (`planService.exit()`) is not on the - // klient surface; `cancelPlan` (`planModeCancel`) has the identical - // state effect (see `agent/plan/planOps.ts`) — only the persisted op - // name differs. - await this.agent.cancelPlan(); + this.modeMutationDepth += 1; + try { + if (plan) { + await this.agent.enterPlan(); + } else { + // KLIENT-GAP(plan): `exitPlan` (`planService.exit()`) is not on the + // klient surface; `cancelPlan` (`planModeCancel`) has the identical + // state effect (see `agent/plan/planOps.ts`) — only the persisted op + // name differs. + await this.agent.cancelPlan(); + } + await this.agent.setPermission(permission); + } finally { + this.modeMutationDepth -= 1; + await this.requestModeRefresh(); } - await this.agent.setPermission(permission); - this.currentModeId = id; - // Both notifications fire: `current_mode_update` serves clients reading - // the first-class `modes` state, `config_option_update` serves clients - // reading the `mode` config-option arm. (Engine-side mode changes are not - // observable — klient exposes no permission/plan change event.) - this.emit(currentModeUpdateNotification(this.sessionId, id)); - await this.emitConfigOptionUpdate(); } /** Push a fresh `config_option_update` to the client. */ - private async emitConfigOptionUpdate(): Promise { + private async emitConfigOptionUpdate(modeId = this.currentModeId): Promise { try { await this.conn.sessionUpdate( - configOptionUpdateNotification(this.sessionId, await this.configOptions()), + configOptionUpdateNotification( + this.sessionId, + await this.configOptionsForMode(modeId), + ), ); } catch (error) { log.warn('acp: failed to push config_option_update', { diff --git a/packages/acp-server/test/config.test.ts b/packages/acp-server/test/config.test.ts index 2b6e14e84ca..e4a695def66 100644 --- a/packages/acp-server/test/config.test.ts +++ b/packages/acp-server/test/config.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { AcpSession } from '../src/session'; import { createTestClient, type TestClient } from './_helpers/acpClient'; @@ -19,12 +19,21 @@ interface ModesState { readonly availableModes: ReadonlyArray<{ readonly id: string }>; } -interface NewSessionResult { - readonly sessionId: string; +interface SessionConfigSnapshot { readonly configOptions: readonly ConfigOption[]; readonly modes?: ModesState; } +interface NewSessionResult extends SessionConfigSnapshot { + readonly sessionId: string; +} + +interface ModeProjectionUpdate { + readonly sessionUpdate?: string; + readonly currentModeId?: string; + readonly configOptions?: readonly ConfigOption[]; +} + describe('acp-server config surface', () => { let homeDir: string | undefined; let client: TestClient | undefined; @@ -48,9 +57,12 @@ describe('acp-server config surface', () => { altThinking?: boolean; altSupportEfforts?: readonly string[]; altDefaultEffort?: string; + configToml?: string; }): Promise { homeDir = await mkdtemp(join(tmpdir(), 'acp-config-')); - if (opts?.fakeModel === true) { + if (opts?.configToml !== undefined) { + await writeFile(join(homeDir, 'config.toml'), opts.configToml, 'utf8'); + } else if (opts?.fakeModel === true) { await writeFakeModelConfig(homeDir, { thinking: opts?.thinking === true, supportEfforts: opts?.supportEfforts, @@ -72,6 +84,24 @@ describe('acp-server config surface', () => { })) as NewSessionResult; } + function modeProjectionUpdates( + messages: ReturnType, + ): { current: string[]; config: string[] } { + const current: string[] = []; + const config: string[] = []; + for (const message of messages) { + const update = (message.params as { update?: ModeProjectionUpdate } | undefined)?.update; + if (update?.sessionUpdate === 'current_mode_update' && update.currentModeId !== undefined) { + current.push(update.currentModeId); + } + if (update?.sessionUpdate === 'config_option_update') { + const mode = update.configOptions?.find((option) => option.id === 'mode')?.currentValue; + if (mode !== undefined) config.push(mode); + } + } + return { current, config }; + } + it( 'session/new advertises mode + model pickers (no thinking without a model)', async () => { @@ -103,6 +133,133 @@ describe('acp-server config surface', () => { 30_000, ); + it.each(['auto', 'yolo'] as const)( + 'session/new reports the engine %s permission mode', + async (permission) => { + await boot({ configToml: `default_permission_mode = "${permission}"\n` }); + + const { sessionId, configOptions, modes } = await newSession(); + await expect( + client!.server.klient.session(sessionId).agent('main').getPermission(), + ).resolves.toBe(permission); + expect(modes?.currentModeId).toBe(permission); + expect(configOptions.find((option) => option.id === 'mode')?.currentValue).toBe(permission); + }, + 30_000, + ); + + it( + 'session/resume restores the persisted permission mode', + async () => { + await boot(); + const { sessionId } = await newSession(); + await client!.send('session/set_mode', { sessionId, modeId: 'auto' }); + await client!.send('session/close', { sessionId }); + + const resumed = (await client!.send('session/resume', { + sessionId, + cwd: homeDir, + mcpServers: [], + })) as SessionConfigSnapshot; + expect(resumed.modes?.currentModeId).toBe('auto'); + expect(resumed.configOptions.find((option) => option.id === 'mode')?.currentValue).toBe( + 'auto', + ); + }, + 30_000, + ); + + it( + 'session/new reports plan mode ahead of the permission mode', + async () => { + await boot({ + configToml: 'default_plan_mode = true\ndefault_permission_mode = "yolo"\n', + }); + + const { configOptions, modes } = await newSession(); + expect(modes?.currentModeId).toBe('plan'); + expect(configOptions.find((option) => option.id === 'mode')?.currentValue).toBe('plan'); + }, + 30_000, + ); + + it.each(['auto', 'yolo'] as const)( + 'pushes consistent ACP projections when permission changes to %s outside ACP', + async (permission) => { + await boot(); + const { sessionId } = await newSession(); + const cursor = client!.sessionUpdates().length; + + await client!.server.klient.session(sessionId).agent('main').setPermission(permission); + + await vi.waitFor(() => { + expect(modeProjectionUpdates(client!.sessionUpdates().slice(cursor))).toEqual({ + current: [permission], + config: [permission], + }); + }); + }, + 30_000, + ); + + it( + 'pushes plan projection when plan mode is entered outside ACP', + async () => { + await boot({ configToml: 'default_permission_mode = "auto"\n' }); + const { sessionId } = await newSession(); + const cursor = client!.sessionUpdates().length; + + await client!.server.klient.session(sessionId).agent('main').enterPlan(); + + await vi.waitFor(() => { + expect(modeProjectionUpdates(client!.sessionUpdates().slice(cursor))).toEqual({ + current: ['plan'], + config: ['plan'], + }); + }); + }, + 30_000, + ); + + it( + 'restores yolo projection when plan mode is exited outside ACP', + async () => { + await boot({ + configToml: 'default_plan_mode = true\ndefault_permission_mode = "yolo"\n', + }); + const { sessionId } = await newSession(); + const cursor = client!.sessionUpdates().length; + + await client!.server.klient.session(sessionId).agent('main').cancelPlan(); + + await vi.waitFor(() => { + expect(modeProjectionUpdates(client!.sessionUpdates().slice(cursor))).toEqual({ + current: ['yolo'], + config: ['yolo'], + }); + }); + }, + 30_000, + ); + + it( + 'publishes only yolo when ACP switches from plan mode', + async () => { + await boot(); + const { sessionId } = await newSession(); + await client!.send('session/set_mode', { sessionId, modeId: 'plan' }); + const cursor = client!.sessionUpdates().length; + + await client!.send('session/set_mode', { sessionId, modeId: 'yolo' }); + + expect(modeProjectionUpdates(client!.sessionUpdates().slice(cursor))).toEqual({ + current: ['yolo'], + config: ['yolo'], + }); + }, + 30_000, + ); + it( 'session/set_mode pushes current_mode_update alongside config_option_update', async () => { @@ -136,6 +293,8 @@ describe('acp-server config surface', () => { enterPlan: async () => { throw new Error('plan toggle failed'); }, + getPermission: async () => 'manual' as const, + getPlan: async () => null, setPermission: async () => {}, }; Object.assign(session as unknown as Record, { @@ -143,6 +302,12 @@ describe('acp-server config surface', () => { conn: { sessionUpdate: async (update: unknown) => updates.push(update) }, sessionId: 'session-test', currentModeId: 'default', + disposed: false, + modeMutationDepth: 0, + modeRefreshDirty: false, + modeRefreshRevision: 0, + modeStateInitialized: true, + reportedModeId: 'default', }); await expect(session.setMode('plan')).rejects.toThrow('plan toggle failed'); diff --git a/packages/klient/src/contract/agent/events.ts b/packages/klient/src/contract/agent/events.ts index 53e650c9377..ad420f0a5b8 100644 --- a/packages/klient/src/contract/agent/events.ts +++ b/packages/klient/src/contract/agent/events.ts @@ -1,15 +1,16 @@ /** * Klient-level agent-scope events — the public, typed, namespaced event - * surface of one agent. All registrations filter the per-agent `events` - * scope stream by `type`; the payload is the whole flat `{ type, ... }` - * event (schemas keep the `type` literal so listeners receive it intact). - * Payload shapes mirror `protocol/src/events.ts`; events that are loose in - * the engine (or absent from the protocol union) are `z.looseObject`s. + * surface of one agent. Most registrations filter the per-agent `events` + * scope stream by `type`; domain-specific changes may bind a Service emitter + * instead. Stream payloads keep the whole flat `{ type, ... }` event intact. + * Payload shapes mirror `protocol/src/events.ts`; events that are loose in the + * engine (or absent from the protocol union) are `z.looseObject`s. */ import { z } from 'zod'; import type { EventRegistration } from '../types.js'; +import { permissionModeSchema } from './rpc.js'; /** * Scope-stream registration (`kind: 'stream'`). Declared structurally here @@ -166,6 +167,11 @@ export const permissionApprovalResolvedEventSchema = z.looseObject({ toolCallId: z.string(), }); +export const permissionModeChangedEventSchema = z.object({ + mode: permissionModeSchema, + previousMode: permissionModeSchema, +}); + /** `error` payloads carry the full `KimiErrorPayload`; kept loose. */ export const errorEventSchema = z.looseObject({ message: z.string(), @@ -180,6 +186,7 @@ export const warningEventSchema = z.object({ /** `agent.status.updated` carries a wide optional status bag; kept loose. */ export const agentStatusUpdatedEventSchema = z.looseObject({ phase: z.string().optional(), + planMode: z.boolean().optional(), }); // ── registrations ─────────────────────────────────────────────────────────── @@ -202,6 +209,7 @@ export interface AgentEventPayloads { 'compaction.completed': z.infer; 'permission.approval.requested': z.infer; 'permission.approval.resolved': z.infer; + 'permission.mode.changed': z.infer; error: z.infer; warning: z.infer; 'agent.status.updated': z.infer; @@ -257,6 +265,12 @@ export const agentEvents = { type: 'permission.approval.resolved', schema: permissionApprovalResolvedEventSchema, }, + 'permission.mode.changed': { + kind: 'emitter', + service: 'agentPermissionModeService', + event: 'onDidChangeMode', + schema: permissionModeChangedEventSchema, + }, error: { kind: 'stream', name: 'events', type: 'error', schema: errorEventSchema }, warning: { kind: 'stream', name: 'events', type: 'warning', schema: warningEventSchema }, 'agent.status.updated': { diff --git a/packages/klient/src/contract/agent/services.ts b/packages/klient/src/contract/agent/services.ts index f7e6526c74d..8b3f4822121 100644 --- a/packages/klient/src/contract/agent/services.ts +++ b/packages/klient/src/contract/agent/services.ts @@ -1,8 +1,8 @@ /** * Agent-scope domain service contracts. These mirror the positional-arg * signatures of the engine's domain Services (shellCommand / profile / usage / - * plan / task) that the agent facade calls directly; payload and result - * schemas are shared with `agent/rpc.ts` (they mirror the same wire shapes). + * permissionMode / plan / task) that the agent facade calls directly; payload + * and result schemas are shared with `agent/rpc.ts`. */ import { z } from 'zod'; @@ -11,6 +11,7 @@ import { maybe, noResult } from '../helpers.js'; import type { ServiceContract } from '../types.js'; import { agentTaskInfoSchema, + permissionModeSchema, planDataSchema, runShellCommandPayloadSchema, setModelResultSchema, @@ -37,6 +38,10 @@ export const agentUsageContract = { status: { input: z.tuple([]), output: usageStatusSchema }, } satisfies ServiceContract; +export const agentPermissionModeContract = { + mode: { input: z.tuple([]), output: permissionModeSchema }, +} satisfies ServiceContract; + export const agentPlanContract = { status: { input: z.tuple([]), output: planDataSchema }, enter: { input: z.tuple([]), output: noResult }, diff --git a/packages/klient/src/contract/index.ts b/packages/klient/src/contract/index.ts index 6f9ef48efad..fe934a1ebe0 100644 --- a/packages/klient/src/contract/index.ts +++ b/packages/klient/src/contract/index.ts @@ -12,6 +12,7 @@ import { agentRpcContract } from './agent/rpc.js'; import { agentFullCompactionContract, agentMcpContract, + agentPermissionModeContract, agentPlanContract, agentProfileContract, agentShellCommandContract, @@ -72,6 +73,7 @@ export const globalContract: KlientContract = { agentShellCommandService: agentShellCommandContract, agentProfileService: agentProfileContract, agentUsageService: agentUsageContract, + agentPermissionModeService: agentPermissionModeContract, agentPlanService: agentPlanContract, agentTaskService: agentTaskContract, agentMcpService: agentMcpContract, diff --git a/packages/klient/src/core/facade/agent.ts b/packages/klient/src/core/facade/agent.ts index 145e5fbaf9f..483aa843eee 100644 --- a/packages/klient/src/core/facade/agent.ts +++ b/packages/klient/src/core/facade/agent.ts @@ -1,9 +1,9 @@ /** * The agent facade — one `session.agent(id)` handle over the agent-scope * services the wire exposes. Turn-driving calls (prompt / steer / cancel) go - * through the `agentRPCService` channel; shell commands, model, usage, plan, - * and task calls go straight to their domain services. Prompt streaming is - * NOT on this interface: it flows through the agent's `events` hub + * through the `agentRPCService` channel; shell commands, model, usage, + * permission, plan, and task calls go straight to their domain services. + * Prompt streaming is NOT on this interface: it flows through the agent's `events` hub * (`turn.*`, `assistant.delta`, `tool.call.*`, `prompt.completed`, …). */ @@ -55,6 +55,7 @@ export interface AgentFacade { getThinking(): Promise; setThinking(level: string): Promise; setPermission(mode: PermissionMode): Promise; + getPermission(): Promise; getUsage(): Promise; getContext(): Promise; listCommands(): Promise; @@ -101,6 +102,8 @@ export function createAgentFacade(call: ScopedCaller, scope: ScopeRef): AgentFac setThinking: (level) => call(scope, 'agentProfileService', 'setThinking', [level]) as Promise, setPermission: (mode) => rpc('setPermission', { mode }) as Promise, + getPermission: () => + call(scope, 'agentPermissionModeService', 'mode', []) as Promise, getUsage: () => call(scope, 'agentUsageService', 'status', []) as Promise, getContext: () => rpc('getContext', {}) as Promise, listCommands: () => rpc('listCommands', {}) as Promise, diff --git a/packages/klient/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts index 00f31551e27..9f97847d766 100644 --- a/packages/klient/src/transports/memory/serviceRegistry.ts +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -32,6 +32,7 @@ import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/ques import { ISessionSkillCatalog } from '@moonshot-ai/agent-core-v2/session/sessionSkillCatalog/skillCatalog'; import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; import { IAgentActivityView } from '@moonshot-ai/agent-core-v2/agent/activityView/activityView'; +import { IAgentPermissionModeService } from '@moonshot-ai/agent-core-v2/agent/permissionMode/permissionMode'; import { IAgentPlanService } from '@moonshot-ai/agent-core-v2/features/plan/plan'; import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; import { IAgentShellCommandService } from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommand'; @@ -68,6 +69,7 @@ export const serviceTokens: Readonly>> agentShellCommandService: IAgentShellCommandService, agentProfileService: IAgentProfileService, agentUsageService: IAgentUsageService, + agentPermissionModeService: IAgentPermissionModeService, agentPlanService: IAgentPlanService, agentTaskService: IAgentTaskService, agentMcpService: IAgentMcpService, diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index fb6bdf032d1..16b42161e95 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -22,6 +22,10 @@ import type { TurnPhase, } from '@moonshot-ai/agent-core-v2/agent/activityView/activityView'; import type { AgentContextData } from '@moonshot-ai/agent-core-v2/agent/contextMemory/types'; +import type { + IAgentPermissionModeService, + PermissionModeChangedContext, +} from '@moonshot-ai/agent-core-v2/agent/permissionMode/permissionMode'; import type { TurnEndReason } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; import type { PlanData } from '@moonshot-ai/agent-core-v2/features/plan/plan'; import type { @@ -167,6 +171,7 @@ import { getTaskOutputPayloadSchema, getTasksPayloadSchema, planDataSchema, + permissionModeSchema, promptLaunchResultSchema, promptPartSchema, promptPayloadSchema, @@ -189,6 +194,7 @@ import { compactionStartedEventSchema, promptAbortedEventSchema, promptCompletedEventSchema, + permissionModeChangedEventSchema, thinkingDeltaEventSchema, toolCallDeltaEventSchema, toolCallStartedEventSchema, @@ -532,6 +538,7 @@ type PromptLaunchResult = NonNullable>; type SteerPayload = Parameters[0]; type CancelPayload = Parameters[0]; type SetPermissionPayload = Parameters[0]; +type PermissionMode = IAgentPermissionModeService['mode']; type AgentCommandInfo = Awaited>[number]; type RunCommandPayload = Parameters[0]; type TokenUsage = NonNullable; @@ -560,6 +567,7 @@ const _setModelPayload: AssertWire = true; const _setPermissionPayload: AssertWire = true; +const _permissionMode: AssertWire = true; const _tokenUsage: AssertWire = true; const _usageStatus: AssertWire = true; // One-directional: `history` entries are full `ContextMessage`s (deep @@ -618,6 +626,10 @@ const _compactionCompletedEvent: AssertWire< typeof compactionCompletedEventSchema, CompactionCompletedEvent > = true; +const _permissionModeChangedEvent: AssertWire< + typeof permissionModeChangedEventSchema, + PermissionModeChangedContext +> = true; const _warningEvent: AssertWire = true; // No parity assertions for `errorEventSchema`, `permissionApproval*Schema`, // and `agentStatusUpdatedEventSchema`: they are deliberately `z.looseObject`s diff --git a/packages/klient/test/facade.test.ts b/packages/klient/test/facade.test.ts index 8c76e8cf9a4..66771679433 100644 --- a/packages/klient/test/facade.test.ts +++ b/packages/klient/test/facade.test.ts @@ -182,6 +182,43 @@ describe('agent profile routing', () => { }); }); +describe('agent permission routing', () => { + it('getPermission routes through the permission mode service', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + const agent = klient.session('s1').agent('main'); + + channel.result = 'auto'; + await expect(agent.getPermission()).resolves.toBe('auto'); + expect(channel.calls[0]).toEqual({ + scope: { sessionId: 's1', agentId: 'main' }, + service: 'agentPermissionModeService', + method: 'mode', + args: [], + }); + }); + + it('permission.mode.changed maps to the permission mode emitter', () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + const agent = klient.session('s1').agent('main'); + const seen: unknown[] = []; + + agent.events.on('permission.mode.changed', (event) => seen.push(event)); + expect(channel.subscriptions[0]).toMatchObject({ + scope: { sessionId: 's1', agentId: 'main' }, + source: { + kind: 'emitter', + service: 'agentPermissionModeService', + event: 'onDidChangeMode', + }, + }); + + channel.emit(0, { mode: 'auto', previousMode: 'manual' }); + expect(seen).toEqual([{ mode: 'auto', previousMode: 'manual' }]); + }); +}); + describe('session skills routing', () => { it('skills.list routes to sessionSkillCatalog.list with the session scope', async () => { const channel = new FakeChannel(); diff --git a/packages/klient/test/helpers/conformance.ts b/packages/klient/test/helpers/conformance.ts index 226dce0b1ab..7445379351c 100644 --- a/packages/klient/test/helpers/conformance.ts +++ b/packages/klient/test/helpers/conformance.ts @@ -323,5 +323,44 @@ export function defineKlientConformance( await target.klient.session(created.id).close(); } }); + + it('reads the current agent permission mode', async () => { + const created = await target.klient.global.sessions.create({ + workDir: process.cwd(), + title: 'conformance permission mode', + }); + + try { + const agent = target.klient.session(created.id).agent('main'); + await agent.setPermission('auto'); + await expect(agent.getPermission()).resolves.toBe('auto'); + } finally { + await target.klient.session(created.id).close(); + } + }); + + it('emits the current permission mode change', async () => { + const created = await target.klient.global.sessions.create({ + workDir: process.cwd(), + title: 'conformance permission event', + }); + + try { + const agent = target.klient.session(created.id).agent('main'); + const changed = new Promise<{ mode: string; previousMode: string }>((resolve) => { + const subscription = agent.events.on('permission.mode.changed', (event) => { + subscription.dispose(); + resolve(event); + }); + }); + await agent.getPermission(); + + await agent.setPermission('auto'); + + await expect(changed).resolves.toEqual({ mode: 'auto', previousMode: 'manual' }); + } finally { + await target.klient.session(created.id).close(); + } + }); }); }