From 468889abef0352dc7b7fcb0c9589852956e80e04 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 03:09:50 +0100 Subject: [PATCH] fix: refine status usage and elevated directives --- CHANGELOG.md | 5 ++ src/auto-reply/reply.directive.test.ts | 34 ++++++++++ src/auto-reply/reply.triggers.test.ts | 76 ++++++++++++++++++++++ src/auto-reply/reply.ts | 20 +++++- src/auto-reply/reply/commands.ts | 2 + src/auto-reply/reply/directive-handling.ts | 32 ++++++++- src/auto-reply/status.test.ts | 4 +- src/auto-reply/status.ts | 4 +- 8 files changed, 168 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cdfb012a..fe365ca12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,11 @@ - Docs: expand parameter descriptions for agent/wake hooks. (#532) โ€” thanks @mcinteerj - Docs: add community showcase entries from Discord. (#476) โ€” thanks @gupsammy - TUI: refresh status bar after think/verbose/reasoning changes. (#519) โ€” thanks @jdrhyne +- Status: show Verbose/Elevated only when enabled. +- Status: filter usage summary to the active model provider. +- Commands: allow /elevated off in groups without a mention; keep /elevated on mention-gated. +- Commands: keep multi-directive messages from clearing directive handling. +- Commands: warn when /elevated runs in direct (unsandboxed) runtime. - Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond. - Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) โ€” thanks @neist diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index a690ad505..32c25f4d9 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -472,6 +472,40 @@ describe("directive behavior", () => { }); }); + it("warns when elevated is used in direct runtime", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + + const res = await getReplyFromConfig( + { + Body: "/elevated off", + From: "+1222", + To: "+1222", + Provider: "whatsapp", + SenderE164: "+1222", + }, + {}, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + elevated: { + allowFrom: { whatsapp: ["+1222"] }, + }, + sandbox: { mode: "off" }, + }, + whatsapp: { allowFrom: ["+1222"] }, + session: { store: path.join(home, "sessions.json") }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode disabled."); + expect(text).toContain("Runtime is direct; sandboxing does not apply."); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("rejects invalid elevated level", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index fb7ad374d..371da1a8c 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -14,6 +14,16 @@ vi.mock("../agents/pi-embedded.js", () => ({ isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), })); +const usageMocks = vi.hoisted(() => ({ + loadProviderUsageSummary: vi.fn().mockResolvedValue({ + updatedAt: 0, + providers: [], + }), + formatUsageSummaryLine: vi.fn().mockReturnValue("๐Ÿ“Š Usage: Claude 80% left"), +})); + +vi.mock("../infra/provider-usage.js", () => usageMocks); + import { abortEmbeddedPiRun, compactEmbeddedPiSession, @@ -66,6 +76,30 @@ afterEach(() => { }); describe("trigger handling", () => { + it("filters usage summary to the current model provider", async () => { + await withTempHome(async (home) => { + usageMocks.loadProviderUsageSummary.mockClear(); + + const res = await getReplyFromConfig( + { + Body: "/status", + From: "+1000", + To: "+2000", + Provider: "whatsapp", + SenderE164: "+1000", + }, + {}, + makeCfg(home), + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("๐Ÿ“Š Usage: Claude 80% left"); + expect(usageMocks.loadProviderUsageSummary).toHaveBeenCalledWith( + expect.objectContaining({ providers: ["anthropic"] }), + ); + }); + }); + it("aborts even with timestamp prefix", async () => { await withTempHome(async (home) => { const res = await getReplyFromConfig( @@ -383,6 +417,48 @@ describe("trigger handling", () => { }); }); + it("allows elevated off in groups without mention", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + const cfg = { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + elevated: { + allowFrom: { whatsapp: ["+1000"] }, + }, + }, + whatsapp: { + allowFrom: ["+1000"], + groups: { "*": { requireMention: false } }, + }, + session: { store: join(home, "sessions.json") }, + }; + + const res = await getReplyFromConfig( + { + Body: "/elevated off", + From: "group:123@g.us", + To: "whatsapp:+2000", + Provider: "whatsapp", + SenderE164: "+1000", + ChatType: "group", + WasMentioned: false, + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode disabled."); + }); + }); + it("allows elevated directive in groups when mentioned", async () => { await withTempHome(async (home) => { const cfg = { diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 5248fb5c6..de038bdfb 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -329,11 +329,19 @@ export async function getReplyFromConfig( .map((entry) => entry.alias?.trim()) .filter((alias): alias is string => Boolean(alias)) .filter((alias) => !reservedCommands.has(alias.toLowerCase())); - const disableElevatedInGroup = isGroup && ctx.WasMentioned !== true; let parsedDirectives = parseInlineDirectives(rawBody, { modelAliases: configuredAliases, - disableElevated: disableElevatedInGroup, }); + if (isGroup && ctx.WasMentioned !== true && parsedDirectives.hasElevatedDirective) { + if (parsedDirectives.elevatedLevel !== "off") { + parsedDirectives = { + ...parsedDirectives, + hasElevatedDirective: false, + elevatedLevel: undefined, + rawElevatedLevel: undefined, + }; + } + } const hasDirective = parsedDirectives.hasThinkDirective || parsedDirectives.hasVerboseDirective || @@ -348,7 +356,13 @@ export async function getReplyFromConfig( ? stripMentions(stripped, ctx, cfg, agentId) : stripped; if (noMentions.trim().length > 0) { - parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned); + const directiveOnlyCheck = parseInlineDirectives(noMentions, { + modelAliases: configuredAliases, + disableElevated: disableElevatedInGroup, + }); + if (directiveOnlyCheck.cleaned.trim().length > 0) { + parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned); + } } } const directives = commandAuthorized diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index ba61e5c0b..4435ef5c9 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -37,6 +37,7 @@ import { normalizeCommandBody, shouldHandleTextCommands, } from "../commands-registry.js"; +import { normalizeProviderId } from "../../agents/model-selection.js"; import { normalizeGroupActivation, parseActivationCommand, @@ -424,6 +425,7 @@ export async function handleCommands(params: { try { const usageSummary = await loadProviderUsageSummary({ timeoutMs: 3500, + providers: [normalizeProviderId(provider)], }); usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() }); } catch { diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index 30c71ccf1..6aea3a980 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -24,7 +24,12 @@ import { resolveModelRefFromString, } from "../../agents/model-selection.js"; import type { ClawdbotConfig } from "../../config/config.js"; -import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; +import { + resolveAgentIdFromSessionKey, + resolveAgentMainSessionKey, + type SessionEntry, + saveSessionStore, +} from "../../config/sessions.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { shortenHomePath } from "../../utils.js"; import { extractModelDirective } from "../model.js"; @@ -57,6 +62,8 @@ const SYSTEM_MARK = "โš™๏ธ"; const formatOptionsLine = (options: string) => `Options: ${options}.`; const withOptions = (line: string, options: string) => `${line}\n${formatOptionsLine(options)}`; +const formatElevatedRuntimeHint = () => + `${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`; const maskApiKey = (value: string): string => { const trimmed = value.trim(); @@ -350,6 +357,21 @@ export async function handleDirectiveOnly(params: { currentReasoningLevel, currentElevatedLevel, } = params; + const runtimeIsSandboxed = (() => { + const sandboxMode = params.cfg.agent?.sandbox?.mode ?? "off"; + if (sandboxMode === "off") return false; + const sessionKey = params.sessionKey?.trim(); + if (!sessionKey) return false; + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const mainKey = resolveAgentMainSessionKey({ + cfg: params.cfg, + agentId, + }); + if (sandboxMode === "all") return true; + return sessionKey !== mainKey; + })(); + const shouldHintDirectRuntime = + directives.hasElevatedDirective && !runtimeIsSandboxed; if (directives.hasModelDirective) { const modelDirective = directives.rawModelDirective?.trim().toLowerCase(); @@ -463,7 +485,12 @@ export async function handleDirectiveOnly(params: { } const level = currentElevatedLevel ?? "off"; return { - text: withOptions(`Current elevated level: ${level}.`, "on, off"), + text: [ + withOptions(`Current elevated level: ${level}.`, "on, off"), + shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null, + ] + .filter(Boolean) + .join("\n"), }; } return { @@ -681,6 +708,7 @@ export async function handleDirectiveOnly(params: { ? `${SYSTEM_MARK} Elevated mode disabled.` : `${SYSTEM_MARK} Elevated mode enabled.`, ); + if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint()); } if (modelSelection) { const label = `${modelSelection.provider}/${modelSelection.model}`; diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 36ef4e848..7ac76b62c 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -74,8 +74,8 @@ describe("buildStatusMessage", () => { expect(text).toContain("Session: agent:main:main"); expect(text).toContain("updated 10m ago"); expect(text).toContain("Think: medium"); - expect(text).toContain("Verbose: off"); - expect(text).toContain("Elevated: on"); + expect(text).not.toContain("Verbose"); + expect(text).toContain("Elevated"); expect(text).toContain("Queue: collect"); }); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index a6a5c8588..fc374d83a 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -267,9 +267,9 @@ export function buildStatusMessage(args: StatusArgs): string { const optionParts = [ `Runtime: ${runtime.label}`, `Think: ${thinkLevel}`, - `Verbose: ${verboseLevel}`, + verboseLevel === "on" ? "Verbose" : null, reasoningLevel !== "off" ? `Reasoning: ${reasoningLevel}` : null, - `Elevated: ${elevatedLevel}`, + elevatedLevel === "on" ? "Elevated" : null, ]; const optionsLine = optionParts.filter(Boolean).join(" ยท "); const activationParts = [