feat(slash-commands): usage footer modes

This commit is contained in:
Peter Steinberger
2026-01-18 05:35:22 +00:00
parent e7a4931932
commit 2dabce59ce
38 changed files with 370 additions and 303 deletions

View File

@@ -146,78 +146,83 @@ const readSessionMessages = async (sessionFile: string) => {
};
describe("runEmbeddedPiAgent", () => {
it("appends new user + assistant after existing transcript entries", { timeout: 90_000 }, async () => {
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
it(
"appends new user + assistant after existing transcript entries",
{ timeout: 90_000 },
async () => {
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "seed user" }],
});
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "text", text: "seed assistant" }],
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "mock-1",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: {
input: 0,
output: 0,
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "seed user" }],
});
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "text", text: "seed assistant" }],
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "mock-1",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
total: 0,
totalTokens: 2,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
},
timestamp: Date.now(),
});
timestamp: Date.now(),
});
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg, agentDir);
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg, agentDir);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: testSessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
enqueue: immediateEnqueue,
});
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: testSessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
enqueue: immediateEnqueue,
});
const messages = await readSessionMessages(sessionFile);
const seedUserIndex = messages.findIndex(
(message) => message?.role === "user" && textFromContent(message.content) === "seed user",
);
const seedAssistantIndex = messages.findIndex(
(message) =>
message?.role === "assistant" && textFromContent(message.content) === "seed assistant",
);
const newUserIndex = messages.findIndex(
(message) => message?.role === "user" && textFromContent(message.content) === "hello",
);
const newAssistantIndex = messages.findIndex(
(message, index) => index > newUserIndex && message?.role === "assistant",
);
expect(seedUserIndex).toBeGreaterThanOrEqual(0);
expect(seedAssistantIndex).toBeGreaterThan(seedUserIndex);
expect(newUserIndex).toBeGreaterThan(seedAssistantIndex);
expect(newAssistantIndex).toBeGreaterThan(newUserIndex);
}, 45_000);
const messages = await readSessionMessages(sessionFile);
const seedUserIndex = messages.findIndex(
(message) => message?.role === "user" && textFromContent(message.content) === "seed user",
);
const seedAssistantIndex = messages.findIndex(
(message) =>
message?.role === "assistant" && textFromContent(message.content) === "seed assistant",
);
const newUserIndex = messages.findIndex(
(message) => message?.role === "user" && textFromContent(message.content) === "hello",
);
const newAssistantIndex = messages.findIndex(
(message, index) => index > newUserIndex && message?.role === "assistant",
);
expect(seedUserIndex).toBeGreaterThanOrEqual(0);
expect(seedAssistantIndex).toBeGreaterThan(seedUserIndex);
expect(newUserIndex).toBeGreaterThan(seedAssistantIndex);
expect(newAssistantIndex).toBeGreaterThan(newUserIndex);
},
45_000,
);
it("persists multi-turn user/assistant ordering across runs", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));

View File

