fix: handle gateway slash command replies in TUI
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user