From 6bed0b4a4fa1fb9c03d648f797c0a49d917f1aea Mon Sep 17 00:00:00 2001 From: liuzhaochen03 Date: Thu, 9 Jul 2026 00:51:25 +0800 Subject: [PATCH] fix(web): permission mode by session --- .changeset/fix-web-vite-ws-proxy.md | 5 + .changeset/web-session-permission-mode.md | 5 + .../src/api/daemon/agentEventProjector.ts | 1 + apps/kimi-web/src/api/types.ts | 2 +- .../composables/client/useWorkspaceState.ts | 133 ++++++++++---- .../src/composables/useKimiWebClient.ts | 164 +++++++++++++----- apps/kimi-web/src/lib/storage.ts | 1 + .../test/agent-event-projector.test.ts | 17 +- apps/kimi-web/test/workspace-state.test.ts | 52 +++++- 9 files changed, 298 insertions(+), 82 deletions(-) create mode 100644 .changeset/fix-web-vite-ws-proxy.md create mode 100644 .changeset/web-session-permission-mode.md diff --git a/.changeset/fix-web-vite-ws-proxy.md b/.changeset/fix-web-vite-ws-proxy.md new file mode 100644 index 0000000000..38056156b2 --- /dev/null +++ b/.changeset/fix-web-vite-ws-proxy.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix WebSocket connections through the web dev and preview proxies. diff --git a/.changeset/web-session-permission-mode.md b/.changeset/web-session-permission-mode.md new file mode 100644 index 0000000000..1642c5126d --- /dev/null +++ b/.changeset/web-session-permission-mode.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep web runtime modes scoped to the active session instead of applying one global mode across sessions. diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index 5a55282b4c..8323bf1a6d 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -911,6 +911,7 @@ export function createAgentProjector(): AgentProjector { // for a "make a plan" prompt). Carry it so the composer's plan toggle // reflects the agent's real state, not just the user's manual choice. planMode: p?.planMode === true ? true : p?.planMode === false ? false : undefined, + permissionMode: typeof p?.permission === 'string' ? p.permission : undefined, }); break; } diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 486ddc8ba6..45c6020752 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -394,7 +394,7 @@ export type AppEvent = | { type: 'sessionDeleted'; sessionId: string } | { type: 'sessionStatusChanged'; sessionId: string; status: AppSessionStatus; previousStatus: AppSessionStatus; currentPromptId?: string } | { type: 'sessionMetaUpdated'; sessionId: string; title?: string; lastPrompt?: string } - | { type: 'sessionUsageUpdated'; sessionId: string; usage: AppSessionUsage; model?: string; swarmMode?: boolean; planMode?: boolean } + | { type: 'sessionUsageUpdated'; sessionId: string; usage: AppSessionUsage; model?: string; swarmMode?: boolean; planMode?: boolean; permissionMode?: string } | { type: 'historyCompacted'; sessionId: string; beforeSeq: number; reason: string; summaryMessageId?: string } | { type: 'compactionStarted'; sessionId: string; trigger: 'manual' | 'auto'; instruction?: string } | { type: 'compactionCompleted'; sessionId: string; tokensBefore?: number; tokensAfter?: number; summary?: string } diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index be2a3ae637..d1986af9c6 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -91,9 +91,13 @@ export interface UseWorkspaceStateDeps { status: ComputedRef; workspaceIdForSession: (s: { workspaceId?: string; cwd: string }) => string; savePermissionToStorage: (mode: PermissionMode) => void; - savePlanModeToStorage: (v: boolean) => void; - saveSwarmModeToStorage: (v: boolean) => void; - saveGoalModeToStorage: (v: boolean) => void; + savePermissionModeToStorage: () => void; + /** Persist the current per-session mode maps (read off rawState). */ + savePlanModeToStorage: () => void; + saveSwarmModeToStorage: () => void; + saveGoalModeToStorage: () => void; + /** Staged mode toggles for the not-yet-created draft session. */ + draftModes: { planMode: boolean; swarmMode: boolean; goalMode: boolean }; saveUnread: (changes: Record) => void; saveActiveWorkspaceToStorage: (id: string) => void; saveHiddenWorkspacesToStorage: (roots: string[]) => void; @@ -133,9 +137,11 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta status, workspaceIdForSession, savePermissionToStorage, + savePermissionModeToStorage, savePlanModeToStorage, saveSwarmModeToStorage, saveGoalModeToStorage, + draftModes, saveUnread, saveActiveWorkspaceToStorage, saveHiddenWorkspacesToStorage, @@ -654,12 +660,45 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // then submitPromptInternal adds the user turn synchronously (no await in // between), so the view goes loading → message with no empty-composer frame. await selectSession(session.id); + // Carry any mode toggles the user staged in the empty composer into the + // newly-created session, so the first prompt honors them. Write them to + // this session's per-session maps by id (not via the activeSessionId-based + // setters): if the user switches to another session while selectSession is + // awaiting the snapshot, the setters would otherwise read the then-current + // activeSessionId and pollute that session while this one loses the modes. + const sid = session.id; + rawState.permissionModeBySession = { + ...rawState.permissionModeBySession, + [sid]: rawState.permission, + }; + savePermissionModeToStorage(); + if (draftModes.planMode) { + rawState.planModeBySession = { ...rawState.planModeBySession, [sid]: true }; + savePlanModeToStorage(); + } + if (draftModes.swarmMode) { + rawState.swarmModeBySession = { ...rawState.swarmModeBySession, [sid]: true }; + saveSwarmModeToStorage(); + } + if (draftModes.goalMode) { + rawState.goalModeBySession = { ...rawState.goalModeBySession, [sid]: true }; + saveGoalModeToStorage(); + } + draftModes.planMode = false; + draftModes.swarmMode = false; + draftModes.goalMode = false; await submitPromptInternal(session.id, text, attachments); } catch (err) { pushOperationFailure('startSessionAndSendPrompt', err); } } + function permissionForSession(sessionId: string | null | undefined): PermissionMode { + return sessionId + ? (rawState.permissionModeBySession[sessionId] ?? rawState.permission) + : rawState.permission; + } + /** * Add a workspace by folder path. Tries the daemon registry; on failure (or in * fallback mode) creates a locally-derived workspace from the path and @@ -893,8 +932,11 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta (promptSession?.model && promptSession.model.length > 0 ? promptSession.model : rawState.defaultModel) ?? undefined; + const planMode = rawState.planModeBySession[sid] ?? false; + const swarmMode = rawState.swarmModeBySession[sid] ?? false; + const goalMode = rawState.goalModeBySession[sid] ?? false; - if (rawState.goalMode && text) { + if (goalMode && text) { try { await api.updateSession(sid, { goalObjective: text.trim() }); } catch (err) { @@ -912,14 +954,14 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta content, model, thinking: rawState.thinking, - permissionMode: rawState.permission, - planMode: rawState.planMode, - swarmMode: rawState.swarmMode, + permissionMode: permissionForSession(sid), + planMode, + swarmMode, }); - if (rawState.goalMode) { - rawState.goalMode = false; - saveGoalModeToStorage(false); + if (goalMode) { + rawState.goalModeBySession = { ...rawState.goalModeBySession, [sid]: false }; + saveGoalModeToStorage(); } // Authoritative prompt_id for :abort — race-free (the projector binding can @@ -1045,9 +1087,9 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta content, model, thinking: rawState.thinking, - permissionMode: rawState.permission, - planMode: rawState.planMode, - swarmMode: rawState.swarmMode, + permissionMode: permissionForSession(sid), + planMode: rawState.planModeBySession[sid] ?? false, + swarmMode: rawState.swarmModeBySession[sid] ?? false, }); // Stamp the real prompt_id onto the optimistic echo. Unlike a normal send, @@ -1243,28 +1285,42 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta /** Persist and apply plan mode (pushed to the session profile + sent per-prompt). */ function setPlanMode(on: boolean): void { - rawState.planMode = on; - savePlanModeToStorage(on); - persistSessionProfile({ planMode: on }); + const sid = rawState.activeSessionId; + if (sid) { + rawState.planModeBySession = { ...rawState.planModeBySession, [sid]: on }; + savePlanModeToStorage(); + persistSessionProfile({ planMode: on }); + } else { + draftModes.planMode = on; + } } /** Flip plan mode on/off. */ function togglePlanMode(): void { - setPlanMode(!rawState.planMode); + const sid = rawState.activeSessionId; + const current = sid ? (rawState.planModeBySession[sid] ?? false) : draftModes.planMode; + setPlanMode(!current); } /** Persist and apply swarm mode (pushed to the session profile + sent per-prompt). */ function setSwarmMode(on: boolean): void { - rawState.swarmMode = on; - saveSwarmModeToStorage(on); - persistSessionProfile({ swarmMode: on }); + const sid = rawState.activeSessionId; + if (sid) { + rawState.swarmModeBySession = { ...rawState.swarmModeBySession, [sid]: on }; + saveSwarmModeToStorage(); + persistSessionProfile({ swarmMode: on }); + } else { + draftModes.swarmMode = on; + } } /** Flip swarm mode on/off. In manual permission mode, ask before enabling. */ - function toggleSwarmMode(): void { - const on = !rawState.swarmMode; - if (on && rawState.permission === 'manual') { - const ok = confirm('Enable swarm mode? The agent will run multiple sub agents in parallel.'); + async function toggleSwarmMode(): Promise { + const sid = rawState.activeSessionId; + const current = sid ? (rawState.swarmModeBySession[sid] ?? false) : draftModes.swarmMode; + const on = !current; + if (on && permissionForSession(sid) === 'manual') { + const ok = globalThis.confirm('Enable swarm mode? The agent will run multiple sub agents in parallel.'); if (!ok) return; } setSwarmMode(on); @@ -1272,21 +1328,28 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta /** Persist goal mode locally. Unlike plan/swarm, this is a one-shot flag consumed on send. */ function setGoalMode(on: boolean): void { - rawState.goalMode = on; - saveGoalModeToStorage(on); + const sid = rawState.activeSessionId; + if (sid) { + rawState.goalModeBySession = { ...rawState.goalModeBySession, [sid]: on }; + saveGoalModeToStorage(); + } else { + draftModes.goalMode = on; + } } /** Flip goal mode on/off. */ function toggleGoalMode(): void { - setGoalMode(!rawState.goalMode); + const sid = rawState.activeSessionId; + const current = sid ? (rawState.goalModeBySession[sid] ?? false) : draftModes.goalMode; + setGoalMode(!current); } /** Create a goal by sending its objective to the session profile, then submit it as a prompt. */ async function createGoal(objective: string): Promise { const trimmed = objective.trim(); if (!trimmed) return; - if (rawState.permission === 'manual') { - const ok = confirm(`Start goal: "${trimmed}"? The agent will run autonomously toward this objective.`); + if (permissionForSession(rawState.activeSessionId) === 'manual') { + const ok = globalThis.confirm(`Start goal: "${trimmed}"? The agent will run autonomously toward this objective.`); if (!ok) return; } const sid = rawState.activeSessionId; @@ -1314,9 +1377,15 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta * the daemon (auto/yolo are resolved server-side), so any pending approvals * are left for the user to answer explicitly. */ function setPermission(mode: PermissionMode): void { - rawState.permission = mode; - savePermissionToStorage(mode); - persistSessionProfile({ permissionMode: mode }); + const sid = rawState.activeSessionId; + if (sid) { + rawState.permissionModeBySession = { ...rawState.permissionModeBySession, [sid]: mode }; + savePermissionModeToStorage(); + persistSessionProfile({ permissionMode: mode }); + } else { + rawState.permission = mode; + savePermissionToStorage(mode); + } } /** Dismiss a warning by index */ diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 36e1e242d3..4b4ea9bfd7 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -85,6 +85,7 @@ import type { // --------------------------------------------------------------------------- const PERMISSION_STORAGE_KEY = STORAGE_KEYS.permission; +const PERMISSION_MODE_STORAGE_KEY = STORAGE_KEYS.permissionMode; const ACTIVE_WORKSPACE_KEY = STORAGE_KEYS.activeWorkspace; const THINKING_STORAGE_KEY = STORAGE_KEYS.thinking; const PLAN_MODE_STORAGE_KEY = STORAGE_KEYS.planMode; @@ -121,6 +122,10 @@ function savePermissionToStorage(mode: PermissionMode): void { } } +function isPermissionMode(value: unknown): value is PermissionMode { + return value === 'auto' || value === 'yolo' || value === 'manual'; +} + function loadThinkingFromStorage(): ThinkingLevel { try { const v = safeGetString(THINKING_STORAGE_KEY); @@ -139,52 +144,64 @@ function saveThinkingToStorage(v: ThinkingLevel): void { } } -function loadPlanModeFromStorage(): boolean { +function loadPermissionModeMapFromStorage(): Record { + const raw = safeGetString(PERMISSION_MODE_STORAGE_KEY); + if (!raw) return {}; try { - return safeGetString(PLAN_MODE_STORAGE_KEY) === 'true'; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out: Record = {}; + for (const [id, value] of Object.entries(parsed as Record)) { + if (isPermissionMode(value)) out[id] = value; + } + return out; } catch { - return false; + return {}; } } -function savePlanModeToStorage(v: boolean): void { +function savePermissionModeToStorage(): void { try { - safeSetString(PLAN_MODE_STORAGE_KEY, v ? 'true' : 'false'); + safeSetString(PERMISSION_MODE_STORAGE_KEY, JSON.stringify(rawState.permissionModeBySession)); } catch { - // ignore + // storage unavailable (private mode, quota, etc.) — ignore } } -function loadSwarmModeFromStorage(): boolean { +function loadModeMapFromStorage(key: string): Record { + const raw = safeGetString(key); + if (!raw) return {}; try { - return safeGetString(SWARM_MODE_STORAGE_KEY) === 'true'; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out: Record = {}; + for (const [id, value] of Object.entries(parsed as Record)) { + if (value === true) out[id] = true; + } + return out; } catch { - return false; + return {}; } } -function saveSwarmModeToStorage(v: boolean): void { +function saveModeMapToStorage(key: string, map: Record): void { try { - safeSetString(SWARM_MODE_STORAGE_KEY, v ? 'true' : 'false'); + safeSetString(key, JSON.stringify(map)); } catch { - // ignore + // storage unavailable (private mode, quota, etc.) — ignore } } -function loadGoalModeFromStorage(): boolean { - try { - return safeGetString(GOAL_MODE_STORAGE_KEY) === 'true'; - } catch { - return false; - } +function savePlanModeToStorage(): void { + saveModeMapToStorage(PLAN_MODE_STORAGE_KEY, rawState.planModeBySession); } -function saveGoalModeToStorage(v: boolean): void { - try { - safeSetString(GOAL_MODE_STORAGE_KEY, v ? 'true' : 'false'); - } catch { - // ignore - } +function saveSwarmModeToStorage(): void { + saveModeMapToStorage(SWARM_MODE_STORAGE_KEY, rawState.swarmModeBySession); +} + +function saveGoalModeToStorage(): void { + saveModeMapToStorage(GOAL_MODE_STORAGE_KEY, rawState.goalModeBySession); } function loadActiveWorkspaceFromStorage(): string | null { @@ -268,10 +285,15 @@ export interface ExtendedState extends KimiClientState { workspaceName: string; connection: ConnectionState; permission: PermissionMode; + /** Permission mode per session. With no active session, `permission` is the draft default. */ + permissionModeBySession: Record; thinking: ThinkingLevel; - planMode: boolean; - swarmMode: boolean; - goalMode: boolean; + /** Plan-mode toggle per session. With no active session, the draft value is staged separately. */ + planModeBySession: Record; + /** Swarm-mode toggle per session. */ + swarmModeBySession: Record; + /** One-shot goal-mode flag per session. */ + goalModeBySession: Record; loading: boolean; sessionLoading: boolean; queuedBySession: Record; @@ -332,10 +354,11 @@ const rawState: ExtendedState = reactive({ workspaceName: 'kimi-web', connection: 'disconnected' as ConnectionState, permission: loadPermissionFromStorage(), + permissionModeBySession: loadPermissionModeMapFromStorage(), thinking: loadThinkingFromStorage(), - planMode: loadPlanModeFromStorage(), - swarmMode: loadSwarmModeFromStorage(), - goalMode: loadGoalModeFromStorage(), + planModeBySession: loadModeMapFromStorage(PLAN_MODE_STORAGE_KEY), + swarmModeBySession: loadModeMapFromStorage(SWARM_MODE_STORAGE_KEY), + goalModeBySession: loadModeMapFromStorage(GOAL_MODE_STORAGE_KEY), loading: false, sessionLoading: false, queuedBySession: {}, @@ -365,6 +388,12 @@ const rawState: ExtendedState = reactive({ sessionsFullyLoaded: false, }); +const draftModes = reactive({ + planMode: false, + swarmMode: false, + goalMode: false, +}); + // --------------------------------------------------------------------------- // rawState.sessions — single mutation funnel. // Every change to the session list goes through one of these helpers, so @@ -505,6 +534,16 @@ function forgetSession(sessionId: string): void { delete rawState.queuedBySession[sessionId]; delete rawState.promptIdBySession[sessionId]; delete rawState.sendingBySession[sessionId]; + // Drop per-session mode toggles and re-persist so a deleted session's entry + // doesn't linger in localStorage. + delete rawState.permissionModeBySession[sessionId]; + delete rawState.planModeBySession[sessionId]; + delete rawState.swarmModeBySession[sessionId]; + delete rawState.goalModeBySession[sessionId]; + savePermissionModeToStorage(); + savePlanModeToStorage(); + saveSwarmModeToStorage(); + saveGoalModeToStorage(); } // Models + Providers reactive state and helpers live in @@ -543,8 +582,18 @@ async function refreshSessionStatus(sessionId: string): Promise { contextLimit: st.maxContextTokens, }, })); - rawState.swarmMode = st.swarmMode; - rawState.planMode = st.planMode; + + if (isPermissionMode(st.permission)) { + rawState.permissionModeBySession = { + ...rawState.permissionModeBySession, + [sessionId]: st.permission, + }; + savePermissionModeToStorage(); + } + rawState.swarmModeBySession = { ...rawState.swarmModeBySession, [sessionId]: st.swarmMode }; + rawState.planModeBySession = { ...rawState.planModeBySession, [sessionId]: st.planMode }; + saveSwarmModeToStorage(); + savePlanModeToStorage(); } /** Persist runtime controls to the active session via POST /profile, then @@ -676,13 +725,24 @@ function applyEvent(event: ReturnType, sessionId: string, seq void modelProvider.loadProviders(); } - if (event.type === 'sessionUsageUpdated' && event.sessionId === rawState.activeSessionId && event.swarmMode !== undefined) { - rawState.swarmMode = event.swarmMode; - } - // Reflect the agent's live plan-mode state (e.g. it auto-entered plan mode) - // in the composer toggle. - if (event.type === 'sessionUsageUpdated' && event.sessionId === rawState.activeSessionId && event.planMode !== undefined) { - rawState.planMode = event.planMode; + // Reflect the agent's live mode state per session. Applied to the event's own + // session, so a background session keeps its independent toggles. + if (event.type === 'sessionUsageUpdated') { + if (isPermissionMode(event.permissionMode)) { + rawState.permissionModeBySession = { + ...rawState.permissionModeBySession, + [event.sessionId]: event.permissionMode, + }; + savePermissionModeToStorage(); + } + if (event.swarmMode !== undefined) { + rawState.swarmModeBySession = { ...rawState.swarmModeBySession, [event.sessionId]: event.swarmMode }; + saveSwarmModeToStorage(); + } + if (event.planMode !== undefined) { + rawState.planModeBySession = { ...rawState.planModeBySession, [event.sessionId]: event.planMode }; + savePlanModeToStorage(); + } } } @@ -1547,16 +1607,28 @@ const loadMoreMessagesError = computed(() => { }); const serverVersion = computed(() => rawState.serverVersion); -const permission = computed(() => rawState.permission); +const permission = computed(() => { + const sid = rawState.activeSessionId; + return sid ? (rawState.permissionModeBySession[sid] ?? rawState.permission) : rawState.permission; +}); const thinking = computed(() => rawState.thinking); -const planMode = computed(() => rawState.planMode); -const swarmMode = computed(() => rawState.swarmMode); -const goalMode = computed(() => rawState.goalMode); +const planMode = computed(() => { + const sid = rawState.activeSessionId; + return sid ? (rawState.planModeBySession[sid] ?? false) : draftModes.planMode; +}); +const swarmMode = computed(() => { + const sid = rawState.activeSessionId; + return sid ? (rawState.swarmModeBySession[sid] ?? false) : draftModes.swarmMode; +}); +const goalMode = computed(() => { + const sid = rawState.activeSessionId; + return sid ? (rawState.goalModeBySession[sid] ?? false) : draftModes.goalMode; +}); const activationBadges = computed(() => { const swarmCounts = countSwarmMembers(swarms.value); return { - plan: rawState.planMode, + plan: planMode.value, goal: goal.value && goal.value.status !== 'complete' ? { status: goal.value.status, @@ -1707,7 +1779,7 @@ const status = computed(() => { modelId: matched?.id ?? rawModel, ctxUsed: activeSession?.usage.contextTokens ?? 0, ctxMax: activeSession?.usage.contextLimit ?? 0, - permission: rawState.permission, + permission: permission.value, branch, cwd: activeSession?.cwd ?? '', isGitRepo: gitInfo.value !== null, @@ -1993,9 +2065,11 @@ const workspaceState = useWorkspaceState(rawState, { status, workspaceIdForSession, savePermissionToStorage, + savePermissionModeToStorage, savePlanModeToStorage, saveSwarmModeToStorage, saveGoalModeToStorage, + draftModes, saveUnread, saveActiveWorkspaceToStorage, saveHiddenWorkspacesToStorage, diff --git a/apps/kimi-web/src/lib/storage.ts b/apps/kimi-web/src/lib/storage.ts index cd7556b59c..8ba83b37ec 100644 --- a/apps/kimi-web/src/lib/storage.ts +++ b/apps/kimi-web/src/lib/storage.ts @@ -9,6 +9,7 @@ export const STORAGE_KEYS = { // useKimiWebClient permission: 'kimi-web.permission', + permissionMode: 'kimi-web.permission-mode', activeWorkspace: 'kimi-active-workspace', thinking: 'kimi-web.thinking', planMode: 'kimi-web.plan-mode', diff --git a/apps/kimi-web/test/agent-event-projector.test.ts b/apps/kimi-web/test/agent-event-projector.test.ts index a01d6f83ce..26423e3d1e 100644 --- a/apps/kimi-web/test/agent-event-projector.test.ts +++ b/apps/kimi-web/test/agent-event-projector.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { subagentProgressText } from '../src/api/daemon/agentEventProjector'; +import { createAgentProjector, subagentProgressText } from '../src/api/daemon/agentEventProjector'; describe('subagentProgressText', () => { it('drops turn.step.started as noise', () => { @@ -39,3 +39,18 @@ describe('subagentProgressText', () => { expect(subagentProgressText('turn.delta', {})).toBeNull(); }); }); + +describe('agent status projection', () => { + it('carries permission mode on session usage updates', () => { + const projector = createAgentProjector(); + const events = projector.project('agent.status.updated', { permission: 'auto' }, 's1'); + + expect(events).toContainEqual( + expect.objectContaining({ + type: 'sessionUsageUpdated', + sessionId: 's1', + permissionMode: 'auto', + }), + ); + }); +}); diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index ef6ff7a31a..4da39b467b 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -9,6 +9,8 @@ import type { ExtendedState } from '../src/composables/useKimiWebClient'; const apiMock = vi.hoisted(() => ({ abortPrompt: vi.fn(), abortSession: vi.fn(), + submitPrompt: vi.fn(), + updateSession: vi.fn(), })); vi.mock('../src/api', () => ({ @@ -51,10 +53,11 @@ function createState(): ExtendedState { workspaceName: 'kimi-web', connection: 'connected', permission: 'manual', + permissionModeBySession: {}, thinking: 'high', - planMode: false, - swarmMode: false, - goalMode: false, + planModeBySession: {}, + swarmModeBySession: {}, + goalModeBySession: {}, loading: false, sessionLoading: false, queuedBySession: {}, @@ -78,6 +81,10 @@ function createState(): ExtendedState { messagesLoadingMoreBySession: {}, messagesHasMoreBySession: {}, messagesLoadMoreErrorBySession: {}, + sessionsHasMoreByWorkspace: {}, + sessionsLoadingMoreByWorkspace: {}, + sessionsCursorByWorkspace: {}, + sessionsFullyLoaded: false, }; } @@ -109,9 +116,11 @@ function createDeps(): UseWorkspaceStateDeps { status: computed(() => ({})), workspaceIdForSession: vi.fn(), savePermissionToStorage: vi.fn(), + savePermissionModeToStorage: vi.fn(), savePlanModeToStorage: vi.fn(), saveSwarmModeToStorage: vi.fn(), saveGoalModeToStorage: vi.fn(), + draftModes: { planMode: false, swarmMode: false, goalMode: false }, saveUnread: vi.fn(), saveActiveWorkspaceToStorage: vi.fn(), saveHiddenWorkspacesToStorage: vi.fn(), @@ -214,3 +223,40 @@ describe('mergeWorkspaces', () => { expect(result.map((w) => w.root)).not.toContain('/agent/A'); }); }); + +describe('useWorkspaceState — permission mode', () => { + beforeEach(() => { + apiMock.submitPrompt.mockReset(); + apiMock.updateSession.mockReset(); + }); + + it('stores permission changes on the active session', () => { + const state = createState(); + const deps = createDeps(); + const ws = useWorkspaceState(state, deps); + + ws.setPermission('auto'); + + expect(state.permission).toBe('manual'); + expect(state.permissionModeBySession).toEqual({ sess_1: 'auto' }); + expect(deps.savePermissionToStorage).not.toHaveBeenCalled(); + expect(deps.savePermissionModeToStorage).toHaveBeenCalledOnce(); + expect(deps.persistSessionProfile).toHaveBeenCalledWith({ permissionMode: 'auto' }); + }); + + it('uses the target session permission when submitting a prompt', async () => { + apiMock.submitPrompt.mockResolvedValue({ promptId: 'prompt_1', status: 'running' }); + const state = createState(); + state.permission = 'manual'; + state.permissionModeBySession = { sess_1: 'yolo' }; + const deps = createDeps(); + const ws = useWorkspaceState(state, deps); + + await ws.submitPromptInternal('sess_1', 'hello'); + + expect(apiMock.submitPrompt).toHaveBeenCalledWith( + 'sess_1', + expect.objectContaining({ permissionMode: 'yolo' }), + ); + }); +});