Auto-reply: ack think directives
parent
5a83a44112
commit
48dfb1c8ca
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
### Highlights
|
### 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`.
|
- **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.
|
- **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`.
|
- **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`.
|
- **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`.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
## Setting a session default
|
## Setting a session default
|
||||||
- Send a message that is **only** the directive (whitespace allowed), e.g. `/think:medium` or `/t high`.
|
- 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.
|
- 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
|
## Application by agent
|
||||||
- **Pi/Tau**: injects `--thinking <level>` (skipped for `off`).
|
- **Pi/Tau**: injects `--thinking <level>` (skipped for `off`).
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,10 @@ function normalizeThinkLevel(raw?: string | null): ThinkLevel | undefined {
|
||||||
function extractThinkDirective(body?: string): {
|
function extractThinkDirective(body?: string): {
|
||||||
cleaned: string;
|
cleaned: string;
|
||||||
thinkLevel?: ThinkLevel;
|
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")
|
// Match the longest keyword first to avoid partial captures (e.g. "/think:high")
|
||||||
const match = body.match(
|
const match = body.match(
|
||||||
/\/(?:thinking|think|t)\s*:?\s*([a-zA-Z-]+)\b/i,
|
/\/(?:thinking|think|t)\s*:?\s*([a-zA-Z-]+)\b/i,
|
||||||
|
|
@ -63,7 +65,12 @@ function extractThinkDirective(body?: string): {
|
||||||
const cleaned = match
|
const cleaned = match
|
||||||
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
|
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
|
||||||
: body.trim();
|
: body.trim();
|
||||||
return { cleaned, thinkLevel };
|
return {
|
||||||
|
cleaned,
|
||||||
|
thinkLevel,
|
||||||
|
rawLevel: match?.[1],
|
||||||
|
hasDirective: !!match,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAbortTrigger(text?: string): boolean {
|
function isAbortTrigger(text?: string): boolean {
|
||||||
|
|
@ -203,9 +210,12 @@ export async function getReplyFromConfig(
|
||||||
IsNewSession: isNewSession ? "true" : "false",
|
IsNewSession: isNewSession ? "true" : "false",
|
||||||
};
|
};
|
||||||
|
|
||||||
const { cleaned: thinkCleaned, thinkLevel: inlineThink } = extractThinkDirective(
|
const {
|
||||||
sessionCtx.BodyStripped ?? sessionCtx.Body ?? "",
|
cleaned: thinkCleaned,
|
||||||
);
|
thinkLevel: inlineThink,
|
||||||
|
rawLevel: rawThinkLevel,
|
||||||
|
hasDirective: hasThinkDirective,
|
||||||
|
} = extractThinkDirective(sessionCtx.BodyStripped ?? sessionCtx.Body ?? "");
|
||||||
sessionCtx.Body = thinkCleaned;
|
sessionCtx.Body = thinkCleaned;
|
||||||
sessionCtx.BodyStripped = thinkCleaned;
|
sessionCtx.BodyStripped = thinkCleaned;
|
||||||
|
|
||||||
|
|
@ -215,7 +225,13 @@ export async function getReplyFromConfig(
|
||||||
(reply?.thinkingDefault as ThinkLevel | undefined);
|
(reply?.thinkingDefault as ThinkLevel | undefined);
|
||||||
|
|
||||||
// Directive-only message => persist session thinking level and return ack
|
// 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 (sessionEntry && sessionStore && sessionKey) {
|
||||||
if (inlineThink === "off") {
|
if (inlineThink === "off") {
|
||||||
delete sessionEntry.thinkingLevel;
|
delete sessionEntry.thinkingLevel;
|
||||||
|
|
@ -226,8 +242,12 @@ export async function getReplyFromConfig(
|
||||||
sessionStore[sessionKey] = sessionEntry;
|
sessionStore[sessionKey] = sessionEntry;
|
||||||
await saveSessionStore(storePath, sessionStore);
|
await saveSessionStore(storePath, sessionStore);
|
||||||
}
|
}
|
||||||
|
const ack =
|
||||||
|
inlineThink === "off"
|
||||||
|
? "Thinking disabled."
|
||||||
|
: `Thinking level set to ${inlineThink}.`;
|
||||||
cleanupTyping();
|
cleanupTyping();
|
||||||
return { text: `Thinking level set to ${inlineThink}` };
|
return { text: ack };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
||||||
|
|
|
||||||
|
|
@ -612,6 +612,82 @@ describe("config and templating", () => {
|
||||||
expect(args.join(" ")).toContain("hi there think harder");
|
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 () => {
|
it("uses global thinkingDefault when no directive or session override", async () => {
|
||||||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||||||
stdout: "ok",
|
stdout: "ok",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue