mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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: () => {
|
||||
|
||||
83
ui/src/ui/chat/slash-command-executor.node.test.ts
Normal file
83
ui/src/ui/chat/slash-command-executor.node.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user