fix(commands): wire /stop across chat commands

This commit is contained in:
Peter Steinberger
2026-01-06 23:05:05 +00:00
parent 0df7c3addf
commit e0efcda77f
11 changed files with 129 additions and 43 deletions

View File

@@ -16,12 +16,14 @@ describe("commands registry", () => {
it("exposes native specs", () => {
const specs = listNativeCommandSpecs();
expect(specs.find((spec) => spec.name === "help")).toBeTruthy();
expect(specs.find((spec) => spec.name === "stop")).toBeTruthy();
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/help")).toBe(true);
expect(detection.regex.test("/status")).toBe(true);
expect(detection.regex.test("/stop")).toBe(true);
expect(detection.regex.test("try /status")).toBe(false);
});

View File

@@ -27,6 +27,12 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [
description: "Show current status.",
textAliases: ["/status"],
},
{
key: "stop",
nativeName: "stop",
description: "Stop the current run.",
textAliases: ["/stop"],
},
{
key: "restart",
nativeName: "restart",

View File

@@ -81,6 +81,23 @@ describe("trigger handling", () => {
});
});
it("handles /stop without invoking the agent", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "/stop",
From: "+1003",
To: "+2000",
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("⚙️ Agent was aborted.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("restarts even with prefix/whitespace", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(

View File

@@ -22,6 +22,7 @@ import {
import { logVerbose } from "../../globals.js";
import { triggerClawdbotRestart } from "../../infra/restart.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { normalizeE164 } from "../../utils.js";
import { resolveHeartbeatSeconds } from "../../web/reconnect.js";
@@ -47,6 +48,21 @@ import type { InlineDirectives } from "./directive-handling.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
import { incrementCompactionCount } from "./session-updates.js";
function resolveSessionEntryForKey(
store: Record<string, SessionEntry> | undefined,
sessionKey: string | undefined,
) {
if (!store || !sessionKey) return {};
const direct = store[sessionKey];
if (direct) return { entry: direct, key: sessionKey };
const parsed = parseAgentSessionKey(sessionKey);
const legacyKey = parsed?.rest;
if (legacyKey && store[legacyKey]) {
return { entry: store[legacyKey], key: legacyKey };
}
return {};
}
export type CommandContext = {
surface: string;
provider: string;
@@ -149,6 +165,29 @@ export function buildCommandContext(params: {
};
}
function resolveAbortTarget(params: {
ctx: MsgContext;
sessionKey?: string;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
}) {
const targetSessionKey =
params.ctx.CommandTargetSessionKey?.trim() || params.sessionKey;
const { entry, key } = resolveSessionEntryForKey(
params.sessionStore,
targetSessionKey,
);
if (entry && key) return { entry, key, sessionId: entry.sessionId };
if (params.sessionEntry && params.sessionKey) {
return {
entry: params.sessionEntry,
key: params.sessionKey,
sessionId: params.sessionEntry.sessionId,
};
}
return { entry: undefined, key: targetSessionKey, sessionId: undefined };
}
export async function handleCommands(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
@@ -375,6 +414,36 @@ export async function handleCommands(params: {
return { shouldContinue: false, reply: { text: statusText } };
}
const stopRequested = command.commandBodyNormalized === "/stop";
if (allowTextCommands && stopRequested) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /stop from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
const abortTarget = resolveAbortTarget({
ctx,
sessionKey,
sessionEntry,
sessionStore,
});
if (abortTarget.sessionId) {
abortEmbeddedPiRun(abortTarget.sessionId);
}
if (abortTarget.entry && sessionStore && abortTarget.key) {
abortTarget.entry.abortedLastRun = true;
abortTarget.entry.updatedAt = Date.now();
sessionStore[abortTarget.key] = abortTarget.entry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
} else if (command.abortKey) {
setAbortMemory(command.abortKey, true);
}
return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } };
}
const compactRequested =
command.commandBodyNormalized === "/compact" ||
command.commandBodyNormalized.startsWith("/compact ");
@@ -455,10 +524,19 @@ export async function handleCommands(params: {
const abortRequested = isAbortTrigger(command.rawBodyNormalized);
if (allowTextCommands && abortRequested) {
if (sessionEntry && sessionStore && sessionKey) {
sessionEntry.abortedLastRun = true;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
const abortTarget = resolveAbortTarget({
ctx,
sessionKey,
sessionEntry,
sessionStore,
});
if (abortTarget.sessionId) {
abortEmbeddedPiRun(abortTarget.sessionId);
}
if (abortTarget.entry && sessionStore && abortTarget.key) {
abortTarget.entry.abortedLastRun = true;
abortTarget.entry.updatedAt = Date.now();
sessionStore[abortTarget.key] = abortTarget.entry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}

View File

@@ -33,6 +33,7 @@ export type MsgContext = {
WasMentioned?: boolean;
CommandAuthorized?: boolean;
CommandSource?: "text" | "native";
CommandTargetSessionKey?: string;
};
export type TemplateContext = MsgContext & {