diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c4d4ccf8..5a1efd02b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ - 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. +- Commands: return /status in directive-only multi-line messages. - Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist ## 2026.1.8 diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 46b9eba51..da18e9ddf 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -571,6 +571,39 @@ describe("directive behavior", () => { }); }); + it("returns status alongside directive-only acks", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + + const res = await getReplyFromConfig( + { + Body: "/elevated off\n/status", + From: "+1222", + To: "+1222", + Provider: "whatsapp", + SenderE164: "+1222", + }, + {}, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + elevated: { + allowFrom: { whatsapp: ["+1222"] }, + }, + }, + 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("status agent:main:main"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("acks queue directive and persists override", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index e194e7d47..eebbe2be0 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -40,7 +40,11 @@ import { getAbortMemory } from "./reply/abort.js"; import { runReplyAgent } from "./reply/agent-runner.js"; import { resolveBlockStreamingChunking } from "./reply/block-streaming.js"; import { applySessionHints } from "./reply/body.js"; -import { buildCommandContext, handleCommands } from "./reply/commands.js"; +import { + buildCommandContext, + buildStatusReply, + handleCommands, +} from "./reply/commands.js"; import { handleDirectiveOnly, type InlineDirectives, @@ -346,7 +350,6 @@ export async function getReplyFromConfig( }; } } - const disableElevatedInGroup = isGroup && ctx.WasMentioned !== true; const hasDirective = parsedDirectives.hasThinkDirective || parsedDirectives.hasVerboseDirective || @@ -483,6 +486,21 @@ export async function getReplyFromConfig( ? undefined : directives.rawModelDirective; + const command = buildCommandContext({ + ctx, + cfg, + agentId, + sessionKey, + isGroup, + triggerBodyNormalized, + commandAuthorized, + }); + const allowTextCommands = shouldHandleTextCommands({ + cfg, + surface: command.surface, + commandSource: ctx.CommandSource, + }); + if ( isDirectiveOnly({ directives, @@ -528,8 +546,36 @@ export async function getReplyFromConfig( currentReasoningLevel, currentElevatedLevel, }); + let statusReply: ReplyPayload | undefined; + if (directives.hasStatusDirective && allowTextCommands) { + statusReply = await buildStatusReply({ + cfg, + command, + sessionEntry, + sessionKey, + sessionScope, + provider, + model, + contextTokens, + resolvedThinkLevel: + currentThinkLevel ?? + (agentCfg?.thinkingDefault as ThinkLevel | undefined), + resolvedVerboseLevel: (currentVerboseLevel ?? "off") as VerboseLevel, + resolvedReasoningLevel: (currentReasoningLevel ?? + "off") as ReasoningLevel, + resolvedElevatedLevel: currentElevatedLevel, + resolveDefaultThinkingLevel: async () => + currentThinkLevel ?? + (agentCfg?.thinkingDefault as ThinkLevel | undefined), + isGroup, + defaultGroupActivation: () => defaultActivation, + }); + } typing.cleanup(); - return directiveReply; + if (statusReply?.text && directiveReply?.text) { + return { text: `${directiveReply.text}\n${statusReply.text}` }; + } + return statusReply ?? directiveReply; } const persisted = await persistInlineDirectives({ @@ -569,20 +615,6 @@ export async function getReplyFromConfig( } : undefined; - const command = buildCommandContext({ - ctx, - cfg, - agentId, - sessionKey, - isGroup, - triggerBodyNormalized, - commandAuthorized, - }); - const allowTextCommands = shouldHandleTextCommands({ - cfg, - surface: command.surface, - commandSource: ctx.CommandSource, - }); const isEmptyConfig = Object.keys(cfg).length === 0; if ( command.isWhatsAppProvider && diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index d27b685b9..2e847c35e 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -7,6 +7,7 @@ import { getCustomProviderApiKey, resolveEnvApiKey, } from "../../agents/model-auth.js"; +import { normalizeProviderId } from "../../agents/model-selection.js"; import { abortEmbeddedPiRun, compactEmbeddedPiSession, @@ -93,6 +94,110 @@ export type CommandContext = { to?: string; }; +export async function buildStatusReply(params: { + cfg: ClawdbotConfig; + command: CommandContext; + sessionEntry?: SessionEntry; + sessionKey?: string; + sessionScope?: SessionScope; + provider: string; + model: string; + contextTokens: number; + resolvedThinkLevel?: ThinkLevel; + resolvedVerboseLevel: VerboseLevel; + resolvedReasoningLevel: ReasoningLevel; + resolvedElevatedLevel?: ElevatedLevel; + resolveDefaultThinkingLevel: () => Promise; + isGroup: boolean; + defaultGroupActivation: () => "always" | "mention"; +}): Promise { + const { + cfg, + command, + sessionEntry, + sessionKey, + sessionScope, + provider, + model, + contextTokens, + resolvedThinkLevel, + resolvedVerboseLevel, + resolvedReasoningLevel, + resolvedElevatedLevel, + resolveDefaultThinkingLevel, + isGroup, + defaultGroupActivation, + } = params; + if (!command.isAuthorizedSender) { + logVerbose( + `Ignoring /status from unauthorized sender: ${command.senderE164 || ""}`, + ); + return undefined; + } + let usageLine: string | null = null; + try { + const usageProvider = resolveUsageProviderId(provider); + if (usageProvider) { + const usageSummary = await loadProviderUsageSummary({ + timeoutMs: 3500, + providers: [usageProvider], + }); + usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() }); + } + } catch { + usageLine = null; + } + const queueSettings = resolveQueueSettings({ + cfg, + provider: command.provider, + sessionEntry, + }); + const queueKey = sessionKey ?? sessionEntry?.sessionId; + const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0; + const queueOverrides = Boolean( + sessionEntry?.queueDebounceMs ?? + sessionEntry?.queueCap ?? + sessionEntry?.queueDrop, + ); + const groupActivation = isGroup + ? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? + defaultGroupActivation()) + : undefined; + const statusText = buildStatusMessage({ + agent: { + ...cfg.agent, + model: { + ...cfg.agent?.model, + primary: `${provider}/${model}`, + }, + contextTokens, + thinkingDefault: cfg.agent?.thinkingDefault, + verboseDefault: cfg.agent?.verboseDefault, + elevatedDefault: cfg.agent?.elevatedDefault, + }, + sessionEntry, + sessionKey, + sessionScope, + groupActivation, + resolvedThink: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), + resolvedVerbose: resolvedVerboseLevel, + resolvedReasoning: resolvedReasoningLevel, + resolvedElevated: resolvedElevatedLevel, + modelAuth: resolveModelAuthLabel(provider, cfg, sessionEntry), + usageLine: usageLine ?? undefined, + queue: { + mode: queueSettings.mode, + depth: queueDepth, + debounceMs: queueSettings.debounceMs, + cap: queueSettings.cap, + dropPolicy: queueSettings.dropPolicy, + showDetails: queueOverrides, + }, + includeTranscriptUsage: false, + }); + return { text: statusText }; +} + function formatApiKeySnippet(apiKey: string): string { const compact = apiKey.replace(/\s+/g, ""); if (!compact) return "unknown"; @@ -113,19 +218,16 @@ function resolveModelAuthLabel( const providerKey = normalizeProviderId(resolved); const store = ensureAuthProfileStore(); const profileOverride = sessionEntry?.authProfileOverride?.trim(); - const lastGood = - store.lastGood?.[providerKey] ?? store.lastGood?.[resolved]; + const lastGood = store.lastGood?.[providerKey] ?? store.lastGood?.[resolved]; const order = resolveAuthProfileOrder({ cfg, store, provider: providerKey, preferredProfile: profileOverride, }); - const candidates = [ - profileOverride, - lastGood, - ...order, - ].filter(Boolean) as string[]; + const candidates = [profileOverride, lastGood, ...order].filter( + Boolean, + ) as string[]; for (const profileId of candidates) { const profile = store.profiles[profileId]; @@ -449,73 +551,24 @@ export async function handleCommands(params: { directives.hasStatusDirective || command.commandBodyNormalized === "/status"; if (allowTextCommands && statusRequested) { - if (!command.isAuthorizedSender) { - logVerbose( - `Ignoring /status from unauthorized sender: ${command.senderE164 || ""}`, - ); - return { shouldContinue: false }; - } - let usageLine: string | null = null; - try { - const usageProvider = resolveUsageProviderId(provider); - const usageSummary = await loadProviderUsageSummary({ - timeoutMs: 3500, - providers: usageProvider ? [usageProvider] : [], - }); - usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() }); - } catch { - usageLine = null; - } - const queueSettings = resolveQueueSettings({ + const reply = await buildStatusReply({ cfg, - provider: command.provider, - sessionEntry, - }); - const queueKey = sessionKey ?? sessionEntry?.sessionId; - const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0; - const queueOverrides = Boolean( - sessionEntry?.queueDebounceMs ?? - sessionEntry?.queueCap ?? - sessionEntry?.queueDrop, - ); - const groupActivation = isGroup - ? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? - defaultGroupActivation()) - : undefined; - const statusText = buildStatusMessage({ - agent: { - ...cfg.agent, - model: { - ...cfg.agent?.model, - primary: `${provider}/${model}`, - }, - contextTokens, - thinkingDefault: cfg.agent?.thinkingDefault, - verboseDefault: cfg.agent?.verboseDefault, - elevatedDefault: cfg.agent?.elevatedDefault, - }, + command, sessionEntry, sessionKey, sessionScope, - groupActivation, - resolvedThink: - resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), - resolvedVerbose: resolvedVerboseLevel, - resolvedReasoning: resolvedReasoningLevel, - resolvedElevated: resolvedElevatedLevel, - modelAuth: resolveModelAuthLabel(provider, cfg, sessionEntry), - usageLine: usageLine ?? undefined, - queue: { - mode: queueSettings.mode, - depth: queueDepth, - debounceMs: queueSettings.debounceMs, - cap: queueSettings.cap, - dropPolicy: queueSettings.dropPolicy, - showDetails: queueOverrides, - }, - includeTranscriptUsage: false, + provider, + model, + contextTokens, + resolvedThinkLevel, + resolvedVerboseLevel, + resolvedReasoningLevel, + resolvedElevatedLevel, + resolveDefaultThinkingLevel, + isGroup, + defaultGroupActivation, }); - return { shouldContinue: false, reply: { text: statusText } }; + return { shouldContinue: false, reply }; } const stopRequested = command.commandBodyNormalized === "/stop"; diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index 6aea3a980..eb62f0d9b 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -744,6 +744,7 @@ export async function handleDirectiveOnly(params: { parts.push(`${SYSTEM_MARK} Queue drop set to ${directives.dropPolicy}.`); } const ack = parts.join(" ").trim(); + if (!ack && directives.hasStatusDirective) return undefined; return { text: ack || "OK." }; }