fix(auto-reply): RawBody commands + locked session updates (#643)
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
- Agents: add human-delay pacing between block replies (modes: off/natural/custom, per-agent configurable). (#446) — thanks @tony-freedomology.
|
- Agents: add human-delay pacing between block replies (modes: off/natural/custom, per-agent configurable). (#446) — thanks @tony-freedomology.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Auto-reply: prefer `RawBody` for command/directive parsing (WhatsApp + Discord) and prevent fallback runs from clobbering concurrent session updates. (#643) — thanks @mcinteerj.
|
||||||
- Agents/OpenAI: fix Responses tool-only → follow-up turn handling (avoid standalone `reasoning` items that trigger 400 “required following item”).
|
- Agents/OpenAI: fix Responses tool-only → follow-up turn handling (avoid standalone `reasoning` items that trigger 400 “required following item”).
|
||||||
- Auth: update Claude Code keychain credentials in-place during refresh sync; share JSON file helpers; add CLI fallback coverage.
|
- Auth: update Claude Code keychain credentials in-place during refresh sync; share JSON file helpers; add CLI fallback coverage.
|
||||||
- Onboarding/Gateway: persist non-interactive gateway token auth in config; add WS wizard + gateway tool-calling regression coverage.
|
- Onboarding/Gateway: persist non-interactive gateway token auth in config; add WS wizard + gateway tool-calling regression coverage.
|
||||||
|
|||||||
@@ -1629,11 +1629,11 @@ public struct ChatSendParams: Codable, Sendable {
|
|||||||
|
|
||||||
public struct ChatAbortParams: Codable, Sendable {
|
public struct ChatAbortParams: Codable, Sendable {
|
||||||
public let sessionkey: String
|
public let sessionkey: String
|
||||||
public let runid: String
|
public let runid: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
sessionkey: String,
|
sessionkey: String,
|
||||||
runid: String
|
runid: String?
|
||||||
) {
|
) {
|
||||||
self.sessionkey = sessionkey
|
self.sessionkey = sessionkey
|
||||||
self.runid = runid
|
self.runid = runid
|
||||||
|
|||||||
@@ -2098,6 +2098,7 @@ Template placeholders are expanded in `audio.transcription.command` (and any fut
|
|||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `{{Body}}` | Full inbound message body |
|
| `{{Body}}` | Full inbound message body |
|
||||||
|
| `{{RawBody}}` | Raw inbound message body (no history/sender wrappers; best for command parsing) |
|
||||||
| `{{BodyStripped}}` | Body with group mentions stripped (best default for agents) |
|
| `{{BodyStripped}}` | Body with group mentions stripped (best default for agents) |
|
||||||
| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per provider) |
|
| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per provider) |
|
||||||
| `{{To}}` | Destination identifier |
|
| `{{To}}` | Destination identifier |
|
||||||
|
|||||||
154
src/auto-reply/reply.raw-body.test.ts
Normal file
154
src/auto-reply/reply.raw-body.test.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||||
|
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||||
|
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||||
|
import { getReplyFromConfig } from "./reply.js";
|
||||||
|
|
||||||
|
vi.mock("../agents/pi-embedded.js", () => ({
|
||||||
|
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||||
|
runEmbeddedPiAgent: vi.fn(),
|
||||||
|
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||||
|
resolveEmbeddedSessionLane: (key: string) =>
|
||||||
|
`session:${key.trim() || "main"}`,
|
||||||
|
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||||
|
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||||
|
}));
|
||||||
|
vi.mock("../agents/model-catalog.js", () => ({
|
||||||
|
loadModelCatalog: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||||
|
return withTempHomeBase(
|
||||||
|
async (home) => {
|
||||||
|
return await fn(home);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
CLAWDBOT_AGENT_DIR: (home) => path.join(home, ".clawdbot", "agent"),
|
||||||
|
PI_CODING_AGENT_DIR: (home) => path.join(home, ".clawdbot", "agent"),
|
||||||
|
},
|
||||||
|
prefix: "clawdbot-rawbody-",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RawBody directive parsing", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
|
vi.mocked(loadModelCatalog).mockResolvedValue([
|
||||||
|
{ id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/model, /think, /verbose directives detected from RawBody even when Body has structural wrapper", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
|
|
||||||
|
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: /think:high\\n[from: Jake McInteer (+6421807830)]`,
|
||||||
|
RawBody: "/think:high",
|
||||||
|
From: "+1222",
|
||||||
|
To: "+1222",
|
||||||
|
ChatType: "group",
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await getReplyFromConfig(
|
||||||
|
groupMessageCtx,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: "anthropic/claude-opus-4-5",
|
||||||
|
workspace: path.join(home, "clawd"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
whatsapp: { allowFrom: ["*"] },
|
||||||
|
session: { store: path.join(home, "sessions.json") },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
|
expect(text).toContain("Thinking level set to high.");
|
||||||
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/model status detected from RawBody", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
|
|
||||||
|
const groupMessageCtx = {
|
||||||
|
Body: `[Context]\nJake: /model status\n[from: Jake]`,
|
||||||
|
RawBody: "/model status",
|
||||||
|
From: "+1222",
|
||||||
|
To: "+1222",
|
||||||
|
ChatType: "group",
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await getReplyFromConfig(
|
||||||
|
groupMessageCtx,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: "anthropic/claude-opus-4-5",
|
||||||
|
workspace: path.join(home, "clawd"),
|
||||||
|
models: {
|
||||||
|
"anthropic/claude-opus-4-5": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
whatsapp: { allowFrom: ["*"] },
|
||||||
|
session: { store: path.join(home, "sessions.json") },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
|
expect(text).toContain("anthropic/claude-opus-4-5");
|
||||||
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Integration: WhatsApp group message with structural wrapper and RawBody command", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
|
|
||||||
|
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",
|
||||||
|
From: "+1222",
|
||||||
|
To: "+1222",
|
||||||
|
SessionKey: "agent:main:whatsapp:group:G1",
|
||||||
|
Provider: "whatsapp",
|
||||||
|
Surface: "whatsapp",
|
||||||
|
SenderE164: "+1222",
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await getReplyFromConfig(
|
||||||
|
groupMessageCtx,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: "anthropic/claude-opus-4-5",
|
||||||
|
workspace: path.join(home, "clawd"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
whatsapp: { allowFrom: ["+1222"] },
|
||||||
|
session: { store: path.join(home, "sessions.json") },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
|
expect(text).toContain("Session: agent:main:whatsapp:group:G1");
|
||||||
|
expect(text).toContain("anthropic/claude-opus-4-5");
|
||||||
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -60,7 +60,11 @@ import {
|
|||||||
defaultGroupActivation,
|
defaultGroupActivation,
|
||||||
resolveGroupRequireMention,
|
resolveGroupRequireMention,
|
||||||
} from "./reply/groups.js";
|
} from "./reply/groups.js";
|
||||||
import { stripMentions, stripStructuralPrefixes } from "./reply/mentions.js";
|
import {
|
||||||
|
CURRENT_MESSAGE_MARKER,
|
||||||
|
stripMentions,
|
||||||
|
stripStructuralPrefixes,
|
||||||
|
} from "./reply/mentions.js";
|
||||||
import {
|
import {
|
||||||
createModelSelectionState,
|
createModelSelectionState,
|
||||||
resolveContextTokens,
|
resolveContextTokens,
|
||||||
@@ -336,7 +340,10 @@ export async function getReplyFromConfig(
|
|||||||
triggerBodyNormalized,
|
triggerBodyNormalized,
|
||||||
} = sessionState;
|
} = sessionState;
|
||||||
|
|
||||||
const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
// Prefer RawBody (clean message without structural context) for directive parsing.
|
||||||
|
// Keep `Body`/`BodyStripped` as the best-available prompt text (may include context).
|
||||||
|
const rawBody =
|
||||||
|
sessionCtx.RawBody ?? sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||||
const clearInlineDirectives = (cleaned: string): InlineDirectives => ({
|
const clearInlineDirectives = (cleaned: string): InlineDirectives => ({
|
||||||
cleaned,
|
cleaned,
|
||||||
hasThinkDirective: false,
|
hasThinkDirective: false,
|
||||||
@@ -426,8 +433,37 @@ export async function getReplyFromConfig(
|
|||||||
hasQueueDirective: false,
|
hasQueueDirective: false,
|
||||||
queueReset: false,
|
queueReset: false,
|
||||||
};
|
};
|
||||||
sessionCtx.Body = parsedDirectives.cleaned;
|
const existingBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||||
sessionCtx.BodyStripped = parsedDirectives.cleaned;
|
const cleanedBody = (() => {
|
||||||
|
if (!existingBody) return parsedDirectives.cleaned;
|
||||||
|
if (!sessionCtx.RawBody) {
|
||||||
|
return parseInlineDirectives(existingBody, {
|
||||||
|
modelAliases: configuredAliases,
|
||||||
|
}).cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
const markerIndex = existingBody.indexOf(CURRENT_MESSAGE_MARKER);
|
||||||
|
if (markerIndex < 0) {
|
||||||
|
return parseInlineDirectives(existingBody, {
|
||||||
|
modelAliases: configuredAliases,
|
||||||
|
}).cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
const head = existingBody.slice(
|
||||||
|
0,
|
||||||
|
markerIndex + CURRENT_MESSAGE_MARKER.length,
|
||||||
|
);
|
||||||
|
const tail = existingBody.slice(
|
||||||
|
markerIndex + CURRENT_MESSAGE_MARKER.length,
|
||||||
|
);
|
||||||
|
const cleanedTail = parseInlineDirectives(tail, {
|
||||||
|
modelAliases: configuredAliases,
|
||||||
|
}).cleaned;
|
||||||
|
return `${head}${cleanedTail}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
sessionCtx.Body = cleanedBody;
|
||||||
|
sessionCtx.BodyStripped = cleanedBody;
|
||||||
|
|
||||||
const messageProviderKey =
|
const messageProviderKey =
|
||||||
sessionCtx.Provider?.trim().toLowerCase() ??
|
sessionCtx.Provider?.trim().toLowerCase() ??
|
||||||
@@ -750,7 +786,8 @@ export async function getReplyFromConfig(
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||||
const rawBodyTrimmed = (ctx.Body ?? "").trim();
|
// Use RawBody for bare reset detection (clean message without structural context).
|
||||||
|
const rawBodyTrimmed = (ctx.RawBody ?? ctx.Body ?? "").trim();
|
||||||
const baseBodyTrimmedRaw = baseBody.trim();
|
const baseBodyTrimmedRaw = baseBody.trim();
|
||||||
if (
|
if (
|
||||||
allowTextCommands &&
|
allowTextCommands &&
|
||||||
|
|||||||
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 ?? "",
|
sessionKey: targetKey ?? ctx.SessionKey ?? "",
|
||||||
config: cfg,
|
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 isGroup = ctx.ChatType?.trim().toLowerCase() === "group";
|
||||||
const stripped = isGroup ? stripMentions(raw, ctx, cfg, agentId) : raw;
|
const stripped = isGroup ? stripMentions(raw, ctx, cfg, agentId) : raw;
|
||||||
const normalized = normalizeCommandBody(stripped);
|
const normalized = normalizeCommandBody(stripped);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
resolveSessionTranscriptPath,
|
resolveSessionTranscriptPath,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
|
updateSessionStoreEntry,
|
||||||
} from "../../config/sessions.js";
|
} from "../../config/sessions.js";
|
||||||
import type { TypingMode } from "../../config/types.js";
|
import type { TypingMode } from "../../config/types.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
@@ -824,46 +825,48 @@ export async function runReplyAgent(params: {
|
|||||||
sessionEntry?.contextTokens ??
|
sessionEntry?.contextTokens ??
|
||||||
DEFAULT_CONTEXT_TOKENS;
|
DEFAULT_CONTEXT_TOKENS;
|
||||||
|
|
||||||
if (sessionStore && sessionKey) {
|
if (storePath && sessionKey) {
|
||||||
if (hasNonzeroUsage(usage)) {
|
if (hasNonzeroUsage(usage)) {
|
||||||
const entry = sessionEntry ?? sessionStore[sessionKey];
|
try {
|
||||||
if (entry) {
|
await updateSessionStoreEntry({
|
||||||
const input = usage.input ?? 0;
|
storePath,
|
||||||
const output = usage.output ?? 0;
|
sessionKey,
|
||||||
const promptTokens =
|
update: async (entry) => {
|
||||||
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
const input = usage.input ?? 0;
|
||||||
const nextEntry = {
|
const output = usage.output ?? 0;
|
||||||
...entry,
|
const promptTokens =
|
||||||
inputTokens: input,
|
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||||
outputTokens: output,
|
return {
|
||||||
totalTokens:
|
inputTokens: input,
|
||||||
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
outputTokens: output,
|
||||||
modelProvider: providerUsed,
|
totalTokens:
|
||||||
model: modelUsed,
|
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
||||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
modelProvider: providerUsed,
|
||||||
updatedAt: Date.now(),
|
model: modelUsed,
|
||||||
};
|
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||||
if (cliSessionId) {
|
updatedAt: Date.now(),
|
||||||
nextEntry.claudeCliSessionId = cliSessionId;
|
claudeCliSessionId: cliSessionId ?? entry.claudeCliSessionId,
|
||||||
}
|
};
|
||||||
sessionStore[sessionKey] = nextEntry;
|
},
|
||||||
if (storePath) {
|
});
|
||||||
await saveSessionStore(storePath, sessionStore);
|
} catch (err) {
|
||||||
}
|
logVerbose(`failed to persist usage update: ${String(err)}`);
|
||||||
}
|
}
|
||||||
} else if (modelUsed || contextTokensUsed) {
|
} else if (modelUsed || contextTokensUsed) {
|
||||||
const entry = sessionEntry ?? sessionStore[sessionKey];
|
try {
|
||||||
if (entry) {
|
await updateSessionStoreEntry({
|
||||||
sessionStore[sessionKey] = {
|
storePath,
|
||||||
...entry,
|
sessionKey,
|
||||||
modelProvider: providerUsed ?? entry.modelProvider,
|
update: async (entry) => ({
|
||||||
model: modelUsed ?? entry.model,
|
modelProvider: providerUsed ?? entry.modelProvider,
|
||||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
model: modelUsed ?? entry.model,
|
||||||
claudeCliSessionId: cliSessionId ?? entry.claudeCliSessionId,
|
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||||
};
|
claudeCliSessionId: cliSessionId ?? entry.claudeCliSessionId,
|
||||||
if (storePath) {
|
updatedAt: Date.now(),
|
||||||
await saveSessionStore(storePath, sessionStore);
|
}),
|
||||||
}
|
});
|
||||||
|
} 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 { runWithModelFallback } from "../../agents/model-fallback.js";
|
||||||
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
||||||
import { hasNonzeroUsage } from "../../agents/usage.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 type { TypingMode } from "../../config/types.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
import { registerAgentRunContext } from "../../infra/agent-events.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 usage = runResult.meta.agentMeta?.usage;
|
||||||
const modelUsed =
|
const modelUsed =
|
||||||
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
||||||
@@ -243,39 +246,48 @@ export function createFollowupRunner(params: {
|
|||||||
DEFAULT_CONTEXT_TOKENS;
|
DEFAULT_CONTEXT_TOKENS;
|
||||||
|
|
||||||
if (hasNonzeroUsage(usage)) {
|
if (hasNonzeroUsage(usage)) {
|
||||||
const entry = sessionStore[sessionKey];
|
try {
|
||||||
if (entry) {
|
await updateSessionStoreEntry({
|
||||||
const input = usage.input ?? 0;
|
storePath,
|
||||||
const output = usage.output ?? 0;
|
sessionKey,
|
||||||
const promptTokens =
|
update: async (entry) => {
|
||||||
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
const input = usage.input ?? 0;
|
||||||
sessionStore[sessionKey] = {
|
const output = usage.output ?? 0;
|
||||||
...entry,
|
const promptTokens =
|
||||||
inputTokens: input,
|
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||||
outputTokens: output,
|
return {
|
||||||
totalTokens:
|
inputTokens: input,
|
||||||
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
outputTokens: output,
|
||||||
modelProvider: fallbackProvider ?? entry.modelProvider,
|
totalTokens:
|
||||||
model: modelUsed,
|
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
||||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
modelProvider: fallbackProvider ?? entry.modelProvider,
|
||||||
updatedAt: Date.now(),
|
model: modelUsed,
|
||||||
};
|
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||||
if (storePath) {
|
updatedAt: Date.now(),
|
||||||
await saveSessionStore(storePath, sessionStore);
|
};
|
||||||
}
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(
|
||||||
|
`failed to persist followup usage update: ${String(err)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (modelUsed || contextTokensUsed) {
|
} else if (modelUsed || contextTokensUsed) {
|
||||||
const entry = sessionStore[sessionKey];
|
try {
|
||||||
if (entry) {
|
await updateSessionStoreEntry({
|
||||||
sessionStore[sessionKey] = {
|
storePath,
|
||||||
...entry,
|
sessionKey,
|
||||||
modelProvider: fallbackProvider ?? entry.modelProvider,
|
update: async (entry) => ({
|
||||||
model: modelUsed ?? entry.model,
|
modelProvider: fallbackProvider ?? entry.modelProvider,
|
||||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
model: modelUsed ?? entry.model,
|
||||||
};
|
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||||
if (storePath) {
|
updatedAt: Date.now(),
|
||||||
await saveSessionStore(storePath, sessionStore);
|
}),
|
||||||
}
|
});
|
||||||
|
} 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";
|
const BACKSPACE_CHAR = "\u0008";
|
||||||
|
|
||||||
|
export const CURRENT_MESSAGE_MARKER = "[Current message - respond to this]";
|
||||||
|
|
||||||
function normalizeMentionPattern(pattern: string): string {
|
function normalizeMentionPattern(pattern: string): string {
|
||||||
if (!pattern.includes(BACKSPACE_CHAR)) return pattern;
|
if (!pattern.includes(BACKSPACE_CHAR)) return pattern;
|
||||||
return pattern.split(BACKSPACE_CHAR).join("\\b");
|
return pattern.split(BACKSPACE_CHAR).join("\\b");
|
||||||
@@ -87,13 +89,18 @@ export function matchesMentionPatterns(
|
|||||||
export function stripStructuralPrefixes(text: string): string {
|
export function stripStructuralPrefixes(text: string): string {
|
||||||
// Ignore wrapper labels, timestamps, and sender prefixes so directive-only
|
// Ignore wrapper labels, timestamps, and sender prefixes so directive-only
|
||||||
// detection still works in group batches that include history/context.
|
// detection still works in group batches that include history/context.
|
||||||
const marker = "[Current message - respond to this]";
|
const afterMarker = text.includes(CURRENT_MESSAGE_MARKER)
|
||||||
const afterMarker = text.includes(marker)
|
? text
|
||||||
? text.slice(text.indexOf(marker) + marker.length)
|
.slice(
|
||||||
|
text.indexOf(CURRENT_MESSAGE_MARKER) + CURRENT_MESSAGE_MARKER.length,
|
||||||
|
)
|
||||||
|
.trimStart()
|
||||||
: text;
|
: text;
|
||||||
|
|
||||||
return afterMarker
|
return afterMarker
|
||||||
.replace(/\[[^\]]+\]\s*/g, "")
|
.replace(/\[[^\]]+\]\s*/g, "")
|
||||||
.replace(/^[ \t]*[A-Za-z0-9+()\-_. ]+:\s*/gm, "")
|
.replace(/^[ \t]*[A-Za-z0-9+()\-_. ]+:\s*/gm, "")
|
||||||
|
.replace(/\\n/g, " ")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
@@ -105,9 +112,9 @@ export function stripMentions(
|
|||||||
agentId?: string,
|
agentId?: string,
|
||||||
): string {
|
): string {
|
||||||
let result = text;
|
let result = text;
|
||||||
const patterns = normalizeMentionPatterns(
|
const rawPatterns = resolveMentionPatterns(cfg, agentId);
|
||||||
resolveMentionPatterns(cfg, agentId),
|
const patterns = normalizeMentionPatterns(rawPatterns);
|
||||||
);
|
|
||||||
for (const p of patterns) {
|
for (const p of patterns) {
|
||||||
try {
|
try {
|
||||||
const re = new RegExp(p, "gi");
|
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;
|
resolveGroupSessionKey(sessionCtxForState) ?? undefined;
|
||||||
const isGroup =
|
const isGroup =
|
||||||
ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution);
|
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()
|
.trim()
|
||||||
.toLowerCase();
|
.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 trimmedBody = rawBody.trim();
|
||||||
const resetAuthorized = resolveCommandAuthorization({
|
const resetAuthorized = resolveCommandAuthorization({
|
||||||
ctx,
|
ctx,
|
||||||
@@ -284,7 +288,9 @@ export async function initSessionState(params: {
|
|||||||
|
|
||||||
const sessionCtx: TemplateContext = {
|
const sessionCtx: TemplateContext = {
|
||||||
...ctx,
|
...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,
|
SessionId: sessionId,
|
||||||
IsNewSession: isNewSession ? "true" : "false",
|
IsNewSession: isNewSession ? "true" : "false",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export type OriginatingChannelType =
|
|||||||
|
|
||||||
export type MsgContext = {
|
export type MsgContext = {
|
||||||
Body?: string;
|
Body?: string;
|
||||||
|
/**
|
||||||
|
* Raw message body without structural context (history, sender labels).
|
||||||
|
* Used for command detection. Falls back to Body if not set.
|
||||||
|
*/
|
||||||
|
RawBody?: string;
|
||||||
From?: string;
|
From?: string;
|
||||||
To?: string;
|
To?: string;
|
||||||
SessionKey?: string;
|
SessionKey?: string;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
resolveSessionTranscriptPath,
|
resolveSessionTranscriptPath,
|
||||||
resolveSessionTranscriptsDir,
|
resolveSessionTranscriptsDir,
|
||||||
updateLastRoute,
|
updateLastRoute,
|
||||||
|
updateSessionStoreEntry,
|
||||||
} from "./sessions.js";
|
} from "./sessions.js";
|
||||||
|
|
||||||
describe("sessions", () => {
|
describe("sessions", () => {
|
||||||
@@ -187,4 +188,52 @@ describe("sessions", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updateSessionStoreEntry merges concurrent patches", async () => {
|
||||||
|
const mainSessionKey = "agent:main:main";
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
|
||||||
|
const storePath = path.join(dir, "sessions.json");
|
||||||
|
await fs.writeFile(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
[mainSessionKey]: {
|
||||||
|
sessionId: "sess-1",
|
||||||
|
updatedAt: 123,
|
||||||
|
thinkingLevel: "low",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
await Promise.all([
|
||||||
|
updateSessionStoreEntry({
|
||||||
|
storePath,
|
||||||
|
sessionKey: mainSessionKey,
|
||||||
|
update: async () => {
|
||||||
|
await sleep(50);
|
||||||
|
return { modelOverride: "anthropic/claude-opus-4-5" };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
updateSessionStoreEntry({
|
||||||
|
storePath,
|
||||||
|
sessionKey: mainSessionKey,
|
||||||
|
update: async () => {
|
||||||
|
await sleep(10);
|
||||||
|
return { thinkingLevel: "high" };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
expect(store[mainSessionKey]?.modelOverride).toBe(
|
||||||
|
"anthropic/claude-opus-4-5",
|
||||||
|
);
|
||||||
|
expect(store[mainSessionKey]?.thinkingLevel).toBe("high");
|
||||||
|
await expect(fs.stat(`${storePath}.lock`)).rejects.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -139,11 +139,14 @@ export function mergeSessionEntry(
|
|||||||
): SessionEntry {
|
): SessionEntry {
|
||||||
const sessionId =
|
const sessionId =
|
||||||
patch.sessionId ?? existing?.sessionId ?? crypto.randomUUID();
|
patch.sessionId ?? existing?.sessionId ?? crypto.randomUUID();
|
||||||
const updatedAt = patch.updatedAt ?? existing?.updatedAt ?? Date.now();
|
const updatedAt = Math.max(
|
||||||
|
existing?.updatedAt ?? 0,
|
||||||
|
patch.updatedAt ?? 0,
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
if (!existing) return { ...patch, sessionId, updatedAt };
|
if (!existing) return { ...patch, sessionId, updatedAt };
|
||||||
return { ...existing, ...patch, sessionId, updatedAt };
|
return { ...existing, ...patch, sessionId, updatedAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GroupKeyResolution = {
|
export type GroupKeyResolution = {
|
||||||
key: string;
|
key: string;
|
||||||
legacyKey?: string;
|
legacyKey?: string;
|
||||||
@@ -487,6 +490,92 @@ export async function saveSessionStore(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionStoreLockOptions = {
|
||||||
|
timeoutMs?: number;
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
staleMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function withSessionStoreLock<T>(
|
||||||
|
storePath: string,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
opts: SessionStoreLockOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const timeoutMs = opts.timeoutMs ?? 10_000;
|
||||||
|
const pollIntervalMs = opts.pollIntervalMs ?? 25;
|
||||||
|
const staleMs = opts.staleMs ?? 30_000;
|
||||||
|
const lockPath = `${storePath}.lock`;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const handle = await fs.promises.open(lockPath, "wx");
|
||||||
|
try {
|
||||||
|
await handle.writeFile(
|
||||||
|
JSON.stringify({ pid: process.pid, startedAt: Date.now() }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
await handle.close();
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
const code =
|
||||||
|
err && typeof err === "object" && "code" in err
|
||||||
|
? String((err as { code?: unknown }).code)
|
||||||
|
: null;
|
||||||
|
if (code !== "EEXIST") throw err;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - startedAt > timeoutMs) {
|
||||||
|
throw new Error(`timeout acquiring session store lock: ${lockPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort stale lock eviction (e.g. crashed process).
|
||||||
|
try {
|
||||||
|
const st = await fs.promises.stat(lockPath);
|
||||||
|
const ageMs = now - st.mtimeMs;
|
||||||
|
if (ageMs > staleMs) {
|
||||||
|
await fs.promises.unlink(lockPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
await fs.promises.unlink(lockPath).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSessionStoreEntry(params: {
|
||||||
|
storePath: string;
|
||||||
|
sessionKey: string;
|
||||||
|
update: (entry: SessionEntry) => Promise<Partial<SessionEntry> | null>;
|
||||||
|
}): Promise<SessionEntry | null> {
|
||||||
|
const { storePath, sessionKey, update } = params;
|
||||||
|
return await withSessionStoreLock(storePath, async () => {
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
const existing = store[sessionKey];
|
||||||
|
if (!existing) return null;
|
||||||
|
const patch = await update(existing);
|
||||||
|
if (!patch) return existing;
|
||||||
|
const next = mergeSessionEntry(existing, patch);
|
||||||
|
store[sessionKey] = next;
|
||||||
|
await saveSessionStore(storePath, store);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateLastRoute(params: {
|
export async function updateLastRoute(params: {
|
||||||
storePath: string;
|
storePath: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
|
|||||||
@@ -1089,6 +1089,7 @@ export function createDiscordMessageHandler(params: {
|
|||||||
});
|
});
|
||||||
const ctxPayload = {
|
const ctxPayload = {
|
||||||
Body: combinedBody,
|
Body: combinedBody,
|
||||||
|
RawBody: baseText,
|
||||||
From: isDirectMessage
|
From: isDirectMessage
|
||||||
? `discord:${author.id}`
|
? `discord:${author.id}`
|
||||||
: `group:${message.channelId}`,
|
: `group:${message.channelId}`,
|
||||||
|
|||||||
@@ -1221,6 +1221,7 @@ export async function monitorWebProvider(
|
|||||||
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
|
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: {
|
ctx: {
|
||||||
Body: combinedBody,
|
Body: combinedBody,
|
||||||
|
RawBody: msg.body,
|
||||||
From: msg.from,
|
From: msg.from,
|
||||||
To: msg.to,
|
To: msg.to,
|
||||||
SessionKey: route.sessionKey,
|
SessionKey: route.sessionKey,
|
||||||
|
|||||||
Reference in New Issue
Block a user