From 58c96468cf49be147dbd5cd5df5ab257bf3df61e Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 5 Mar 2026 17:48:29 -0600 Subject: [PATCH] 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 " 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. --- ui/src/ui/app-render.ts | 8 +- .../chat/slash-command-executor.node.test.ts | 83 +++++++++++++++++++ ui/src/ui/chat/slash-command-executor.ts | 74 ++++++++++++++++- 3 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 ui/src/ui/chat/slash-command-executor.node.test.ts diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 51b657243ad..741163bfed3 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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: () => { diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts new file mode 100644 index 00000000000..706bfed0c3c --- /dev/null +++ b/ui/src/ui/chat/slash-command-executor.node.test.ts @@ -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 ", 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", + }); + }); +}); diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index 0a92d98f973..3392095c7c1 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -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 { const target = args.trim(); @@ -282,15 +284,81 @@ async function executeKill( return { content: "Usage: `/kill `" }; } try { - await client.request("chat.abort", target === "all" ? {} : { agentId: target }); + const sessions = await client.request("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(); + 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`;