diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 5abfdf0fa..94d8c0726 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -49,15 +49,9 @@ const unitIsolatedFilesRaw = [ "src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts", // Heavy trigger command scenarios; keep off unit-fast critical path to reduce contention noise. "src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.runs-greeting-prompt-bare-reset.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.test.ts", + "src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts", "src/auto-reply/reply.triggers.group-intro-prompts.test.ts", "src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.keeps-inline-status-unauthorized-senders.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.test.ts", "src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts", // Setup-heavy bot bootstrap suite. "src/telegram/bot.create-telegram-bot.test.ts", diff --git a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts index e517c244e..dc78e7ac5 100644 --- a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts @@ -2,6 +2,7 @@ import "./reply.directive.directive-behavior.e2e-mocks.js"; import { describe, expect, it, vi } from "vitest"; import { loadSessionStore } from "../config/sessions.js"; import { + assertModelSelection, installDirectiveBehaviorE2EHooks, loadModelCatalog, makeEmbeddedTextResult, @@ -13,6 +14,7 @@ import { sessionStorePath, withTempHome, } from "./reply.directive.directive-behavior.e2e-harness.js"; +import { runModelDirectiveText } from "./reply.directive.directive-behavior.model-directive-test-utils.js"; import { getReplyFromConfig } from "./reply.js"; function makeDefaultModelConfig(home: string) { @@ -84,6 +86,142 @@ describe("directive behavior", () => { expectedLevel: "off", }); }); + it("aliases /model list to /models", async () => { + await withTempHome(async (home) => { + const text = await runModelDirectiveText(home, "/model list"); + expect(text).toContain("Providers:"); + expect(text).toContain("- anthropic"); + expect(text).toContain("- openai"); + expect(text).toContain("Use: /models "); + expect(text).toContain("Switch: /model "); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("shows current model when catalog is unavailable", async () => { + await withTempHome(async (home) => { + vi.mocked(loadModelCatalog).mockResolvedValueOnce([]); + const text = await runModelDirectiveText(home, "/model"); + expect(text).toContain("Current: anthropic/claude-opus-4-5"); + expect(text).toContain("Switch: /model "); + expect(text).toContain("Browse: /models (providers) or /models (models)"); + expect(text).toContain("More: /model status"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("lists allowlisted models on /model status", async () => { + await withTempHome(async (home) => { + const text = await runModelDirectiveText(home, "/model status", { + includeSessionStore: false, + }); + expect(text).toContain("anthropic/claude-opus-4-5"); + expect(text).toContain("openai/gpt-4.1-mini"); + expect(text).not.toContain("claude-sonnet-4-1"); + expect(text).toContain("auth:"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("includes catalog providers when no allowlist is set", async () => { + await withTempHome(async (home) => { + vi.mocked(loadModelCatalog).mockResolvedValue([ + { id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, + { id: "grok-4", name: "Grok 4", provider: "xai" }, + ]); + const text = await runModelDirectiveText(home, "/model list", { + defaults: { + model: { + primary: "anthropic/claude-opus-4-5", + fallbacks: ["openai/gpt-4.1-mini"], + }, + imageModel: { primary: "minimax/MiniMax-M2.1" }, + models: undefined, + }, + }); + expect(text).toContain("Providers:"); + expect(text).toContain("- anthropic"); + expect(text).toContain("- openai"); + expect(text).toContain("- xai"); + expect(text).toContain("Use: /models "); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("lists config-only providers when catalog is present", async () => { + await withTempHome(async (home) => { + vi.mocked(loadModelCatalog).mockResolvedValueOnce([ + { + provider: "anthropic", + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + }, + { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" }, + ]); + const text = await runModelDirectiveText(home, "/models minimax", { + defaults: { + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + "minimax/MiniMax-M2.1": { alias: "minimax" }, + }, + }, + extra: { + models: { + mode: "merge", + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + api: "anthropic-messages", + models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }], + }, + }, + }, + }, + }); + expect(text).toContain("Models (minimax"); + expect(text).toContain("minimax/MiniMax-M2.1"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("does not repeat missing auth labels on /model list", async () => { + await withTempHome(async (home) => { + const text = await runModelDirectiveText(home, "/model list", { + defaults: { + models: { + "anthropic/claude-opus-4-5": {}, + }, + }, + }); + expect(text).toContain("Providers:"); + expect(text).not.toContain("missing (missing)"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("sets model override on /model directive", async () => { + await withTempHome(async (home) => { + const storePath = sessionStorePath(home); + + await getReplyFromConfig( + { Body: "/model openai/gpt-4.1-mini", From: "+1222", To: "+1222", CommandAuthorized: true }, + {}, + makeWhatsAppDirectiveConfig( + home, + { + model: { primary: "anthropic/claude-opus-4-5" }, + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, + }, + { session: { store: storePath } }, + ), + ); + + assertModelSelection(storePath, { + model: "gpt-4.1-mini", + provider: "openai", + }); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); it("ignores inline /model and uses the default model", async () => { await withTempHome(async (home) => { mockEmbeddedTextResult("done"); diff --git a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts b/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts deleted file mode 100644 index 759136fc6..000000000 --- a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import "./reply.directive.directive-behavior.e2e-mocks.js"; -import { describe, expect, it, vi } from "vitest"; -import { - assertModelSelection, - installDirectiveBehaviorE2EHooks, - loadModelCatalog, - makeWhatsAppDirectiveConfig, - runEmbeddedPiAgent, - sessionStorePath, - withTempHome, -} from "./reply.directive.directive-behavior.e2e-harness.js"; -import { runModelDirectiveText } from "./reply.directive.directive-behavior.model-directive-test-utils.js"; -import { getReplyFromConfig } from "./reply.js"; - -describe("directive behavior", () => { - installDirectiveBehaviorE2EHooks(); - - it("aliases /model list to /models", async () => { - await withTempHome(async (home) => { - const text = await runModelDirectiveText(home, "/model list"); - expect(text).toContain("Providers:"); - expect(text).toContain("- anthropic"); - expect(text).toContain("- openai"); - expect(text).toContain("Use: /models "); - expect(text).toContain("Switch: /model "); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("shows current model when catalog is unavailable", async () => { - await withTempHome(async (home) => { - vi.mocked(loadModelCatalog).mockResolvedValueOnce([]); - const text = await runModelDirectiveText(home, "/model"); - expect(text).toContain("Current: anthropic/claude-opus-4-5"); - expect(text).toContain("Switch: /model "); - expect(text).toContain("Browse: /models (providers) or /models (models)"); - expect(text).toContain("More: /model status"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("lists allowlisted models on /model status", async () => { - await withTempHome(async (home) => { - const text = await runModelDirectiveText(home, "/model status", { - includeSessionStore: false, - }); - expect(text).toContain("anthropic/claude-opus-4-5"); - expect(text).toContain("openai/gpt-4.1-mini"); - expect(text).not.toContain("claude-sonnet-4-1"); - expect(text).toContain("auth:"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("includes catalog providers when no allowlist is set", async () => { - await withTempHome(async (home) => { - vi.mocked(loadModelCatalog).mockResolvedValue([ - { id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" }, - { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, - { id: "grok-4", name: "Grok 4", provider: "xai" }, - ]); - const text = await runModelDirectiveText(home, "/model list", { - defaults: { - model: { - primary: "anthropic/claude-opus-4-5", - fallbacks: ["openai/gpt-4.1-mini"], - }, - imageModel: { primary: "minimax/MiniMax-M2.1" }, - models: undefined, - }, - }); - expect(text).toContain("Providers:"); - expect(text).toContain("- anthropic"); - expect(text).toContain("- openai"); - expect(text).toContain("- xai"); - expect(text).toContain("Use: /models "); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("lists config-only providers when catalog is present", async () => { - await withTempHome(async (home) => { - // Catalog present but missing custom providers: /model should still include - // allowlisted provider/model keys from config. - vi.mocked(loadModelCatalog).mockResolvedValueOnce([ - { - provider: "anthropic", - id: "claude-opus-4-5", - name: "Claude Opus 4.5", - }, - { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" }, - ]); - const text = await runModelDirectiveText(home, "/models minimax", { - defaults: { - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, - "minimax/MiniMax-M2.1": { alias: "minimax" }, - }, - }, - extra: { - models: { - mode: "merge", - providers: { - minimax: { - baseUrl: "https://api.minimax.io/anthropic", - api: "anthropic-messages", - models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }], - }, - }, - }, - }, - }); - expect(text).toContain("Models (minimax"); - expect(text).toContain("minimax/MiniMax-M2.1"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("does not repeat missing auth labels on /model list", async () => { - await withTempHome(async (home) => { - const text = await runModelDirectiveText(home, "/model list", { - defaults: { - models: { - "anthropic/claude-opus-4-5": {}, - }, - }, - }); - expect(text).toContain("Providers:"); - expect(text).not.toContain("missing (missing)"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("sets model override on /model directive", async () => { - await withTempHome(async (home) => { - const storePath = sessionStorePath(home); - - await getReplyFromConfig( - { Body: "/model openai/gpt-4.1-mini", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - makeWhatsAppDirectiveConfig( - home, - { - model: { primary: "anthropic/claude-opus-4-5" }, - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, - }, - }, - { session: { store: storePath } }, - ), - ); - - assertModelSelection(storePath, { - model: "gpt-4.1-mini", - provider: "openai", - }); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("supports model aliases on /model directive", async () => { - await withTempHome(async (home) => { - const storePath = sessionStorePath(home); - - await getReplyFromConfig( - { Body: "/model Opus", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - makeWhatsAppDirectiveConfig( - home, - { - model: { primary: "openai/gpt-4.1-mini" }, - models: { - "openai/gpt-4.1-mini": {}, - "anthropic/claude-opus-4-5": { alias: "Opus" }, - }, - }, - { session: { store: storePath } }, - ), - ); - - assertModelSelection(storePath, { - model: "claude-opus-4-5", - provider: "anthropic", - }); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts deleted file mode 100644 index 10152a8bf..000000000 --- a/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import fs from "node:fs/promises"; -import { beforeAll, describe, expect, it } from "vitest"; -import { - expectDirectElevatedToggleOn, - getRunEmbeddedPiAgentMock, - installTriggerHandlingE2eTestHooks, - loadGetReplyFromConfig, - MAIN_SESSION_KEY, - makeCfg, - makeWhatsAppElevatedCfg, - readSessionStore, - requireSessionStorePath, - withTempHome, -} from "./reply.triggers.trigger-handling.test-harness.js"; - -let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; -beforeAll(async () => { - getReplyFromConfig = await loadGetReplyFromConfig(); -}); - -installTriggerHandlingE2eTestHooks(); - -describe("trigger handling", () => { - it("allows approved sender to toggle elevated mode", async () => { - await expectDirectElevatedToggleOn({ getReplyFromConfig }); - }); - it("rejects elevated toggles when disabled", async () => { - await withTempHome(async (home) => { - const cfg = makeWhatsAppElevatedCfg(home, { elevatedEnabled: false }); - - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "+1000", - To: "+2000", - Provider: "whatsapp", - SenderE164: "+1000", - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("tools.elevated.enabled"); - - const storeRaw = await fs.readFile(requireSessionStorePath(cfg), "utf-8"); - const store = JSON.parse(storeRaw) as Record; - expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBeUndefined(); - }); - }); - - it("allows elevated off in groups without mention", async () => { - await withTempHome(async (home) => { - const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: false }); - - const res = await getReplyFromConfig( - { - Body: "/elevated off", - From: "whatsapp:group:123@g.us", - To: "whatsapp:+2000", - Provider: "whatsapp", - SenderE164: "+1000", - CommandAuthorized: true, - ChatType: "group", - WasMentioned: false, - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Elevated mode disabled."); - - const store = await readSessionStore(cfg); - expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe("off"); - }); - }); - - it("allows elevated directive in groups when mentioned", async () => { - await withTempHome(async (home) => { - const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: true }); - - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "whatsapp:group:123@g.us", - To: "whatsapp:+2000", - Provider: "whatsapp", - SenderE164: "+1000", - CommandAuthorized: true, - ChatType: "group", - WasMentioned: true, - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Elevated mode set to ask"); - - const store = await readSessionStore(cfg); - expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe("on"); - }); - }); - - it("ignores elevated directive in groups when not mentioned", async () => { - await withTempHome(async (home) => { - getRunEmbeddedPiAgentMock().mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: false }); - - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "whatsapp:group:123@g.us", - To: "whatsapp:+2000", - Provider: "whatsapp", - SenderE164: "+1000", - ChatType: "group", - WasMentioned: false, - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBeUndefined(); - expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); - }); - }); - - it("ignores inline elevated directive for unapproved sender", async () => { - await withTempHome(async (home) => { - getRunEmbeddedPiAgentMock().mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - const cfg = makeWhatsAppElevatedCfg(home); - - const res = await getReplyFromConfig( - { - Body: "please /elevated on now", - From: "+2000", - To: "+2000", - Provider: "whatsapp", - SenderE164: "+2000", - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).not.toContain("elevated is not available right now"); - expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalled(); - }); - }); - - it("uses tools.elevated.allowFrom.discord for elevated approval", async () => { - await withTempHome(async (home) => { - const cfg = makeCfg(home); - cfg.tools = { elevated: { allowFrom: { discord: ["123"] } } }; - - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "discord:123", - To: "user:123", - Provider: "discord", - SenderName: "Peter Steinberger", - SenderUsername: "steipete", - SenderTag: "steipete", - CommandAuthorized: true, - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Elevated mode set to ask"); - - const store = await readSessionStore(cfg); - expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on"); - }); - }); - - it("treats explicit discord elevated allowlist as override", async () => { - await withTempHome(async (home) => { - const cfg = makeCfg(home); - cfg.tools = { - elevated: { - allowFrom: { discord: [] }, - }, - }; - - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "discord:123", - To: "user:123", - Provider: "discord", - SenderName: "steipete", - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("tools.elevated.allowFrom.discord"); - expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); - }); - }); - - it("returns a context overflow fallback when the embedded agent throws", async () => { - await withTempHome(async (home) => { - getRunEmbeddedPiAgentMock().mockRejectedValue(new Error("Context window exceeded")); - - const res = await getReplyFromConfig( - { - Body: "hello", - From: "+1002", - To: "+2000", - }, - {}, - makeCfg(home), - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe( - "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model.", - ); - expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); - }); - }); -}); diff --git a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts index 3f92d80c1..6072608ae 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts @@ -4,12 +4,15 @@ import { join } from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; import { expectInlineCommandHandledAndStripped, + expectDirectElevatedToggleOn, getRunEmbeddedPiAgentMock, installTriggerHandlingE2eTestHooks, loadGetReplyFromConfig, MAIN_SESSION_KEY, makeCfg, + makeWhatsAppElevatedCfg, mockRunEmbeddedPiAgentOk, + readSessionStore, requireSessionStorePath, runGreetingPromptForBareNewOrReset, withTempHome, @@ -307,4 +310,217 @@ describe("trigger handling", () => { expect(store[MAIN_SESSION_KEY]?.sendPolicy).toBe("deny"); }); }); + + it("allows approved sender to toggle elevated mode", async () => { + await expectDirectElevatedToggleOn({ getReplyFromConfig }); + }); + + it("rejects elevated toggles when disabled", async () => { + await withTempHome(async (home) => { + const cfg = makeWhatsAppElevatedCfg(home, { elevatedEnabled: false }); + + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "+1000", + To: "+2000", + Provider: "whatsapp", + SenderE164: "+1000", + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("tools.elevated.enabled"); + + const storeRaw = await fs.readFile(requireSessionStorePath(cfg), "utf-8"); + const store = JSON.parse(storeRaw) as Record; + expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBeUndefined(); + }); + }); + + it("allows elevated off in groups without mention", async () => { + await withTempHome(async (home) => { + const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: false }); + + const res = await getReplyFromConfig( + { + Body: "/elevated off", + From: "whatsapp:group:123@g.us", + To: "whatsapp:+2000", + Provider: "whatsapp", + SenderE164: "+1000", + CommandAuthorized: true, + ChatType: "group", + WasMentioned: false, + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode disabled."); + + const store = await readSessionStore(cfg); + expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe("off"); + }); + }); + + it("allows elevated directive in groups when mentioned", async () => { + await withTempHome(async (home) => { + const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: true }); + + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "whatsapp:group:123@g.us", + To: "whatsapp:+2000", + Provider: "whatsapp", + SenderE164: "+1000", + CommandAuthorized: true, + ChatType: "group", + WasMentioned: true, + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode set to ask"); + + const store = await readSessionStore(cfg); + expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe("on"); + }); + }); + + it("ignores elevated directive in groups when not mentioned", async () => { + await withTempHome(async (home) => { + getRunEmbeddedPiAgentMock().mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: false }); + + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "whatsapp:group:123@g.us", + To: "whatsapp:+2000", + Provider: "whatsapp", + SenderE164: "+1000", + ChatType: "group", + WasMentioned: false, + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBeUndefined(); + expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); + }); + }); + + it("ignores inline elevated directive for unapproved sender", async () => { + await withTempHome(async (home) => { + getRunEmbeddedPiAgentMock().mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + const cfg = makeWhatsAppElevatedCfg(home); + + const res = await getReplyFromConfig( + { + Body: "please /elevated on now", + From: "+2000", + To: "+2000", + Provider: "whatsapp", + SenderE164: "+2000", + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).not.toContain("elevated is not available right now"); + expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalled(); + }); + }); + + it("uses tools.elevated.allowFrom.discord for elevated approval", async () => { + await withTempHome(async (home) => { + const cfg = makeCfg(home); + cfg.tools = { elevated: { allowFrom: { discord: ["123"] } } }; + + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "discord:123", + To: "user:123", + Provider: "discord", + SenderName: "Peter Steinberger", + SenderUsername: "steipete", + SenderTag: "steipete", + CommandAuthorized: true, + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode set to ask"); + + const store = await readSessionStore(cfg); + expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on"); + }); + }); + + it("treats explicit discord elevated allowlist as override", async () => { + await withTempHome(async (home) => { + const cfg = makeCfg(home); + cfg.tools = { + elevated: { + allowFrom: { discord: [] }, + }, + }; + + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "discord:123", + To: "user:123", + Provider: "discord", + SenderName: "steipete", + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("tools.elevated.allowFrom.discord"); + expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); + }); + }); + + it("returns a context overflow fallback when the embedded agent throws", async () => { + await withTempHome(async (home) => { + getRunEmbeddedPiAgentMock().mockRejectedValue(new Error("Context window exceeded")); + + const res = await getReplyFromConfig( + { + Body: "hello", + From: "+1002", + To: "+2000", + }, + {}, + makeCfg(home), + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe( + "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model.", + ); + expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); + }); + }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.test.ts b/src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.test.ts deleted file mode 100644 index 73bea2eec..000000000 --- a/src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import fs from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { beforeAll, describe, expect, it } from "vitest"; -import { loadSessionStore, resolveSessionKey } from "../config/sessions.js"; -import { - getCompactEmbeddedPiSessionMock, - getRunEmbeddedPiAgentMock, - installTriggerHandlingE2eTestHooks, - MAIN_SESSION_KEY, - makeCfg, - mockRunEmbeddedPiAgentOk, - withTempHome, -} from "./reply.triggers.trigger-handling.test-harness.js"; -import { HEARTBEAT_TOKEN } from "./tokens.js"; - -let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; -beforeAll(async () => { - ({ getReplyFromConfig } = await import("./reply.js")); -}); - -installTriggerHandlingE2eTestHooks(); - -const BASE_MESSAGE = { - Body: "hello", - From: "+1002", - To: "+2000", -} as const; - -function mockEmbeddedOkPayload() { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - return runEmbeddedPiAgentMock; -} - -function requireSessionStorePath(cfg: { session?: { store?: string } }): string { - const storePath = cfg.session?.store; - if (!storePath) { - throw new Error("expected session store path"); - } - return storePath; -} - -async function writeStoredModelOverride(cfg: ReturnType): Promise { - await fs.writeFile( - requireSessionStorePath(cfg), - JSON.stringify({ - [MAIN_SESSION_KEY]: { - sessionId: "main", - updatedAt: Date.now(), - providerOverride: "openai", - modelOverride: "gpt-5.2", - }, - }), - "utf-8", - ); -} - -function mockSuccessfulCompaction() { - getCompactEmbeddedPiSessionMock().mockResolvedValue({ - ok: true, - compacted: true, - result: { - summary: "summary", - firstKeptEntryId: "x", - tokensBefore: 12000, - }, - }); -} - -function replyText(res: Awaited>) { - return Array.isArray(res) ? res[0]?.text : res?.text; -} - -describe("trigger handling", () => { - it("includes the error cause when the embedded agent throws", async () => { - await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockRejectedValue(new Error("sandbox is not defined.")); - - const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe( - "⚠️ Agent failed before reply: sandbox is not defined.\nLogs: openclaw logs --follow", - ); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - }); - }); - - it("uses heartbeat model override for heartbeat runs", async () => { - await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = mockEmbeddedOkPayload(); - const cfg = makeCfg(home); - await writeStoredModelOverride(cfg); - cfg.agents = { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" }, - }, - }; - - await getReplyFromConfig(BASE_MESSAGE, { isHeartbeat: true }, cfg); - - const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; - expect(call?.provider).toBe("anthropic"); - expect(call?.model).toBe("claude-haiku-4-5-20251001"); - }); - }); - - it("keeps stored model override for heartbeat runs when heartbeat model is not configured", async () => { - await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = mockEmbeddedOkPayload(); - const cfg = makeCfg(home); - await writeStoredModelOverride(cfg); - await getReplyFromConfig(BASE_MESSAGE, { isHeartbeat: true }, cfg); - - const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; - expect(call?.provider).toBe("openai"); - expect(call?.model).toBe("gpt-5.2"); - }); - }); - - it("suppresses HEARTBEAT_OK replies outside heartbeat runs", async () => { - await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: HEARTBEAT_TOKEN }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - - const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); - - expect(res).toBeUndefined(); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - }); - }); - - it("strips HEARTBEAT_OK at edges outside heartbeat runs", async () => { - await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: `${HEARTBEAT_TOKEN} hello` }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - - const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe("hello"); - }); - }); - - it("updates group activation when the owner sends /activation", async () => { - await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - const cfg = makeCfg(home); - const res = await getReplyFromConfig( - { - Body: "/activation always", - From: "123@g.us", - To: "+2000", - ChatType: "group", - Provider: "whatsapp", - SenderE164: "+2000", - CommandAuthorized: true, - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Group activation set to always"); - const store = JSON.parse(await fs.readFile(requireSessionStorePath(cfg), "utf-8")) as Record< - string, - { groupActivation?: string } - >; - expect(store["agent:main:whatsapp:group:123@g.us"]?.groupActivation).toBe("always"); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); - }); - }); - - it("runs /compact as a gated command", async () => { - await withTempHome(async (home) => { - const storePath = join(tmpdir(), `openclaw-session-test-${Date.now()}.json`); - const cfg = makeCfg(home); - cfg.session = { ...cfg.session, store: storePath }; - mockSuccessfulCompaction(); - - const res = await getReplyFromConfig( - { - Body: "/compact focus on decisions", - From: "+1003", - To: "+2000", - CommandAuthorized: true, - }, - {}, - cfg, - ); - const text = replyText(res); - expect(text?.startsWith("⚙️ Compacted")).toBe(true); - expect(getCompactEmbeddedPiSessionMock()).toHaveBeenCalledOnce(); - expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); - const store = loadSessionStore(storePath); - const sessionKey = resolveSessionKey("per-sender", { - Body: "/compact focus on decisions", - From: "+1003", - To: "+2000", - }); - expect(store[sessionKey]?.compactionCount).toBe(1); - }); - }); - - it("runs /compact for non-default agents without transcript path validation failures", async () => { - await withTempHome(async (home) => { - getCompactEmbeddedPiSessionMock().mockClear(); - mockSuccessfulCompaction(); - - const res = await getReplyFromConfig( - { - Body: "/compact", - From: "+1004", - To: "+2000", - SessionKey: "agent:worker1:telegram:12345", - CommandAuthorized: true, - }, - {}, - makeCfg(home), - ); - - const text = replyText(res); - expect(text?.startsWith("⚙️ Compacted")).toBe(true); - expect(getCompactEmbeddedPiSessionMock()).toHaveBeenCalledOnce(); - expect(getCompactEmbeddedPiSessionMock().mock.calls[0]?.[0]?.sessionFile).toContain( - join("agents", "worker1", "sessions"), - ); - expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); - }); - }); - - it("ignores think directives that only appear in the context wrapper", async () => { - await withTempHome(async (home) => { - mockRunEmbeddedPiAgentOk(); - - const res = await getReplyFromConfig( - { - Body: [ - "[Chat messages since your last reply - for context]", - "Peter: /thinking high [2025-12-05T21:45:00.000Z]", - "", - "[Current message - respond to this]", - "Give me the status", - ].join("\n"), - From: "+1002", - To: "+2000", - }, - {}, - makeCfg(home), - ); - - const text = replyText(res); - expect(text).toBe("ok"); - expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); - const prompt = getRunEmbeddedPiAgentMock().mock.calls[0]?.[0]?.prompt ?? ""; - expect(prompt).toContain("Give me the status"); - expect(prompt).not.toContain("/thinking high"); - expect(prompt).not.toContain("/think high"); - }); - }); - - it("does not emit directive acks for heartbeats with /think", async () => { - await withTempHome(async (home) => { - mockRunEmbeddedPiAgentOk(); - - const res = await getReplyFromConfig( - { - Body: "HEARTBEAT /think:high", - From: "+1003", - To: "+1003", - }, - { isHeartbeat: true }, - makeCfg(home), - ); - - const text = replyText(res); - expect(text).toBe("ok"); - expect(text).not.toMatch(/Thinking level set/i); - expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); - }); - }); -}); diff --git a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts index dff015c00..61b9be19d 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts @@ -1,18 +1,23 @@ import fs from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { normalizeTestText } from "../../test/helpers/normalize-text.js"; import type { OpenClawConfig } from "../config/config.js"; -import { loadSessionStore } from "../config/sessions.js"; +import { loadSessionStore, resolveSessionKey } from "../config/sessions.js"; import { getAbortEmbeddedPiRunMock, + getCompactEmbeddedPiSessionMock, getRunEmbeddedPiAgentMock, installTriggerHandlingE2eTestHooks, MAIN_SESSION_KEY, makeCfg, + mockRunEmbeddedPiAgentOk, + requireSessionStorePath, withTempHome, } from "./reply.triggers.trigger-handling.test-harness.js"; import { enqueueFollowupRun, getFollowupQueueDepth, type FollowupRun } from "./reply/queue.js"; +import { HEARTBEAT_TOKEN } from "./tokens.js"; let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; let previousFastTestEnv: string | undefined; @@ -32,14 +37,11 @@ afterAll(() => { installTriggerHandlingE2eTestHooks(); const DEFAULT_SESSION_KEY = "telegram:slash:111"; - -function requireSessionStorePath(cfg: { session?: { store?: string } }): string { - const storePath = cfg.session?.store; - if (!storePath) { - throw new Error("expected session store path"); - } - return storePath; -} +const BASE_MESSAGE = { + Body: "hello", + From: "+1002", + To: "+2000", +} as const; function makeTelegramModelCommand(body: string, sessionKey = DEFAULT_SESSION_KEY) { return { @@ -70,27 +72,257 @@ async function runModelCommand(home: string, body: string, sessionKey = DEFAULT_ }; } -describe("trigger handling", () => { - it("shows a /model summary and points to /models", async () => { - await withTempHome(async (home) => { - const { normalized } = await runModelCommand(home, "/model"); +function maybeReplyText(reply: Awaited>) { + return Array.isArray(reply) ? reply[0]?.text : reply?.text; +} - expect(normalized).toContain("Current: anthropic/claude-opus-4-5"); - expect(normalized).toContain("/model to switch"); - expect(normalized).toContain("Tap below to browse models"); - expect(normalized).toContain("/model status for details"); - expect(normalized).not.toContain("reasoning"); - expect(normalized).not.toContain("image"); +function mockEmbeddedOkPayload() { + return mockRunEmbeddedPiAgentOk("ok"); +} + +async function writeStoredModelOverride(cfg: ReturnType): Promise { + await fs.writeFile( + requireSessionStorePath(cfg), + JSON.stringify({ + [MAIN_SESSION_KEY]: { + sessionId: "main", + updatedAt: Date.now(), + providerOverride: "openai", + modelOverride: "gpt-5.2", + }, + }), + "utf-8", + ); +} + +function mockSuccessfulCompaction() { + getCompactEmbeddedPiSessionMock().mockResolvedValue({ + ok: true, + compacted: true, + result: { + summary: "summary", + firstKeptEntryId: "x", + tokensBefore: 12000, + }, + }); +} + +describe("trigger handling", () => { + it("includes the error cause when the embedded agent throws", async () => { + await withTempHome(async (home) => { + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + runEmbeddedPiAgentMock.mockRejectedValue(new Error("sandbox is not defined.")); + + const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); + + expect(maybeReplyText(res)).toBe( + "⚠️ Agent failed before reply: sandbox is not defined.\nLogs: openclaw logs --follow", + ); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); }); }); - it("aliases /model list to /models", async () => { + it("uses heartbeat model override for heartbeat runs", async () => { await withTempHome(async (home) => { - const { normalized } = await runModelCommand(home, "/model list"); + const runEmbeddedPiAgentMock = mockEmbeddedOkPayload(); + const cfg = makeCfg(home); + await writeStoredModelOverride(cfg); + cfg.agents = { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" }, + }, + }; - expect(normalized).toContain("Providers:"); - expect(normalized).toContain("Use: /models "); - expect(normalized).toContain("Switch: /model "); + await getReplyFromConfig(BASE_MESSAGE, { isHeartbeat: true }, cfg); + + const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; + expect(call?.provider).toBe("anthropic"); + expect(call?.model).toBe("claude-haiku-4-5-20251001"); + }); + }); + + it("keeps stored model override for heartbeat runs when heartbeat model is not configured", async () => { + await withTempHome(async (home) => { + const runEmbeddedPiAgentMock = mockEmbeddedOkPayload(); + const cfg = makeCfg(home); + await writeStoredModelOverride(cfg); + await getReplyFromConfig(BASE_MESSAGE, { isHeartbeat: true }, cfg); + + const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; + expect(call?.provider).toBe("openai"); + expect(call?.model).toBe("gpt-5.2"); + }); + }); + + it("suppresses HEARTBEAT_OK replies outside heartbeat runs", async () => { + await withTempHome(async (home) => { + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + runEmbeddedPiAgentMock.mockResolvedValue({ + payloads: [{ text: HEARTBEAT_TOKEN }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); + + expect(res).toBeUndefined(); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + }); + }); + + it("strips HEARTBEAT_OK at edges outside heartbeat runs", async () => { + await withTempHome(async (home) => { + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + runEmbeddedPiAgentMock.mockResolvedValue({ + payloads: [{ text: `${HEARTBEAT_TOKEN} hello` }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); + + expect(maybeReplyText(res)).toBe("hello"); + }); + }); + + it("updates group activation when the owner sends /activation", async () => { + await withTempHome(async (home) => { + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + const cfg = makeCfg(home); + const res = await getReplyFromConfig( + { + Body: "/activation always", + From: "123@g.us", + To: "+2000", + ChatType: "group", + Provider: "whatsapp", + SenderE164: "+2000", + CommandAuthorized: true, + }, + {}, + cfg, + ); + expect(maybeReplyText(res)).toContain("Group activation set to always"); + const store = JSON.parse(await fs.readFile(requireSessionStorePath(cfg), "utf-8")) as Record< + string, + { groupActivation?: string } + >; + expect(store["agent:main:whatsapp:group:123@g.us"]?.groupActivation).toBe("always"); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + }); + }); + + it("runs /compact as a gated command", async () => { + await withTempHome(async (home) => { + const storePath = join(tmpdir(), `openclaw-session-test-${Date.now()}.json`); + const cfg = makeCfg(home); + cfg.session = { ...cfg.session, store: storePath }; + mockSuccessfulCompaction(); + + const request = { + Body: "/compact focus on decisions", + From: "+1003", + To: "+2000", + }; + + const res = await getReplyFromConfig( + { + ...request, + CommandAuthorized: true, + }, + {}, + cfg, + ); + const text = maybeReplyText(res); + expect(text?.startsWith("⚙️ Compacted")).toBe(true); + expect(getCompactEmbeddedPiSessionMock()).toHaveBeenCalledOnce(); + expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); + const store = loadSessionStore(storePath); + const sessionKey = resolveSessionKey("per-sender", request); + expect(store[sessionKey]?.compactionCount).toBe(1); + }); + }); + + it("runs /compact for non-default agents without transcript path validation failures", async () => { + await withTempHome(async (home) => { + getCompactEmbeddedPiSessionMock().mockClear(); + mockSuccessfulCompaction(); + + const res = await getReplyFromConfig( + { + Body: "/compact", + From: "+1004", + To: "+2000", + SessionKey: "agent:worker1:telegram:12345", + CommandAuthorized: true, + }, + {}, + makeCfg(home), + ); + + const text = maybeReplyText(res); + expect(text?.startsWith("⚙️ Compacted")).toBe(true); + expect(getCompactEmbeddedPiSessionMock()).toHaveBeenCalledOnce(); + expect(getCompactEmbeddedPiSessionMock().mock.calls[0]?.[0]?.sessionFile).toContain( + join("agents", "worker1", "sessions"), + ); + expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); + }); + }); + + it("ignores think directives that only appear in the context wrapper", async () => { + await withTempHome(async (home) => { + mockRunEmbeddedPiAgentOk(); + + const res = await getReplyFromConfig( + { + Body: [ + "[Chat messages since your last reply - for context]", + "Peter: /thinking high [2025-12-05T21:45:00.000Z]", + "", + "[Current message - respond to this]", + "Give me the status", + ].join("\n"), + From: "+1002", + To: "+2000", + }, + {}, + makeCfg(home), + ); + + expect(maybeReplyText(res)).toBe("ok"); + expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); + const prompt = getRunEmbeddedPiAgentMock().mock.calls[0]?.[0]?.prompt ?? ""; + expect(prompt).toContain("Give me the status"); + expect(prompt).not.toContain("/thinking high"); + expect(prompt).not.toContain("/think high"); + }); + }); + + it("does not emit directive acks for heartbeats with /think", async () => { + await withTempHome(async (home) => { + mockRunEmbeddedPiAgentOk(); + + const res = await getReplyFromConfig( + { + Body: "HEARTBEAT /think:high", + From: "+1003", + To: "+1003", + }, + { isHeartbeat: true }, + makeCfg(home), + ); + + const text = maybeReplyText(res); + expect(text).toBe("ok"); + expect(text).not.toMatch(/Thinking level set/i); + expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); }); });