UI: align slash commands with session tree scope

This commit is contained in:
Vincent Koc
2026-03-11 01:26:47 -04:00
parent 28646aca14
commit 64607e9980
7 changed files with 351 additions and 44 deletions

View File

@@ -810,6 +810,7 @@ export function listSessionsFromStore(params: {
const model = resolvedModel.model ?? DEFAULT_MODEL;
return {
key,
spawnedBy: entry?.spawnedBy,
entry,
kind: classifySessionKey(key, entry),
label: entry?.label,

View File

@@ -15,6 +15,7 @@ export type GatewaySessionsDefaults = {
export type GatewaySessionRow = {
key: string;
spawnedBy?: string;
kind: "direct" | "group" | "global" | "unknown";
label?: string;
displayName?: string;

View File

@@ -3,11 +3,13 @@ import type { GatewayBrowserClient } from "../gateway.ts";
import type { GatewaySessionRow } from "../types.ts";
import { executeSlashCommand } from "./slash-command-executor.ts";
function row(key: string): GatewaySessionRow {
function row(key: string, overrides?: Partial<GatewaySessionRow>): GatewaySessionRow {
return {
key,
spawnedBy: overrides?.spawnedBy,
kind: "direct",
updatedAt: null,
...overrides,
};
}
@@ -18,8 +20,11 @@ describe("executeSlashCommand /kill", () => {
return {
sessions: [
row("main"),
row("agent:main:subagent:one"),
row("agent:main:subagent:parent:subagent:child"),
row("agent:main:subagent:one", { spawnedBy: "main" }),
row("agent:main:subagent:parent", { spawnedBy: "main" }),
row("agent:main:subagent:parent:subagent:child", {
spawnedBy: "agent:main:subagent:parent",
}),
row("agent:other:main"),
],
};
@@ -37,12 +42,15 @@ describe("executeSlashCommand /kill", () => {
"all",
);
expect(result.content).toBe("Aborted 2 sub-agent sessions.");
expect(result.content).toBe("Aborted 3 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",
});
expect(request).toHaveBeenNthCalledWith(4, "chat.abort", {
sessionKey: "agent:main:subagent:parent:subagent:child",
});
});
@@ -52,9 +60,9 @@ describe("executeSlashCommand /kill", () => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:subagent:one"),
row("agent:main:subagent:two"),
row("agent:other:subagent:three"),
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }),
],
};
}
@@ -86,9 +94,11 @@ describe("executeSlashCommand /kill", () => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:subagent:parent"),
row("agent:main:subagent:parent:subagent:child"),
row("agent:main:subagent:sibling"),
row("agent:main:subagent:parent", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:parent:subagent:child", {
spawnedBy: "agent:main:subagent:parent",
}),
row("agent:main:subagent:sibling", { spawnedBy: "agent:main:main" }),
],
};
}
@@ -116,7 +126,10 @@ describe("executeSlashCommand /kill", () => {
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")],
sessions: [
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
],
};
}
if (method === "chat.abort") {
@@ -148,9 +161,9 @@ describe("executeSlashCommand /kill", () => {
return {
sessions: [
row("main"),
row("agent:main:subagent:one"),
row("agent:main:subagent:two"),
row("agent:other:subagent:three"),
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }),
],
};
}
@@ -176,4 +189,128 @@ describe("executeSlashCommand /kill", () => {
sessionKey: "agent:main:subagent:two",
});
});
it("does not abort unrelated same-agent subagents from another root session", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:main"),
row("agent:main:subagent:mine", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:mine:subagent:child", {
spawnedBy: "agent:main:subagent:mine",
}),
row("agent:main:subagent:other-root", {
spawnedBy: "agent:main:discord:dm:alice",
}),
],
};
}
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:mine",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: "agent:main:subagent:mine:subagent:child",
});
});
});
describe("executeSlashCommand directives", () => {
it("reports the current thinking level for bare /think", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:main", {
modelProvider: "openai",
model: "gpt-4.1-mini",
}),
],
};
}
if (method === "models.list") {
return {
models: [{ id: "gpt-4.1-mini", provider: "openai", reasoning: true }],
};
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"think",
"",
);
expect(result.content).toBe(
"Current thinking level: low.\nOptions: off, minimal, low, medium, high, adaptive.",
);
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "models.list", {});
});
it("accepts minimal and xhigh thinking levels", async () => {
const request = vi.fn().mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({ ok: true });
const minimal = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"think",
"minimal",
);
const xhigh = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"think",
"xhigh",
);
expect(minimal.content).toBe("Thinking level set to **minimal**.");
expect(xhigh.content).toBe("Thinking level set to **xhigh**.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.patch", {
key: "agent:main:main",
thinkingLevel: "minimal",
});
expect(request).toHaveBeenNthCalledWith(2, "sessions.patch", {
key: "agent:main:main",
thinkingLevel: "xhigh",
});
});
it("reports the current verbose level for bare /verbose", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [row("agent:main:main", { verboseLevel: "full" })],
};
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"verbose",
"",
);
expect(result.content).toBe("Current verbose level: full.\nOptions: on, full, off.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
});
});

