fix(auto-reply): RawBody commands + locked session updates (#643)
This commit is contained in:
42
src/auto-reply/reply/abort.test.ts
Normal file
42
src/auto-reply/reply/abort.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { isAbortTrigger } from "./abort.js";
|
||||
import { initSessionState } from "./session.js";
|
||||
|
||||
describe("abort detection", () => {
|
||||
it("triggerBodyNormalized extracts /stop from RawBody for abort detection", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-abort-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||
|
||||
const groupMessageCtx = {
|
||||
Body: `[Context]\nJake: /stop\n[from: Jake]`,
|
||||
RawBody: "/stop",
|
||||
ChatType: "group",
|
||||
SessionKey: "agent:main:whatsapp:group:G1",
|
||||
};
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: groupMessageCtx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
// /stop is detected via exact match in handleAbort, not isAbortTrigger
|
||||
expect(result.triggerBodyNormalized).toBe("/stop");
|
||||
});
|
||||
|
||||
it("isAbortTrigger matches bare word triggers (without slash)", () => {
|
||||
expect(isAbortTrigger("stop")).toBe(true);
|
||||
expect(isAbortTrigger("esc")).toBe(true);
|
||||
expect(isAbortTrigger("abort")).toBe(true);
|
||||
expect(isAbortTrigger("wait")).toBe(true);
|
||||
expect(isAbortTrigger("exit")).toBe(true);
|
||||
expect(isAbortTrigger("hello")).toBe(false);
|
||||
// /stop is NOT matched by isAbortTrigger - it's handled separately
|
||||
expect(isAbortTrigger("/stop")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -81,7 +81,8 @@ export async function tryFastAbortFromMessage(params: {
|
||||
sessionKey: targetKey ?? ctx.SessionKey ?? "",
|
||||
config: cfg,
|
||||
});
|
||||
const raw = stripStructuralPrefixes(ctx.Body ?? "");
|
||||
// Use RawBody for abort detection (clean message without structural context).
|
||||
const raw = stripStructuralPrefixes(ctx.RawBody ?? ctx.Body ?? "");
|
||||
const isGroup = ctx.ChatType?.trim().toLowerCase() === "group";
|
||||
const stripped = isGroup ? stripMentions(raw, ctx, cfg, agentId) : raw;
|
||||
const normalized = normalizeCommandBody(stripped);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
resolveSessionTranscriptPath,
|
||||
type SessionEntry,
|
||||
saveSessionStore,
|
||||
updateSessionStoreEntry,
|
||||
} from "../../config/sessions.js";
|
||||
import type { TypingMode } from "../../config/types.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
@@ -824,46 +825,48 @@ export async function runReplyAgent(params: {
|
||||
sessionEntry?.contextTokens ??
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
|
||||
if (sessionStore && sessionKey) {
|
||||
if (storePath && sessionKey) {
|
||||
if (hasNonzeroUsage(usage)) {
|
||||
const entry = sessionEntry ?? sessionStore[sessionKey];
|
||||
if (entry) {
|
||||
const input = usage.input ?? 0;
|
||||
const output = usage.output ?? 0;
|
||||
const promptTokens =
|
||||
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||
const nextEntry = {
|
||||
...entry,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
totalTokens:
|
||||
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
||||
modelProvider: providerUsed,
|
||||
model: modelUsed,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (cliSessionId) {
|
||||
nextEntry.claudeCliSessionId = cliSessionId;
|
||||
}
|
||||
sessionStore[sessionKey] = nextEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
try {
|
||||
await updateSessionStoreEntry({
|
||||
storePath,
|
||||
sessionKey,
|
||||
update: async (entry) => {
|
||||
const input = usage.input ?? 0;
|
||||
const output = usage.output ?? 0;
|
||||
const promptTokens =
|
||||
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||
return {
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
totalTokens:
|
||||
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
||||
modelProvider: providerUsed,
|
||||
model: modelUsed,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
updatedAt: Date.now(),
|
||||
claudeCliSessionId: cliSessionId ?? entry.claudeCliSessionId,
|
||||
};
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`failed to persist usage update: ${String(err)}`);
|
||||
}
|
||||
} else if (modelUsed || contextTokensUsed) {
|
||||
const entry = sessionEntry ?? sessionStore[sessionKey];
|
||||
if (entry) {
|
||||
sessionStore[sessionKey] = {
|
||||
...entry,
|
||||
modelProvider: providerUsed ?? entry.modelProvider,
|
||||
model: modelUsed ?? entry.model,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
claudeCliSessionId: cliSessionId ?? entry.claudeCliSessionId,
|
||||
};
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
try {
|
||||
await updateSessionStoreEntry({
|
||||
storePath,
|
||||
sessionKey,
|
||||
update: async (entry) => ({
|
||||
modelProvider: providerUsed ?? entry.modelProvider,
|
||||
model: modelUsed ?? entry.model,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
claudeCliSessionId: cliSessionId ?? entry.claudeCliSessionId,
|
||||
updatedAt: Date.now(),
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`failed to persist model/context update: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
||||
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
||||
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||
import {
|
||||
type SessionEntry,
|
||||
updateSessionStoreEntry,
|
||||
} from "../../config/sessions.js";
|
||||
import type { TypingMode } from "../../config/types.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
@@ -232,7 +235,7 @@ export function createFollowupRunner(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionStore && sessionKey) {
|
||||
if (storePath && sessionKey) {
|
||||
const usage = runResult.meta.agentMeta?.usage;
|
||||
const modelUsed =
|
||||
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
||||
@@ -243,39 +246,48 @@ export function createFollowupRunner(params: {
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
|
||||
if (hasNonzeroUsage(usage)) {
|
||||
const entry = sessionStore[sessionKey];
|
||||
if (entry) {
|
||||
const input = usage.input ?? 0;
|
||||
const output = usage.output ?? 0;
|
||||
const promptTokens =
|
||||
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||
sessionStore[sessionKey] = {
|
||||
...entry,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
totalTokens:
|
||||
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
||||
modelProvider: fallbackProvider ?? entry.modelProvider,
|
||||
model: modelUsed,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
try {
|
||||
await updateSessionStoreEntry({
|
||||
storePath,
|
||||
sessionKey,
|
||||
update: async (entry) => {
|
||||
const input = usage.input ?? 0;
|
||||
const output = usage.output ?? 0;
|
||||
const promptTokens =
|
||||
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||
return {
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
totalTokens:
|
||||
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
||||
modelProvider: fallbackProvider ?? entry.modelProvider,
|
||||
model: modelUsed,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`failed to persist followup usage update: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
} else if (modelUsed || contextTokensUsed) {
|
||||
const entry = sessionStore[sessionKey];
|
||||
if (entry) {
|
||||
sessionStore[sessionKey] = {
|
||||
...entry,
|
||||
modelProvider: fallbackProvider ?? entry.modelProvider,
|
||||
model: modelUsed ?? entry.model,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
};
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
try {
|
||||
await updateSessionStoreEntry({
|
||||
storePath,
|
||||
sessionKey,
|
||||
update: async (entry) => ({
|
||||
modelProvider: fallbackProvider ?? entry.modelProvider,
|
||||
model: modelUsed ?? entry.model,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
updatedAt: Date.now(),
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`failed to persist followup model/context update: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ function deriveMentionPatterns(identity?: { name?: string; emoji?: string }) {
|
||||
|
||||
const BACKSPACE_CHAR = "\u0008";
|
||||
|
||||
export const CURRENT_MESSAGE_MARKER = "[Current message - respond to this]";
|
||||
|
||||
function normalizeMentionPattern(pattern: string): string {
|
||||
if (!pattern.includes(BACKSPACE_CHAR)) return pattern;
|
||||
return pattern.split(BACKSPACE_CHAR).join("\\b");
|
||||
@@ -87,13 +89,18 @@ export function matchesMentionPatterns(
|
||||
export function stripStructuralPrefixes(text: string): string {
|
||||
// Ignore wrapper labels, timestamps, and sender prefixes so directive-only
|
||||
// detection still works in group batches that include history/context.
|
||||
const marker = "[Current message - respond to this]";
|
||||
const afterMarker = text.includes(marker)
|
||||
? text.slice(text.indexOf(marker) + marker.length)
|
||||
const afterMarker = text.includes(CURRENT_MESSAGE_MARKER)
|
||||
? text
|
||||
.slice(
|
||||
text.indexOf(CURRENT_MESSAGE_MARKER) + CURRENT_MESSAGE_MARKER.length,
|
||||
)
|
||||
.trimStart()
|
||||
: text;
|
||||
|
||||
return afterMarker
|
||||
.replace(/\[[^\]]+\]\s*/g, "")
|
||||
.replace(/^[ \t]*[A-Za-z0-9+()\-_. ]+:\s*/gm, "")
|
||||
.replace(/\\n/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
@@ -105,9 +112,9 @@ export function stripMentions(
|
||||
agentId?: string,
|
||||
): string {
|
||||
let result = text;
|
||||
const patterns = normalizeMentionPatterns(
|
||||
resolveMentionPatterns(cfg, agentId),
|
||||
);
|
||||
const rawPatterns = resolveMentionPatterns(cfg, agentId);
|
||||
const patterns = normalizeMentionPatterns(rawPatterns);
|
||||
|
||||
for (const p of patterns) {
|
||||
try {
|
||||
const re = new RegExp(p, "gi");
|
||||
|
||||
@@ -110,3 +110,71 @@ describe("initSessionState thread forking", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initSessionState RawBody", () => {
|
||||
it("triggerBodyNormalized correctly extracts commands when Body contains context but RawBody is clean", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-rawbody-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||
|
||||
const groupMessageCtx = {
|
||||
Body: `[Chat messages since your last reply - for context]\n[WhatsApp ...] Someone: hello\n\n[Current message - respond to this]\n[WhatsApp ...] Jake: /status\n[from: Jake McInteer (+6421807830)]`,
|
||||
RawBody: "/status",
|
||||
ChatType: "group",
|
||||
SessionKey: "agent:main:whatsapp:group:G1",
|
||||
};
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: groupMessageCtx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.triggerBodyNormalized).toBe("/status");
|
||||
});
|
||||
|
||||
it("Reset triggers (/new, /reset) work with RawBody", async () => {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-rawbody-reset-"),
|
||||
);
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||
|
||||
const groupMessageCtx = {
|
||||
Body: `[Context]\nJake: /new\n[from: Jake]`,
|
||||
RawBody: "/new",
|
||||
ChatType: "group",
|
||||
SessionKey: "agent:main:whatsapp:group:G1",
|
||||
};
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: groupMessageCtx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.bodyStripped).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to Body when RawBody is undefined", async () => {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-rawbody-fallback-"),
|
||||
);
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Body: "/status",
|
||||
SessionKey: "agent:main:whatsapp:dm:S1",
|
||||
};
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.triggerBodyNormalized).toBe("/status");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,11 +136,15 @@ export async function initSessionState(params: {
|
||||
resolveGroupSessionKey(sessionCtxForState) ?? undefined;
|
||||
const isGroup =
|
||||
ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution);
|
||||
const triggerBodyNormalized = stripStructuralPrefixes(ctx.Body ?? "")
|
||||
// Prefer RawBody (clean message) for command detection; fall back to Body
|
||||
// which may contain structural context (history, sender labels).
|
||||
const commandSource = ctx.RawBody ?? ctx.Body ?? "";
|
||||
const triggerBodyNormalized = stripStructuralPrefixes(commandSource)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const rawBody = ctx.Body ?? "";
|
||||
// Use RawBody for reset trigger matching (clean message without structural context).
|
||||
const rawBody = ctx.RawBody ?? ctx.Body ?? "";
|
||||
const trimmedBody = rawBody.trim();
|
||||
const resetAuthorized = resolveCommandAuthorization({
|
||||
ctx,
|
||||
@@ -284,7 +288,9 @@ export async function initSessionState(params: {
|
||||
|
||||
const sessionCtx: TemplateContext = {
|
||||
...ctx,
|
||||
BodyStripped: bodyStripped ?? ctx.Body,
|
||||
// Keep BodyStripped aligned with Body (best default for agent prompts).
|
||||
// RawBody is reserved for command/directive parsing and may omit context.
|
||||
BodyStripped: bodyStripped ?? ctx.Body ?? ctx.RawBody,
|
||||
SessionId: sessionId,
|
||||
IsNewSession: isNewSession ? "true" : "false",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user