feat: add reply tags and replyToMode

This commit is contained in:
Peter Steinberger
2026-01-02 23:18:41 +01:00
parent a9ff03acaf
commit 2c92ccd66e
19 changed files with 353 additions and 27 deletions

View File

@@ -14,6 +14,7 @@ import {
import { drainSystemEvents } from "../infra/system-events.js";
import {
extractQueueDirective,
extractReplyToTag,
extractThinkDirective,
extractVerboseDirective,
getReplyFromConfig,
@@ -96,6 +97,90 @@ describe("directive parsing", () => {
expect(res.cleaned).toBe("please now");
});
it("extracts reply_to_current tag", () => {
const res = extractReplyToTag("ok [[reply_to_current]]", "msg-1");
expect(res.replyToId).toBe("msg-1");
expect(res.cleaned).toBe("ok");
});
it("extracts reply_to id tag", () => {
const res = extractReplyToTag("see [[reply_to:12345]] now", "msg-1");
expect(res.replyToId).toBe("12345");
expect(res.cleaned).toBe("see now");
});
it("strips reply tags and maps reply_to_current to MessageSid", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello [[reply_to_current]]" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await getReplyFromConfig(
{
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: "msg-123",
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
},
);
const payload = Array.isArray(res) ? res[0] : res;
expect(payload?.text).toBe("hello");
expect(payload?.replyToId).toBe("msg-123");
});
});
it("prefers explicit reply_to id over reply_to_current", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [
{
text: "hi [[reply_to_current]] [[reply_to:abc-456]]",
},
],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await getReplyFromConfig(
{
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: "msg-123",
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
},
);
const payload = Array.isArray(res) ? res[0] : res;
expect(payload?.text).toBe("hi");
expect(payload?.replyToId).toBe("abc-456");
});
});
it("applies inline think and still runs agent content", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({

View File

@@ -164,6 +164,39 @@ export function extractQueueDirective(body?: string): {
};
}
export function extractReplyToTag(
text?: string,
currentMessageId?: string,
): {
cleaned: string;
replyToId?: string;
hasTag: boolean;
} {
if (!text) return { cleaned: "", hasTag: false };
let cleaned = text;
let replyToId: string | undefined;
let hasTag = false;
const currentMatch = cleaned.match(/\[\[reply_to_current\]\]/i);
if (currentMatch) {
cleaned = cleaned.replace(/\[\[reply_to_current\]\]/gi, " ");
hasTag = true;
if (currentMessageId?.trim()) {
replyToId = currentMessageId.trim();
}
}
const idMatch = cleaned.match(/\[\[reply_to:([^\]\n]+)\]\]/i);
if (idMatch?.[1]) {
cleaned = cleaned.replace(/\[\[reply_to:[^\]\n]+\]\]/gi, " ");
replyToId = idMatch[1].trim();
hasTag = true;
}
cleaned = cleaned.replace(/\s+/g, " ").trim();
return { cleaned, replyToId, hasTag };
}
function isAbortTrigger(text?: string): boolean {
if (!text) return false;
const normalized = text.trim().toLowerCase();
@@ -1123,6 +1156,12 @@ export async function getReplyFromConfig(
ABORT_MEMORY.set(abortKey, false);
}
}
const messageIdHint = sessionCtx.MessageSid?.trim()
? `[message_id: ${sessionCtx.MessageSid.trim()}]`
: "";
if (messageIdHint) {
prefixedBodyBase = `${prefixedBodyBase}\n${messageIdHint}`;
}
// Prepend queued system events (transitions only) and (for new main sessions) a provider snapshot.
// Token efficiency: we filter out periodic/heartbeat noise and keep the lines compact.
@@ -1399,9 +1438,28 @@ export async function getReplyFromConfig(
return [{ ...payload, text: stripped.text }];
});
if (sanitizedPayloads.length === 0) return undefined;
const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads
.map((payload) => {
const { cleaned, replyToId } = extractReplyToTag(
payload.text,
sessionCtx.MessageSid,
);
return {
...payload,
text: cleaned ? cleaned : undefined,
replyToId: replyToId ?? payload.replyToId,
};
})
.filter(
(payload) =>
payload.text ||
payload.mediaUrl ||
(payload.mediaUrls && payload.mediaUrls.length > 0),
);
const shouldSignalTyping = sanitizedPayloads.some((payload) => {
if (replyTaggedPayloads.length === 0) return undefined;
const shouldSignalTyping = replyTaggedPayloads.some((payload) => {
const trimmed = payload.text?.trim();
if (trimmed && trimmed !== SILENT_REPLY_TOKEN) return true;
if (payload.mediaUrl) return true;
@@ -1456,7 +1514,7 @@ export async function getReplyFromConfig(
}
// If verbose is enabled and this is a new session, prepend a session hint.
let finalPayloads = sanitizedPayloads;
let finalPayloads = replyTaggedPayloads;
if (resolvedVerboseLevel === "on" && isNewSession) {
finalPayloads = [
{ text: `🧭 New session: ${sessionIdFinal}` },

View File

@@ -9,4 +9,5 @@ export type ReplyPayload = {
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
replyToId?: string;
};