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`.
|
- TUI: include Gateway slash commands in autocomplete and `/help`.
|
||||||
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
|
- 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.
|
- 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.
|
- 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)
|
- 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.
|
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ Session lifecycle:
|
|||||||
- `/settings`
|
- `/settings`
|
||||||
- `/exit`
|
- `/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
|
## Local shell commands
|
||||||
- Prefix a line with `!` to run a local shell command on the TUI host.
|
- 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.
|
- 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 fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { resolveSessionAgentId, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
|
||||||
import { resolveThinkingDefault } from "../../agents/model-selection.js";
|
import { resolveThinkingDefault } from "../../agents/model-selection.js";
|
||||||
import { resolveAgentTimeoutMs } from "../../agents/timeout.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 { 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 { agentCommand } from "../../commands/agent.js";
|
||||||
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
|
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
@@ -212,7 +228,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
|
const { cfg, storePath, entry, canonicalKey, store } = loadSessionEntry(p.sessionKey);
|
||||||
const timeoutMs = resolveAgentTimeoutMs({
|
const timeoutMs = resolveAgentTimeoutMs({
|
||||||
cfg,
|
cfg,
|
||||||
overrideMs: p.timeoutMs,
|
overrideMs: p.timeoutMs,
|
||||||
@@ -223,6 +239,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
sessionId,
|
sessionId,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
|
store[canonicalKey] = sessionEntry;
|
||||||
const clientRunId = p.idempotencyKey;
|
const clientRunId = p.idempotencyKey;
|
||||||
registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey });
|
registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey });
|
||||||
|
|
||||||
@@ -303,6 +320,141 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
};
|
};
|
||||||
respond(true, ackPayload, undefined, { runId: clientRunId });
|
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 envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||||
const envelopedMessage = formatInboundEnvelope({
|
const envelopedMessage = formatInboundEnvelope({
|
||||||
channel: "WebChat",
|
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 () => {
|
test("agent events include sessionKey and agent.wait covers lifecycle flows", async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { TUI } from "@mariozechner/pi-tui";
|
import type { TUI } from "@mariozechner/pi-tui";
|
||||||
import type { ChatLog } from "./components/chat-log.js";
|
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 { TuiStreamAssembler } from "./tui-stream-assembler.js";
|
||||||
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
|
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
|
||||||
|
|
||||||
@@ -49,6 +49,17 @@ export function createEventHandlers(context: EventHandlerContext) {
|
|||||||
setActivityStatus("streaming");
|
setActivityStatus("streaming");
|
||||||
}
|
}
|
||||||
if (evt.state === "final") {
|
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 =
|
const stopReason =
|
||||||
evt.message && typeof evt.message === "object" && !Array.isArray(evt.message)
|
evt.message && typeof evt.message === "object" && !Array.isArray(evt.message)
|
||||||
? typeof (evt.message as Record<string, unknown>).stopReason === "string"
|
? typeof (evt.message as Record<string, unknown>).stopReason === "string"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
extractContentFromMessage,
|
extractContentFromMessage,
|
||||||
extractTextFromMessage,
|
extractTextFromMessage,
|
||||||
extractThinkingFromMessage,
|
extractThinkingFromMessage,
|
||||||
|
isCommandMessage,
|
||||||
} from "./tui-formatters.js";
|
} from "./tui-formatters.js";
|
||||||
|
|
||||||
describe("extractTextFromMessage", () => {
|
describe("extractTextFromMessage", () => {
|
||||||
@@ -98,3 +99,11 @@ describe("extractContentFromMessage", () => {
|
|||||||
expect(text).toContain("HTTP 429");
|
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);
|
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) {
|
export function formatTokens(total?: number | null, context?: number | null) {
|
||||||
if (total == null && context == null) return "tokens ?";
|
if (total == null && context == null) return "tokens ?";
|
||||||
const totalLabel = total == null ? "?" : formatTokenCount(total);
|
const totalLabel = total == null ? "?" : formatTokenCount(total);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from "../routing/session-key.js";
|
} from "../routing/session-key.js";
|
||||||
import type { ChatLog } from "./components/chat-log.js";
|
import type { ChatLog } from "./components/chat-log.js";
|
||||||
import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.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";
|
import type { TuiOptions, TuiStateAccess } from "./tui-types.js";
|
||||||
|
|
||||||
type SessionActionContext = {
|
type SessionActionContext = {
|
||||||
@@ -161,6 +161,11 @@ export function createSessionActions(context: SessionActionContext) {
|
|||||||
for (const entry of record.messages ?? []) {
|
for (const entry of record.messages ?? []) {
|
||||||
if (!entry || typeof entry !== "object") continue;
|
if (!entry || typeof entry !== "object") continue;
|
||||||
const message = entry as Record<string, unknown>;
|
const message = entry as Record<string, unknown>;
|
||||||
|
if (isCommandMessage(message)) {
|
||||||
|
const text = extractTextFromMessage(message);
|
||||||
|
if (text) chatLog.addSystem(text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
const text = extractTextFromMessage(message);
|
const text = extractTextFromMessage(message);
|
||||||
if (text) chatLog.addUser(text);
|
if (text) chatLog.addUser(text);
|
||||||
|
|||||||
Reference in New Issue
Block a user