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: keep multi-directive messages from clearing directive handling.
|
||||||
- Commands: warn when /elevated runs in direct (unsandboxed) runtime.
|
- Commands: warn when /elevated runs in direct (unsandboxed) runtime.
|
||||||
- Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond.
|
- 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
|
- Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist
|
||||||
|
|
||||||
## 2026.1.8
|
## 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 () => {
|
it("acks queue directive and persists override", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
|
|||||||
@@ -40,7 +40,11 @@ import { getAbortMemory } from "./reply/abort.js";
|
|||||||
import { runReplyAgent } from "./reply/agent-runner.js";
|
import { runReplyAgent } from "./reply/agent-runner.js";
|
||||||
import { resolveBlockStreamingChunking } from "./reply/block-streaming.js";
|
import { resolveBlockStreamingChunking } from "./reply/block-streaming.js";
|
||||||
import { applySessionHints } from "./reply/body.js";
|
import { applySessionHints } from "./reply/body.js";
|
||||||
import { buildCommandContext, handleCommands } from "./reply/commands.js";
|
import {
|
||||||
|
buildCommandContext,
|
||||||
|
buildStatusReply,
|
||||||
|
handleCommands,
|
||||||
|
} from "./reply/commands.js";
|
||||||
import {
|
import {
|
||||||
handleDirectiveOnly,
|
handleDirectiveOnly,
|
||||||
type InlineDirectives,
|
type InlineDirectives,
|
||||||
@@ -346,7 +350,6 @@ export async function getReplyFromConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const disableElevatedInGroup = isGroup && ctx.WasMentioned !== true;
|
|
||||||
const hasDirective =
|
const hasDirective =
|
||||||
parsedDirectives.hasThinkDirective ||
|
parsedDirectives.hasThinkDirective ||
|
||||||
parsedDirectives.hasVerboseDirective ||
|
parsedDirectives.hasVerboseDirective ||
|
||||||
@@ -483,6 +486,21 @@ export async function getReplyFromConfig(
|
|||||||
? undefined
|
? undefined
|
||||||
: directives.rawModelDirective;
|
: directives.rawModelDirective;
|
||||||
|
|
||||||
|
const command = buildCommandContext({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
agentId,
|
||||||
|
sessionKey,
|
||||||
|
isGroup,
|
||||||
|
triggerBodyNormalized,
|
||||||
|
commandAuthorized,
|
||||||
|
});
|
||||||
|
const allowTextCommands = shouldHandleTextCommands({
|
||||||
|
cfg,
|
||||||
|
surface: command.surface,
|
||||||
|
commandSource: ctx.CommandSource,
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isDirectiveOnly({
|
isDirectiveOnly({
|
||||||
directives,
|
directives,
|
||||||
@@ -528,8 +546,36 @@ export async function getReplyFromConfig(
|
|||||||
currentReasoningLevel,
|
currentReasoningLevel,
|
||||||
currentElevatedLevel,
|
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();
|
typing.cleanup();
|
||||||
return directiveReply;
|
if (statusReply?.text && directiveReply?.text) {
|
||||||
|
return { text: `${directiveReply.text}\n${statusReply.text}` };
|
||||||
|
}
|
||||||
|
return statusReply ?? directiveReply;
|
||||||
}
|
}
|
||||||
|
|
||||||
const persisted = await persistInlineDirectives({
|
const persisted = await persistInlineDirectives({
|
||||||
@@ -569,20 +615,6 @@ export async function getReplyFromConfig(
|
|||||||
}
|
}
|
||||||
: undefined;
|
: 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;
|
const isEmptyConfig = Object.keys(cfg).length === 0;
|
||||||
if (
|
if (
|
||||||
command.isWhatsAppProvider &&
|
command.isWhatsAppProvider &&
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getCustomProviderApiKey,
|
getCustomProviderApiKey,
|
||||||
resolveEnvApiKey,
|
resolveEnvApiKey,
|
||||||
} from "../../agents/model-auth.js";
|
} from "../../agents/model-auth.js";
|
||||||
|
import { normalizeProviderId } from "../../agents/model-selection.js";
|
||||||
import {
|
import {
|
||||||
abortEmbeddedPiRun,
|
abortEmbeddedPiRun,
|
||||||
compactEmbeddedPiSession,
|
compactEmbeddedPiSession,
|
||||||
@@ -93,6 +94,110 @@ export type CommandContext = {
|
|||||||
to?: string;
|
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 {
|
function formatApiKeySnippet(apiKey: string): string {
|
||||||
const compact = apiKey.replace(/\s+/g, "");
|
const compact = apiKey.replace(/\s+/g, "");
|
||||||
if (!compact) return "unknown";
|
if (!compact) return "unknown";
|
||||||
@@ -113,19 +218,16 @@ function resolveModelAuthLabel(
|
|||||||
const providerKey = normalizeProviderId(resolved);
|
const providerKey = normalizeProviderId(resolved);
|
||||||
const store = ensureAuthProfileStore();
|
const store = ensureAuthProfileStore();
|
||||||
const profileOverride = sessionEntry?.authProfileOverride?.trim();
|
const profileOverride = sessionEntry?.authProfileOverride?.trim();
|
||||||
const lastGood =
|
const lastGood = store.lastGood?.[providerKey] ?? store.lastGood?.[resolved];
|
||||||
store.lastGood?.[providerKey] ?? store.lastGood?.[resolved];
|
|
||||||
const order = resolveAuthProfileOrder({
|
const order = resolveAuthProfileOrder({
|
||||||
cfg,
|
cfg,
|
||||||
store,
|
store,
|
||||||
provider: providerKey,
|
provider: providerKey,
|
||||||
preferredProfile: profileOverride,
|
preferredProfile: profileOverride,
|
||||||
});
|
});
|
||||||
const candidates = [
|
const candidates = [profileOverride, lastGood, ...order].filter(
|
||||||
profileOverride,
|
Boolean,
|
||||||
lastGood,
|
) as string[];
|
||||||
...order,
|
|
||||||
].filter(Boolean) as string[];
|
|
||||||
|
|
||||||
for (const profileId of candidates) {
|
for (const profileId of candidates) {
|
||||||
const profile = store.profiles[profileId];
|
const profile = store.profiles[profileId];
|
||||||
@@ -449,73 +551,24 @@ export async function handleCommands(params: {
|
|||||||
directives.hasStatusDirective ||
|
directives.hasStatusDirective ||
|
||||||
command.commandBodyNormalized === "/status";
|
command.commandBodyNormalized === "/status";
|
||||||
if (allowTextCommands && statusRequested) {
|
if (allowTextCommands && statusRequested) {
|
||||||
if (!command.isAuthorizedSender) {
|
const reply = await buildStatusReply({
|
||||||
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({
|
|
||||||
cfg,
|
cfg,
|
||||||
provider: command.provider,
|
command,
|
||||||
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,
|
sessionEntry,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
sessionScope,
|
sessionScope,
|
||||||
groupActivation,
|
provider,
|
||||||
resolvedThink:
|
model,
|
||||||
resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
|
contextTokens,
|
||||||
resolvedVerbose: resolvedVerboseLevel,
|
resolvedThinkLevel,
|
||||||
resolvedReasoning: resolvedReasoningLevel,
|
resolvedVerboseLevel,
|
||||||
resolvedElevated: resolvedElevatedLevel,
|
resolvedReasoningLevel,
|
||||||
modelAuth: resolveModelAuthLabel(provider, cfg, sessionEntry),
|
resolvedElevatedLevel,
|
||||||
usageLine: usageLine ?? undefined,
|
resolveDefaultThinkingLevel,
|
||||||
queue: {
|
isGroup,
|
||||||
mode: queueSettings.mode,
|
defaultGroupActivation,
|
||||||
depth: queueDepth,
|
|
||||||
debounceMs: queueSettings.debounceMs,
|
|
||||||
cap: queueSettings.cap,
|
|
||||||
dropPolicy: queueSettings.dropPolicy,
|
|
||||||
showDetails: queueOverrides,
|
|
||||||
},
|
|
||||||
includeTranscriptUsage: false,
|
|
||||||
});
|
});
|
||||||
return { shouldContinue: false, reply: { text: statusText } };
|
return { shouldContinue: false, reply };
|
||||||
}
|
}
|
||||||
|
|
||||||
const stopRequested = command.commandBodyNormalized === "/stop";
|
const stopRequested = command.commandBodyNormalized === "/stop";
|
||||||
|
|||||||
@@ -744,6 +744,7 @@ export async function handleDirectiveOnly(params: {
|
|||||||
parts.push(`${SYSTEM_MARK} Queue drop set to ${directives.dropPolicy}.`);
|
parts.push(`${SYSTEM_MARK} Queue drop set to ${directives.dropPolicy}.`);
|
||||||
}
|
}
|
||||||
const ack = parts.join(" ").trim();
|
const ack = parts.join(" ").trim();
|
||||||
|
if (!ack && directives.hasStatusDirective) return undefined;
|
||||||
return { text: ack || "OK." };
|
return { text: ack || "OK." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user