fix(web): run new-session slash actions (skills, /goal, /btw) from the empty composer (#1445)

* 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): 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.

* 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.

* 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.

* 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.

* fix(web): start /goal from the new-session composer

createGoal() short-circuited when there was no active session, so
`/goal <objective>` 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.

* fix(web): use active-workspace fallback for empty-composer /goal

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 <objective>` works in empty
workspaces too.

* fix(web): start /btw from the new-session composer

openSideChat() reads rawState.activeSessionId directly, so `/btw
[<question>]` 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.

* 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.

* 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.

* 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 <objective>`) 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 <objective>` has exactly the same effect as the
flag's consumption.
This commit is contained in:
qer 2026-07-07 14:18:40 +08:00 committed by GitHub
parent 65d30177ad
commit 809a88cb34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 839 additions and 93 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Fix `/btw [<question>]` opening an empty side chat on the new-session screen.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Fix `/goal <objective>` silently doing nothing on the new-session screen.

View file

@ -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.

View file

@ -488,11 +488,18 @@ function handleCommand(cmd: string): void {
// Not a built-in command treat it as a session skill activation
// (the user picked `/<skill>` from the menu, or typed `/<skill> 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;
}
}

View file

@ -54,7 +54,7 @@ export interface UseModelProviderStateDeps {
opts?: { title?: string; message?: string; sessionId?: string },
) => void;
refreshSessionStatus: (sessionId: string) => Promise<void>;
persistSessionProfile: (patch: PersistSessionProfilePatch) => void;
persistSessionProfile: (patch: PersistSessionProfilePatch, sessionId?: string) => Promise<void>;
activity: ComputedRef<ActivityState>;
inFlightPromptSessions: Set<string>;
saveThinkingToStorage: (v: ThinkingLevel) => void;
@ -241,9 +241,13 @@ export function useModelProviderState(
* Activate a session skill (the web analogue of typing `/<skill> <args>` 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<void> {
const sid = rawState.activeSessionId;
async function activateSkill(skillName: string, args?: string, sessionId?: string): Promise<void> {
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 {

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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,

View file

@ -126,7 +126,7 @@ export interface UseWorkspaceStateDeps {
reopenSession: (sessionId: string) => Promise<SyncSessionResult>;
hasLoadedMessages: (sessionId: string) => boolean;
refreshSessionStatus: (sessionId: string) => Promise<void>;
persistSessionProfile: (patch: PersistSessionProfilePatch) => void;
persistSessionProfile: (patch: PersistSessionProfilePatch, sessionId?: string) => Promise<void>;
mergedWorkspaces: ComputedRef<AppWorkspace[]>;
/** Sidebar-facing workspaces in the user's (dragged) display order. */
workspacesView: ComputedRef<WorkspaceView[]>;
@ -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<string | null> {
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<void> {
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, `/<skill>` 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<void> {
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 <question>`
* 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<void> {
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 <objective>` 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 <objective>`),
// 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 <objective>` 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,

View file

@ -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<void> {
await client.openSideChat(prompt);
// Empty-composer heal: `/btw [<question>]` 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';
}

View file

@ -601,8 +601,15 @@ async function refreshSessionStatus(sessionId: string): Promise<void> {
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<void> {
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<AppTask[]>(() => {
@ -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,

View file

@ -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' }),
);
});
});

View file

@ -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<typeof vi.fn>): 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<void>((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 <objective>`. 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<typeof vi.fn>): 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();
});
});