fix: keep status for directive-only messages

This commit is contained in:
Peter Steinberger
2026-01-09 03:46:00 +01:00
parent dc81d0a649
commit 0cbc5fea93
5 changed files with 206 additions and 86 deletions

View File

@@ -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

View File

@@ -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();

View File

@@ -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 &&

View File

@@ -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<ThinkLevel | undefined>;
isGroup: boolean;
defaultGroupActivation: () => "always" | "mention";
}): Promise<ReplyPayload | undefined> {
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 || "<unknown>"}`,
);
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 || "<unknown>"}`,
);
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";

View File

@@ -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." };
}