Auto-reply: ack think directives

This commit is contained in:
Peter Steinberger
2025-12-03 08:54:38 +00:00
parent 5a83a44112
commit 48dfb1c8ca
4 changed files with 105 additions and 7 deletions

View File

@@ -4,6 +4,7 @@
### Highlights
- **Thinking directives & state:** `/t|/think|/thinking <level>` (aliases off|minimal|low|medium|high|max/highest). Inline applies to that message; directive-only message pins the level for the session; `/think:off` clears. Resolution: inline > session override > `inbound.reply.thinkingDefault` > off. Pi/Tau get `--thinking <level>` (except off); other agents append cue words (`think``think hard``think harder``ultrathink`). Heartbeat probe uses `HEARTBEAT /think:high`.
- **Directive confirmations:** Directive-only messages now reply with an acknowledgement (`Thinking level set to high.` / `Thinking disabled.`) and reject unknown levels with a helpful hint (state is unchanged).
- **Pi/Tau stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Tau RPC process to avoid cold starts.
- **Claude prompt flow:** One-time `sessionIntro` with per-message `/think:high` bodyPrefix; system prompt always sent on first turn even with `sendSystemOnce`.
- **Heartbeat UX:** Backpressure skips reply heartbeats while other commands run; skips dont refresh session `updatedAt`; web/Twilio heartbeats normalize array payloads and optional `heartbeatCommand`.

View File

@@ -18,6 +18,7 @@
## Setting a session default
- Send a message that is **only** the directive (whitespace allowed), e.g. `/think:medium` or `/t high`.
- That sticks for the current session (per-sender by default); cleared by `/think:off` or session idle reset.
- Confirmation reply is sent (`Thinking level set to high.` / `Thinking disabled.`). If the level is invalid (e.g. `/thinking big`), the command is rejected with a hint and the session state is left unchanged.
## Application by agent
- **Pi/Tau**: injects `--thinking <level>` (skipped for `off`).

View File

@@ -53,8 +53,10 @@ function normalizeThinkLevel(raw?: string | null): ThinkLevel | undefined {
function extractThinkDirective(body?: string): {
cleaned: string;
thinkLevel?: ThinkLevel;
rawLevel?: string;
hasDirective: boolean;
} {
if (!body) return { cleaned: "" };
if (!body) return { cleaned: "", hasDirective: false };
// Match the longest keyword first to avoid partial captures (e.g. "/think:high")
const match = body.match(
/\/(?:thinking|think|t)\s*:?\s*([a-zA-Z-]+)\b/i,
@@ -63,7 +65,12 @@ function extractThinkDirective(body?: string): {
const cleaned = match
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
: body.trim();
return { cleaned, thinkLevel };
return {
cleaned,
thinkLevel,
rawLevel: match?.[1],
hasDirective: !!match,
};
}
function isAbortTrigger(text?: string): boolean {
@@ -203,9 +210,12 @@ export async function getReplyFromConfig(
IsNewSession: isNewSession ? "true" : "false",
};
const { cleaned: thinkCleaned, thinkLevel: inlineThink } = extractThinkDirective(
sessionCtx.BodyStripped ?? sessionCtx.Body ?? "",
);
const {
cleaned: thinkCleaned,
thinkLevel: inlineThink,
rawLevel: rawThinkLevel,
hasDirective: hasThinkDirective,
} = extractThinkDirective(sessionCtx.BodyStripped ?? sessionCtx.Body ?? "");
sessionCtx.Body = thinkCleaned;
sessionCtx.BodyStripped = thinkCleaned;
@@ -215,7 +225,13 @@ export async function getReplyFromConfig(
(reply?.thinkingDefault as ThinkLevel | undefined);
// Directive-only message => persist session thinking level and return ack
if (inlineThink && !thinkCleaned) {
if (hasThinkDirective && !thinkCleaned) {
if (!inlineThink) {
cleanupTyping();
return {
text: `Unrecognized thinking level "${rawThinkLevel ?? ""}". Valid levels: off, minimal, low, medium, high.`,
};
}
if (sessionEntry && sessionStore && sessionKey) {
if (inlineThink === "off") {
delete sessionEntry.thinkingLevel;
@@ -226,8 +242,12 @@ export async function getReplyFromConfig(
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
}
const ack =
inlineThink === "off"
? "Thinking disabled."
: `Thinking level set to ${inlineThink}.`;
cleanupTyping();
return { text: `Thinking level set to ${inlineThink}` };
return { text: ack };
}
// Optional allowlist by origin number (E.164 without whatsapp: prefix)

View File

@@ -612,6 +612,82 @@ describe("config and templating", () => {
expect(args.join(" ")).toContain("hi there think harder");
});
it("confirms directive-only think level and skips command", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: "ok",
stderr: "",
code: 0,
signal: null,
killed: false,
});
const cfg = {
inbound: {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
},
},
};
const ack = await index.getReplyFromConfig(
{ Body: "/thinking high", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
expect(runSpy).not.toHaveBeenCalled();
expect(ack?.text).toBe("Thinking level set to high.");
});
it("rejects invalid directive-only think level without changing state", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: "ok",
stderr: "",
code: 0,
signal: null,
killed: false,
});
const storeDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "warelay-session-"),
);
const storePath = path.join(storeDir, "sessions.json");
const cfg = {
inbound: {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
agent: { kind: "claude" },
session: { store: storePath },
},
},
};
const ack = await index.getReplyFromConfig(
{ Body: "/thinking big", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
expect(runSpy).not.toHaveBeenCalled();
expect(ack?.text).toContain("Unrecognized thinking level \"big\"");
// Send another message; state should not carry any level.
const second = await index.getReplyFromConfig(
{ Body: "hi", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
expect(runSpy).toHaveBeenCalledTimes(1);
const args = runSpy.mock.calls[0][0] as string[];
const bodyArg = args[args.length - 1];
expect(bodyArg).toBe("hi");
expect(second?.text).toBe("ok");
});
it("uses global thinkingDefault when no directive or session override", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: "ok",