@@ -183,7 +183,7 @@ export function buildAgentSystemPrompt(params: {
sessions_send: "Send a message to another session/sub-agent",
sessions_spawn: "Spawn a sub-agent session",
session_status:
"Show a /status-equivalent status card (usage/cost + Reasoning/Verbose/Elevated); optional per-session model override",
"Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); optional per-session model override",
image: "Analyze an image with the configured image model",
};

View File

@@ -48,8 +48,8 @@ describe("commands registry args", () => {
it("resolves auto arg menus when missing a choice arg", () => {
const command: ChatCommandDefinition = {
key: "cost",
description: "cost",
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
@@ -59,20 +59,20 @@ describe("commands registry args", () => {
name: "mode",
description: "mode",
type: "string",
choices: ["on", "off"],
choices: ["off", "tokens", "full"],
},
],
};
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("mode");
expect(menu?.choices).toEqual(["on", "off"]);
expect(menu?.choices).toEqual(["off", "tokens", "full"]);
});
it("does not show menus when arg already provided", () => {
const command: ChatCommandDefinition = {
key: "cost",
description: "cost",
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
@@ -82,14 +82,14 @@ describe("commands registry args", () => {
name: "mode",
description: "mode",
type: "string",
choices: ["on", "off"],
choices: ["off", "tokens", "full"],
},
],
};
const menu = resolveCommandArgMenu({
command,
args: { values: { mode: "on" } },
args: { values: { mode: "tokens" } },
cfg: {} as never,
});
expect(menu).toBeNull();
@@ -130,8 +130,8 @@ describe("commands registry args", () => {
it("does not show menus when args were provided as raw text only", () => {
const command: ChatCommandDefinition = {
key: "cost",
description: "cost",
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
@@ -141,7 +141,7 @@ describe("commands registry args", () => {
name: "mode",
description: "on or off",
type: "string",
choices: ["on", "off"],
choices: ["off", "tokens", "full"],
},
],
};

View File

@@ -225,16 +225,16 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
formatArgs: COMMAND_ARG_FORMATTERS.debug,
}),
defineChatCommand({
key: "cost",
nativeName: "cost",
key: "usage",
nativeName: "usage",
description: "Toggle per-response usage line.",
textAlias: "/cost",
textAlias: "/usage",
args: [
{
name: "mode",
description: "on or off",
description: "off, tokens, or full",
type: "string",
choices: ["on", "off"],
choices: ["off", "tokens", "full"],
},
],
argsMenu: "auto",
@@ -431,7 +431,6 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
.map((dock) => defineDockCommand(dock)),
];
registerAlias(commands, "status", "/usage");
registerAlias(commands, "whoami", "/id");
registerAlias(commands, "think", "/thinking", "/t");
registerAlias(commands, "verbose", "/v");

View File

@@ -144,10 +144,10 @@ describe("directive parsing", () => {
expect(res.cleaned).toBe("thats not /tmp/hello");
});
it("preserves spacing when stripping usage directives before paths", () => {
it("does not treat /usage as a status directive", () => {
const res = extractStatusDirective("thats not /usage:/tmp/hello");
expect(res.hasDirective).toBe(true);
expect(res.cleaned).toBe("thats not /tmp/hello");
expect(res.hasDirective).toBe(false);
expect(res.cleaned).toBe("thats not /usage:/tmp/hello");
});
it("parses queue options and modes", () => {

View File

@@ -159,12 +159,12 @@ describe("trigger handling", () => {
expect(String(replies[0]?.text ?? "")).toContain("Model:");
});
});
it("emits /usage once (alias of /status)", async () => {
it("sets per-response usage footer via /usage", async () => {
await withTempHome(async (home) => {
const blockReplies: Array<{ text?: string }> = [];
const res = await getReplyFromConfig(
{
Body: "/usage",
Body: "/usage tokens",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
@@ -181,7 +181,8 @@ describe("trigger handling", () => {
const replies = res ? (Array.isArray(res) ? res : [res]) : [];
expect(blockReplies.length).toBe(0);
expect(replies.length).toBe(1);
expect(String(replies[0]?.text ?? "")).toContain("Model:");
expect(String(replies[0]?.text ?? "")).toContain("Usage footer: tokens");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("sends one inline status and still returns agent reply for mixed text", async () => {

View File

@@ -203,21 +203,4 @@ describe("trigger handling", () => {
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("reports status via /usage without invoking the agent", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "/usage",
From: "+1002",
To: "+2000",
CommandAuthorized: true,
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Clawdbot");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
});

View File

@@ -457,10 +457,16 @@ export async function runReplyAgent(params: {
}
}
const responseUsageEnabled =
(activeSessionEntry?.responseUsage ??
(sessionKey ? activeSessionStore?.[sessionKey]?.responseUsage : undefined)) === "on";
if (responseUsageEnabled && hasNonzeroUsage(usage)) {
const responseUsageRaw =
activeSessionEntry?.responseUsage ??
(sessionKey ? activeSessionStore?.[sessionKey]?.responseUsage : undefined);
const responseUsageMode =
responseUsageRaw === "full"
? "full"
: responseUsageRaw === "tokens" || responseUsageRaw === "on"
? "tokens"
: "off";
if (responseUsageMode !== "off" && hasNonzeroUsage(usage)) {
const authMode = resolveModelAuthMode(providerUsed, cfg);
const showCost = authMode === "api-key";
const costConfig = showCost
@@ -470,11 +476,14 @@ export async function runReplyAgent(params: {
config: cfg,
})
: undefined;
const formatted = formatResponseUsageLine({
let formatted = formatResponseUsageLine({
usage,
showCost,
costConfig,
});
if (formatted && responseUsageMode === "full" && sessionKey) {
formatted = `${formatted} · session ${sessionKey}`;
}
if (formatted) responseUsageLine = formatted;
}

View File

@@ -20,6 +20,7 @@ import {
handleRestartCommand,
handleSendPolicyCommand,
handleStopCommand,
handleUsageCommand,
} from "./commands-session.js";
import type {
CommandHandler,
@@ -31,6 +32,7 @@ const HANDLERS: CommandHandler[] = [
handleBashCommand,
handleActivationCommand,
handleSendPolicyCommand,
handleUsageCommand,
handleRestartCommand,
handleHelpCommand,
handleCommandsListCommand,

View File

@@ -6,6 +6,7 @@ import { createInternalHookEvent, triggerInternalHook } from "../../hooks/intern
import { scheduleGatewaySigusr1Restart, triggerClawdbotRestart } from "../../infra/restart.js";
import { parseActivationCommand } from "../group-activation.js";
import { parseSendPolicyCommand } from "../send-policy.js";
import { normalizeUsageDisplay } from "../thinking.js";
import {
formatAbortReplyText,
isAbortTrigger,
@@ -127,6 +128,57 @@ export const handleSendPolicyCommand: CommandHandler = async (params, allowTextC
};
};
export const handleUsageCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const normalized = params.command.commandBodyNormalized;
if (normalized !== "/usage" && !normalized.startsWith("/usage ")) return null;
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /usage from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
const rawArgs = normalized === "/usage" ? "" : normalized.slice("/usage".length).trim();
const requested = rawArgs ? normalizeUsageDisplay(rawArgs) : undefined;
if (rawArgs && !requested) {
return {
shouldContinue: false,
reply: { text: "⚙️ Usage: /usage off|tokens|full" },
};
}
const currentRaw =
params.sessionEntry?.responseUsage ??
(params.sessionKey ? params.sessionStore?.[params.sessionKey]?.responseUsage : undefined);
const current =
currentRaw === "full"
? "full"
: currentRaw === "tokens" || currentRaw === "on"
? "tokens"
: "off";
const next = requested ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off");
if (params.sessionEntry && params.sessionStore && params.sessionKey) {
if (next === "off") delete params.sessionEntry.responseUsage;
else params.sessionEntry.responseUsage = next;
params.sessionEntry.updatedAt = Date.now();
params.sessionStore[params.sessionKey] = params.sessionEntry;
if (params.storePath) {
await updateSessionStore(params.storePath, (store) => {
store[params.sessionKey] = params.sessionEntry as SessionEntry;
});
}
}
return {
shouldContinue: false,
reply: {
text: `⚙️ Usage footer: ${next}.`,
},
};
};
export const handleRestartCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
if (params.command.commandBodyNormalized !== "/restart") return null;

View File

@@ -10,7 +10,10 @@ import {
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "../../agents/tools/sessions-helpers.js";
import {
resolveInternalSessionKey,
resolveMainSessionAlias,
} from "../../agents/tools/sessions-helpers.js";
import { normalizeProviderId } from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry, SessionScope } from "../../config/sessions.js";

View File

@@ -180,10 +180,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo
const sorted = sortSubagentRuns(runs);
const active = sorted.filter((entry) => !entry.endedAt);
const done = sorted.length - active.length;
const lines = [
"🧭 Subagents (current session)",
`Active: ${active.length} · Done: ${done}`,
];
const lines = ["🧭 Subagents (current session)", `Active: ${active.length} · Done: ${done}`];
sorted.forEach((entry, index) => {
const status = formatRunStatus(entry);
const label = formatRunLabel(entry);
@@ -396,8 +393,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo
shouldContinue: false,
reply: {
text:
replyText ??
`✅ Sent to ${formatRunLabel(resolved.entry)} (run ${runId.slice(0, 8)}).`,
replyText ?? `✅ Sent to ${formatRunLabel(resolved.entry)} (run ${runId.slice(0, 8)}).`,
},
};
}

View File

@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { addSubagentRunForTests, resetSubagentRegistryForTests } from "../../agents/subagent-registry.js";
import {
addSubagentRunForTests,
resetSubagentRegistryForTests,
} from "../../agents/subagent-registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
import * as internalHooks from "../../hooks/internal-hooks.js";
import type { MsgContext } from "../templating.js";

View File

@@ -149,7 +149,7 @@ export function extractStatusDirective(body?: string): {
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
return extractSimpleDirective(body, ["status", "usage"]);
return extractSimpleDirective(body, ["status"]);
}
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };

View File

@@ -6,7 +6,7 @@ const INLINE_SIMPLE_COMMAND_ALIASES = new Map<string, string>([
]);
const INLINE_SIMPLE_COMMAND_RE = /(?:^|\s)\/(help|commands|whoami|id)(?=$|\s|:)/i;
const INLINE_STATUS_RE = /(?:^|\s)\/(?:status|usage)(?=$|\s|:)(?:\s*:\s*)?/gi;
const INLINE_STATUS_RE = /(?:^|\s)\/status(?=$|\s|:)(?:\s*:\s*)?/gi;
export function extractInlineSimpleCommand(body?: string): {
command: string;

View File

@@ -49,12 +49,10 @@ describe("subagents utils", () => {
it("formats run status from outcome and timestamps", () => {
expect(formatRunStatus({ ...baseRun })).toBe("running");
expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "ok" } })).toBe(
"done",
expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "ok" } })).toBe("done");
expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "timeout" } })).toBe(
"timeout",
);
expect(
formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "timeout" } }),
).toBe("timeout");
});
it("formats duration short for seconds and minutes", () => {

View File

@@ -28,10 +28,7 @@ export function resolveSubagentLabel(entry: SubagentRunRecord, fallback = "subag
return raw || fallback;
}
export function formatRunLabel(
entry: SubagentRunRecord,
options?: { maxLength?: number },
) {
export function formatRunLabel(entry: SubagentRunRecord, options?: { maxLength?: number }) {
const raw = resolveSubagentLabel(entry);
const maxLength = options?.maxLength ?? 72;
if (!Number.isFinite(maxLength) || maxLength <= 0) return raw;

View File

@@ -383,7 +383,7 @@ export function buildHelpMessage(cfg?: ClawdbotConfig): string {
"/reasoning on|off",
"/elevated on|off",
"/model <id>",
"/cost on|off",
"/usage off|tokens|full",
];
if (cfg?.commands?.config === true) options.push("/config show");
if (cfg?.commands?.debug === true) options.push("/debug show");

View File

@@ -2,7 +2,7 @@ export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
export type VerboseLevel = "off" | "on" | "full";
export type ElevatedLevel = "off" | "on";
export type ReasoningLevel = "off" | "on" | "stream";
export type UsageDisplayLevel = "off" | "on";
export type UsageDisplayLevel = "off" | "tokens" | "full";
function normalizeProviderId(provider?: string | null): string {
if (!provider) return "";
@@ -92,12 +92,14 @@ export function normalizeVerboseLevel(raw?: string | null): VerboseLevel | undef
return undefined;
}
// Normalize response-usage display flags used to toggle cost/token lines.
// Normalize response-usage display modes used to toggle per-response usage footers.
export function normalizeUsageDisplay(raw?: string | null): UsageDisplayLevel | undefined {
if (!raw) return undefined;
const key = raw.toLowerCase();
if (["off", "false", "no", "0", "disable", "disabled"].includes(key)) return "off";
if (["on", "true", "yes", "1", "enable", "enabled"].includes(key)) return "on";
if (["on", "true", "yes", "1", "enable", "enabled"].includes(key)) return "tokens";
if (["tokens", "token", "tok", "minimal", "min"].includes(key)) return "tokens";
if (["full", "session"].includes(key)) return "full";
return undefined;
}

View File

@@ -42,7 +42,7 @@ export type SessionEntry = {
verboseLevel?: string;
reasoningLevel?: string;
elevatedLevel?: string;
responseUsage?: "on" | "off";
responseUsage?: "on" | "off" | "tokens" | "full";
providerOverride?: string;
modelOverride?: string;
authProfileOverride?: string;

View File

@@ -252,153 +252,158 @@ async function connectClient(params: { url: string; token: string }) {
}
describe("gateway (mock openai): tool calling", () => {
it("runs a Read tool call end-to-end via gateway agent loop", { timeout: 90_000 }, async () => {
const prev = {
home: process.env.HOME,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
};
it(
"runs a Read tool call end-to-end via gateway agent loop",
{ timeout: 90_000 },
async () => {
const prev = {
home: process.env.HOME,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
};
const originalFetch = globalThis.fetch;
const openaiResponsesUrl = "https://api.openai.com/v1/responses";
const isOpenAIResponsesRequest = (url: string) =>
url === openaiResponsesUrl ||
url.startsWith(`${openaiResponsesUrl}/`) ||
url.startsWith(`${openaiResponsesUrl}?`);
const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const originalFetch = globalThis.fetch;
const openaiResponsesUrl = "https://api.openai.com/v1/responses";
const isOpenAIResponsesRequest = (url: string) =>
url === openaiResponsesUrl ||
url.startsWith(`${openaiResponsesUrl}/`) ||
url.startsWith(`${openaiResponsesUrl}?`);
const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (isOpenAIResponsesRequest(url)) {
const bodyText =
typeof (init as { body?: unknown } | undefined)?.body !== "undefined"
? decodeBodyText((init as { body?: unknown }).body)
: input instanceof Request
? await input.clone().text()
: "";
if (isOpenAIResponsesRequest(url)) {
const bodyText =
typeof (init as { body?: unknown } | undefined)?.body !== "undefined"
? decodeBodyText((init as { body?: unknown }).body)
: input instanceof Request
? await input.clone().text()
: "";
const parsed = bodyText ? (JSON.parse(bodyText) as Record<string, unknown>) : {};
const inputItems = Array.isArray(parsed.input) ? parsed.input : [];
return await buildOpenAIResponsesSse({ input: inputItems });
}
const parsed = bodyText ? (JSON.parse(bodyText) as Record<string, unknown>) : {};
const inputItems = Array.isArray(parsed.input) ? parsed.input : [];
return await buildOpenAIResponsesSse({ input: inputItems });
}
if (!originalFetch) {
throw new Error(`fetch is not available (url=${url})`);
}
return await originalFetch(input, init);
};
// TypeScript: Bun's fetch typing includes extra properties; keep this test portable.
(globalThis as unknown as { fetch: unknown }).fetch = fetchImpl;
if (!originalFetch) {
throw new Error(`fetch is not available (url=${url})`);
}
return await originalFetch(input, init);
};
// TypeScript: Bun's fetch typing includes extra properties; keep this test portable.
(globalThis as unknown as { fetch: unknown }).fetch = fetchImpl;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-mock-home-"));
process.env.HOME = tempHome;
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-mock-home-"));
process.env.HOME = tempHome;
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
const token = `test-${randomUUID()}`;
process.env.CLAWDBOT_GATEWAY_TOKEN = token;
const token = `test-${randomUUID()}`;
process.env.CLAWDBOT_GATEWAY_TOKEN = token;
const workspaceDir = path.join(tempHome, "clawd");
await fs.mkdir(workspaceDir, { recursive: true });
const workspaceDir = path.join(tempHome, "clawd");
await fs.mkdir(workspaceDir, { recursive: true });
const nonceA = randomUUID();
const nonceB = randomUUID();
const toolProbePath = path.join(workspaceDir, `.clawdbot-tool-probe.${nonceA}.txt`);
await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`);
const nonceA = randomUUID();
const nonceB = randomUUID();
const toolProbePath = path.join(workspaceDir, `.clawdbot-tool-probe.${nonceA}.txt`);
await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`);
const configDir = path.join(tempHome, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
const configPath = path.join(configDir, "clawdbot.json");
const configDir = path.join(tempHome, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
const configPath = path.join(configDir, "clawdbot.json");
const cfg = {
agents: { defaults: { workspace: workspaceDir } },
models: {
mode: "replace",
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "test",
api: "openai-responses",
models: [
{
id: "gpt-5.2",
name: "gpt-5.2",
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4096,
},
],
const cfg = {
agents: { defaults: { workspace: workspaceDir } },
models: {
mode: "replace",
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "test",
api: "openai-responses",
models: [
{
id: "gpt-5.2",
name: "gpt-5.2",
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4096,
},
],
},
},
},
},
gateway: { auth: { token } },
};
gateway: { auth: { token } },
};
await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
process.env.CLAWDBOT_CONFIG_PATH = configPath;
await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
process.env.CLAWDBOT_CONFIG_PATH = configPath;
const port = await getFreeGatewayPort();
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "token", token },
controlUiEnabled: false,
});
const client = await connectClient({
url: `ws://127.0.0.1:${port}`,
token,
});
try {
const sessionKey = "agent:dev:mock-openai";
await client.request<Record<string, unknown>>("sessions.patch", {
key: sessionKey,
model: "openai/gpt-5.2",
const port = await getFreeGatewayPort();
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "token", token },
controlUiEnabled: false,
});
const runId = randomUUID();
const payload = await client.request<{
status?: unknown;
result?: unknown;
}>(
"agent",
{
sessionKey,
idempotencyKey: `idem-${runId}`,
message:
`Call the read tool on "${toolProbePath}". ` +
`Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`,
deliver: false,
},
{ expectFinal: true },
);
const client = await connectClient({
url: `ws://127.0.0.1:${port}`,
token,
});
expect(payload?.status).toBe("ok");
const text = extractPayloadText(payload?.result);
expect(text).toContain(nonceA);
expect(text).toContain(nonceB);
} finally {
client.stop();
await server.close({ reason: "mock openai test complete" });
await fs.rm(tempHome, { recursive: true, force: true });
(globalThis as unknown as { fetch: unknown }).fetch = originalFetch;
process.env.HOME = prev.home;
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
}
}, 30_000);
try {
const sessionKey = "agent:dev:mock-openai";
await client.request<Record<string, unknown>>("sessions.patch", {
key: sessionKey,
model: "openai/gpt-5.2",
});
const runId = randomUUID();
const payload = await client.request<{
status?: unknown;
result?: unknown;
}>(
"agent",
{
sessionKey,
idempotencyKey: `idem-${runId}`,
message:
`Call the read tool on "${toolProbePath}". ` +
`Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`,
deliver: false,
},
{ expectFinal: true },
);
expect(payload?.status).toBe("ok");
const text = extractPayloadText(payload?.result);
expect(text).toContain(nonceA);
expect(text).toContain(nonceB);
} finally {
client.stop();
await server.close({ reason: "mock openai test complete" });
await fs.rm(tempHome, { recursive: true, force: true });
(globalThis as unknown as { fetch: unknown }).fetch = originalFetch;
process.env.HOME = prev.home;
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
}
},
30_000,
);
});

View File

@@ -35,7 +35,14 @@ export const SessionsPatchParamsSchema = Type.Object(
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
responseUsage: Type.Optional(
Type.Union([Type.Literal("on"), Type.Literal("off"), Type.Null()]),
Type.Union([
Type.Literal("off"),
Type.Literal("tokens"),
Type.Literal("full"),
// Backward compat with older clients/stores.
Type.Literal("on"),
Type.Null(),
]),
),
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),

View File

@@ -196,7 +196,6 @@ export const handleBridgeEvent = async (
? obj.exitCode
: undefined;
const timedOut = obj.timedOut === true;
const success = obj.success === true;
const output = typeof obj.output === "string" ? obj.output.trim() : "";
const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";

View File

@@ -31,7 +31,7 @@ export type GatewaySessionRow = {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
responseUsage?: "on" | "off";
responseUsage?: "on" | "off" | "tokens" | "full";
modelProvider?: string;
model?: string;
contextTokens?: number;

View File

@@ -132,7 +132,7 @@ export async function applySessionsPatchToStore(params: {
delete next.responseUsage;
} else if (raw !== undefined) {
const normalized = normalizeUsageDisplay(String(raw));
if (!normalized) return invalid('invalid responseUsage (use "on"|"off")');
if (!normalized) return invalid('invalid responseUsage (use "off"|"tokens"|"full")');
if (normalized === "off") delete next.responseUsage;
else next.responseUsage = normalized;
}

View File

@@ -97,8 +97,8 @@ describe("Slack native command argument menus", () => {
const { commands, ctx, account } = createHarness();
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
const handler = commands.get("/cost");
if (!handler) throw new Error("Missing /cost handler");
const handler = commands.get("/usage");
if (!handler) throw new Error("Missing /usage handler");
const respond = vi.fn().mockResolvedValue(undefined);
const ack = vi.fn().mockResolvedValue(undefined);
@@ -133,7 +133,7 @@ describe("Slack native command argument menus", () => {
await handler({
ack: vi.fn().mockResolvedValue(undefined),
action: {
value: encodeValue({ command: "cost", arg: "mode", value: "on", userId: "U1" }),
value: encodeValue({ command: "usage", arg: "mode", value: "tokens", userId: "U1" }),
},
body: {
user: { id: "U1", name: "Ada" },
@@ -145,7 +145,7 @@ describe("Slack native command argument menus", () => {
expect(dispatchMock).toHaveBeenCalledTimes(1);
const call = dispatchMock.mock.calls[0]?.[0] as { ctx?: { Body?: string } };
expect(call.ctx?.Body).toBe("/cost on");
expect(call.ctx?.Body).toBe("/usage tokens");
});
it("rejects menu clicks from other users", async () => {
@@ -159,7 +159,7 @@ describe("Slack native command argument menus", () => {
await handler({
ack: vi.fn().mockResolvedValue(undefined),
action: {
value: encodeValue({ command: "cost", arg: "mode", value: "on", userId: "U1" }),
value: encodeValue({ command: "usage", arg: "mode", value: "tokens", userId: "U1" }),
},
body: {
user: { id: "U2", name: "Eve" },

View File

@@ -5,7 +5,7 @@ const VERBOSE_LEVELS = ["on", "off"];
const REASONING_LEVELS = ["on", "off"];
const ELEVATED_LEVELS = ["on", "off"];
const ACTIVATION_LEVELS = ["mention", "always"];
const TOGGLE = ["on", "off"];
const USAGE_FOOTER_LEVELS = ["off", "tokens", "full"];
export type ParsedCommand = {
name: string;
@@ -73,10 +73,10 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman
})),
},
{
name: "cost",
name: "usage",
description: "Toggle per-response usage line",
getArgumentCompletions: (prefix) =>
TOGGLE.filter((v) => v.startsWith(prefix.toLowerCase())).map((value) => ({
USAGE_FOOTER_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map((value) => ({
value,
label: value,
})),
@@ -129,7 +129,7 @@ export function helpText(options: SlashCommandOptions = {}): string {
`/think <${thinkLevels}>`,
"/verbose <on|off>",
"/reasoning <on|off>",
"/cost <on|off>",
"/usage <off|tokens|full>",
"/elevated <on|off>",
"/elev <on|off>",
"/activation <mention|always>",

View File

@@ -52,7 +52,7 @@ export type GatewaySessionList = {
inputTokens?: number | null;
outputTokens?: number | null;
totalTokens?: number | null;
responseUsage?: "on" | "off";
responseUsage?: "on" | "off" | "tokens" | "full";
modelProvider?: string;
label?: string;
displayName?: string;

View File

@@ -317,23 +317,30 @@ export function createCommandHandlers(context: CommandHandlerContext) {
chatLog.addSystem(`reasoning failed: ${String(err)}`);
}
break;
case "cost": {
case "usage": {
const normalized = args ? normalizeUsageDisplay(args) : undefined;
if (args && !normalized) {
chatLog.addSystem("usage: /cost <on|off>");
chatLog.addSystem("usage: /usage <off|tokens|full>");
break;
}
const current = state.sessionInfo.responseUsage === "on" ? "on" : "off";
const next = normalized ?? (current === "on" ? "off" : "on");
const currentRaw = state.sessionInfo.responseUsage;
const current =
currentRaw === "full"
? "full"
: currentRaw === "tokens" || currentRaw === "on"
? "tokens"
: "off";
const next =
normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off");
try {
await client.patchSession({
key: state.currentSessionKey,
responseUsage: next === "off" ? null : next,
});
chatLog.addSystem(next === "on" ? "usage line enabled" : "usage line disabled");
chatLog.addSystem(`usage footer: ${next}`);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`cost failed: ${String(err)}`);
chatLog.addSystem(`usage failed: ${String(err)}`);
}
break;
}

View File

@@ -34,7 +34,7 @@ export type SessionInfo = {
inputTokens?: number | null;
outputTokens?: number | null;
totalTokens?: number | null;
responseUsage?: "on" | "off";
responseUsage?: "on" | "off" | "tokens" | "full";
updatedAt?: number | null;
displayName?: string;
};