fix: keep status for directive-only messages
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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." };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user