Add slash commands for ending and restarting agents (#1034)
* Add slash commands for ending agents * Stabilize slash command e2e submit * Unslop client slash command draft setup * Fix slash commands while agent is running * Fix slash command submit race * Reshape client slash command execution * Fix slash command tab cleanup
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CLIENT_SLASH_COMMANDS,
|
||||
buildDraftAgentSetup,
|
||||
resolveClientSlashCommand,
|
||||
} from "@/client-slash-commands";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
|
||||
function createAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
const now = new Date("2026-05-15T00:00:00.000Z");
|
||||
return {
|
||||
serverId: "server-1",
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
status: "idle",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastUserMessageAt: now,
|
||||
lastActivityAt: now,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: "mode-current",
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: {
|
||||
provider: "codex",
|
||||
sessionId: "session-1",
|
||||
model: "runtime-model",
|
||||
modeId: "runtime-mode",
|
||||
thinkingOptionId: "runtime-thinking",
|
||||
},
|
||||
title: "Agent",
|
||||
cwd: "/repo",
|
||||
model: "agent-model",
|
||||
thinkingOptionId: "think-hard",
|
||||
features: [
|
||||
{ type: "toggle", id: "web-search", label: "Web search", value: true },
|
||||
{
|
||||
type: "select",
|
||||
id: "effort",
|
||||
label: "Effort",
|
||||
value: "high",
|
||||
options: [{ id: "high", label: "High" }],
|
||||
},
|
||||
],
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveClientSlashCommand", () => {
|
||||
it("declares the exact client commands that execute immediately", () => {
|
||||
expect(CLIENT_SLASH_COMMANDS.map((command) => [command.name, command.execution])).toEqual([
|
||||
["quit", "immediate"],
|
||||
["exit", "immediate"],
|
||||
["q", "immediate"],
|
||||
["clear", "immediate"],
|
||||
["new", "immediate"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves exact client commands after trimming", () => {
|
||||
expect(resolveClientSlashCommand({ text: " /quit ", hasAttachments: false })).toMatchObject({
|
||||
kind: "archive-agent",
|
||||
execution: "immediate",
|
||||
});
|
||||
expect(resolveClientSlashCommand({ text: "/exit", hasAttachments: false })?.kind).toBe(
|
||||
"archive-agent",
|
||||
);
|
||||
expect(resolveClientSlashCommand({ text: "/q", hasAttachments: false })?.kind).toBe(
|
||||
"archive-agent",
|
||||
);
|
||||
expect(resolveClientSlashCommand({ text: "/clear", hasAttachments: false })?.kind).toBe(
|
||||
"replace-agent-with-draft",
|
||||
);
|
||||
expect(resolveClientSlashCommand({ text: "/new", hasAttachments: false })?.kind).toBe(
|
||||
"replace-agent-with-draft",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves provider commands, arguments, ordinary messages, and attachment submits alone", () => {
|
||||
expect(resolveClientSlashCommand({ text: "/clear now", hasAttachments: false })).toBeNull();
|
||||
expect(resolveClientSlashCommand({ text: "/quit now", hasAttachments: false })).toBeNull();
|
||||
expect(
|
||||
resolveClientSlashCommand({ text: "/provider-command", hasAttachments: false }),
|
||||
).toBeNull();
|
||||
expect(resolveClientSlashCommand({ text: "hello /quit", hasAttachments: false })).toBeNull();
|
||||
expect(resolveClientSlashCommand({ text: "/quit", hasAttachments: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDraftAgentSetup", () => {
|
||||
it("builds draft setup from the active agent snapshot", () => {
|
||||
expect(buildDraftAgentSetup(createAgent())).toEqual({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "mode-current",
|
||||
model: "agent-model",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: {
|
||||
"web-search": true,
|
||||
effort: "high",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to runtime model setup when top-level fields are absent", () => {
|
||||
expect(
|
||||
buildDraftAgentSetup(
|
||||
createAgent({
|
||||
currentModeId: null,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
modeId: "runtime-mode",
|
||||
model: "runtime-model",
|
||||
thinkingOptionId: "runtime-thinking",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
||||
|
||||
export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft";
|
||||
export type ClientSlashCommandExecution = "immediate" | "insert";
|
||||
|
||||
export interface ClientSlashCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint: string;
|
||||
kind: ClientSlashCommandKind;
|
||||
execution: ClientSlashCommandExecution;
|
||||
}
|
||||
|
||||
export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
|
||||
{
|
||||
name: "quit",
|
||||
description: "Archive the current agent",
|
||||
argumentHint: "",
|
||||
kind: "archive-agent",
|
||||
execution: "immediate",
|
||||
},
|
||||
{
|
||||
name: "exit",
|
||||
description: "Archive the current agent",
|
||||
argumentHint: "",
|
||||
kind: "archive-agent",
|
||||
execution: "immediate",
|
||||
},
|
||||
{
|
||||
name: "q",
|
||||
description: "Archive the current agent",
|
||||
argumentHint: "",
|
||||
kind: "archive-agent",
|
||||
execution: "immediate",
|
||||
},
|
||||
{
|
||||
name: "clear",
|
||||
description: "Archive this agent and start a fresh draft",
|
||||
argumentHint: "",
|
||||
kind: "replace-agent-with-draft",
|
||||
execution: "immediate",
|
||||
},
|
||||
{
|
||||
name: "new",
|
||||
description: "Archive this agent and start a fresh draft",
|
||||
argumentHint: "",
|
||||
kind: "replace-agent-with-draft",
|
||||
execution: "immediate",
|
||||
},
|
||||
];
|
||||
|
||||
const COMMAND_BY_NAME = new Map(CLIENT_SLASH_COMMANDS.map((command) => [command.name, command]));
|
||||
|
||||
export function resolveClientSlashCommand(input: {
|
||||
text: string;
|
||||
hasAttachments: boolean;
|
||||
}): ClientSlashCommand | null {
|
||||
if (input.hasAttachments) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = input.text.trim();
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const commandName = trimmed.slice(1);
|
||||
if (!commandName || /\s/.test(commandName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return COMMAND_BY_NAME.get(commandName) ?? null;
|
||||
}
|
||||
|
||||
export function buildDraftAgentSetup(agent: Agent): WorkspaceDraftTabSetup {
|
||||
const featureValues: Record<string, unknown> = {};
|
||||
for (const feature of agent.features ?? []) {
|
||||
featureValues[feature.id] = feature.value;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
|
||||
model: agent.model ?? agent.runtimeInfo?.model ?? null,
|
||||
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
|
||||
featureValues,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user