diff --git a/src/features/chat/application/composer/slash-commands.ts b/src/features/chat/application/composer/slash-commands.ts index 002aa04a..4e17811a 100644 --- a/src/features/chat/application/composer/slash-commands.ts +++ b/src/features/chat/application/composer/slash-commands.ts @@ -172,11 +172,17 @@ export type SlashCommandName = SlashCommand extends `/${infer Name}` ? Name : ne export type SlashCommandDefinition = (typeof SLASH_COMMANDS)[number]; +const CONNECTION_INDEPENDENT_SLASH_COMMANDS = new Set(["compact", "reconnect"]); + export interface SlashCommandHelpSection { readonly title: string; readonly auditFacts: readonly { key: string; value: string }[]; } +export function slashCommandRequiresConnection(command: SlashCommandName): boolean { + return !CONNECTION_INDEPENDENT_SLASH_COMMANDS.has(command); +} + export function slashCommandDefinition(command: SlashCommandName): SlashCommandDefinition { const definition = SLASH_COMMANDS.find((item) => item.command === `/${command}`); if (!definition) throw new Error(`Unknown slash command: ${command}`); diff --git a/src/features/chat/application/conversation/composer-submit-actions.ts b/src/features/chat/application/conversation/composer-submit-actions.ts index 2efa37e1..8d628fe4 100644 --- a/src/features/chat/application/conversation/composer-submit-actions.ts +++ b/src/features/chat/application/conversation/composer-submit-actions.ts @@ -4,7 +4,7 @@ import { submissionStateSnapshot } from "../state/selectors"; import type { ChatStateStore } from "../state/store"; import { parseSlashCommand } from "../composer/suggestions"; import type { SlashCommandExecutionResult } from "./slash-command-execution"; -import type { SlashCommandName } from "../composer/slash-commands"; +import { slashCommandRequiresConnection, type SlashCommandName } from "../composer/slash-commands"; import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference"; const STATUS_INTERRUPT_REQUESTED = "Interrupt requested."; @@ -54,7 +54,7 @@ async function sendMessage(host: ComposerSubmitActionsHost): Promise { const slashCommand = parseSlashCommand(text); if (slashCommand) { - if (!(await host.connection.connectedClient())) return; + if (slashCommandRequiresConnection(slashCommand.command) && !(await host.connection.connectedClient())) return; host.composer.setDraft("", { clearSuggestions: true }); const result = await host.slashCommandExecutor.execute(slashCommand.command, slashCommand.args); if (result?.composerDraft !== undefined) { diff --git a/src/features/chat/application/conversation/slash-command-execution.ts b/src/features/chat/application/conversation/slash-command-execution.ts index a69479b1..8ed5d0db 100644 --- a/src/features/chat/application/conversation/slash-command-execution.ts +++ b/src/features/chat/application/conversation/slash-command-execution.ts @@ -18,6 +18,7 @@ import { type SlashCommandSubcommandDefinition, } from "../composer/slash-commands"; import type { MessageStreamAuditFact, MessageStreamNoticeSection } from "../../domain/message-stream/items"; +import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../presentation/runtime/messages"; const DEFAULT_RUNTIME_SETTING_ALIASES = new Set(["default", "reset", "clear", "off"]); @@ -92,16 +93,6 @@ function noActiveThreadToCompactMessage(): string { return "No active thread to compact."; } -function modelOverrideMessage(model: string | null): string { - return model === null ? "Model reset to default for subsequent turns." : `Model set to ${model} for subsequent turns.`; -} - -function reasoningEffortOverrideMessage(effort: ReasoningEffort | null): string { - return effort === null - ? "Reasoning effort reset to default for subsequent turns." - : `Reasoning effort set to ${effort} for subsequent turns.`; -} - export async function executeSlashCommand( command: SlashCommandName, args: string, diff --git a/src/features/chat/application/runtime/settings-actions.ts b/src/features/chat/application/runtime/settings-actions.ts index d3767f69..4991f682 100644 --- a/src/features/chat/application/runtime/settings-actions.ts +++ b/src/features/chat/application/runtime/settings-actions.ts @@ -12,6 +12,7 @@ import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; import { nextCollaborationMode, type CollaborationModeSelection, type RequestedFastMode } from "../../domain/runtime/intent"; import type { ChatAction, ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; +import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../presentation/runtime/messages"; interface ApplyPendingThreadSettingsResult { ok: boolean; @@ -183,16 +184,6 @@ async function setAutoReview(host: RuntimeSettingsActionsHost, mode: AutoReviewS host.addSystemMessage(autoReviewToggleMessage(mode)); } -export function modelOverrideMessage(model: string | null): string { - return model === null ? "Model reset to default for subsequent turns." : `Model set to ${model} for subsequent turns.`; -} - -export function reasoningEffortOverrideMessage(effort: ReasoningEffort | null): string { - return effort === null - ? "Reasoning effort reset to default for subsequent turns." - : `Reasoning effort set to ${effort} for subsequent turns.`; -} - function fastModeToggleMessage(state: FastModeState): string { return state === "enabled" ? "Fast mode on for subsequent turns." : "Fast mode off for subsequent turns."; } diff --git a/src/features/chat/presentation/runtime/messages.ts b/src/features/chat/presentation/runtime/messages.ts index 316dd7fb..e156e41d 100644 --- a/src/features/chat/presentation/runtime/messages.ts +++ b/src/features/chat/presentation/runtime/messages.ts @@ -11,6 +11,16 @@ export function collaborationModeLabel(mode: CollaborationModeSelection): string return mode === "plan" ? "Plan" : "Default"; } +export function modelOverrideMessage(model: string | null): string { + return model === null ? "Model reset to default for subsequent turns." : `Model set to ${model} for subsequent turns.`; +} + +export function reasoningEffortOverrideMessage(effort: ReasoningEffort | null): string { + return effort === null + ? "Reasoning effort reset to default for subsequent turns." + : `Reasoning effort set to ${effort} for subsequent turns.`; +} + export function pendingRuntimeSettingLabel( setting: { kind: "unchanged" } | { kind: "set"; value: unknown } | { kind: "resetToConfig" }, ): string { diff --git a/tests/features/chat/conversation/turns/composer-submit-actions.test.ts b/tests/features/chat/conversation/turns/composer-submit-actions.test.ts index 425b1f88..79cb53fe 100644 --- a/tests/features/chat/conversation/turns/composer-submit-actions.test.ts +++ b/tests/features/chat/conversation/turns/composer-submit-actions.test.ts @@ -79,6 +79,37 @@ describe("submitComposer", () => { expect(sendTurnText).toHaveBeenCalledWith("hello", undefined, undefined); }); + it("does not execute connection-dependent slash commands when connection fails", async () => { + const { host, connectedClient, execute, setDraft } = createHost("/clear"); + connectedClient.mockResolvedValue(null); + + await submitComposer(host); + + expect(connectedClient).toHaveBeenCalledOnce(); + expect(setDraft).not.toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); + }); + + it("executes reconnect without a connected client preflight", async () => { + const { host, connectedClient, execute, setDraft } = createHost("/reconnect"); + + await submitComposer(host); + + expect(connectedClient).not.toHaveBeenCalled(); + expect(setDraft).toHaveBeenCalledWith("", { clearSuggestions: true }); + expect(execute).toHaveBeenCalledWith("reconnect", ""); + }); + + it("executes compact without a connected client preflight", async () => { + const { host, connectedClient, execute, setDraft } = createHost("/compact"); + + await submitComposer(host); + + expect(connectedClient).not.toHaveBeenCalled(); + expect(setDraft).toHaveBeenCalledWith("", { clearSuggestions: true }); + expect(execute).toHaveBeenCalledWith("compact", ""); + }); + it("restores slash command composer drafts from command results", async () => { const { host, connectedClient, execute, sendTurnText, setDraft, showLatest } = createHost("/goal edit"); execute.mockResolvedValue({ composerDraft: "/goal set Current objective" }); diff --git a/tests/runtime/runtime-settings.test.ts b/tests/runtime/runtime-settings.test.ts index 94e724b0..2f9760b6 100644 --- a/tests/runtime/runtime-settings.test.ts +++ b/tests/runtime/runtime-settings.test.ts @@ -3,8 +3,11 @@ import { describe, expect, it } from "vitest"; import { runtimeConfigSnapshotFromAppServerConfig, type ConfigReadResult } from "../../src/app-server/protocol/runtime-config"; import type { RuntimeConfigSnapshot } from "../../src/domain/runtime/config"; import type { ModelMetadata } from "../../src/domain/catalog/metadata"; -import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../src/features/chat/application/runtime/settings-actions"; -import { compactReasoningEffortLabel } from "../../src/features/chat/presentation/runtime/messages"; +import { + compactReasoningEffortLabel, + modelOverrideMessage, + reasoningEffortOverrideMessage, +} from "../../src/features/chat/presentation/runtime/messages"; import { autoReviewActive, currentModel,