feat(slash-commands): usage footer modes
This commit is contained in:
@@ -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-"));
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)}).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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()])),
|
||||
|
||||
@@ -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() : "";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user