Auto-reply: add thinking directives
This commit is contained in:
@@ -36,32 +36,34 @@ const ABORT_MEMORY = new Map<string, boolean>();
|
|||||||
type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high";
|
type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high";
|
||||||
|
|
||||||
function normalizeThinkLevel(raw?: string | null): ThinkLevel | undefined {
|
function normalizeThinkLevel(raw?: string | null): ThinkLevel | undefined {
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
const key = raw.toLowerCase();
|
const key = raw.toLowerCase();
|
||||||
if (["off"].includes(key)) return "off";
|
if (["off"].includes(key)) return "off";
|
||||||
if (["min", "minimal"].includes(key)) return "minimal";
|
if (["min", "minimal"].includes(key)) return "minimal";
|
||||||
if (["low", "thinkhard", "think-hard", "think_hard"].includes(key))
|
if (["low", "thinkhard", "think-hard", "think_hard"].includes(key))
|
||||||
return "low";
|
return "low";
|
||||||
if (["med", "medium", "thinkharder", "think-harder", "harder"].includes(key))
|
if (["med", "medium", "thinkharder", "think-harder", "harder"].includes(key))
|
||||||
return "medium";
|
return "medium";
|
||||||
if (
|
if (["high", "ultra", "ultrathink", "think-hard", "thinkhardest", "highest", "max"].includes(key))
|
||||||
["high", "ultra", "ultrathink", "think-hard", "thinkhardest"].includes(key)
|
return "high";
|
||||||
)
|
if (["think"].includes(key)) return "minimal";
|
||||||
return "high";
|
return undefined;
|
||||||
if (["think"].includes(key)) return "minimal";
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractThinkDirective(body?: string): {
|
function extractThinkDirective(body?: string): {
|
||||||
cleaned: string;
|
cleaned: string;
|
||||||
thinkLevel?: ThinkLevel;
|
thinkLevel?: ThinkLevel;
|
||||||
} {
|
} {
|
||||||
if (!body) return { cleaned: "" };
|
if (!body) return { cleaned: "" };
|
||||||
const re = /\/think:([a-zA-Z-]+)/i;
|
// Match the longest keyword first to avoid partial captures (e.g. "/think:high")
|
||||||
const match = body.match(re);
|
const match = body.match(
|
||||||
const thinkLevel = normalizeThinkLevel(match?.[1]);
|
/\/(?:thinking|think|t)\s*:?\s*([a-zA-Z-]+)\b/i,
|
||||||
const cleaned = match ? body.replace(match[0], "").trim() : body;
|
);
|
||||||
return { cleaned, thinkLevel };
|
const thinkLevel = normalizeThinkLevel(match?.[1]);
|
||||||
|
const cleaned = match
|
||||||
|
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
|
||||||
|
: body.trim();
|
||||||
|
return { cleaned, thinkLevel };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAbortTrigger(text?: string): boolean {
|
function isAbortTrigger(text?: string): boolean {
|
||||||
@@ -146,6 +148,8 @@ export async function getReplyFromConfig(
|
|||||||
let systemSent = false;
|
let systemSent = false;
|
||||||
let abortedLastRun = false;
|
let abortedLastRun = false;
|
||||||
|
|
||||||
|
let persistedThinking: string | undefined;
|
||||||
|
|
||||||
if (sessionCfg) {
|
if (sessionCfg) {
|
||||||
const trimmedBody = (ctx.Body ?? "").trim();
|
const trimmedBody = (ctx.Body ?? "").trim();
|
||||||
for (const trigger of resetTriggers) {
|
for (const trigger of resetTriggers) {
|
||||||
@@ -173,6 +177,7 @@ export async function getReplyFromConfig(
|
|||||||
sessionId = entry.sessionId;
|
sessionId = entry.sessionId;
|
||||||
systemSent = entry.systemSent ?? false;
|
systemSent = entry.systemSent ?? false;
|
||||||
abortedLastRun = entry.abortedLastRun ?? false;
|
abortedLastRun = entry.abortedLastRun ?? false;
|
||||||
|
persistedThinking = entry.thinkingLevel;
|
||||||
} else {
|
} else {
|
||||||
sessionId = crypto.randomUUID();
|
sessionId = crypto.randomUUID();
|
||||||
isNewSession = true;
|
isNewSession = true;
|
||||||
@@ -185,6 +190,7 @@ export async function getReplyFromConfig(
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
systemSent,
|
systemSent,
|
||||||
abortedLastRun,
|
abortedLastRun,
|
||||||
|
thinkingLevel: persistedThinking,
|
||||||
};
|
};
|
||||||
sessionStore[sessionKey] = sessionEntry;
|
sessionStore[sessionKey] = sessionEntry;
|
||||||
await saveSessionStore(storePath, sessionStore);
|
await saveSessionStore(storePath, sessionStore);
|
||||||
@@ -197,11 +203,32 @@ export async function getReplyFromConfig(
|
|||||||
IsNewSession: isNewSession ? "true" : "false",
|
IsNewSession: isNewSession ? "true" : "false",
|
||||||
};
|
};
|
||||||
|
|
||||||
const { cleaned: thinkCleaned, thinkLevel } = extractThinkDirective(
|
const { cleaned: thinkCleaned, thinkLevel: inlineThink } = extractThinkDirective(
|
||||||
sessionCtx.BodyStripped ?? sessionCtx.Body ?? "",
|
sessionCtx.BodyStripped ?? sessionCtx.Body ?? "",
|
||||||
);
|
);
|
||||||
sessionCtx.Body = thinkCleaned;
|
sessionCtx.Body = thinkCleaned;
|
||||||
sessionCtx.BodyStripped = thinkCleaned;
|
sessionCtx.BodyStripped = thinkCleaned;
|
||||||
|
|
||||||
|
let resolvedThinkLevel =
|
||||||
|
inlineThink ??
|
||||||
|
(sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
|
||||||
|
(reply?.thinkingDefault as ThinkLevel | undefined);
|
||||||
|
|
||||||
|
// Directive-only message => persist session thinking level and return ack
|
||||||
|
if (inlineThink && !thinkCleaned) {
|
||||||
|
if (sessionEntry && sessionStore && sessionKey) {
|
||||||
|
if (inlineThink === "off") {
|
||||||
|
delete sessionEntry.thinkingLevel;
|
||||||
|
} else {
|
||||||
|
sessionEntry.thinkingLevel = inlineThink;
|
||||||
|
}
|
||||||
|
sessionEntry.updatedAt = Date.now();
|
||||||
|
sessionStore[sessionKey] = sessionEntry;
|
||||||
|
await saveSessionStore(storePath, sessionStore);
|
||||||
|
}
|
||||||
|
cleanupTyping();
|
||||||
|
return { text: `Thinking level set to ${inlineThink}` };
|
||||||
|
}
|
||||||
|
|
||||||
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
||||||
const allowFrom = cfg.inbound?.allowFrom;
|
const allowFrom = cfg.inbound?.allowFrom;
|
||||||
@@ -319,12 +346,22 @@ export async function getReplyFromConfig(
|
|||||||
mediaNote && reply?.mode === "command"
|
mediaNote && reply?.mode === "command"
|
||||||
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
|
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
|
||||||
: undefined;
|
: undefined;
|
||||||
const commandBody = mediaNote
|
let commandBody = mediaNote
|
||||||
? [mediaNote, mediaReplyHint, prefixedBody ?? ""]
|
? [mediaNote, mediaReplyHint, prefixedBody ?? ""]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
.trim()
|
.trim()
|
||||||
: prefixedBody;
|
: prefixedBody;
|
||||||
|
|
||||||
|
// Fallback: if a stray leading level token remains, consume it
|
||||||
|
if (!resolvedThinkLevel && commandBody) {
|
||||||
|
const parts = commandBody.split(/\s+/);
|
||||||
|
const maybeLevel = normalizeThinkLevel(parts[0]);
|
||||||
|
if (maybeLevel) {
|
||||||
|
resolvedThinkLevel = maybeLevel;
|
||||||
|
commandBody = parts.slice(1).join(" ").trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
const templatingCtx: TemplateContext = {
|
const templatingCtx: TemplateContext = {
|
||||||
...sessionCtx,
|
...sessionCtx,
|
||||||
Body: commandBody,
|
Body: commandBody,
|
||||||
@@ -379,7 +416,7 @@ export async function getReplyFromConfig(
|
|||||||
timeoutMs,
|
timeoutMs,
|
||||||
timeoutSeconds,
|
timeoutSeconds,
|
||||||
commandRunner,
|
commandRunner,
|
||||||
thinkLevel,
|
thinkLevel: resolvedThinkLevel,
|
||||||
});
|
});
|
||||||
const payloadArray = runResult.payloads ?? [];
|
const payloadArray = runResult.payloads ?? [];
|
||||||
const meta = runResult.meta;
|
const meta = runResult.meta;
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export type WarelayConfig = {
|
|||||||
mode: ReplyMode;
|
mode: ReplyMode;
|
||||||
text?: string;
|
text?: string;
|
||||||
command?: string[];
|
command?: string[];
|
||||||
|
heartbeatCommand?: string[];
|
||||||
|
thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high";
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
template?: string;
|
template?: string;
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
@@ -85,6 +87,16 @@ const ReplySchema = z
|
|||||||
mode: z.union([z.literal("text"), z.literal("command")]),
|
mode: z.union([z.literal("text"), z.literal("command")]),
|
||||||
text: z.string().optional(),
|
text: z.string().optional(),
|
||||||
command: z.array(z.string()).optional(),
|
command: z.array(z.string()).optional(),
|
||||||
|
heartbeatCommand: z.array(z.string()).optional(),
|
||||||
|
thinkingDefault: z
|
||||||
|
.union([
|
||||||
|
z.literal("off"),
|
||||||
|
z.literal("minimal"),
|
||||||
|
z.literal("low"),
|
||||||
|
z.literal("medium"),
|
||||||
|
z.literal("high"),
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
cwd: z.string().optional(),
|
cwd: z.string().optional(),
|
||||||
template: z.string().optional(),
|
template: z.string().optional(),
|
||||||
timeoutSeconds: z.number().int().positive().optional(),
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
@@ -125,10 +137,13 @@ const ReplySchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)),
|
(val) =>
|
||||||
|
val.mode === "text"
|
||||||
|
? Boolean(val.text)
|
||||||
|
: Boolean(val.command || val.heartbeatCommand),
|
||||||
{
|
{
|
||||||
message:
|
message:
|
||||||
"reply.text is required for mode=text; reply.command is required for mode=command",
|
"reply.text is required for mode=text; reply.command or reply.heartbeatCommand is required for mode=command",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import { CONFIG_DIR, normalizeE164 } from "../utils.js";
|
|||||||
export type SessionScope = "per-sender" | "global";
|
export type SessionScope = "per-sender" | "global";
|
||||||
|
|
||||||
export type SessionEntry = {
|
export type SessionEntry = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
systemSent?: boolean;
|
systemSent?: boolean;
|
||||||
abortedLastRun?: boolean;
|
abortedLastRun?: boolean;
|
||||||
|
thinkingLevel?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json");
|
export const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createMockTwilio } from "../test/mocks/twilio.js";
|
import { createMockTwilio } from "../test/mocks/twilio.js";
|
||||||
import * as exec from "./process/exec.js";
|
import * as exec from "./process/exec.js";
|
||||||
|
import * as tauRpc from "./process/tau-rpc.js";
|
||||||
import { withWhatsAppPrefix } from "./utils.js";
|
import { withWhatsAppPrefix } from "./utils.js";
|
||||||
|
|
||||||
// Mock config to avoid loading real user config
|
// Mock config to avoid loading real user config
|
||||||
@@ -346,6 +347,66 @@ describe("config and templating", () => {
|
|||||||
expect(result?.text).toBe("caption before caption after");
|
expect(result?.text).toBe("caption before caption after");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses heartbeatCommand only for heartbeat polls", 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", "normal {{Body}}"],
|
||||||
|
heartbeatCommand: ["echo", "heartbeat {{Body}}"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await index.getReplyFromConfig(
|
||||||
|
{ Body: "PING", From: "+1", To: "+2" },
|
||||||
|
{ isHeartbeat: true },
|
||||||
|
cfg,
|
||||||
|
runSpy,
|
||||||
|
);
|
||||||
|
expect(runSpy).toHaveBeenCalledWith(
|
||||||
|
["echo", "heartbeat PING"],
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to default command for non-heartbeat calls", 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", "normal {{Body}}"],
|
||||||
|
heartbeatCommand: ["echo", "heartbeat {{Body}}"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await index.getReplyFromConfig(
|
||||||
|
{ Body: "PING", From: "+1", To: "+2" },
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
runSpy,
|
||||||
|
);
|
||||||
|
expect(runSpy).toHaveBeenCalledWith(
|
||||||
|
["echo", "normal PING"],
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("captures MEDIA wrapped in backticks", async () => {
|
it("captures MEDIA wrapped in backticks", async () => {
|
||||||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||||||
stdout: "MEDIA:`/tmp/pic.png` cool",
|
stdout: "MEDIA:`/tmp/pic.png` cool",
|
||||||
@@ -396,6 +457,331 @@ describe("config and templating", () => {
|
|||||||
expect(result?.mediaUrl).toBe("/tmp/pic.png");
|
expect(result?.mediaUrl).toBe("/tmp/pic.png");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("injects --thinking for pi when /think directive is present", async () => {
|
||||||
|
const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
|
||||||
|
stdout: "ok",
|
||||||
|
stderr: "",
|
||||||
|
code: 0,
|
||||||
|
signal: null,
|
||||||
|
killed: false,
|
||||||
|
});
|
||||||
|
const cfg = {
|
||||||
|
inbound: {
|
||||||
|
reply: {
|
||||||
|
mode: "command" as const,
|
||||||
|
command: ["pi", "--mode", "json", "{{Body}}"],
|
||||||
|
agent: { kind: "pi" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await index.getReplyFromConfig(
|
||||||
|
{ Body: "/think:high hello", From: "+1", To: "+2" },
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
expect(rpcSpy).toHaveBeenCalled();
|
||||||
|
const args = rpcSpy.mock.calls[0][0].argv;
|
||||||
|
expect(args).toContain("--thinking");
|
||||||
|
expect(args).toContain("high");
|
||||||
|
expect(rpcSpy.mock.calls[0][0].prompt).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites /think directive to textual cue for non-pi agents", 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" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await index.getReplyFromConfig(
|
||||||
|
{ Body: "/think:medium hi there", From: "+1", To: "+2" },
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
runSpy,
|
||||||
|
);
|
||||||
|
expect(runSpy).toHaveBeenCalled();
|
||||||
|
const args = runSpy.mock.calls[0][0] as string[];
|
||||||
|
expect(args[1]).toBe("hi there think harder");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats /think:off as no-op for non-pi agents", 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" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await index.getReplyFromConfig(
|
||||||
|
{ Body: "/think:off hi there", From: "+1", To: "+2" },
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
runSpy,
|
||||||
|
);
|
||||||
|
expect(runSpy).toHaveBeenCalled();
|
||||||
|
const args = runSpy.mock.calls[0][0] as string[];
|
||||||
|
expect(args[1]).toBe("hi there");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats /think:off as no-op for pi (no --thinking injected)", async () => {
|
||||||
|
const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
|
||||||
|
stdout: "ok",
|
||||||
|
stderr: "",
|
||||||
|
code: 0,
|
||||||
|
signal: null,
|
||||||
|
killed: false,
|
||||||
|
});
|
||||||
|
const cfg = {
|
||||||
|
inbound: {
|
||||||
|
reply: {
|
||||||
|
mode: "command" as const,
|
||||||
|
command: ["pi", "--mode", "json", "{{Body}}"],
|
||||||
|
agent: { kind: "pi" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await index.getReplyFromConfig(
|
||||||
|
{ Body: "/think:off hello", From: "+1", To: "+2" },
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
expect(rpcSpy).toHaveBeenCalled();
|
||||||
|
const args = rpcSpy.mock.calls[0][0].argv;
|
||||||
|
expect(args).not.toContain("--thinking");
|
||||||
|
expect(rpcSpy.mock.calls[0][0].prompt).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists session thinking level when directive-only message is sent", 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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await index.getReplyFromConfig(
|
||||||
|
{ Body: "/think:medium", From: "+1", To: "+2" },
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
runSpy,
|
||||||
|
);
|
||||||
|
|
||||||
|
await index.getReplyFromConfig(
|
||||||
|
{ Body: "hi there", From: "+1", To: "+2" },
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
runSpy,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(runSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const args = runSpy.mock.calls[0][0] as string[];
|
||||||
|
expect(args.join(" ")).toContain("hi there think harder");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses global thinkingDefault when no directive or session override", 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" },
|
||||||
|
thinkingDefault: "low" as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await index.getReplyFromConfig(
|
||||||
|
{ Body: "hello", From: "+1", To: "+2" },
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
runSpy,
|
||||||
|
);
|
||||||
|
expect(runSpy).toHaveBeenCalled();
|
||||||
|
const args = runSpy.mock.calls[0][0] as string[];
|
||||||
|
expect(args[1]).toBe("hello think hard");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts spaced directive form '/think high' and applies cue", 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" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await index.getReplyFromConfig(
|
||||||
|
{ Body: "/think high hello world", From: "+1", To: "+2" },
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
runSpy,
|
||||||
|
);
|
||||||
|
expect(runSpy).toHaveBeenCalled();
|
||||||
|
const args = runSpy.mock.calls[0][0] as string[];
|
||||||
|
expect(args[1]).toBe("hello world ultrathink");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts shorthand '/t:medium' and applies cue", 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" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await index.getReplyFromConfig(
|
||||||
|
{ Body: "/t:medium greetings", From: "+1", To: "+2" },
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
runSpy,
|
||||||
|
);
|
||||||
|
expect(runSpy).toHaveBeenCalled();
|
||||||
|
const args = runSpy.mock.calls[0][0] as string[];
|
||||||
|
expect(args[1]).toBe("greetings think harder");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores session thinking for pi and injects on next message", async () => {
|
||||||
|
const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").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: ["pi", "--mode", "json", "{{Body}}"],
|
||||||
|
agent: { kind: "pi" },
|
||||||
|
session: { store: storePath },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await index.getReplyFromConfig(
|
||||||
|
{ Body: "/thinking max", From: "+1", To: "+2" },
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
|
||||||
|
await index.getReplyFromConfig(
|
||||||
|
{ Body: "next run", From: "+1", To: "+2" },
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rpcSpy).toHaveBeenCalled();
|
||||||
|
const args = rpcSpy.mock.calls[0][0].argv;
|
||||||
|
expect(args).toContain("--thinking");
|
||||||
|
expect(args).toContain("high");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears stored thinking when directive-only /think:off is sent", async () => {
|
||||||
|
const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").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: ["pi", "--mode", "json", "{{Body}}"],
|
||||||
|
agent: { kind: "pi" },
|
||||||
|
session: { store: storePath },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await index.getReplyFromConfig(
|
||||||
|
{ Body: "/think:medium", From: "+1", To: "+2" },
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
await index.getReplyFromConfig(
|
||||||
|
{ Body: "/think:off", From: "+1", To: "+2" },
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
rpcSpy.mockClear();
|
||||||
|
await index.getReplyFromConfig(
|
||||||
|
{ Body: "plain text", From: "+1", To: "+2" },
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
expect(rpcSpy).toHaveBeenCalled();
|
||||||
|
const args = rpcSpy.mock.calls[0][0].argv;
|
||||||
|
expect(args).not.toContain("--thinking");
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores invalid MEDIA lines with whitespace", async () => {
|
it("ignores invalid MEDIA lines with whitespace", async () => {
|
||||||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||||||
stdout: "hello\nMEDIA: not a url with spaces\nrest\n",
|
stdout: "hello\nMEDIA: not a url with spaces\nrest\n",
|
||||||
|
|||||||
Reference in New Issue
Block a user