fix: handle gateway slash command replies in TUI

This commit is contained in:
Peter Steinberger
2026-01-23 19:47:45 +00:00
parent 75a54f0259
commit 6fba598eaf
8 changed files with 227 additions and 3 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.clawd.bot
- TUI: include Gateway slash commands in autocomplete and `/help`.
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.

View File

@@ -88,6 +88,8 @@ Session lifecycle:
- `/settings`
- `/exit`
Other Gateway slash commands (for example, `/context`) are forwarded to the Gateway and shown as system output. See [Slash commands](/tools/slash-commands).
## Local shell commands
- Prefix a line with `!` to run a local shell command on the TUI host.
- The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session.

View File

@@ -2,9 +2,25 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveSessionAgentId, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
import { resolveThinkingDefault } from "../../agents/model-selection.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { ensureAgentWorkspace } from "../../agents/workspace.js";
import { isControlCommandMessage } from "../../auto-reply/command-detection.js";
import { normalizeCommandBody } from "../../auto-reply/commands-registry.js";
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js";
import { buildCommandContext, handleCommands } from "../../auto-reply/reply/commands.js";
import { parseInlineDirectives } from "../../auto-reply/reply/directive-handling.js";
import { defaultGroupActivation } from "../../auto-reply/reply/groups.js";
import { resolveContextTokens } from "../../auto-reply/reply/model-selection.js";
import { resolveElevatedPermissions } from "../../auto-reply/reply/reply-elevated.js";
import {
normalizeElevatedLevel,
normalizeReasoningLevel,
normalizeThinkLevel,
normalizeVerboseLevel,
} from "../../auto-reply/thinking.js";
import type { MsgContext } from "../../auto-reply/templating.js";
import { agentCommand } from "../../commands/agent.js";
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
@@ -212,7 +228,7 @@ export const chatHandlers: GatewayRequestHandlers = {
return;
}
}
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
const { cfg, storePath, entry, canonicalKey, store } = loadSessionEntry(p.sessionKey);
const timeoutMs = resolveAgentTimeoutMs({
cfg,
overrideMs: p.timeoutMs,
@@ -223,6 +239,7 @@ export const chatHandlers: GatewayRequestHandlers = {
sessionId,
updatedAt: now,
});
store[canonicalKey] = sessionEntry;
const clientRunId = p.idempotencyKey;
registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey });
@@ -303,6 +320,141 @@ export const chatHandlers: GatewayRequestHandlers = {
};
respond(true, ackPayload, undefined, { runId: clientRunId });
if (isControlCommandMessage(parsedMessage, cfg)) {
try {
const isFastTestEnv = process.env.CLAWDBOT_TEST_FAST === "1";
const agentId = resolveSessionAgentId({ sessionKey: p.sessionKey, config: cfg });
const agentCfg = cfg.agents?.defaults;
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const workspace = await ensureAgentWorkspace({
dir: workspaceDir,
ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv,
});
const ctx: MsgContext = {
Body: parsedMessage,
CommandBody: parsedMessage,
BodyForCommands: parsedMessage,
CommandSource: "text",
CommandAuthorized: true,
Provider: INTERNAL_MESSAGE_CHANNEL,
Surface: "tui",
From: p.sessionKey,
To: INTERNAL_MESSAGE_CHANNEL,
SessionKey: p.sessionKey,
ChatType: "direct",
};
const command = buildCommandContext({
ctx,
cfg,
agentId,
sessionKey: p.sessionKey,
isGroup: false,
triggerBodyNormalized: normalizeCommandBody(parsedMessage),
commandAuthorized: true,
});
const directives = parseInlineDirectives(parsedMessage);
const { provider, model } = resolveSessionModelRef(cfg, sessionEntry);
const contextTokens = resolveContextTokens({ agentCfg, model });
const resolveDefaultThinkingLevel = async () => {
const configured = agentCfg?.thinkingDefault;
if (configured) return configured;
const catalog = await context.loadGatewayModelCatalog();
return resolveThinkingDefault({ cfg, provider, model, catalog });
};
const resolvedThinkLevel =
normalizeThinkLevel(sessionEntry?.thinkingLevel ?? agentCfg?.thinkingDefault) ??
(await resolveDefaultThinkingLevel());
const resolvedVerboseLevel =
normalizeVerboseLevel(sessionEntry?.verboseLevel ?? agentCfg?.verboseDefault) ?? "off";
const resolvedReasoningLevel =
normalizeReasoningLevel(sessionEntry?.reasoningLevel) ?? "off";
const resolvedElevatedLevel = normalizeElevatedLevel(
sessionEntry?.elevatedLevel ?? agentCfg?.elevatedDefault,
);
const elevated = resolveElevatedPermissions({
cfg,
agentId,
ctx,
provider: INTERNAL_MESSAGE_CHANNEL,
});
const commandResult = await handleCommands({
ctx,
cfg,
command,
agentId,
directives,
elevated,
sessionEntry,
previousSessionEntry: entry,
sessionStore: store,
sessionKey: p.sessionKey,
storePath,
sessionScope: (cfg.session?.scope ?? "per-sender") as "per-sender" | "global",
workspaceDir: workspace.dir,
defaultGroupActivation: () => defaultGroupActivation(true),
resolvedThinkLevel,
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
resolveDefaultThinkingLevel,
provider,
model,
contextTokens,
isGroup: false,
});
if (!commandResult.shouldContinue) {
const text = commandResult.reply?.text ?? "";
const message = {
role: "assistant",
content: text.trim() ? [{ type: "text", text }] : [],
timestamp: Date.now(),
command: true,
};
const payload = {
runId: clientRunId,
sessionKey: p.sessionKey,
seq: 0,
state: "final" as const,
message,
};
context.broadcast("chat", payload);
context.nodeSendToSession(p.sessionKey, "chat", payload);
context.dedupe.set(`chat:${clientRunId}`, {
ts: Date.now(),
ok: true,
payload: { runId: clientRunId, status: "ok" as const },
});
context.chatAbortControllers.delete(clientRunId);
context.removeChatRun(clientRunId, clientRunId, p.sessionKey);
return;
}
} catch (err) {
const payload = {
runId: clientRunId,
sessionKey: p.sessionKey,
seq: 0,
state: "error" as const,
errorMessage: formatForLog(err),
};
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
context.broadcast("chat", payload);
context.nodeSendToSession(p.sessionKey, "chat", payload);
context.dedupe.set(`chat:${clientRunId}`, {
ts: Date.now(),
ok: false,
payload: {
runId: clientRunId,
status: "error" as const,
summary: String(err),
},
error,
});
context.chatAbortControllers.delete(clientRunId);
context.removeChatRun(clientRunId, clientRunId, p.sessionKey);
return;
}
}
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const envelopedMessage = formatInboundEnvelope({
channel: "WebChat",

View File

@@ -259,6 +259,45 @@ describe("gateway server chat", () => {
}
});
test("routes chat.send slash commands without agent runs", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
try {
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
const spy = vi.mocked(agentCommand);
const callsBefore = spy.mock.calls.length;
const eventPromise = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "final" &&
o.payload?.runId === "idem-command-1",
8000,
);
const res = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "/context list",
idempotencyKey: "idem-command-1",
});
expect(res.ok).toBe(true);
const evt = await eventPromise;
expect(evt.payload?.message?.command).toBe(true);
expect(spy.mock.calls.length).toBe(callsBefore);
} finally {
testState.sessionStorePath = undefined;
await fs.rm(dir, { recursive: true, force: true });
}
});
test("agent events include sessionKey and agent.wait covers lifecycle flows", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");

View File

@@ -1,6 +1,6 @@
import type { TUI } from "@mariozechner/pi-tui";
import type { ChatLog } from "./components/chat-log.js";
import { asString } from "./tui-formatters.js";
import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js";
import { TuiStreamAssembler } from "./tui-stream-assembler.js";
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
@@ -49,6 +49,17 @@ export function createEventHandlers(context: EventHandlerContext) {
setActivityStatus("streaming");
}
if (evt.state === "final") {
if (isCommandMessage(evt.message)) {
const text = extractTextFromMessage(evt.message);
if (text) chatLog.addSystem(text);
streamAssembler.drop(evt.runId);
noteFinalizedRun(evt.runId);
state.activeChatRunId = null;
setActivityStatus("idle");
void refreshSessionInfo?.();
tui.requestRender();
return;
}
const stopReason =
evt.message && typeof evt.message === "object" && !Array.isArray(evt.message)
? typeof (evt.message as Record<string, unknown>).stopReason === "string"

View File

@@ -4,6 +4,7 @@ import {
extractContentFromMessage,
extractTextFromMessage,
extractThinkingFromMessage,
isCommandMessage,
} from "./tui-formatters.js";
describe("extractTextFromMessage", () => {
@@ -98,3 +99,11 @@ describe("extractContentFromMessage", () => {
expect(text).toContain("HTTP 429");
});
});
describe("isCommandMessage", () => {
it("detects command-marked messages", () => {
expect(isCommandMessage({ command: true })).toBe(true);
expect(isCommandMessage({ command: false })).toBe(false);
expect(isCommandMessage({})).toBe(false);
});
});

View File

@@ -140,6 +140,11 @@ export function extractTextFromMessage(
return formatRawAssistantErrorForUi(errorMessage);
}
export function isCommandMessage(message: unknown): boolean {
if (!message || typeof message !== "object") return false;
return (message as Record<string, unknown>).command === true;
}
export function formatTokens(total?: number | null, context?: number | null) {
if (total == null && context == null) return "tokens ?";
const totalLabel = total == null ? "?" : formatTokenCount(total);

View File

@@ -6,7 +6,7 @@ import {
} from "../routing/session-key.js";
import type { ChatLog } from "./components/chat-log.js";
import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js";
import { asString, extractTextFromMessage } from "./tui-formatters.js";
import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js";
import type { TuiOptions, TuiStateAccess } from "./tui-types.js";
type SessionActionContext = {
@@ -161,6 +161,11 @@ export function createSessionActions(context: SessionActionContext) {
for (const entry of record.messages ?? []) {
if (!entry || typeof entry !== "object") continue;
const message = entry as Record<string, unknown>;
if (isCommandMessage(message)) {
const text = extractTextFromMessage(message);
if (text) chatLog.addSystem(text);
continue;
}
if (message.role === "user") {
const text = extractTextFromMessage(message);
if (text) chatLog.addUser(text);