From 48dfb1c8ca80f428483e4447d23955237a083df4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 3 Dec 2025 08:54:38 +0000 Subject: [PATCH] Auto-reply: ack think directives --- CHANGELOG.md | 1 + docs/thinking.md | 1 + src/auto-reply/reply.ts | 34 ++++++++++++++---- src/index.core.test.ts | 76 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3080e3163..3e9c02e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Highlights - **Thinking directives & state:** `/t|/think|/thinking ` (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 ` (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 don’t refresh session `updatedAt`; web/Twilio heartbeats normalize array payloads and optional `heartbeatCommand`. diff --git a/docs/thinking.md b/docs/thinking.md index fd160806a..2baa04325 100644 --- a/docs/thinking.md +++ b/docs/thinking.md @@ -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 ` (skipped for `off`). diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index c2dbaf53c..410d896d2 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -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) diff --git a/src/index.core.test.ts b/src/index.core.test.ts index c4e355f08..d29126568 100644 --- a/src/index.core.test.ts +++ b/src/index.core.test.ts @@ -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",