diff --git a/.changeset/fix-web-btw-new-session.md b/.changeset/fix-web-btw-new-session.md new file mode 100644 index 000000000..efe3b9d13 --- /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/.changeset/fix-web-goal-new-session.md b/.changeset/fix-web-goal-new-session.md new file mode 100644 index 000000000..a180311a9 --- /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/.changeset/fix-web-skill-activation-new-session.md b/.changeset/fix-web-skill-activation-new-session.md new file mode 100644 index 000000000..7a07e154c --- /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 2e6133f3f..6c82df986 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 c8409f8af..6eb852e1f 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; @@ -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)}`; @@ -387,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/useSideChat.ts b/apps/kimi-web/src/composables/client/useSideChat.ts index 1655666ab..f5f2329de 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) { @@ -146,7 +151,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 +179,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 +204,33 @@ 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; + // 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: coerceThinkingForModel(promptModel, rawState.thinking), + permissionMode: rawState.permission, + planMode: rawState.planModeBySession[sid] ?? false, + swarmMode: rawState.swarmModeBySession[sid] ?? false, }); stampLastSideChatUserPrompt(agentId, result.promptId); rawState.sideChatUserMessageIdsBySession = { @@ -213,6 +244,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 +281,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 1cff66133..9fe107d34 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) => Promise; mergedWorkspaces: ComputedRef; /** Sidebar-facing workspaces in the user's (dragged) display order. */ workspacesView: ComputedRef; @@ -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,88 @@ 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; then persists any draft + * plan/swarm modes here, because skill activation carries only `args`. + */ + async function startSessionAndActivateSkill( + workspaceId: string, + skillName: string, + args?: string, + ): Promise { + try { + const sid = await createDraftSession(workspaceId); + if (!sid) return; + // Unlike a plain prompt, skill activation only carries `args`, so the + // 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; + // 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: coercePromptThinking(model), + }, + sid, + ); + await modelProvider.activateSkill(skillName, args, sid); + } catch (err) { + pushOperationFailure('startSessionAndActivateSkill', err); + } + } + + /** + * 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 @@ -1461,7 +1548,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; } @@ -1481,7 +1568,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; } @@ -1532,15 +1619,66 @@ 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) { + // 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; + // 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 { await getKimiWebApi().updateSession(sid, { goalObjective: trimmed }); } catch (err) { pushOperationFailure('createGoal', err, { sessionId: sid, message: goalErrorMessage(err) }); return; } - await sendPrompt(trimmed); + // 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 + // 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). */ @@ -1559,7 +1697,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 */ @@ -1994,6 +2132,8 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta clearActiveSession, 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 c583b3336..5a8dc93b3 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 f65fd0647..95d620046 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -601,8 +601,15 @@ 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. `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; @@ -611,11 +618,11 @@ function persistSessionProfile(patch: { goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string; -}): void { - const sid = rawState.activeSessionId; - if (!sid) return; +}, sessionId?: string): Promise { + const sid = sessionId ?? rawState.activeSessionId; + 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 */ @@ -1634,6 +1641,7 @@ const sideChat = useSideChat(rawState, { nextOptimisticMsgId, connectEventsIfNeeded, getEventConn: () => eventConn, + models: () => modelProvider.models.value, }); const activeAppTasks = computed(() => { @@ -2471,6 +2479,8 @@ export function useKimiWebClient() { openWorkspace: workspaceState.openWorkspace, 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 000000000..e3dd4bde4 --- /dev/null +++ b/apps/kimi-web/test/side-chat.test.ts @@ -0,0 +1,127 @@ +// 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 { AppModel } from '../src/api/types'; +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, + models: () => [], + }); + + 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(); + }); + + 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 ee1abfe63..045b6c024 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -13,6 +13,9 @@ const apiMock = vi.hoisted(() => ({ abortSession: vi.fn(), addWorkspace: vi.fn(), updateWorkspace: vi.fn(), + createSession: vi.fn(), + updateSession: vi.fn(), + submitPrompt: vi.fn(), respondQuestion: vi.fn(), respondApproval: vi.fn(), dismissQuestion: vi.fn(), @@ -557,3 +560,385 @@ 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('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 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; + }); + const activateSkill = vi.fn().mockResolvedValue(undefined); + const persistSessionProfile = vi.fn().mockReturnValue(profileGate); + const deps = { + ...skillDeps(activateSkill), + persistSessionProfile, + draftModes: { planMode: true, swarmMode: true, goalMode: false }, + }; + 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 + // 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, permissionMode: 'auto', thinking: 'high' }, + 'sess_new', + ); + expect(activateSkill).not.toHaveBeenCalled(); + + resolveProfile(); + await pending; + + 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); + 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(); + }); +}); + +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; + } + + function goalDeps(): UseWorkspaceStateDeps { + return { + ...createDeps(), + taskPoller: { loadTasksForSession: vi.fn() } as unknown as UseWorkspaceStateDeps['taskPoller'], + modelProvider: { + draftModel: ref(null), + 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'); + + 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('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 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('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; + const deps: UseWorkspaceStateDeps = { + ...createDeps(), + mergedWorkspaces: computed(() => []), + workspacesView: computed(() => []), + }; + 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 ws = useWorkspaceState(state, goalDeps()); + + await ws.createGoal(' '); + + expect(apiMock.createSession).not.toHaveBeenCalled(); + 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 — + // 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', () => { + 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(); + }); +});