feat: implement /kill command for managing sub-agent sessions

- Enhanced the executeSlashCommand function to support the /kill command, allowing users to abort sub-agent sessions.
- Added logic to handle both "kill all" and "kill <agentId>" scenarios, providing appropriate feedback based on the number of sessions aborted.
- Introduced a new utility function, resolveKillTargets, to identify matching sub-agent sessions based on the provided target.
- Added unit tests for the /kill command to ensure correct functionality and response messages.
This commit is contained in:
Val Alexander
2026-03-05 17:48:29 -06:00
parent 1f1f444aa1
commit 58c96468cf
3 changed files with 160 additions and 5 deletions

View File

@@ -998,8 +998,12 @@ export function renderApp(state: AppViewState) {
onConfigSave: () => saveConfig(state),
onChannelsRefresh: () => loadChannels(state, false),
onCronRefresh: () => state.loadCron(),
onCronRunNow: (_jobId) => {
// Stub: backend support pending
onCronRunNow: (jobId) => {
const job = state.cronJobs.find((entry) => entry.id === jobId);
if (!job) {
return;
}
void runCronJob(state, job, "force");
},
onSkillsFilterChange: (next) => (state.skillsFilter = next),
onSkillsRefresh: () => {

View File

@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../gateway.ts";
import type { GatewaySessionRow } from "../types.ts";
import { executeSlashCommand } from "./slash-command-executor.ts";
function row(key: string): GatewaySessionRow {
return {
key,
kind: "direct",
updatedAt: null,
};
}
describe("executeSlashCommand /kill", () => {
it("aborts every sub-agent session for /kill all", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("main"),
row("agent:main:subagent:one"),
row("agent:main:subagent:parent:subagent:child"),
row("agent:other:main"),
],
};
}
if (method === "chat.abort") {
return { ok: true, aborted: true };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"kill",
"all",
);
expect(result.content).toBe("Aborted 2 sub-agent sessions.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: "agent:main:subagent:one",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: "agent:main:subagent:parent:subagent:child",
});
});
it("aborts matching sub-agent sessions for /kill <agentId>", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:subagent:one"),
row("agent:main:subagent:two"),
row("agent:other:subagent:three"),
],
};
}
if (method === "chat.abort") {
return { ok: true, aborted: true };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"kill",
"main",
);
expect(result.content).toBe("Aborted 2 matching sub-agent sessions for `main`.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: "agent:main:subagent:one",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: "agent:main:subagent:two",
});
});
});

View File

@@ -3,6 +3,7 @@
* Calls gateway RPC methods and returns formatted results.
*/
import { isSubagentSessionKey, parseAgentSessionKey } from "../../../../src/routing/session-key.js";
import type { GatewayBrowserClient } from "../gateway.ts";
import type {
AgentsListResult,
@@ -64,7 +65,7 @@ export async function executeSlashCommand(
case "agents":
return await executeAgents(client);
case "kill":
return await executeKill(client, args);
return await executeKill(client, sessionKey, args);
default:
return { content: `Unknown command: \`/${commandName}\`` };
}
@@ -275,6 +276,7 @@ async function executeAgents(client: GatewayBrowserClient): Promise<SlashCommand
async function executeKill(
client: GatewayBrowserClient,
sessionKey: string,
args: string,
): Promise<SlashCommandResult> {
const target = args.trim();
@@ -282,15 +284,81 @@ async function executeKill(
return { content: "Usage: `/kill <id|all>`" };
}
try {
await client.request("chat.abort", target === "all" ? {} : { agentId: target });
const sessions = await client.request<SessionsListResult>("sessions.list", {});
const matched = resolveKillTargets(sessions?.sessions ?? [], sessionKey, target);
if (matched.length === 0) {
return {
content:
target.toLowerCase() === "all"
? "No active sub-agent sessions found."
: `No matching sub-agent sessions found for \`${target}\`.`,
};
}
const results = await Promise.allSettled(
matched.map((key) => client.request("chat.abort", { sessionKey: key })),
);
const successCount = results.filter((entry) => entry.status === "fulfilled").length;
if (successCount === 0) {
const firstFailure = results.find((entry) => entry.status === "rejected");
throw firstFailure?.reason ?? new Error("abort failed");
}
if (target.toLowerCase() === "all") {
return {
content:
successCount === matched.length
? `Aborted ${successCount} sub-agent session${successCount === 1 ? "" : "s"}.`
: `Aborted ${successCount} of ${matched.length} sub-agent sessions.`,
};
}
return {
content: target === "all" ? "All agents aborted." : `Agent \`${target}\` aborted.`,
content:
successCount === matched.length
? `Aborted ${successCount} matching sub-agent session${successCount === 1 ? "" : "s"} for \`${target}\`.`
: `Aborted ${successCount} of ${matched.length} matching sub-agent sessions for \`${target}\`.`,
};
} catch (err) {
return { content: `Failed to abort: ${String(err)}` };
}
}
function resolveKillTargets(
sessions: GatewaySessionRow[],
currentSessionKey: string,
target: string,
): string[] {
const normalizedTarget = target.trim().toLowerCase();
if (!normalizedTarget) {
return [];
}
const keys = new Set<string>();
const currentParsed = parseAgentSessionKey(currentSessionKey);
for (const session of sessions) {
const key = session?.key?.trim();
if (!key || !isSubagentSessionKey(key)) {
continue;
}
const normalizedKey = key.toLowerCase();
const parsed = parseAgentSessionKey(normalizedKey);
const isMatch =
normalizedTarget === "all" ||
normalizedKey === normalizedTarget ||
(parsed?.agentId ?? "") === normalizedTarget ||
normalizedKey.endsWith(`:subagent:${normalizedTarget}`) ||
normalizedKey === `subagent:${normalizedTarget}` ||
(currentParsed?.agentId != null &&
parsed?.agentId === currentParsed.agentId &&
normalizedKey.endsWith(`:subagent:${normalizedTarget}`));
if (isMatch) {
keys.add(key);
}
}
return [...keys];
}
function fmtTokens(n: number): string {
if (n >= 1_000_000) {
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;