From 670bfd6fbd984dc97b4d2f788b8f43d84beaa891 Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 6 Jul 2026 23:50:42 +0800 Subject: [PATCH 01/11] fix(web): activate slash skills on the new-session screen The composer now lists workspace skills before a session exists, but activating one from the empty-session composer silently did nothing: activateSkill() short-circuits when there is no active session id, so the command was cleared with no turn started. Mirror the first-prompt path: when a slash skill is activated with no active session (but a workspace is active), create the session first and activate on the new session id. Extract the shared session-creation block out of startSessionAndSendPrompt into createDraftSession and add a startSessionAndActivateSkill counterpart; activateSkill now also accepts an explicit session id so a concurrent session switch can't redirect it. Unrecognized '/cmd' text still falls through to a plain-text message as before. --- .../fix-web-skill-activation-new-session.md | 5 + apps/kimi-web/src/App.vue | 11 +- .../client/useModelProviderState.ts | 8 +- .../composables/client/useWorkspaceState.ts | 152 +++++++++++------- .../src/composables/useKimiWebClient.ts | 1 + apps/kimi-web/test/workspace-state.test.ts | 63 ++++++++ 6 files changed, 178 insertions(+), 62 deletions(-) create mode 100644 .changeset/fix-web-skill-activation-new-session.md diff --git a/.changeset/fix-web-skill-activation-new-session.md b/.changeset/fix-web-skill-activation-new-session.md new file mode 100644 index 0000000000..7a07e154c7 --- /dev/null +++ b/.changeset/fix-web-skill-activation-new-session.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix slash skill activations (for example `/pre-changelog`) silently doing nothing on the new-session screen. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 2e6133f3fc..6c82df9863 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -488,11 +488,18 @@ function handleCommand(cmd: string): void { // Not a built-in command → treat it as a session skill activation // (the user picked `/` from the menu, or typed `/ args`). // The daemon answers an unknown name with skill.not_found, surfaced as a - // warning, so a stray slash is harmless. + // warning, so a stray slash is harmless. With no active session, create + // one first (same path as the first prompt) so the activation isn't + // silently dropped on the new-session screen. const space = cmd.indexOf(' '); const name = (space === -1 ? cmd : cmd.slice(0, space)).slice(1); const args = space === -1 ? undefined : cmd.slice(space + 1).trim() || undefined; - if (name) void client.activateSkill(name, args); + if (!name) break; + if (!client.activeSessionId.value && client.activeWorkspaceId.value) { + void client.startSessionAndActivateSkill(client.activeWorkspaceId.value, name, args); + } else { + void client.activateSkill(name, args); + } break; } } diff --git a/apps/kimi-web/src/composables/client/useModelProviderState.ts b/apps/kimi-web/src/composables/client/useModelProviderState.ts index c8409f8af9..5c84e1a6e7 100644 --- a/apps/kimi-web/src/composables/client/useModelProviderState.ts +++ b/apps/kimi-web/src/composables/client/useModelProviderState.ts @@ -241,9 +241,13 @@ export function useModelProviderState( * Activate a session skill (the web analogue of typing `/ ` in the * TUI). The daemon starts a turn with a `skill_activation` origin; progress * arrives over the WS stream like any other turn. Never crashes the caller. + * + * `sessionId` overrides the active session — used when activating right after + * creating a session, so a concurrent session switch can't redirect the + * activation to the wrong session. No session at all is a no-op. */ - async function activateSkill(skillName: string, args?: string): Promise { - const sid = rawState.activeSessionId; + async function activateSkill(skillName: string, args?: string, sessionId?: string): Promise { + const sid = sessionId ?? rawState.activeSessionId; if (!sid) return; const guarded = activity.value === 'idle' && !inFlightPromptSessions.has(sid); const tempId = `msg_skill_opt_${Date.now().toString(36)}`; diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 1cff661331..d46431f0cb 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -736,6 +736,75 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta clearFileDiff(); } + /** + * Create a session in a workspace for an immediate first action — the first + * prompt (`startSessionAndSendPrompt`) or a skill activation + * (`startSessionAndActivateSkill`) from the empty-session composer. Returns + * the new session id, or null if the workspace is unknown. Applies the staged + * draft model + modes onto the new session. Throws on daemon failure so the + * caller can surface the error via pushOperationFailure. + */ + async function createDraftSession(workspaceId: string): Promise { + const ws = mergedWorkspaces.value.find((w) => w.id === workspaceId); + if (!ws) return null; + const api = getKimiWebApi(); + let workspaceIdForCreate: string | undefined; + let cwdForCreate = ws.root; + try { + const registered = await api.addWorkspace({ root: ws.root }); + workspaceIdForCreate = registered.id; + cwdForCreate = registered.root; + upsertWorkspacePreserveOrder(registered); + } catch { + // Older daemons may not have /workspaces. + } + const draftPick = modelProvider.draftModel.value ?? undefined; + const session = await api.createSession({ + workspaceId: workspaceIdForCreate, + cwd: cwdForCreate, + model: draftPick, + }); + modelProvider.draftModel.value = null; // applied — the next draft starts from the default + // The create echo may return model as '' (same daemon quirk as /profile); + // keep the user's pick so the status line doesn't snap back to the default. + const created = + draftPick !== undefined && (!session.model || session.model.length === 0) + ? { ...session, model: draftPick } + : session; + upsertSessionFront(created); + selectWorkspace(session.workspaceId ?? workspaceIdForCreate ?? workspaceId); + // NOTE: do NOT mark this session known-empty. Unlike "open a new empty + // session" (createSession), here we immediately act on it: keeping + // sessionLoading=true through the snapshot avoids flashing the empty-session + // composer before the optimistic first turn lands. selectSession resolves, + // then the caller adds the first 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 action 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; + 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; + return sid; + } + /** * Create a session and immediately submit the first prompt. * This is the unified path when there is no active session (e.g. after @@ -746,70 +815,36 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta text: string, attachments?: PromptAttachment[], ): Promise { - const ws = mergedWorkspaces.value.find((w) => w.id === workspaceId); - if (!ws) return; try { - const api = getKimiWebApi(); - let workspaceIdForCreate: string | undefined; - let cwdForCreate = ws.root; - try { - const registered = await api.addWorkspace({ root: ws.root }); - workspaceIdForCreate = registered.id; - cwdForCreate = registered.root; - upsertWorkspacePreserveOrder(registered); - } catch { - // Older daemons may not have /workspaces. - } - const draftPick = modelProvider.draftModel.value ?? undefined; - const session = await api.createSession({ - workspaceId: workspaceIdForCreate, - cwd: cwdForCreate, - model: draftPick, - }); - modelProvider.draftModel.value = null; // applied — the next draft starts from the default - // The create echo may return model as '' (same daemon quirk as /profile); - // keep the user's pick so the status line doesn't snap back to the default. - const created = - draftPick !== undefined && (!session.model || session.model.length === 0) - ? { ...session, model: draftPick } - : session; - upsertSessionFront(created); - selectWorkspace(session.workspaceId ?? workspaceIdForCreate ?? workspaceId); - // NOTE: do NOT mark this session known-empty. Unlike "open a new empty - // session" (createSession), here we immediately send a prompt: keeping - // sessionLoading=true through the snapshot avoids flashing the empty-session - // composer before the optimistic user message lands. selectSession resolves, - // 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; - 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); + const sid = await createDraftSession(workspaceId); + if (!sid) return; + await submitPromptInternal(sid, text, attachments); } catch (err) { pushOperationFailure('startSessionAndSendPrompt', err); } } + /** + * Create a session and immediately activate a skill — the empty-composer + * counterpart to startSessionAndSendPrompt. Without this, `/` from the + * new-session screen silently dropped the activation (`activateSkill` needs a + * session id). Shares createDraftSession so the model and draft modes are + * applied identically to a prompt-started session. + */ + async function startSessionAndActivateSkill( + workspaceId: string, + skillName: string, + args?: string, + ): Promise { + try { + const sid = await createDraftSession(workspaceId); + if (!sid) return; + await modelProvider.activateSkill(skillName, args, sid); + } catch (err) { + pushOperationFailure('startSessionAndActivateSkill', err); + } + } + /** * Add a workspace by folder path, registering it with the daemon. Returns true * when the workspace was registered and selected; false when the daemon @@ -1994,6 +2029,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta clearActiveSession, openWorkspaceDraft, startSessionAndSendPrompt, + startSessionAndActivateSkill, addWorkspaceByPath, browseFs, getFsHome, diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index f65fd06478..5a444bbc36 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -2471,6 +2471,7 @@ export function useKimiWebClient() { openWorkspace: workspaceState.openWorkspace, openWorkspaceDraft: workspaceState.openWorkspaceDraft, startSessionAndSendPrompt: workspaceState.startSessionAndSendPrompt, + startSessionAndActivateSkill: workspaceState.startSessionAndActivateSkill, addWorkspaceByPath: workspaceState.addWorkspaceByPath, browseFs: workspaceState.browseFs, getFsHome: workspaceState.getFsHome, diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index ee1abfe63e..0ee764c010 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -13,6 +13,7 @@ const apiMock = vi.hoisted(() => ({ abortSession: vi.fn(), addWorkspace: vi.fn(), updateWorkspace: vi.fn(), + createSession: vi.fn(), respondQuestion: vi.fn(), respondApproval: vi.fn(), dismissQuestion: vi.fn(), @@ -557,3 +558,65 @@ describe('useWorkspaceState — cancelTask', () => { await first; }); }); + +describe('useWorkspaceState — startSessionAndActivateSkill', () => { + const registered = { id: 'wd_1', root: '/abs/path', name: 'A', isGitRepo: false, sessionCount: 0 }; + const newSession = { ...createSession(), id: 'sess_new', workspaceId: 'wd_1', cwd: '/abs/path' }; + + beforeEach(() => { + apiMock.addWorkspace.mockReset(); + apiMock.createSession.mockReset(); + apiMock.addWorkspace.mockResolvedValue(registered); + apiMock.createSession.mockResolvedValue(newSession); + }); + + function skillDeps(activateSkill: ReturnType): UseWorkspaceStateDeps { + return { + ...createDeps(), + taskPoller: { loadTasksForSession: vi.fn() } as unknown as UseWorkspaceStateDeps['taskPoller'], + modelProvider: { + draftModel: ref(null), + skillsBySession: ref({}), + loadSkillsForSession: vi.fn(), + activateSkill, + } as unknown as UseWorkspaceStateDeps['modelProvider'], + mergedWorkspaces: computed(() => [workspace('wd_1', '/abs/path', 'A')]), + }; + } + + it('creates a session, then activates the skill on the new session id', async () => { + const activateSkill = vi.fn().mockResolvedValue(undefined); + const deps = skillDeps(activateSkill); + const ws = useWorkspaceState(createState(), deps); + + await ws.startSessionAndActivateSkill('wd_1', 'pre-changelog'); + + expect(apiMock.createSession).toHaveBeenCalledOnce(); + // The activation targets the freshly created session, so a concurrent + // session switch can't redirect it. + expect(activateSkill).toHaveBeenCalledWith('pre-changelog', undefined, 'sess_new'); + expect(deps.pushOperationFailure).not.toHaveBeenCalled(); + }); + + it('passes through skill args', async () => { + const activateSkill = vi.fn().mockResolvedValue(undefined); + const deps = skillDeps(activateSkill); + const ws = useWorkspaceState(createState(), deps); + + await ws.startSessionAndActivateSkill('wd_1', 'write-goal', 'ship it'); + + expect(activateSkill).toHaveBeenCalledWith('write-goal', 'ship it', 'sess_new'); + }); + + it('is a no-op for an unknown workspace', async () => { + const activateSkill = vi.fn().mockResolvedValue(undefined); + const deps = skillDeps(activateSkill); + const ws = useWorkspaceState(createState(), deps); + + await ws.startSessionAndActivateSkill('wd_missing', 'pre-changelog'); + + expect(apiMock.createSession).not.toHaveBeenCalled(); + expect(activateSkill).not.toHaveBeenCalled(); + expect(deps.pushOperationFailure).not.toHaveBeenCalled(); + }); +}); From 822238f041f051552b64f89257f621aba848cb46 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 7 Jul 2026 00:03:01 +0800 Subject: [PATCH 02/11] fix(web): persist draft modes when a new session is opened via skill The skill-activate request carries only `args`, so for a skill launched from the new-session composer createDraftSession's local plan/swarm maps never reached the daemon, and the first skill turn ran at default modes while the UI showed them enabled. Persist the draft plan/swarm modes to the new session's profile inside createDraftSession (by the new session id), and teach persistSessionProfile to take an explicit session id so a concurrent session switch during the snapshot load can't write the patch to the wrong session. Plain prompts already send planMode/swarmMode on the prompt request itself, so this is redundant but harmless on the first-prompt path. Goal mode is a one-shot flag consumed per send, not a profile field, so there is nothing to persist for it. Add coverage that the profile is persisted before activation when draft plan/swarm modes are enabled. --- .../composables/client/useWorkspaceState.ts | 18 ++++++++++++- .../src/composables/useKimiWebClient.ts | 11 +++++--- apps/kimi-web/test/workspace-state.test.ts | 26 +++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index d46431f0cb..5395fd0e50 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -126,7 +126,7 @@ export interface UseWorkspaceStateDeps { reopenSession: (sessionId: string) => Promise; hasLoadedMessages: (sessionId: string) => boolean; refreshSessionStatus: (sessionId: string) => Promise; - persistSessionProfile: (patch: PersistSessionProfilePatch) => void; + persistSessionProfile: (patch: PersistSessionProfilePatch, sessionId?: string) => void; mergedWorkspaces: ComputedRef; /** Sidebar-facing workspaces in the user's (dragged) display order. */ workspacesView: ComputedRef; @@ -799,6 +799,22 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta rawState.goalModeBySession = { ...rawState.goalModeBySession, [sid]: true }; saveGoalModeToStorage(); } + // Persist plan/swarm onto the new session's profile so non-prompt origins + // (e.g. a skill activation, which carries only `args`) run with the same + // modes the UI shows. Plain prompts already send planMode/swarmMode on the + // prompt request itself, so this is redundant but harmless there. Sent by + // id (not via the active-session setter) for the same race reason as the + // map writes above. Goal mode is a one-shot flag consumed per send, not a + // profile field, so there is nothing to persist for it. + if (draftModes.planMode || draftModes.swarmMode) { + persistSessionProfile( + { + planMode: draftModes.planMode, + swarmMode: draftModes.swarmMode, + }, + sid, + ); + } draftModes.planMode = false; draftModes.swarmMode = false; draftModes.goalMode = false; diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 5a444bbc36..b760bf7462 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -601,8 +601,11 @@ async function refreshSessionStatus(sessionId: string): Promise { rawState.planModeBySession = { ...rawState.planModeBySession, [sessionId]: st.planMode }; } -/** Persist runtime controls to the active session via POST /profile, then - * re-read /status. Fire-and-forget: the UI already updated optimistically. */ +/** Persist runtime controls to a session via POST /profile, then re-read + * /status. Fire-and-forget: the UI already updated optimistically. `sessionId` + * overrides the active session — used when creating a session and immediately + * persisting its draft modes, so a concurrent session switch can't write the + * patch to the wrong session. */ function persistSessionProfile(patch: { model?: string; permissionMode?: string; @@ -611,8 +614,8 @@ function persistSessionProfile(patch: { goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string; -}): void { - const sid = rawState.activeSessionId; +}, sessionId?: string): void { + const sid = sessionId ?? rawState.activeSessionId; if (!sid) return; // Promise.resolve wrap: tolerate a sync/undefined return (e.g. test mocks). void Promise.resolve(getKimiWebApi().updateSession(sid, patch)) diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 0ee764c010..700bf3887c 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -608,6 +608,32 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { expect(activateSkill).toHaveBeenCalledWith('write-goal', 'ship it', 'sess_new'); }); + it('persists draft plan/swarm modes to the new session before activation', async () => { + // Skill activation only carries `args`, so the daemon never sees the per- + // prompt planMode/swarmMode flags. Persisting them to the new session's + // profile keeps the first skill turn in the modes the UI shows. + const activateSkill = vi.fn().mockResolvedValue(undefined); + const persistSessionProfile = vi.fn(); + const deps = { + ...skillDeps(activateSkill), + persistSessionProfile, + draftModes: { planMode: true, swarmMode: true, goalMode: false }, + }; + const ws = useWorkspaceState(createState(), deps); + + await ws.startSessionAndActivateSkill('wd_1', 'pre-changelog'); + + expect(persistSessionProfile).toHaveBeenCalledWith( + { planMode: true, swarmMode: true }, + 'sess_new', + ); + // Ordering: profile is persisted (synchronously, even if the POST is async) + // before the skill activation is issued. + expect(persistSessionProfile.mock.invocationCallOrder[0]).toBeLessThan( + activateSkill.mock.invocationCallOrder[0]!, + ); + }); + it('is a no-op for an unknown workspace', async () => { const activateSkill = vi.fn().mockResolvedValue(undefined); const deps = skillDeps(activateSkill); From fd82602fe93cc4f1bd0c4637d3e31d2ed9996e81 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 7 Jul 2026 02:23:10 +0800 Subject: [PATCH 03/11] fix(web): await draft-mode profile POST before activating a skill The previous fix wrote the draft plan/swarm modes to the new session's profile but fire-and-forget: persistSessionProfile returned immediately after starting POST /profile, so startSessionAndActivateSkill sent :activate without waiting. Since skill activation carries only args, the daemon could process :activate (and start the turn) before applyAgentState from /profile finished, running the first skill turn at default modes while the UI showed them enabled. Make persistSessionProfile return the update promise and await it inside createDraftSession, so any origin that follows (the skill activation, or a plain prompt) only starts after the profile is applied. Existing callers still fire-and-forget via `void persistSessionProfile(...)`. Test coverage now blocks activation behind a deferred profile POST and asserts it is issued only after the profile resolves. --- .../client/useModelProviderState.ts | 4 +-- .../composables/client/useWorkspaceState.ts | 23 ++++++++------ .../src/composables/useKimiWebClient.ts | 18 ++++++----- apps/kimi-web/test/workspace-state.test.ts | 31 ++++++++++++------- 4 files changed, 46 insertions(+), 30 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useModelProviderState.ts b/apps/kimi-web/src/composables/client/useModelProviderState.ts index 5c84e1a6e7..6eb852e1ff 100644 --- a/apps/kimi-web/src/composables/client/useModelProviderState.ts +++ b/apps/kimi-web/src/composables/client/useModelProviderState.ts @@ -54,7 +54,7 @@ export interface UseModelProviderStateDeps { opts?: { title?: string; message?: string; sessionId?: string }, ) => void; refreshSessionStatus: (sessionId: string) => Promise; - persistSessionProfile: (patch: PersistSessionProfilePatch) => void; + persistSessionProfile: (patch: PersistSessionProfilePatch, sessionId?: string) => Promise; activity: ComputedRef; inFlightPromptSessions: Set; saveThinkingToStorage: (v: ThinkingLevel) => void; @@ -391,7 +391,7 @@ export function useModelProviderState( * session profile so the daemon's /status reflects it; still sent per-prompt). */ function setThinking(level: ThinkingLevel): void { const next = applyThinkingLevel(level); - persistSessionProfile({ thinking: next }); + void persistSessionProfile({ thinking: next }); } return { diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 5395fd0e50..79cb686a35 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -126,7 +126,7 @@ export interface UseWorkspaceStateDeps { reopenSession: (sessionId: string) => Promise; hasLoadedMessages: (sessionId: string) => boolean; refreshSessionStatus: (sessionId: string) => Promise; - persistSessionProfile: (patch: PersistSessionProfilePatch, sessionId?: string) => void; + persistSessionProfile: (patch: PersistSessionProfilePatch, sessionId?: string) => Promise; mergedWorkspaces: ComputedRef; /** Sidebar-facing workspaces in the user's (dragged) display order. */ workspacesView: ComputedRef; @@ -801,13 +801,16 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta } // Persist plan/swarm onto the new session's profile so non-prompt origins // (e.g. a skill activation, which carries only `args`) run with the same - // modes the UI shows. Plain prompts already send planMode/swarmMode on the - // prompt request itself, so this is redundant but harmless there. Sent by - // id (not via the active-session setter) for the same race reason as the - // map writes above. Goal mode is a one-shot flag consumed per send, not a - // profile field, so there is nothing to persist for it. + // modes the UI shows. Awaited so the origin that follows — especially + // startSessionAndActivateSkill — can't race ahead of applyAgentState on the + // daemon and have its first turn run at the default modes. Sent by id (not + // via the active-session setter) for the same race reason as the map writes + // above. Plain prompts already send planMode/swarmMode on the prompt request + // itself, so awaiting here is redundant but harmless there. Goal mode is a + // one-shot flag consumed per send, not a profile field, so there is nothing + // to persist for it. if (draftModes.planMode || draftModes.swarmMode) { - persistSessionProfile( + await persistSessionProfile( { planMode: draftModes.planMode, swarmMode: draftModes.swarmMode, @@ -1512,7 +1515,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta if (sid) { rawState.planModeBySession = { ...rawState.planModeBySession, [sid]: on }; savePlanModeToStorage(); - persistSessionProfile({ planMode: on }); + void persistSessionProfile({ planMode: on }); } else { draftModes.planMode = on; } @@ -1532,7 +1535,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta if (sid) { rawState.swarmModeBySession = { ...rawState.swarmModeBySession, [sid]: on }; saveSwarmModeToStorage(); - persistSessionProfile({ swarmMode: on }); + void persistSessionProfile({ swarmMode: on }); } else { draftModes.swarmMode = on; } @@ -1610,7 +1613,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta function setPermission(mode: PermissionMode): void { rawState.permission = mode; savePermissionToStorage(mode); - persistSessionProfile({ permissionMode: mode }); + void persistSessionProfile({ permissionMode: 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 b760bf7462..d29b3ef6f6 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -602,10 +602,14 @@ async function refreshSessionStatus(sessionId: string): Promise { } /** Persist runtime controls to a session via POST /profile, then re-read - * /status. Fire-and-forget: the UI already updated optimistically. `sessionId` - * overrides the active session — used when creating a session and immediately - * persisting its draft modes, so a concurrent session switch can't write the - * patch to the wrong session. */ + * /status. `sessionId` overrides the active session — used when creating a + * session and immediately persisting its draft modes, so a concurrent session + * switch can't write the patch to the wrong session. + * + * Returns the update promise (errors swallowed — the UI already updated + * optimistically). Most callers fire-and-forget via `void persistSessionProfile(...)`; + * call sites that must order strictly after the profile (e.g. a skill + * activation that can't carry its own modes) await it. */ function persistSessionProfile(patch: { model?: string; permissionMode?: string; @@ -614,11 +618,11 @@ function persistSessionProfile(patch: { goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string; -}, sessionId?: string): void { +}, sessionId?: string): Promise { const sid = sessionId ?? rawState.activeSessionId; - if (!sid) return; + if (!sid) return Promise.resolve(); // Promise.resolve wrap: tolerate a sync/undefined return (e.g. test mocks). - void Promise.resolve(getKimiWebApi().updateSession(sid, patch)) + return Promise.resolve(getKimiWebApi().updateSession(sid, patch)) .then(() => refreshSessionStatus(sid)) .catch(() => { /* ignore — local state already reflects the change */ diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 700bf3887c..abb0a2286f 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -608,12 +608,17 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { expect(activateSkill).toHaveBeenCalledWith('write-goal', 'ship it', 'sess_new'); }); - it('persists draft plan/swarm modes to the new session before activation', async () => { + it('awaits the profile POST before activating, so plan/swarm apply first', async () => { // Skill activation only carries `args`, so the daemon never sees the per- - // prompt planMode/swarmMode flags. Persisting them to the new session's - // profile keeps the first skill turn in the modes the UI shows. + // prompt planMode/swarmMode flags. We persist them to the new session's + // profile and must WAIT for it; otherwise the :activate request can race + // ahead of applyAgentState and the first skill turn runs at default modes. + let resolveProfile!: () => void; + const profileGate = new Promise((r) => { + resolveProfile = r; + }); const activateSkill = vi.fn().mockResolvedValue(undefined); - const persistSessionProfile = vi.fn(); + const persistSessionProfile = vi.fn().mockReturnValue(profileGate); const deps = { ...skillDeps(activateSkill), persistSessionProfile, @@ -621,17 +626,21 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { }; const ws = useWorkspaceState(createState(), deps); - await ws.startSessionAndActivateSkill('wd_1', 'pre-changelog'); - + const pending = ws.startSessionAndActivateSkill('wd_1', 'pre-changelog'); + // Yield a macrotask so createDraftSession's chain (which awaits selectSession + // before persisting the profile) progresses to the in-flight /profile POST. + // Activation must NOT have started while /profile is still pending. + await new Promise((r) => setTimeout(r, 0)); expect(persistSessionProfile).toHaveBeenCalledWith( { planMode: true, swarmMode: true }, 'sess_new', ); - // Ordering: profile is persisted (synchronously, even if the POST is async) - // before the skill activation is issued. - expect(persistSessionProfile.mock.invocationCallOrder[0]).toBeLessThan( - activateSkill.mock.invocationCallOrder[0]!, - ); + expect(activateSkill).not.toHaveBeenCalled(); + + resolveProfile(); + await pending; + + expect(activateSkill).toHaveBeenCalledWith('pre-changelog', undefined, 'sess_new'); }); it('is a no-op for an unknown workspace', async () => { From 8989661190018deba21ba7388b6b3cd059bd4d32 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 7 Jul 2026 11:49:39 +0800 Subject: [PATCH 04/11] refactor(web): persist draft modes on the skill path only Move the awaited plan/swarm profile write out of createDraftSession and into startSessionAndActivateSkill. The create path now only builds the session and mirrors draft modes into the per-session maps, matching the old first-prompt behavior: plain prompts already send planMode/swarmMode on the prompt request itself, so a /profile write there was redundant. The profile write stays on the one path that needs it: skill activation carries only args, so the draft modes must be stored on the new session and applied before :activate is sent. --- .../composables/client/useWorkspaceState.ts | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 79cb686a35..adab581485 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -799,25 +799,6 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta rawState.goalModeBySession = { ...rawState.goalModeBySession, [sid]: true }; saveGoalModeToStorage(); } - // Persist plan/swarm onto the new session's profile so non-prompt origins - // (e.g. a skill activation, which carries only `args`) run with the same - // modes the UI shows. Awaited so the origin that follows — especially - // startSessionAndActivateSkill — can't race ahead of applyAgentState on the - // daemon and have its first turn run at the default modes. Sent by id (not - // via the active-session setter) for the same race reason as the map writes - // above. Plain prompts already send planMode/swarmMode on the prompt request - // itself, so awaiting here is redundant but harmless there. Goal mode is a - // one-shot flag consumed per send, not a profile field, so there is nothing - // to persist for it. - if (draftModes.planMode || draftModes.swarmMode) { - await persistSessionProfile( - { - planMode: draftModes.planMode, - swarmMode: draftModes.swarmMode, - }, - sid, - ); - } draftModes.planMode = false; draftModes.swarmMode = false; draftModes.goalMode = false; @@ -848,7 +829,8 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta * counterpart to startSessionAndSendPrompt. Without this, `/` from the * new-session screen silently dropped the activation (`activateSkill` needs a * session id). Shares createDraftSession so the model and draft modes are - * applied identically to a prompt-started session. + * applied identically to a prompt-started session; then persists any draft + * plan/swarm modes here, because skill activation carries only `args`. */ async function startSessionAndActivateSkill( workspaceId: string, @@ -858,6 +840,18 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta try { const sid = await createDraftSession(workspaceId); if (!sid) return; + // Unlike a plain prompt, skill activation only carries `args`, so the + // daemon never sees prompt-time planMode/swarmMode. Persist any draft + // plan/swarm modes onto this new session's profile and await it before + // activating, otherwise the first skill turn can start before applyAgentState + // and run at default modes while the UI shows them enabled. Goal mode is + // a one-shot flag consumed per send, not a profile field, so there is + // nothing to persist for it. + const planMode = rawState.planModeBySession[sid] ?? false; + const swarmMode = rawState.swarmModeBySession[sid] ?? false; + if (planMode || swarmMode) { + await persistSessionProfile({ planMode, swarmMode }, sid); + } await modelProvider.activateSkill(skillName, args, sid); } catch (err) { pushOperationFailure('startSessionAndActivateSkill', err); From a628e316d40f43080499b328917ecf70c550b50d Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 7 Jul 2026 12:01:28 +0800 Subject: [PATCH 05/11] fix(web): persist draft permission and thinking for new-session skill /auto, /yolo, and /thinking on the new-session composer only update rawState (there is no session to persist to yet). A plain first prompt still honors them because submitPromptInternal sends permissionMode and thinking on the request, but skill activation carries only args, so the first skill turn was running at daemon defaults. Include permissionMode and thinking in the awaited profile patch alongside planMode/swarmMode before activating, so the skill turn matches the controls the UI shows. --- .../composables/client/useWorkspaceState.ts | 25 ++++++++++++------- apps/kimi-web/test/workspace-state.test.ts | 16 +++++++----- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index adab581485..ece9f0e77a 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -841,17 +841,24 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta const sid = await createDraftSession(workspaceId); if (!sid) return; // Unlike a plain prompt, skill activation only carries `args`, so the - // daemon never sees prompt-time planMode/swarmMode. Persist any draft - // plan/swarm modes onto this new session's profile and await it before - // activating, otherwise the first skill turn can start before applyAgentState - // and run at default modes while the UI shows them enabled. Goal mode is - // a one-shot flag consumed per send, not a profile field, so there is - // nothing to persist for it. + // daemon never sees the prompt-time controls the user may have changed on + // the draft (plan/swarm, plus permission via /auto|/yolo and thinking via + // /thinking). Persist them onto this new session's profile and await it + // before activating, otherwise the first skill turn can start before + // applyAgentState and run at daemon defaults while the UI shows otherwise. + // Goal mode is a one-shot flag consumed per send, not a profile field, so + // there is nothing to persist for it. const planMode = rawState.planModeBySession[sid] ?? false; const swarmMode = rawState.swarmModeBySession[sid] ?? false; - if (planMode || swarmMode) { - await persistSessionProfile({ planMode, swarmMode }, sid); - } + await persistSessionProfile( + { + planMode, + swarmMode, + permissionMode: rawState.permission, + thinking: rawState.thinking, + }, + sid, + ); await modelProvider.activateSkill(skillName, args, sid); } catch (err) { pushOperationFailure('startSessionAndActivateSkill', err); diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index abb0a2286f..c5b4bd9a96 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -608,11 +608,12 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { expect(activateSkill).toHaveBeenCalledWith('write-goal', 'ship it', 'sess_new'); }); - it('awaits the profile POST before activating, so plan/swarm apply first', async () => { + it('awaits the profile POST before activating, so draft controls apply first', async () => { // Skill activation only carries `args`, so the daemon never sees the per- - // prompt planMode/swarmMode flags. We persist them to the new session's - // profile and must WAIT for it; otherwise the :activate request can race - // ahead of applyAgentState and the first skill turn runs at default modes. + // prompt controls (plan/swarm plus permission and thinking) the user set on + // the draft. We persist them to the new session's profile and must WAIT for + // it; otherwise :activate can race ahead of applyAgentState and the first + // skill turn runs at daemon defaults while the UI shows otherwise. let resolveProfile!: () => void; const profileGate = new Promise((r) => { resolveProfile = r; @@ -624,7 +625,10 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { persistSessionProfile, draftModes: { planMode: true, swarmMode: true, goalMode: false }, }; - const ws = useWorkspaceState(createState(), deps); + const state = createState(); + state.permission = 'auto'; + state.thinking = 'high'; + const ws = useWorkspaceState(state, deps); const pending = ws.startSessionAndActivateSkill('wd_1', 'pre-changelog'); // Yield a macrotask so createDraftSession's chain (which awaits selectSession @@ -632,7 +636,7 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { // Activation must NOT have started while /profile is still pending. await new Promise((r) => setTimeout(r, 0)); expect(persistSessionProfile).toHaveBeenCalledWith( - { planMode: true, swarmMode: true }, + { planMode: true, swarmMode: true, permissionMode: 'auto', thinking: 'high' }, 'sess_new', ); expect(activateSkill).not.toHaveBeenCalled(); From b04f4fb0bc481278828c2065877bdf362049ce83 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 7 Jul 2026 12:11:18 +0800 Subject: [PATCH 06/11] fix(web): start /goal from the new-session composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createGoal() short-circuited when there was no active session, so `/goal ` from the empty-session composer silently cleared and ran nothing — the same bug class as the slash-skill activation fixed earlier in this PR. Mirror startSessionAndSendPrompt: when no session exists but a workspace is active, create one first then target it with the goal profile update and the objective prompt. Send via submitPromptInternal with the explicit sid so a concurrent session switch during creation can't redirect it. Plain prompts already carry their own permissionMode / thinking / plan / swarm, so no profile fallback is needed here. --- .changeset/fix-web-goal-new-session.md | 5 ++ .../composables/client/useWorkspaceState.ts | 19 ++++- apps/kimi-web/test/workspace-state.test.ts | 81 +++++++++++++++++++ 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-web-goal-new-session.md diff --git a/.changeset/fix-web-goal-new-session.md b/.changeset/fix-web-goal-new-session.md new file mode 100644 index 0000000000..a180311a98 --- /dev/null +++ b/.changeset/fix-web-goal-new-session.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix `/goal ` silently doing nothing on the new-session screen. diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index ece9f0e77a..3829c66d23 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -1587,15 +1587,28 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta }); if (!ok) return; } - const sid = rawState.activeSessionId; - if (!sid) return; + // Empty-composer heal: `/goal ` from the new-session screen + // would otherwise silently clear and run nothing. Create the session first + // (same path as the first prompt / a new-session skill), then target it. + let sid = rawState.activeSessionId; + if (!sid) { + const wsId = rawState.activeWorkspaceId; + if (!wsId) return; + sid = (await createDraftSession(wsId)) ?? undefined; + if (!sid) return; + } try { await getKimiWebApi().updateSession(sid, { goalObjective: trimmed }); } catch (err) { pushOperationFailure('createGoal', err, { sessionId: sid, message: goalErrorMessage(err) }); return; } - await sendPrompt(trimmed); + // Send by explicit sid (not sendPrompt, which reads activeSessionId): a + // concurrent session switch during createDraftSession would otherwise + // redirect the goal prompt to the wrong session. Plain prompts carry their + // own permissionMode / thinking / plan / swarm on the request, so we don't + // need to persist them onto the profile here. + await submitPromptInternal(sid, trimmed); } /** Send a one-shot goal control action (pause/resume/cancel). */ diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index c5b4bd9a96..cd72dc7465 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -14,6 +14,8 @@ const apiMock = vi.hoisted(() => ({ addWorkspace: vi.fn(), updateWorkspace: vi.fn(), createSession: vi.fn(), + updateSession: vi.fn(), + submitPrompt: vi.fn(), respondQuestion: vi.fn(), respondApproval: vi.fn(), dismissQuestion: vi.fn(), @@ -659,3 +661,82 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { expect(deps.pushOperationFailure).not.toHaveBeenCalled(); }); }); + +describe('useWorkspaceState — createGoal from an empty composer', () => { + const registered = { id: 'wd_1', root: '/abs/path', name: 'A', isGitRepo: false, sessionCount: 0 }; + const newSession = { ...createSession(), id: 'sess_new', workspaceId: 'wd_1', cwd: '/abs/path' }; + + beforeEach(() => { + apiMock.addWorkspace.mockReset(); + apiMock.createSession.mockReset(); + apiMock.updateSession.mockReset(); + apiMock.submitPrompt.mockReset(); + apiMock.addWorkspace.mockResolvedValue(registered); + apiMock.createSession.mockResolvedValue(newSession); + apiMock.updateSession.mockResolvedValue({}); + apiMock.submitPrompt.mockResolvedValue({ promptId: 'pr_goal' }); + }); + + function emptyComposerState() { + const state = createState(); + state.activeSessionId = null; + state.activeWorkspaceId = 'wd_1'; + state.workspaces = [workspace('wd_1', '/abs/path', 'A')]; + state.permission = 'auto'; // skip the interactive goal-start confirmation + return state; + } + + it('creates a session, sets the goal profile, and submits the objective', async () => { + const state = emptyComposerState(); + const deps: UseWorkspaceStateDeps = { + ...createDeps(), + taskPoller: { loadTasksForSession: vi.fn() } as unknown as UseWorkspaceStateDeps['taskPoller'], + modelProvider: { + draftModel: ref(null), + skillsBySession: ref({}), + loadSkillsForSession: vi.fn(), + } as unknown as UseWorkspaceStateDeps['modelProvider'], + mergedWorkspaces: computed(() => [workspace('wd_1', '/abs/path', 'A')]), + }; + const ws = useWorkspaceState(state, deps); + + await ws.createGoal('improve test coverage'); + + expect(apiMock.createSession).toHaveBeenCalledOnce(); + // Profile is updated on the new session: that's what marks the prompt as a goal. + expect(apiMock.updateSession).toHaveBeenCalledWith('sess_new', { goalObjective: 'improve test coverage' }); + // And the objective is sent as the first user prompt on the new session. + expect(apiMock.submitPrompt).toHaveBeenCalledWith( + 'sess_new', + expect.objectContaining({ + content: [{ type: 'text', text: 'improve test coverage' }], + }), + ); + expect(deps.pushOperationFailure).not.toHaveBeenCalled(); + }); + + it('is a no-op when there is no active session and no active workspace', async () => { + const state = emptyComposerState(); + state.activeWorkspaceId = null; + const deps = createDeps(); + const ws = useWorkspaceState(state, deps); + + await ws.createGoal('improve test coverage'); + + expect(apiMock.createSession).not.toHaveBeenCalled(); + expect(apiMock.updateSession).not.toHaveBeenCalled(); + expect(apiMock.submitPrompt).not.toHaveBeenCalled(); + expect(deps.pushOperationFailure).not.toHaveBeenCalled(); + }); + + it('ignores empty/whitespace objectives', async () => { + const state = emptyComposerState(); + const deps = createDeps(); + const ws = useWorkspaceState(state, deps); + + await ws.createGoal(' '); + + expect(apiMock.createSession).not.toHaveBeenCalled(); + expect(apiMock.updateSession).not.toHaveBeenCalled(); + }); +}); From feccaf475ac99db1417f215cc5ff8654db323c2b Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 7 Jul 2026 13:15:43 +0800 Subject: [PATCH 07/11] fix(web): use active-workspace fallback for empty-composer /goal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a fresh-booted empty workspace, load() never writes rawState.activeWorkspaceId (no most-recent session to anchor it). The UI still has a usable workspace via the client-wide activeWorkspaceId computed, which falls back to the first sidebar-visible workspace — but createGoal read the raw value directly and silently no-op'd when it was null. Normal first prompts and skill activations didn't hit this because App.vue passes the computed activeWorkspaceId in. Make createGoal use the same fallback so a first-session `/goal ` works in empty workspaces too. --- .../composables/client/useWorkspaceState.ts | 13 +++++- apps/kimi-web/test/workspace-state.test.ts | 41 +++++++++++++++---- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 3829c66d23..7ddea2b223 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -1592,7 +1592,18 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // (same path as the first prompt / a new-session skill), then target it. let sid = rawState.activeSessionId; if (!sid) { - const wsId = rawState.activeWorkspaceId; + // Use the same fallback as the client-wide computed activeWorkspaceId + // (raw value if it exists, else the first sidebar-visible workspace). On a + // fresh empty workspace load() never writes rawState.activeWorkspaceId + // (there's no most-recent session to anchor it), so a raw read here would + // be null and silently no-op even though the UI can still show a usable + // workspace. Plain first-prompts and skill activations don't hit this + // because App.vue passes the computed activeWorkspaceId in. + const raw = rawState.activeWorkspaceId; + const wsId = + raw && workspacesView.value.some((w) => w.id === raw) + ? raw + : (workspacesView.value[0]?.id ?? null); if (!wsId) return; sid = (await createDraftSession(wsId)) ?? undefined; if (!sid) return; diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index cd72dc7465..988b7760db 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -686,9 +686,8 @@ describe('useWorkspaceState — createGoal from an empty composer', () => { return state; } - it('creates a session, sets the goal profile, and submits the objective', async () => { - const state = emptyComposerState(); - const deps: UseWorkspaceStateDeps = { + function goalDeps(): UseWorkspaceStateDeps { + return { ...createDeps(), taskPoller: { loadTasksForSession: vi.fn() } as unknown as UseWorkspaceStateDeps['taskPoller'], modelProvider: { @@ -696,8 +695,15 @@ describe('useWorkspaceState — createGoal from an empty composer', () => { skillsBySession: ref({}), loadSkillsForSession: vi.fn(), } as unknown as UseWorkspaceStateDeps['modelProvider'], + // Something the goal can land in + what's visible in the sidebar. mergedWorkspaces: computed(() => [workspace('wd_1', '/abs/path', 'A')]), - }; + workspacesView: computed(() => [workspace('wd_1', '/abs/path', 'A')]), + } as unknown as UseWorkspaceStateDeps; + } + + it('creates a session, sets the goal profile, and submits the objective', async () => { + const state = emptyComposerState(); // rawState.activeWorkspaceId = 'wd_1' + const deps = goalDeps(); const ws = useWorkspaceState(state, deps); await ws.createGoal('improve test coverage'); @@ -715,10 +721,30 @@ describe('useWorkspaceState — createGoal from an empty composer', () => { expect(deps.pushOperationFailure).not.toHaveBeenCalled(); }); - it('is a no-op when there is no active session and no active workspace', async () => { + it('falls back to the first visible workspace when raw activeWorkspaceId is unset', async () => { + // Regression for a real empty-workspace boot: load() never writes + // rawState.activeWorkspaceId when there are no sessions, so the raw read is + // null, but the sidebar still shows a usable workspace via the computed + // fallback. First-session goals must work there too. const state = emptyComposerState(); state.activeWorkspaceId = null; - const deps = createDeps(); + const ws = useWorkspaceState(state, goalDeps()); + + await ws.createGoal('improve test coverage'); + + expect(apiMock.createSession).toHaveBeenCalledOnce(); + expect(apiMock.updateSession).toHaveBeenCalledWith('sess_new', { goalObjective: 'improve test coverage' }); + expect(apiMock.submitPrompt).toHaveBeenCalledOnce(); + }); + + it('is a no-op when there is no active session and no usable workspace', async () => { + const state = emptyComposerState(); + state.activeWorkspaceId = null; + const deps: UseWorkspaceStateDeps = { + ...createDeps(), + mergedWorkspaces: computed(() => []), + workspacesView: computed(() => []), + }; const ws = useWorkspaceState(state, deps); await ws.createGoal('improve test coverage'); @@ -731,8 +757,7 @@ describe('useWorkspaceState — createGoal from an empty composer', () => { it('ignores empty/whitespace objectives', async () => { const state = emptyComposerState(); - const deps = createDeps(); - const ws = useWorkspaceState(state, deps); + const ws = useWorkspaceState(state, goalDeps()); await ws.createGoal(' '); From 3b90fefc628e6b72ee7f091a590fe06d2ec1fa32 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 7 Jul 2026 13:23:38 +0800 Subject: [PATCH 08/11] fix(web): start /btw from the new-session composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openSideChat() reads rawState.activeSessionId directly, so `/btw []` from the empty-session composer silently no-oped — it still set detailTarget to 'btw', leaving the side chat panel open but empty. Add startSessionAndOpenSideChat(workspaceId, prompt?) that creates the parent session first, then calls a new sideChat.openSideChatOn(sid, prompt) which targets the explicit parent session id (race-safe against a concurrent session switch, like the skill activation case). Route through it from the empty-composer branch in openSideChatTab. Side-chat prompts now also carry model / thinking / permissionMode / plan / swarm (via a shared sendSideChatPromptOn), so a BTW first turn matches the UI even when the parent /profile is still in flight. Unlike skill activation, this means the BTW path needs no profile fallback. Tests: startSessionAndOpenSideChat creates a session then opens BTW on the new id, works without an initial question, and is a no-op for an unknown workspace; sendSideChatPromptOn carries the runtime controls on the submitted prompt. --- .changeset/fix-web-btw-new-session.md | 5 ++ .../src/composables/client/useSideChat.ts | 63 +++++++++++--- .../composables/client/useWorkspaceState.ts | 23 +++++ .../src/composables/useDetailPanel.ts | 10 ++- .../src/composables/useKimiWebClient.ts | 1 + apps/kimi-web/test/side-chat.test.ts | 87 +++++++++++++++++++ apps/kimi-web/test/workspace-state.test.ts | 62 +++++++++++++ 7 files changed, 236 insertions(+), 15 deletions(-) create mode 100644 .changeset/fix-web-btw-new-session.md create mode 100644 apps/kimi-web/test/side-chat.test.ts diff --git a/.changeset/fix-web-btw-new-session.md b/.changeset/fix-web-btw-new-session.md new file mode 100644 index 0000000000..efe3b9d13d --- /dev/null +++ b/.changeset/fix-web-btw-new-session.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix `/btw []` opening an empty side chat on the new-session screen. diff --git a/apps/kimi-web/src/composables/client/useSideChat.ts b/apps/kimi-web/src/composables/client/useSideChat.ts index 1655666abf..57c1074264 100644 --- a/apps/kimi-web/src/composables/client/useSideChat.ts +++ b/apps/kimi-web/src/composables/client/useSideChat.ts @@ -146,7 +146,14 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) { async function openSideChat(initialPrompt?: string): Promise { const parent = rawState.activeSessionId; if (!parent) return; - // Reuse the existing side chat for this session if it already exists. + await openSideChatOn(parent, initialPrompt); + } + + /** Low-level: open the side chat on an explicit parent session id. + * Used when the parent was just created from the empty composer so the call + * can target it directly instead of reading the active session (which could + * race with a concurrent session switch). */ + async function openSideChatOn(parent: string, initialPrompt?: string): Promise { if (!sideChatTargetBySession.value[parent]) { let agentId: string; try { @@ -167,24 +174,19 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) { getEventConn()?.markSideChannelAgent(agentId); } if (initialPrompt && initialPrompt.trim()) { - await sendSideChatPrompt(initialPrompt.trim()); + await sendSideChatPromptOn(parent, initialPrompt.trim()); } } - function closeSideChat(): void { - const sid = rawState.activeSessionId; - if (!sid) return; - const { [sid]: _removed, ...rest } = sideChatTargetBySession.value; - void _removed; - sideChatTargetBySession.value = rest; - } - - /** Send a plain prompt to the side-chat child (no plan/swarm/goal modes). */ - async function sendSideChatPrompt(text: string): Promise { - const target = activeSideChatTarget.value; + /** Low-level: send a prompt to the side-chat child of an explicit parent session. + * Always uses `parent` as the session id, carrying model / thinking / + * permissionMode / plan / swarm so the turn matches the UI regardless of + * parent /profile inheritance or race. */ + async function sendSideChatPromptOn(parent: string, text: string): Promise { + const target = sideChatTargetBySession.value[parent]; const trimmed = text.trim(); if (!target || !trimmed) return; - const sid = target.parentId; + const sid = parent; const agentId = target.agentId; rawState.sideChatSendingByAgent = { ...rawState.sideChatSendingByAgent, [agentId]: true }; const userMsg: AppMessage = { @@ -197,9 +199,23 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) { }; appendSideChatMessage(agentId, userMsg); try { + // Carry the parent's current thinking level, model, and permission so a + // BTW first-turn reflects the same draft/runtime controls the UI shows — + // the parent session profile mirrors them, but the prompt itself is the + // only thing the daemon reads for this turn. + const promptSession = rawState.sessions.find((s) => s.id === sid); + const model = + (promptSession?.model && promptSession.model.length > 0 + ? promptSession.model + : rawState.defaultModel) ?? undefined; const result = await getKimiWebApi().submitPrompt(sid, { content: [{ type: 'text', text: trimmed }], agentId, + model, + thinking: rawState.thinking, + permissionMode: rawState.permission, + planMode: rawState.planModeBySession[sid] ?? false, + swarmMode: rawState.swarmModeBySession[sid] ?? false, }); stampLastSideChatUserPrompt(agentId, result.promptId); rawState.sideChatUserMessageIdsBySession = { @@ -213,6 +229,24 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) { } } + function closeSideChat(): void { + const sid = rawState.activeSessionId; + if (!sid) return; + const { [sid]: _removed, ...rest } = sideChatTargetBySession.value; + void _removed; + sideChatTargetBySession.value = rest; + } + + /** Send a plain prompt to the active session's side chat, carrying the + * controls (model, thinking, permissionMode, plan/swarm) the UI shows so a + * BTW first turn matches them even if the parent's /profile is still in + * flight. */ + async function sendSideChatPrompt(text: string): Promise { + const target = activeSideChatTarget.value; + if (!target) return; + await sendSideChatPromptOn(target.parentId, text); + } + // When a session is deleted, drop its side-chat target so it cannot leak into a // later session that happens to reuse the same id. function clearSideChatForSession(sessionId: string): void { @@ -232,6 +266,7 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) { appendSideChatAssistantText, finishSideChatAgent, openSideChat, + openSideChatOn, closeSideChat, sendSideChatPrompt, clearSideChatForSession, diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 7ddea2b223..f1024a6f34 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -865,6 +865,28 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta } } + /** + * Create a session and open a BTW side chat under it — the empty-composer + * counterpart to startSessionAndSendPrompt. Without this, `/btw ` + * from the new-session screen silently no-ops (the panel still opens, but + * empty), because openSideChat reads the active session id directly. The side + * chat prompt itself carries model / thinking / permissionMode / plan / swarm + * (see sendSideChatPromptOn), so unlike skill activation we don't need to + * persist them onto the parent profile here. + */ + async function startSessionAndOpenSideChat( + workspaceId: string, + prompt?: string, + ): Promise { + try { + const sid = await createDraftSession(workspaceId); + if (!sid) return; + await sideChat.openSideChatOn(sid, prompt); + } catch (err) { + pushOperationFailure('startSessionAndOpenSideChat', err); + } + } + /** * Add a workspace by folder path, registering it with the daemon. Returns true * when the workspace was registered and selected; false when the daemon @@ -2074,6 +2096,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta openWorkspaceDraft, startSessionAndSendPrompt, startSessionAndActivateSkill, + startSessionAndOpenSideChat, addWorkspaceByPath, browseFs, getFsHome, diff --git a/apps/kimi-web/src/composables/useDetailPanel.ts b/apps/kimi-web/src/composables/useDetailPanel.ts index c583b33362..5a8dc93b37 100644 --- a/apps/kimi-web/src/composables/useDetailPanel.ts +++ b/apps/kimi-web/src/composables/useDetailPanel.ts @@ -244,7 +244,15 @@ export function useDetailPanel({ // Side chat (BTW) — now rendered in the unified right-side detail layer. // --------------------------------------------------------------------------- async function openSideChatTab(prompt?: string): Promise { - await client.openSideChat(prompt); + // Empty-composer heal: `/btw []` from the new-session screen needs + // a parent session before openSideChat can start a BTW sub-agent. Create one + // in the active workspace (same path as the first prompt / a new-session + // skill / goal), then open the side chat on it. + if (!client.activeSessionId.value && client.activeWorkspaceId.value) { + await client.startSessionAndOpenSideChat(client.activeWorkspaceId.value, prompt); + } else { + await client.openSideChat(prompt); + } detailTarget.value = 'btw'; } diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index d29b3ef6f6..7cf1e3ad95 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -2479,6 +2479,7 @@ export function useKimiWebClient() { openWorkspaceDraft: workspaceState.openWorkspaceDraft, startSessionAndSendPrompt: workspaceState.startSessionAndSendPrompt, startSessionAndActivateSkill: workspaceState.startSessionAndActivateSkill, + startSessionAndOpenSideChat: workspaceState.startSessionAndOpenSideChat, addWorkspaceByPath: workspaceState.addWorkspaceByPath, browseFs: workspaceState.browseFs, getFsHome: workspaceState.getFsHome, diff --git a/apps/kimi-web/test/side-chat.test.ts b/apps/kimi-web/test/side-chat.test.ts new file mode 100644 index 0000000000..41b7d01e2e --- /dev/null +++ b/apps/kimi-web/test/side-chat.test.ts @@ -0,0 +1,87 @@ +// apps/kimi-web/test/side-chat.test.ts +import { describe, expect, it, vi } from 'vitest'; +import { createInitialState } from '../src/api/daemon/eventReducer'; +import { useSideChat } from '../src/composables/client/useSideChat'; +import type { ExtendedState } from '../src/composables/useKimiWebClient'; + +const apiMock = vi.hoisted(() => ({ + startBtw: vi.fn(), + submitPrompt: vi.fn(), +})); + +vi.mock('../src/api', () => ({ + getKimiWebApi: () => apiMock, +})); + +function createState(): ExtendedState { + return { + ...createInitialState(), + sessions: [ + { + id: 'sess_1', + title: 'Session', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + status: 'idle' as const, + archived: false, + currentPromptId: null, + cwd: '/workspace', + model: 'kimi-code', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0, + contextTokens: 0, + contextLimit: 0, + turnCount: 0, + }, + messageCount: 0, + lastSeq: 0, + }, + ], + activeSessionId: 'sess_1', + permission: 'auto', + thinking: 'high', + planModeBySession: { sess_1: true }, + swarmModeBySession: {}, + sideChatMessagesByAgent: {}, + sideChatSendingByAgent: {}, + sideChatUserMessageIdsBySession: {}, + } as unknown as ExtendedState; +} + +describe('useSideChat — sendSideChatPromptOn', () => { + it('carries model, thinking, permission and plan/swarm modes on the prompt', async () => { + apiMock.startBtw.mockReset(); + apiMock.submitPrompt.mockReset(); + apiMock.startBtw.mockResolvedValue({ agentId: 'agent_btw_1' }); + apiMock.submitPrompt.mockResolvedValue({ promptId: 'pr_btw', userMessageId: 'msg_opt_btw' }); + + const state = createState(); + const pushOperationFailure = vi.fn(); + const sideChat = useSideChat(state, { + pushOperationFailure, + nextOptimisticMsgId: () => 'msg_opt_btw', + connectEventsIfNeeded: vi.fn(), + getEventConn: () => null, + }); + + await sideChat.openSideChatOn('sess_1', 'what changed?'); + + expect(apiMock.startBtw).toHaveBeenCalledWith('sess_1'); + expect(apiMock.submitPrompt).toHaveBeenCalledWith( + 'sess_1', + expect.objectContaining({ + agentId: 'agent_btw_1', + model: 'kimi-code', + thinking: 'high', + permissionMode: 'auto', + planMode: true, + swarmMode: false, + }), + ); + expect(pushOperationFailure).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 988b7760db..f0a295ff7c 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -765,3 +765,65 @@ describe('useWorkspaceState — createGoal from an empty composer', () => { expect(apiMock.updateSession).not.toHaveBeenCalled(); }); }); + +describe('useWorkspaceState — startSessionAndOpenSideChat', () => { + const registered = { id: 'wd_1', root: '/abs/path', name: 'A', isGitRepo: false, sessionCount: 0 }; + const newSession = { ...createSession(), id: 'sess_new', workspaceId: 'wd_1', cwd: '/abs/path' }; + + beforeEach(() => { + apiMock.addWorkspace.mockReset(); + apiMock.createSession.mockReset(); + apiMock.addWorkspace.mockResolvedValue(registered); + apiMock.createSession.mockResolvedValue(newSession); + }); + + function sideChatDeps(openSideChatOn: ReturnType): UseWorkspaceStateDeps { + return { + ...createDeps(), + taskPoller: { loadTasksForSession: vi.fn() } as unknown as UseWorkspaceStateDeps['taskPoller'], + sideChat: { openSideChatOn } as unknown as UseWorkspaceStateDeps['sideChat'], + modelProvider: { + draftModel: ref(null), + skillsBySession: ref({}), + loadSkillsForSession: vi.fn(), + } as unknown as UseWorkspaceStateDeps['modelProvider'], + mergedWorkspaces: computed(() => [workspace('wd_1', '/abs/path', 'A')]), + }; + } + + it('creates a session, then opens BTW on the new session id with the question', async () => { + const openSideChatOn = vi.fn().mockResolvedValue(undefined); + const deps = sideChatDeps(openSideChatOn); + const ws = useWorkspaceState(createState(), deps); + + await ws.startSessionAndOpenSideChat('wd_1', 'what changed?'); + + expect(apiMock.createSession).toHaveBeenCalledOnce(); + // The BTW sub-agent is opened on the freshly created session, so a + // concurrent session switch can't redirect it. + expect(openSideChatOn).toHaveBeenCalledWith('sess_new', 'what changed?'); + expect(deps.pushOperationFailure).not.toHaveBeenCalled(); + }); + + it('works without an initial question (bare /btw)', async () => { + const openSideChatOn = vi.fn().mockResolvedValue(undefined); + const deps = sideChatDeps(openSideChatOn); + const ws = useWorkspaceState(createState(), deps); + + await ws.startSessionAndOpenSideChat('wd_1'); + + expect(openSideChatOn).toHaveBeenCalledWith('sess_new', undefined); + }); + + it('is a no-op for an unknown workspace', async () => { + const openSideChatOn = vi.fn().mockResolvedValue(undefined); + const deps = sideChatDeps(openSideChatOn); + const ws = useWorkspaceState(createState(), deps); + + await ws.startSessionAndOpenSideChat('wd_missing', 'what changed?'); + + expect(apiMock.createSession).not.toHaveBeenCalled(); + expect(openSideChatOn).not.toHaveBeenCalled(); + expect(deps.pushOperationFailure).not.toHaveBeenCalled(); + }); +}); From 5ac7fe1aadbd52e510b402ff710315931c088319 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 7 Jul 2026 13:35:51 +0800 Subject: [PATCH 09/11] fix(web): preserve send queue when creating goals Switching createGoal from sendPrompt to submitPromptInternal avoided the activeSessionId race during the empty-composer create window, but it also bypassed sendPrompt's queue guard: when a goal is created against an already-active session that is running another turn, the prompt posted immediately instead of being locally queued. Restore the guard for the overwhelmingly common case (the goal still targets the active session): route through sendPrompt when activeSessionId still matches the resolved sid, which enqueues when the session is running or a prompt is already in flight. Only fall back to the explicit- session submitPromptInternal(sid) when activeSessionId moved during the create window, so a concurrent session switch can't redirect the goal prompt. The newly-created session is idle+not-in-flight in that branch, so the explicit submit does not race another turn. Add a regression test: createGoal against a running existing session enqueues instead of submits. --- .../composables/client/useWorkspaceState.ts | 18 ++++++++++----- apps/kimi-web/test/workspace-state.test.ts | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index f1024a6f34..5a6b60baeb 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -1636,12 +1636,18 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta pushOperationFailure('createGoal', err, { sessionId: sid, message: goalErrorMessage(err) }); return; } - // Send by explicit sid (not sendPrompt, which reads activeSessionId): a - // concurrent session switch during createDraftSession would otherwise - // redirect the goal prompt to the wrong session. Plain prompts carry their - // own permissionMode / thinking / plan / swarm on the request, so we don't - // need to persist them onto the profile here. - await submitPromptInternal(sid, trimmed); + // Preserve normal send queueing semantics whenever the goal still targets the + // active session (the overwhelmingly common case): sendPrompt enqueues when + // another turn is running or a prompt is already in flight. Only fall back to + // the explicit-session send when activeSessionId moved during the create + // window above, so a concurrent session switch can't redirect the goal prompt. + // (The new session is otherwise idle+not-in-flight, so this does not race + // another turn.) + if (rawState.activeSessionId === sid) { + await sendPrompt(trimmed); + } else { + await submitPromptInternal(sid, trimmed); + } } /** Send a one-shot goal control action (pause/resume/cancel). */ diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index f0a295ff7c..34558f416c 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -737,6 +737,29 @@ describe('useWorkspaceState — createGoal from an empty composer', () => { expect(apiMock.submitPrompt).toHaveBeenCalledOnce(); }); + it('queues the objective when the active session is running (no queue bypass)', async () => { + // Regression: creating a goal against an already-active session must honor + // sendPrompt's queue guard, not bypass straight to submitPromptInternal. + // Otherwise a /goal message sent while another turn is running races with + // the active turn instead of being locally queued like normal sends. + const state = createState(); + state.activeSessionId = 'sess_1'; + state.permission = 'auto'; // skip the interactive goal-start confirmation + const ws = useWorkspaceState(state, createDeps()); + + await ws.createGoal('improve test coverage'); + + // Didn't create a session: we targeted the existing one. + expect(apiMock.createSession).not.toHaveBeenCalled(); + expect(apiMock.updateSession).toHaveBeenCalledWith('sess_1', { goalObjective: 'improve test coverage' }); + // And because the session is running (createDeps' default activity is + // 'running'), sendPrompt queues rather than posting immediately. + expect(apiMock.submitPrompt).not.toHaveBeenCalled(); + expect(state.queuedBySession['sess_1']).toEqual([ + { text: 'improve test coverage', attachments: undefined }, + ]); + }); + it('is a no-op when there is no active session and no usable workspace', async () => { const state = emptyComposerState(); state.activeWorkspaceId = null; From bca67b671fba71927a0c37c12fdf7d03c66fc456 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 7 Jul 2026 13:54:02 +0800 Subject: [PATCH 10/11] fix(web): coerce thinking for skill/BTW and handle goal creation failures Three follow-ups from review: - createGoal now wraps createDraftSession in a try/catch: App.vue invokes it fire-and-forget, so a rejection from session creation previously leaked as an unhandled rejection with no operation failure surfaced. Mirrors the skill / BTW / first-prompt paths that already wrap it. - startSessionAndActivateSkill coerces the draft thinking level against the new session's model before persisting the profile (via coercePromptThinking), matching what the first-prompt path submits. A value carried over from another/default model (e.g. 'max' from an effort model) would otherwise be persisted verbatim and the first skill turn would run at a level the UI wouldn't send for this model. - sendSideChatPromptOn coerces thinking against the parent session's model the same way normal prompts do (coerceThinkingForModel against the model catalog). Model catalog threaded in via a new 'models' dep on useSideChat. Same reasoning: stale rawState.thinking must not be submitted raw into a BTW first turn. --- .../src/composables/client/useSideChat.ts | 19 +++++- .../composables/client/useWorkspaceState.ts | 23 ++++++- .../src/composables/useKimiWebClient.ts | 1 + apps/kimi-web/test/side-chat.test.ts | 40 ++++++++++++ apps/kimi-web/test/workspace-state.test.ts | 62 +++++++++++++++++++ 5 files changed, 141 insertions(+), 4 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useSideChat.ts b/apps/kimi-web/src/composables/client/useSideChat.ts index 57c1074264..f5f2329def 100644 --- a/apps/kimi-web/src/composables/client/useSideChat.ts +++ b/apps/kimi-web/src/composables/client/useSideChat.ts @@ -9,10 +9,11 @@ import { computed, ref } from 'vue'; import { getKimiWebApi } from '../../api'; -import type { AppMessage } from '../../api/types'; +import type { AppMessage, AppModel } from '../../api/types'; import type { KimiEventConnection } from '../../api/types'; import { messagesToTurns } from '../messagesToTurns'; import type { ChatTurn } from '../../types'; +import { coerceThinkingForModel } from '../../lib/modelThinking'; import type { ExtendedState } from '../useKimiWebClient'; export interface UseSideChatDeps { @@ -24,6 +25,10 @@ export interface UseSideChatDeps { nextOptimisticMsgId: () => string; connectEventsIfNeeded: () => void; getEventConn: () => KimiEventConnection | null; + /** Provider model catalog — used to coerce thinking against the parent + * session's model the same way normal prompts do (so a value carried over + * from another model isn't submitted raw). */ + models: () => AppModel[]; } export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) { @@ -208,11 +213,21 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) { (promptSession?.model && promptSession.model.length > 0 ? promptSession.model : rawState.defaultModel) ?? undefined; + // Coerce thinking against the parent model the same way a normal prompt + // does (coercePromptThinking in useWorkspaceState): a level carried over + // from another/default model would otherwise be submitted raw and run + // differently from what the UI shows. + const promptModel = + model === undefined + ? undefined + : deps.models().find( + (m) => m.model === model || m.id === model || m.displayName === model, + ); const result = await getKimiWebApi().submitPrompt(sid, { content: [{ type: 'text', text: trimmed }], agentId, model, - thinking: rawState.thinking, + thinking: coerceThinkingForModel(promptModel, rawState.thinking), permissionMode: rawState.permission, planMode: rawState.planModeBySession[sid] ?? false, swarmMode: rawState.swarmModeBySession[sid] ?? false, diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 5a6b60baeb..c2ea1e40e0 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -850,12 +850,22 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // there is nothing to persist for it. const planMode = rawState.planModeBySession[sid] ?? false; const swarmMode = rawState.swarmModeBySession[sid] ?? false; + // Coerce thinking against the new session's model the same way the + // first-prompt path does (coercePromptThinking below): a value carried + // over from another/default model (e.g. 'max' from an effort model) would + // otherwise be persisted verbatim, and the first skill turn would run at + // a level the UI wouldn't send for this model. + const promptSession = rawState.sessions.find((s) => s.id === sid); + const model = + (promptSession?.model && promptSession.model.length > 0 + ? promptSession.model + : rawState.defaultModel) ?? undefined; await persistSessionProfile( { planMode, swarmMode, permissionMode: rawState.permission, - thinking: rawState.thinking, + thinking: coercePromptThinking(model), }, sid, ); @@ -1627,7 +1637,16 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta ? raw : (workspacesView.value[0]?.id ?? null); if (!wsId) return; - sid = (await createDraftSession(wsId)) ?? undefined; + // App.vue invokes createGoal fire-and-forget, so a rejection here would + // otherwise surface as an unhandled rejection instead of an operation + // failure. Mirror the other draft-session paths (skill / BTW / first + // prompt) which wrap createDraftSession. + try { + sid = (await createDraftSession(wsId)) ?? undefined; + } catch (err) { + pushOperationFailure('createGoal', err); + return; + } if (!sid) return; } try { diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 7cf1e3ad95..95d6200461 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -1641,6 +1641,7 @@ const sideChat = useSideChat(rawState, { nextOptimisticMsgId, connectEventsIfNeeded, getEventConn: () => eventConn, + models: () => modelProvider.models.value, }); const activeAppTasks = computed(() => { diff --git a/apps/kimi-web/test/side-chat.test.ts b/apps/kimi-web/test/side-chat.test.ts index 41b7d01e2e..e3dd4bde4b 100644 --- a/apps/kimi-web/test/side-chat.test.ts +++ b/apps/kimi-web/test/side-chat.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { createInitialState } from '../src/api/daemon/eventReducer'; import { useSideChat } from '../src/composables/client/useSideChat'; +import type { AppModel } from '../src/api/types'; import type { ExtendedState } from '../src/composables/useKimiWebClient'; const apiMock = vi.hoisted(() => ({ @@ -66,6 +67,7 @@ describe('useSideChat — sendSideChatPromptOn', () => { nextOptimisticMsgId: () => 'msg_opt_btw', connectEventsIfNeeded: vi.fn(), getEventConn: () => null, + models: () => [], }); await sideChat.openSideChatOn('sess_1', 'what changed?'); @@ -84,4 +86,42 @@ describe('useSideChat — sendSideChatPromptOn', () => { ); expect(pushOperationFailure).not.toHaveBeenCalled(); }); + + it('coerces a stale thinking level against the parent model', async () => { + // Regression for: switching the parent session from an effort model to one + // that doesn't support thinking leaves rawState.thinking at a stale effort + // (e.g. 'max'). Normal prompts coerce this; BTW prompts must too, otherwise + // the first BTW turn runs at a level the UI wouldn't send. + apiMock.startBtw.mockReset(); + apiMock.submitPrompt.mockReset(); + apiMock.startBtw.mockResolvedValue({ agentId: 'agent_btw_1' }); + apiMock.submitPrompt.mockResolvedValue({ promptId: 'pr_btw', userMessageId: 'msg_opt_btw' }); + + const state = createState(); + state.thinking = 'max'; + // 'kimi-code' here doesn't declare thinking → 'unsupported' → coerced to 'off'. + const models: AppModel[] = [ + { + id: 'kimi-code', + model: 'kimi-code', + provider: 'kimi', + displayName: 'kimi-code', + capabilities: [], + } as unknown as AppModel, + ]; + const sideChat = useSideChat(state, { + pushOperationFailure: vi.fn(), + nextOptimisticMsgId: () => 'msg_opt_btw', + connectEventsIfNeeded: vi.fn(), + getEventConn: () => null, + models: () => models, + }); + + await sideChat.openSideChatOn('sess_1', 'what changed?'); + + expect(apiMock.submitPrompt).toHaveBeenCalledWith( + 'sess_1', + expect.objectContaining({ thinking: 'off' }), + ); + }); }); diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 34558f416c..fb76935425 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -649,6 +649,51 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { expect(activateSkill).toHaveBeenCalledWith('pre-changelog', undefined, 'sess_new'); }); + it('coerces a stale thinking level against the new session model before persisting', async () => { + // Regression for: rawState.thinking can be stale relative to the new + // session's model (e.g. 'max' carried over from an effort model). Persisting + // the raw value would make the first skill turn run at a level the UI + // wouldn't send for this model; we must coerce it like the first-prompt + // path does. + const activateSkill2 = vi.fn().mockResolvedValue(undefined); + const persistSessionProfile2 = vi.fn().mockResolvedValue(undefined); + const state2 = createState(); + state2.thinking = 'max'; + const deps2: UseWorkspaceStateDeps = { + ...skillDeps(activateSkill2), + persistSessionProfile: persistSessionProfile2, + // upsertSessionFront must actually land the new session in rawState.sessions + // so startSessionAndActivateSkill can read its model. + upsertSessionFront: vi.fn((s) => { + state2.sessions = [s, ...state2.sessions.filter((x) => x.id !== s.id)]; + }), + draftModes: { planMode: true, swarmMode: false, goalMode: false }, + }; + // 'kimi-code' declares efforts ['low','medium','high']; 'max' isn't in the + // list so coercion picks the default (middle) level → 'medium'. + (deps2.modelProvider as unknown as { models: unknown }).models = ref([ + { + id: 'kimi-code', + model: 'kimi-code', + provider: 'kimi', + displayName: 'kimi-code', + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high'], + }, + ]); + const ws2 = useWorkspaceState(state2, deps2); + + await ws2.startSessionAndActivateSkill('wd_1', 'pre-changelog'); + + // Effort model default level = middle of supportEfforts: 'medium'. + // Confirms the raw carry-over 'max' was coerced, not persisted verbatim. + expect(persistSessionProfile2).toHaveBeenCalledWith( + expect.objectContaining({ thinking: 'medium' }), + 'sess_new', + ); + expect(activateSkill2).toHaveBeenCalledWith('pre-changelog', undefined, 'sess_new'); + }); + it('is a no-op for an unknown workspace', async () => { const activateSkill = vi.fn().mockResolvedValue(undefined); const deps = skillDeps(activateSkill); @@ -787,6 +832,23 @@ describe('useWorkspaceState — createGoal from an empty composer', () => { expect(apiMock.createSession).not.toHaveBeenCalled(); expect(apiMock.updateSession).not.toHaveBeenCalled(); }); + + it('surfaces session-creation failures instead of leaking an unhandled rejection', async () => { + // App.vue invokes createGoal fire-and-forget, so a rejection from + // createDraftSession must be caught and reported via pushOperationFailure — + // mirroring the other draft-session paths (skill / BTW / first prompt). + const state = emptyComposerState(); + const deps = goalDeps(); + const ws = useWorkspaceState(state, deps); + const err = new Error('snapshot failed'); + apiMock.createSession.mockRejectedValue(err); + + await expect(ws.createGoal('improve test coverage')).resolves.toBeUndefined(); + + expect(deps.pushOperationFailure).toHaveBeenCalledWith('createGoal', err); + expect(apiMock.updateSession).not.toHaveBeenCalled(); + expect(apiMock.submitPrompt).not.toHaveBeenCalled(); + }); }); describe('useWorkspaceState — startSessionAndOpenSideChat', () => { From 4edba6f8810a84a8ff8e4f300ab44ff68c3792a4 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 7 Jul 2026 14:05:30 +0800 Subject: [PATCH 11/11] fix(web): clear staged goal mode when submitting explicit /goal When the empty composer has goal mode staged (e.g. the user runs bare `/goal`, then `/goal `) and an explicit objective is then submitted, createDraftSession copies draftModes.goalMode into goalModeBySession[sid]. createGoal then updateSession(goalObjective) for the explicit goal, and sendPrompt(trimmed) re-enters submitPromptInternal which sees goalModeBySession[sid] still set and POSTs a second goalObjective. The daemon rejects that as an existing goal, the catch in submitPromptInternal rolls back the optimistic message, and the user's objective prompt never lands. Clear the staged goalModeBySession[sid] flag right after the explicit update, since `/goal ` has exactly the same effect as the flag's consumption. --- .../composables/client/useWorkspaceState.ts | 12 ++++++++ apps/kimi-web/test/workspace-state.test.ts | 30 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index c2ea1e40e0..9fe107d34c 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -1655,6 +1655,18 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta pushOperationFailure('createGoal', err, { sessionId: sid, message: goalErrorMessage(err) }); return; } + // The goal objective is set explicitly above. If goal mode was staged on the + // draft (e.g. the user ran bare `/goal`, then `/goal `), + // createDraftSession copied it into this session's goalModeBySession map. + // Leaving it on would make submitPromptInternal (via sendPrompt) re-POST + // another goalObjective — which the daemon rejects because a goal already + // exists — and the user's objective prompt would never be submitted. + // Clear the one-shot flag here: an explicit `/goal ` has exactly + // the same effect as the goal-mode flag's consumption. + if (rawState.goalModeBySession[sid]) { + rawState.goalModeBySession = { ...rawState.goalModeBySession, [sid]: false }; + saveGoalModeToStorage(); + } // Preserve normal send queueing semantics whenever the goal still targets the // active session (the overwhelmingly common case): sendPrompt enqueues when // another turn is running or a prompt is already in flight. Only fall back to diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index fb76935425..045b6c024f 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -833,6 +833,36 @@ describe('useWorkspaceState — createGoal from an empty composer', () => { expect(apiMock.updateSession).not.toHaveBeenCalled(); }); + it('clears staged goal mode so the objective prompt is submitted once', async () => { + // Regression for: empty composer with bare `/goal` staged (draftModes.goalMode), + // then `/goal `. createDraftSession copies draftModes.goalMode into + // goalModeBySession[sid]. If we don't clear it after the explicit + // updateSession(goalObjective), submitPromptInternal re-POSTs a goalObjective, + // the daemon rejects it (existing goal), and the objective prompt never sends. + const state = emptyComposerState(); + const deps: UseWorkspaceStateDeps = { + ...goalDeps(), + draftModes: { planMode: false, swarmMode: false, goalMode: true }, + }; + const ws = useWorkspaceState(state, deps); + + await ws.createGoal('improve test coverage'); + + // The explicit goal objective went through... + expect(apiMock.updateSession).toHaveBeenCalledWith('sess_new', { goalObjective: 'improve test coverage' }); + // ...and the objective prompt itself was submitted exactly once as a user prompt. + expect(apiMock.submitPrompt).toHaveBeenCalledTimes(1); + expect(apiMock.submitPrompt).toHaveBeenCalledWith( + 'sess_new', + expect.objectContaining({ + content: [{ type: 'text', text: 'improve test coverage' }], + }), + ); + // goal mode flag was consumed by the explicit goal. + expect(state.goalModeBySession['sess_new']).toBe(false); + expect(deps.pushOperationFailure).not.toHaveBeenCalled(); + }); + it('surfaces session-creation failures instead of leaking an unhandled rejection', async () => { // App.vue invokes createGoal fire-and-forget, so a rejection from // createDraftSession must be caught and reported via pushOperationFailure —