Fix slash command connection gating

This commit is contained in:
murashit 2026-06-22 15:52:28 +09:00
parent 2d490c6fca
commit 71feebcde0
7 changed files with 56 additions and 24 deletions

View file

@ -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<SlashCommandName>(["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}`);

View file

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

View file

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

View file

@ -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.";
}

View file

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

View file

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

View file

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