From 2dabce59ce0e40392cb5de2589e4c85189d713ed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 05:35:22 +0000 Subject: [PATCH] feat(slash-commands): usage footer modes --- CHANGELOG.md | 1 + README.md | 2 +- docs/cli/index.md | 2 +- docs/concepts/context.md | 3 +- docs/concepts/usage-tracking.md | 2 +- docs/token-use.md | 4 +- docs/tools/slash-commands.md | 15 +- docs/tui.md | 2 +- ...ssistant-after-existing-transcript.test.ts | 133 ++++----- src/agents/system-prompt.ts | 2 +- src/auto-reply/commands-registry.args.test.ts | 22 +- src/auto-reply/commands-registry.data.ts | 11 +- src/auto-reply/reply.directive.parse.test.ts | 6 +- ...age-summary-current-model-provider.test.ts | 7 +- ...efault-model-status-not-configured.test.ts | 17 -- src/auto-reply/reply/agent-runner.ts | 19 +- src/auto-reply/reply/commands-core.ts | 2 + src/auto-reply/reply/commands-session.ts | 52 ++++ src/auto-reply/reply/commands-status.ts | 5 +- src/auto-reply/reply/commands-subagents.ts | 8 +- src/auto-reply/reply/commands.test.ts | 5 +- src/auto-reply/reply/directives.ts | 2 +- src/auto-reply/reply/reply-inline.ts | 2 +- src/auto-reply/reply/subagents-utils.test.ts | 8 +- src/auto-reply/reply/subagents-utils.ts | 5 +- src/auto-reply/status.ts | 2 +- src/auto-reply/thinking.ts | 8 +- src/config/sessions/types.ts | 2 +- .../gateway.tool-calling.mock-openai.test.ts | 269 +++++++++--------- src/gateway/protocol/schema/sessions.ts | 9 +- src/gateway/server-bridge-events.ts | 1 - src/gateway/session-utils.types.ts | 2 +- src/gateway/sessions-patch.ts | 2 +- .../monitor/slash.command-arg-menus.test.ts | 10 +- src/tui/commands.ts | 8 +- src/tui/gateway-chat.ts | 2 +- src/tui/tui-command-handlers.ts | 19 +- src/tui/tui-types.ts | 2 +- 38 files changed, 370 insertions(+), 303 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d89d606b..b93fcac89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.clawd.bot - Exec: add host/security/ask routing for gateway + node exec. - macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle. - macOS: add approvals socket UI server + node exec lifecycle events. +- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639. - Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals ### Fixes diff --git a/README.md b/README.md index 132419141..6c28bc24f 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar - `/compact` — compact session context (summary) - `/think ` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only) - `/verbose on|off` -- `/cost on|off` — append per-response token/cost usage lines +- `/usage off|tokens|full` — per-response usage footer - `/restart` — restart the gateway (owner-only in groups) - `/activation mention|always` — group activation toggle (groups only) diff --git a/docs/cli/index.md b/docs/cli/index.md index d07bb3407..760924519 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -522,7 +522,7 @@ Options: Clawdbot can surface provider usage/quota when OAuth/API creds are available. Surfaces: -- `/status` (alias: `/usage`; adds a short usage line when available) +- `/status` (adds a short provider usage line when available) - `clawdbot status --usage` (prints full provider breakdown) - macOS menu bar (Usage section under Context) diff --git a/docs/concepts/context.md b/docs/concepts/context.md index c9cb6c143..9121ba378 100644 --- a/docs/concepts/context.md +++ b/docs/concepts/context.md @@ -21,7 +21,7 @@ Context is *not the same thing* as “memory”: memory can be stored on disk an - `/status` → quick “how full is my window?” view + session settings. - `/context list` → what’s injected + rough sizes (per file + totals). - `/context detail` → deeper breakdown: per-file, per-tool schema sizes, per-skill entry sizes, and system prompt size. -- `/cost on` → append per-reply usage line to normal replies. +- `/usage tokens` → append per-reply usage footer to normal replies. - `/compact` → summarize older history into a compact entry to free window space. See also: [Slash commands](/tools/slash-commands), [Token use & costs](/token-use), [Compaction](/concepts/compaction). @@ -149,4 +149,3 @@ Docs: [Session](/concepts/session), [Compaction](/concepts/compaction), [Session - `System prompt (estimate)` = computed on the fly when no run report exists (or when running via a CLI backend that doesn’t generate the report). Either way, it reports sizes and top contributors; it does **not** dump the full system prompt or tool schemas. - diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md index 77122007b..93d52983f 100644 --- a/docs/concepts/usage-tracking.md +++ b/docs/concepts/usage-tracking.md @@ -12,7 +12,7 @@ read_when: ## Where it shows up - `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available. -- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only). +- `/usage off|tokens|full` in chats: per-response usage footer (OAuth shows tokens only). - CLI: `clawdbot status --usage` prints a full per-provider breakdown. - CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip). - macOS menu bar: “Usage” section under Context (only if available). diff --git a/docs/token-use.md b/docs/token-use.md index 3fe2b5c04..c5d1a8f92 100644 --- a/docs/token-use.md +++ b/docs/token-use.md @@ -42,13 +42,13 @@ Use these in chat: - `/status` → **emoji‑rich status card** with the session model, context usage, last response input/output tokens, and **estimated cost** (API key only). -- `/cost on|off` → appends a **per-response usage line** to every reply. +- `/usage off|tokens|full` → appends a **per-response usage footer** to every reply. - Persists per session (stored as `responseUsage`). - OAuth auth **hides cost** (tokens only). Other surfaces: -- **TUI/Web TUI:** `/status` + `/cost` are supported. +- **TUI/Web TUI:** `/status` + `/usage` are supported. - **CLI:** `clawdbot status --usage` and `clawdbot channels list` show provider quota windows (not per-response costs). diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 50acbc871..76f838feb 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -17,7 +17,7 @@ There are two related systems: - In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings. - In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement. -There are also a few **inline shortcuts** (allowlisted/authorized senders only): `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`). +There are also a few **inline shortcuts** (allowlisted/authorized senders only): `/help`, `/commands`, `/status`, `/whoami` (`/id`). They run immediately, are stripped before the model sees the message, and the remaining text continues through the normal flow. ## Config @@ -60,12 +60,11 @@ Text + native (when enabled): - `/commands` - `/status` (show current status; includes provider usage/quota for the current model provider when available) - `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) -- `/usage` (alias: `/status`) - `/whoami` (show your sender id; alias: `/id`) - `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session) - `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`) - `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`) -- `/cost on|off` (toggle per-response usage line) +- `/usage off|tokens|full` (per-response usage footer) - `/stop` - `/restart` - `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram) @@ -90,8 +89,8 @@ Text-only: Notes: - Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`). -- `/status` and `/usage` show the same status output; for full provider usage breakdown, use `clawdbot status --usage`. -- `/cost` appends per-response token usage; it only shows dollar cost when the model uses an API key (OAuth hides cost). +- For full provider usage breakdown, use `clawdbot status --usage`. +- `/usage` controls the per-response usage footer. It only shows dollar cost when the model uses an API key (OAuth hides cost). - `/restart` is disabled by default; set `commands.restart: true` to enable it. - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. @@ -99,15 +98,15 @@ Notes: - **Group mention gating:** command-only messages from allowlisted senders bypass mention requirements. - **Inline shortcuts (allowlisted senders only):** certain commands also work when embedded in a normal message and are stripped before the model sees the remaining text. - Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow. - - Currently: `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`). +- Currently: `/help`, `/commands`, `/status`, `/whoami` (`/id`). - Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text. - **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`). - **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg. -## Usage vs cost (what shows where) +## Usage surfaces (what shows where) - **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` for the current model provider when usage tracking is enabled. -- **Per-response tokens/cost** is controlled by `/cost on|off` (appended to normal replies). +- **Per-response tokens/cost** is controlled by `/usage off|tokens|full` (appended to normal replies). - `/model status` is about **models/auth/endpoints**, not usage. ## Model selection (`/model`) diff --git a/docs/tui.md b/docs/tui.md index f10f20164..57fffa493 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -77,7 +77,7 @@ Session controls: - `/think ` - `/verbose ` - `/reasoning ` -- `/cost ` +- `/usage ` - `/elevated ` (alias: `/elev`) - `/activation ` - `/deliver ` diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.appends-new-user-assistant-after-existing-transcript.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.appends-new-user-assistant-after-existing-transcript.test.ts index 275b51e0e..63a5443a3 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.appends-new-user-assistant-after-existing-transcript.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.appends-new-user-assistant-after-existing-transcript.test.ts @@ -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-")); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index fea4ef28e..5a6a7c699 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -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", }; diff --git a/src/auto-reply/commands-registry.args.test.ts b/src/auto-reply/commands-registry.args.test.ts index 0d37e0aae..8dda3a706 100644 --- a/src/auto-reply/commands-registry.args.test.ts +++ b/src/auto-reply/commands-registry.args.test.ts @@ -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"], }, ], }; diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 7d9607853..887a519fc 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -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"); diff --git a/src/auto-reply/reply.directive.parse.test.ts b/src/auto-reply/reply.directive.parse.test.ts index cbbea25d0..a85718a38 100644 --- a/src/auto-reply/reply.directive.parse.test.ts +++ b/src/auto-reply/reply.directive.parse.test.ts @@ -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", () => { diff --git a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts index f236f88ee..e1664deb7 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts @@ -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 () => { diff --git a/src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.test.ts b/src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.test.ts index 2c2461dcd..daa25b82b 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.test.ts @@ -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(); - }); - }); }); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 5630586a9..364bb0d61 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -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; } diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index 9afb24f1d..887c45568 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -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, diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 6e9b04c2d..2fe5e41f0 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -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 || ""}`, + ); + 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; diff --git a/src/auto-reply/reply/commands-status.ts b/src/auto-reply/reply/commands-status.ts index 55219a2c1..c75aedddf 100644 --- a/src/auto-reply/reply/commands-status.ts +++ b/src/auto-reply/reply/commands-status.ts @@ -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"; diff --git a/src/auto-reply/reply/commands-subagents.ts b/src/auto-reply/reply/commands-subagents.ts index 2fc619db5..f57c34f95 100644 --- a/src/auto-reply/reply/commands-subagents.ts +++ b/src/auto-reply/reply/commands-subagents.ts @@ -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)}).`, }, }; } diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 6312d0ca8..2f98c6abb 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -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"; diff --git a/src/auto-reply/reply/directives.ts b/src/auto-reply/reply/directives.ts index 95aea371e..642beb0e8 100644 --- a/src/auto-reply/reply/directives.ts +++ b/src/auto-reply/reply/directives.ts @@ -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 }; diff --git a/src/auto-reply/reply/reply-inline.ts b/src/auto-reply/reply/reply-inline.ts index 811584fc4..37fb4fb8b 100644 --- a/src/auto-reply/reply/reply-inline.ts +++ b/src/auto-reply/reply/reply-inline.ts @@ -6,7 +6,7 @@ const INLINE_SIMPLE_COMMAND_ALIASES = new Map([ ]); 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; diff --git a/src/auto-reply/reply/subagents-utils.test.ts b/src/auto-reply/reply/subagents-utils.test.ts index ba2545c0a..a7496a16d 100644 --- a/src/auto-reply/reply/subagents-utils.test.ts +++ b/src/auto-reply/reply/subagents-utils.test.ts @@ -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", () => { diff --git a/src/auto-reply/reply/subagents-utils.ts b/src/auto-reply/reply/subagents-utils.ts index 238f3b8c7..9afbff9e0 100644 --- a/src/auto-reply/reply/subagents-utils.ts +++ b/src/auto-reply/reply/subagents-utils.ts @@ -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; diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 28b59321f..cd3a4e332 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -383,7 +383,7 @@ export function buildHelpMessage(cfg?: ClawdbotConfig): string { "/reasoning on|off", "/elevated on|off", "/model ", - "/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"); diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index 9abb8c0ca..75862f91c 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -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; } diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 486184292..94c6177e3 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -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; diff --git a/src/gateway/gateway.tool-calling.mock-openai.test.ts b/src/gateway/gateway.tool-calling.mock-openai.test.ts index b756c2e7a..bcc66ad06 100644 --- a/src/gateway/gateway.tool-calling.mock-openai.test.ts +++ b/src/gateway/gateway.tool-calling.mock-openai.test.ts @@ -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 => { - 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 => { + 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) : {}; - const inputItems = Array.isArray(parsed.input) ? parsed.input : []; - return await buildOpenAIResponsesSse({ input: inputItems }); - } + const parsed = bodyText ? (JSON.parse(bodyText) as Record) : {}; + 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>("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>("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, + ); }); diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index c644a3d74..01831f429 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -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()])), diff --git a/src/gateway/server-bridge-events.ts b/src/gateway/server-bridge-events.ts index 31d222cb3..f42c5f522 100644 --- a/src/gateway/server-bridge-events.ts +++ b/src/gateway/server-bridge-events.ts @@ -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() : ""; diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 726787a36..4735a0ff4 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -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; diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index d2f09b275..2949fe34e 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -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; } diff --git a/src/slack/monitor/slash.command-arg-menus.test.ts b/src/slack/monitor/slash.command-arg-menus.test.ts index 9f47cb1bf..95cb69ecf 100644 --- a/src/slack/monitor/slash.command-arg-menus.test.ts +++ b/src/slack/monitor/slash.command-arg-menus.test.ts @@ -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" }, diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 1e07c8e18..b85049472 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -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 ", "/reasoning ", - "/cost ", + "/usage ", "/elevated ", "/elev ", "/activation ", diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 3a23b013a..1ba09df49 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -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; diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 3783116ce..d9f12696e 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -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 "); + chatLog.addSystem("usage: /usage "); 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; } diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts index 8914d5d12..d92fc5b0f 100644 --- a/src/tui/tui-types.ts +++ b/src/tui/tui-types.ts @@ -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; };