feat: unify provider history context

This commit is contained in:
Peter Steinberger
2026-01-10 18:53:33 +01:00
parent 8c1d39064d
commit d41372b9d9
19 changed files with 718 additions and 80 deletions

View File

@@ -114,6 +114,39 @@ describe("RawBody directive parsing", () => {
});
});
it("CommandBody is honored when RawBody is missing", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const groupMessageCtx = {
Body: `[Context]\nJake: /verbose on\n[from: Jake]`,
CommandBody: "/verbose on",
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("Verbose logging enabled.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("Integration: WhatsApp group message with structural wrapper and RawBody command", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
@@ -151,4 +184,58 @@ describe("RawBody directive parsing", () => {
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("preserves history when RawBody is provided for command parsing", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const groupMessageCtx = {
Body: [
"[Chat messages since your last reply - for context]",
"[WhatsApp ...] Peter: hello",
"",
"[Current message - respond to this]",
"[WhatsApp ...] Jake: /think:high status please",
"[from: Jake McInteer (+6421807830)]",
].join("\n"),
RawBody: "/think:high status please",
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).toBe("ok");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const prompt =
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).toContain(
"[Chat messages since your last reply - for context]",
);
expect(prompt).toContain("Peter: hello");
expect(prompt).toContain("status please");
expect(prompt).not.toContain("/think:high");
});
});
});

View File