View File

@@ -4,7 +4,14 @@
*/
import type { ModelCatalogEntry } from "../../../../src/agents/model-catalog.js";
import { resolveThinkingDefault } from "../../../../src/agents/model-selection.js";
import {
formatThinkingLevels,
normalizeThinkLevel,
normalizeVerboseLevel,
} from "../../../../src/auto-reply/thinking.js";
import type { HealthSummary } from "../../../../src/commands/health.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import {
DEFAULT_AGENT_ID,
DEFAULT_MAIN_KEY,
@@ -166,18 +173,31 @@ async function executeThink(
sessionKey: string,
args: string,
): Promise<SlashCommandResult> {
const valid = ["off", "low", "medium", "high"];
const level = args.trim().toLowerCase();
if (!level) {
return {
content: `Usage: \`/think <${valid.join("|")}>\``,
};
const rawLevel = args.trim();
if (!rawLevel) {
try {
const { session, models } = await loadThinkingCommandState(client, sessionKey);
return {
content: formatDirectiveOptions(
`Current thinking level: ${resolveCurrentThinkingLevel(session, models)}.`,
formatThinkingLevels(session?.modelProvider, session?.model),
),
};
} catch (err) {
return { content: `Failed to get thinking level: ${String(err)}` };
}
}
if (!valid.includes(level)) {
return {
content: `Invalid thinking level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`,
};
const level = normalizeThinkLevel(rawLevel);
if (!level) {
try {
const session = await loadCurrentSession(client, sessionKey);
return {
content: `Unrecognized thinking level "${rawLevel}". Valid levels: ${formatThinkingLevels(session?.modelProvider, session?.model)}.`,
};
} catch (err) {
return { content: `Failed to validate thinking level: ${String(err)}` };
}
}
try {
@@ -196,17 +216,25 @@ async function executeVerbose(
sessionKey: string,
args: string,
): Promise<SlashCommandResult> {
const valid = ["on", "off", "full"];
const level = args.trim().toLowerCase();
const rawLevel = args.trim();
if (!rawLevel) {
try {
const session = await loadCurrentSession(client, sessionKey);
return {
content: formatDirectiveOptions(
`Current verbose level: ${normalizeVerboseLevel(session?.verboseLevel) ?? "off"}.`,
"on, full, off",
),
};
} catch (err) {
return { content: `Failed to get verbose level: ${String(err)}` };
}
}
const level = normalizeVerboseLevel(rawLevel);
if (!level) {
return {
content: `Usage: \`/verbose <${valid.join("|")}>\``,
};
}
if (!valid.includes(level)) {
return {
content: `Invalid verbose level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`,
content: `Unrecognized verbose level "${rawLevel}". Valid levels: off, on, full.`,
};
}
@@ -354,6 +382,7 @@ function resolveKillTargets(
const currentAgentId =
currentParsed?.agentId ??
(normalizedCurrentSessionKey === DEFAULT_MAIN_KEY ? DEFAULT_AGENT_ID : undefined);
const sessionIndex = buildSessionIndex(sessions);
for (const session of sessions) {
const key = session?.key?.trim();
if (!key || !isSubagentSessionKey(key)) {
@@ -364,6 +393,7 @@ function resolveKillTargets(
const belongsToCurrentSession = isWithinCurrentSessionSubtree(
normalizedKey,
normalizedCurrentSessionKey,
sessionIndex,
currentAgentId,
parsed?.agentId,
);
@@ -384,19 +414,125 @@ function resolveKillTargets(
function isWithinCurrentSessionSubtree(
candidateSessionKey: string,
currentSessionKey: string,
sessionIndex: Map<string, GatewaySessionRow>,
currentAgentId: string | undefined,
candidateAgentId: string | undefined,
): boolean {
if (!currentAgentId || candidateAgentId !== currentAgentId) {
return false;
}
if (!isSubagentSessionKey(currentSessionKey)) {
return true;
const currentAliases = resolveEquivalentSessionKeys(currentSessionKey, currentAgentId);
const seen = new Set<string>();
let parentSessionKey = normalizeSessionKey(sessionIndex.get(candidateSessionKey)?.spawnedBy);
while (parentSessionKey && !seen.has(parentSessionKey)) {
if (currentAliases.has(parentSessionKey)) {
return true;
}
seen.add(parentSessionKey);
parentSessionKey = normalizeSessionKey(sessionIndex.get(parentSessionKey)?.spawnedBy);
}
return (
candidateSessionKey === currentSessionKey ||
candidateSessionKey.startsWith(`${currentSessionKey}:subagent:`)
);
// Older gateways may not include spawnedBy on session rows yet; keep prefix
// matching for nested subagent sessions as a compatibility fallback.
return isSubagentSessionKey(currentSessionKey)
? candidateSessionKey.startsWith(`${currentSessionKey}:subagent:`)
: false;
}
function buildSessionIndex(sessions: GatewaySessionRow[]): Map<string, GatewaySessionRow> {
const index = new Map<string, GatewaySessionRow>();
for (const session of sessions) {
const normalizedKey = normalizeSessionKey(session?.key);
if (!normalizedKey) {
continue;
}
index.set(normalizedKey, session);
}
return index;
}
function normalizeSessionKey(key?: string | null): string | undefined {
const normalized = key?.trim().toLowerCase();
return normalized || undefined;
}
function resolveEquivalentSessionKeys(
currentSessionKey: string,
currentAgentId: string | undefined,
): Set<string> {
const keys = new Set<string>([currentSessionKey]);
if (currentAgentId === DEFAULT_AGENT_ID) {
const canonicalDefaultMain = `agent:${DEFAULT_AGENT_ID}:main`;
if (currentSessionKey === DEFAULT_MAIN_KEY) {
keys.add(canonicalDefaultMain);
} else if (currentSessionKey === canonicalDefaultMain) {
keys.add(DEFAULT_MAIN_KEY);
}
}
return keys;
}
function formatDirectiveOptions(text: string, options: string): string {
return `${text}\nOptions: ${options}.`;
}
async function loadCurrentSession(
client: GatewayBrowserClient,
sessionKey: string,
): Promise<GatewaySessionRow | undefined> {
const sessions = await client.request<SessionsListResult>("sessions.list", {});
const normalizedSessionKey = normalizeSessionKey(sessionKey);
const currentAgentId =
parseAgentSessionKey(normalizedSessionKey ?? "")?.agentId ??
(normalizedSessionKey === DEFAULT_MAIN_KEY ? DEFAULT_AGENT_ID : undefined);
const aliases = normalizedSessionKey
? resolveEquivalentSessionKeys(normalizedSessionKey, currentAgentId)
: new Set<string>();
return sessions?.sessions?.find((session: GatewaySessionRow) => {
const key = normalizeSessionKey(session.key);
return key ? aliases.has(key) : false;
});
}
async function loadThinkingCommandState(client: GatewayBrowserClient, sessionKey: string) {
const [sessions, models] = await Promise.all([
client.request<SessionsListResult>("sessions.list", {}),
client.request<{ models: ModelCatalogEntry[] }>("models.list", {}),
]);
const normalizedSessionKey = normalizeSessionKey(sessionKey);
const currentAgentId =
parseAgentSessionKey(normalizedSessionKey ?? "")?.agentId ??
(normalizedSessionKey === DEFAULT_MAIN_KEY ? DEFAULT_AGENT_ID : undefined);
const aliases = normalizedSessionKey
? resolveEquivalentSessionKeys(normalizedSessionKey, currentAgentId)
: new Set<string>();
return {
session: sessions?.sessions?.find((session: GatewaySessionRow) => {
const key = normalizeSessionKey(session.key);
return key ? aliases.has(key) : false;
}),
models: models?.models ?? [],
};
}
function resolveCurrentThinkingLevel(
session: GatewaySessionRow | undefined,
models: ModelCatalogEntry[],
): string {
const persisted = normalizeThinkLevel(session?.thinkingLevel);
if (persisted) {
return persisted;
}
if (!session?.modelProvider || !session.model) {
return "off";
}
return resolveThinkingDefault({
cfg: {} as OpenClawConfig,
provider: session.modelProvider,
model: session.model,
catalog: models,
});
}
function fmtTokens(n: number): string {

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { parseSlashCommand } from "./slash-commands.ts";
describe("parseSlashCommand", () => {
it("parses commands with an optional colon separator", () => {
expect(parseSlashCommand("/think: high")).toMatchObject({
command: { name: "think" },
args: "high",
});
expect(parseSlashCommand("/think:high")).toMatchObject({
command: { name: "think" },
args: "high",
});
expect(parseSlashCommand("/help:")).toMatchObject({
command: { name: "help" },
args: "",
});
});
it("still parses space-delimited commands", () => {
expect(parseSlashCommand("/verbose full")).toMatchObject({
command: { name: "verbose" },
args: "full",
});
});
});

View File

@@ -77,7 +77,7 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
icon: "brain",
category: "model",
executeLocal: true,
argOptions: ["off", "low", "medium", "high"],
argOptions: ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"],
},
{
name: "verbose",
@@ -192,7 +192,7 @@ export type ParsedSlashCommand = {
/**
* Parse a message as a slash command. Returns null if it doesn't match.
* Supports `/command` and `/command args...`.
* Supports `/command`, `/command args...`, and `/command: args...`.
*/
export function parseSlashCommand(text: string): ParsedSlashCommand | null {
const trimmed = text.trim();
@@ -200,9 +200,14 @@ export function parseSlashCommand(text: string): ParsedSlashCommand | null {
return null;
}
const spaceIdx = trimmed.indexOf(" ");
const name = spaceIdx === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIdx);
const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim();
const body = trimmed.slice(1);
const firstSeparator = body.search(/[\s:]/u);
const name = firstSeparator === -1 ? body : body.slice(0, firstSeparator);
let remainder = firstSeparator === -1 ? "" : body.slice(firstSeparator).trimStart();
if (remainder.startsWith(":")) {
remainder = remainder.slice(1).trimStart();
}
const args = remainder.trim();
if (!name) {
return null;

View File

@@ -395,6 +395,7 @@ export type AgentsFilesSetResult = {
export type GatewaySessionRow = {
key: string;
spawnedBy?: string;
kind: "direct" | "group" | "global" | "unknown";
label?: string;
displayName?: string;