From 6fba598eaf16051ebc1ed5df7e019247252f7a2f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 19:47:45 +0000 Subject: [PATCH] fix: handle gateway slash command replies in TUI --- CHANGELOG.md | 1 + docs/tui.md | 2 + src/gateway/server-methods/chat.ts | 154 +++++++++++++++++- ...erver.chat.gateway-server-chat.e2e.test.ts | 39 +++++ src/tui/tui-event-handlers.ts | 13 +- src/tui/tui-formatters.test.ts | 9 + src/tui/tui-formatters.ts | 5 + src/tui/tui-session-actions.ts | 7 +- 8 files changed, 227 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f7865602..e49b37002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/tui.md b/docs/tui.md index e67b22032..4d094dc6b 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -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. diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 8c71dca75..0e55b45f5 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -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", diff --git a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts index 75f541f39..d4035037b 100644 --- a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts @@ -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"); diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index 3f8e2befd..148dca67a 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -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).stopReason === "string" diff --git a/src/tui/tui-formatters.test.ts b/src/tui/tui-formatters.test.ts index 541c58727..3200b237a 100644 --- a/src/tui/tui-formatters.test.ts +++ b/src/tui/tui-formatters.test.ts @@ -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); + }); +}); diff --git a/src/tui/tui-formatters.ts b/src/tui/tui-formatters.ts index 11e8e68c9..f77eb9ff1 100644 --- a/src/tui/tui-formatters.ts +++ b/src/tui/tui-formatters.ts @@ -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).command === true; +} + export function formatTokens(total?: number | null, context?: number | null) { if (total == null && context == null) return "tokens ?"; const totalLabel = total == null ? "?" : formatTokenCount(total); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 327363653..5dc6696ad 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -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; + 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);