@@ -340,10 +340,14 @@ export async function getReplyFromConfig(
triggerBodyNormalized,
} = sessionState;
// Prefer RawBody (clean message without structural context) for directive parsing.
// Prefer CommandBody/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 commandSource =
sessionCtx.CommandBody ??
sessionCtx.RawBody ??
sessionCtx.BodyStripped ??
sessionCtx.Body ??
"";
const clearInlineDirectives = (cleaned: string): InlineDirectives => ({
cleaned,
hasThinkDirective: false,
@@ -382,7 +386,7 @@ export async function getReplyFromConfig(
.map((entry) => entry.alias?.trim())
.filter((alias): alias is string => Boolean(alias))
.filter((alias) => !reservedCommands.has(alias.toLowerCase()));
let parsedDirectives = parseInlineDirectives(rawBody, {
let parsedDirectives = parseInlineDirectives(commandSource, {
modelAliases: configuredAliases,
});
if (
@@ -436,7 +440,7 @@ export async function getReplyFromConfig(
const existingBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
const cleanedBody = (() => {
if (!existingBody) return parsedDirectives.cleaned;
if (!sessionCtx.RawBody) {
if (!sessionCtx.CommandBody && !sessionCtx.RawBody) {
return parseInlineDirectives(existingBody, {
modelAliases: configuredAliases,
}).cleaned;
@@ -786,14 +790,19 @@ export async function getReplyFromConfig(
.filter(Boolean)
.join("\n\n");
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
// Use RawBody for bare reset detection (clean message without structural context).
const rawBodyTrimmed = (ctx.RawBody ?? ctx.Body ?? "").trim();
// Use CommandBody/RawBody for bare reset detection (clean message without structural context).
const rawBodyTrimmed = (
ctx.CommandBody ??
ctx.RawBody ??
ctx.Body ??
""
).trim();
const baseBodyTrimmedRaw = baseBody.trim();
if (
allowTextCommands &&
!commandAuthorized &&
!baseBodyTrimmedRaw &&
hasControlCommand(rawBody)
hasControlCommand(commandSource)
) {
typing.cleanup();
return undefined;
@@ -863,18 +872,18 @@ export async function getReplyFromConfig(
const mediaReplyHint = mediaNote
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
: undefined;
let commandBody = mediaNote
let prefixedCommandBody = mediaNote
? [mediaNote, mediaReplyHint, prefixedBody ?? ""]
.filter(Boolean)
.join("\n")
.trim()
: prefixedBody;
if (!resolvedThinkLevel && commandBody) {
const parts = commandBody.split(/\s+/);
if (!resolvedThinkLevel && prefixedCommandBody) {
const parts = prefixedCommandBody.split(/\s+/);
const maybeLevel = normalizeThinkLevel(parts[0]);
if (maybeLevel) {
resolvedThinkLevel = maybeLevel;
commandBody = parts.slice(1).join(" ").trim();
prefixedCommandBody = parts.slice(1).join(" ").trim();
}
}
if (!resolvedThinkLevel) {
@@ -968,7 +977,7 @@ export async function getReplyFromConfig(
}
return runReplyAgent({
commandBody,
commandBody: prefixedCommandBody,
followupRun,
queueKey,
resolvedQueue,

View File

@@ -81,8 +81,10 @@ export async function tryFastAbortFromMessage(params: {
sessionKey: targetKey ?? ctx.SessionKey ?? "",
config: cfg,
});
// Use RawBody for abort detection (clean message without structural context).
const raw = stripStructuralPrefixes(ctx.RawBody ?? ctx.Body ?? "");
// Use RawBody/CommandBody for abort detection (clean message without structural context).
const raw = stripStructuralPrefixes(
ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "",
);
const isGroup = ctx.ChatType?.trim().toLowerCase() === "group";
const stripped = isGroup ? stripMentions(raw, ctx, cfg, agentId) : raw;
const normalized = normalizeCommandBody(stripped);

View File

@@ -900,7 +900,7 @@ export async function handleCommands(params: {
await waitForEmbeddedPiRunEnd(sessionId, 15_000);
}
const customInstructions = extractCompactInstructions({
rawBody: ctx.Body,
rawBody: ctx.CommandBody ?? ctx.RawBody ?? ctx.Body,
ctx,
cfg,
agentId: params.agentId,

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { CURRENT_MESSAGE_MARKER } from "./mentions.js";
import {
HISTORY_CONTEXT_MARKER,
appendHistoryEntry,
buildHistoryContext,
buildHistoryContextFromEntries,
} from "./history.js";
describe("history helpers", () => {
it("returns current message when history is empty", () => {
const result = buildHistoryContext({
historyText: " ",
currentMessage: "hello",
});
expect(result).toBe("hello");
});
it("wraps history entries and excludes current by default", () => {
const result = buildHistoryContextFromEntries({
entries: [
{ sender: "A", body: "one" },
{ sender: "B", body: "two" },
],
currentMessage: "current",
formatEntry: (entry) => `${entry.sender}: ${entry.body}`,
});
expect(result).toContain(HISTORY_CONTEXT_MARKER);
expect(result).toContain("A: one");
expect(result).not.toContain("B: two");
expect(result).toContain(CURRENT_MESSAGE_MARKER);
expect(result).toContain("current");
});
it("trims history to configured limit", () => {
const historyMap = new Map<string, { sender: string; body: string }[]>();
appendHistoryEntry({
historyMap,
historyKey: "room",
limit: 2,
entry: { sender: "A", body: "one" },
});
appendHistoryEntry({
historyMap,
historyKey: "room",
limit: 2,
entry: { sender: "B", body: "two" },
});
appendHistoryEntry({
historyMap,
historyKey: "room",
limit: 2,
entry: { sender: "C", body: "three" },
});
expect(historyMap.get("room")?.map((entry) => entry.body)).toEqual([
"two",
"three",
]);
});
});

View File

@@ -0,0 +1,64 @@
import { CURRENT_MESSAGE_MARKER } from "./mentions.js";
export const HISTORY_CONTEXT_MARKER =
"[Chat messages since your last reply - for context]";
export const DEFAULT_GROUP_HISTORY_LIMIT = 50;
export type HistoryEntry = {
sender: string;
body: string;
timestamp?: number;
messageId?: string;
};
export function buildHistoryContext(params: {
historyText: string;
currentMessage: string;
lineBreak?: string;
}): string {
const { historyText, currentMessage } = params;
const lineBreak = params.lineBreak ?? "\n";
if (!historyText.trim()) return currentMessage;
return [
HISTORY_CONTEXT_MARKER,
historyText,
"",
CURRENT_MESSAGE_MARKER,
currentMessage,
].join(lineBreak);
}
export function appendHistoryEntry(params: {
historyMap: Map<string, HistoryEntry[]>;
historyKey: string;
entry: HistoryEntry;
limit: number;
}): HistoryEntry[] {
const { historyMap, historyKey, entry } = params;
if (params.limit <= 0) return [];
const history = historyMap.get(historyKey) ?? [];
history.push(entry);
while (history.length > params.limit) history.shift();
historyMap.set(historyKey, history);
return history;
}
export function buildHistoryContextFromEntries(params: {
entries: HistoryEntry[];
currentMessage: string;
formatEntry: (entry: HistoryEntry) => string;
lineBreak?: string;
excludeLast?: boolean;
}): string {
const lineBreak = params.lineBreak ?? "\n";
const entries = params.excludeLast === false
? params.entries
: params.entries.slice(0, -1);
if (entries.length === 0) return params.currentMessage;
const historyText = entries.map(params.formatEntry).join(lineBreak);
return buildHistoryContext({
historyText,
currentMessage: params.currentMessage,
lineBreak,
});
}

View File

@@ -136,15 +136,15 @@ export async function initSessionState(params: {
resolveGroupSessionKey(sessionCtxForState) ?? undefined;
const isGroup =
ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution);
// 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 ?? "";
// Prefer CommandBody/RawBody (clean message) for command detection; fall back
// to Body which may contain structural context (history, sender labels).
const commandSource = ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "";
const triggerBodyNormalized = stripStructuralPrefixes(commandSource)
.trim()
.toLowerCase();
// Use RawBody for reset trigger matching (clean message without structural context).
const rawBody = ctx.RawBody ?? ctx.Body ?? "";
// Use CommandBody/RawBody for reset trigger matching (clean message without structural context).
const rawBody = commandSource;
const trimmedBody = rawBody.trim();
const resetAuthorized = resolveCommandAuthorization({
ctx,
@@ -290,7 +290,7 @@ export async function initSessionState(params: {
...ctx,
// 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,
BodyStripped: bodyStripped ?? ctx.Body ?? ctx.CommandBody ?? ctx.RawBody,
SessionId: sessionId,
IsNewSession: isNewSession ? "true" : "false",
};

View File

@@ -13,9 +13,13 @@ export type MsgContext = {
Body?: string;
/**
* Raw message body without structural context (history, sender labels).
* Used for command detection. Falls back to Body if not set.
* Legacy alias for CommandBody. Falls back to Body if not set.
*/
RawBody?: string;
/**
* Prefer for command detection; RawBody is treated as legacy alias.
*/
CommandBody?: string;
From?: string;
To?: string;
SessionKey?: string;