import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, type MockInstance, vi, } from "vitest"; vi.mock("../agents/pi-embedded.js", () => ({ abortEmbeddedPiRun: vi.fn().mockReturnValue(false), runEmbeddedPiAgent: vi.fn(), resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, })); vi.mock("../agents/model-catalog.js", () => ({ loadModelCatalog: vi.fn(), })); import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import type { ClawdbotConfig } from "../config/config.js"; import * as configModule from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; import { agentCommand } from "./agent.js"; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn(() => { throw new Error("exit"); }), }; const configSpy = vi.spyOn(configModule, "loadConfig"); async function withTempHome(fn: (home: string) => Promise): Promise { const base = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-agent-")); const previousHome = process.env.HOME; process.env.HOME = base; try { return await fn(base); } finally { process.env.HOME = previousHome; fs.rmSync(base, { recursive: true, force: true }); } } function mockConfig( home: string, storePath: string, routingOverrides?: Partial>, agentOverrides?: Partial>, telegramOverrides?: Partial>, ) { configSpy.mockReturnValue({ agent: { model: { primary: "anthropic/claude-opus-4-5" }, models: { "anthropic/claude-opus-4-5": {} }, workspace: path.join(home, "clawd"), ...agentOverrides, }, session: { store: storePath, mainKey: "main" }, routing: routingOverrides ? { ...routingOverrides } : undefined, telegram: telegramOverrides ? { ...telegramOverrides } : undefined, }); } beforeEach(() => { vi.clearAllMocks(); vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ payloads: [{ text: "ok" }], meta: { durationMs: 5, agentMeta: { sessionId: "s", provider: "p", model: "m" }, }, }); vi.mocked(loadModelCatalog).mockResolvedValue([]); }); describe("agentCommand", () => { it("creates a session entry when deriving from --to", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store); await agentCommand({ message: "hello", to: "+1555" }, runtime); const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< string, { sessionId: string } >; const entry = Object.values(saved)[0]; expect(entry.sessionId).toBeTruthy(); }); }); it("persists thinking and verbose overrides", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store); await agentCommand( { message: "hi", to: "+1222", thinking: "high", verbose: "on" }, runtime, ); const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< string, { thinkingLevel?: string; verboseLevel?: string } >; const entry = Object.values(saved)[0]; expect(entry.thinkingLevel).toBe("high"); expect(entry.verboseLevel).toBe("on"); const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; expect(callArgs?.thinkLevel).toBe("high"); expect(callArgs?.verboseLevel).toBe("on"); }); }); it("resumes when session-id is provided", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); fs.mkdirSync(path.dirname(store), { recursive: true }); fs.writeFileSync( store, JSON.stringify( { foo: { sessionId: "session-123", updatedAt: Date.now(), systemSent: true, }, }, null, 2, ), ); mockConfig(home, store); await agentCommand( { message: "resume me", sessionId: "session-123" }, runtime, ); const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; expect(callArgs?.sessionId).toBe("session-123"); }); }); it("uses provider/model from agent.model", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store, undefined, { model: "openai/gpt-4.1-mini", }); await agentCommand({ message: "hi", to: "+1555" }, runtime); const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; expect(callArgs?.provider).toBe("openai"); expect(callArgs?.model).toBe("gpt-4.1-mini"); }); }); it("keeps explicit sessionKey even when sessionId exists elsewhere", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); fs.mkdirSync(path.dirname(store), { recursive: true }); fs.writeFileSync( store, JSON.stringify( { "agent:main:main": { sessionId: "sess-main", updatedAt: Date.now(), }, }, null, 2, ), ); mockConfig(home, store); await agentCommand( { message: "hi", sessionId: "sess-main", sessionKey: "agent:main:subagent:abc", }, runtime, ); const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; expect(callArgs?.sessionKey).toBe("agent:main:subagent:abc"); const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< string, { sessionId?: string } >; expect(saved["agent:main:subagent:abc"]?.sessionId).toBe("sess-main"); }); }); it("defaults thinking to low for reasoning-capable models", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store); vi.mocked(loadModelCatalog).mockResolvedValueOnce([ { id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic", reasoning: true, }, ]); await agentCommand({ message: "hi", to: "+1555" }, runtime); const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; expect(callArgs?.thinkLevel).toBe("low"); }); }); it("prints JSON payload when requested", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }], meta: { durationMs: 42, agentMeta: { sessionId: "s", provider: "p", model: "m" }, }, }); const store = path.join(home, "sessions.json"); mockConfig(home, store); await agentCommand({ message: "hi", to: "+1999", json: true }, runtime); const logged = (runtime.log as MockInstance).mock.calls.at( -1, )?.[0] as string; const parsed = JSON.parse(logged) as { payloads: Array<{ text: string; mediaUrl?: string | null }>; meta: { durationMs: number }; }; expect(parsed.payloads[0].text).toBe("json-reply"); expect(parsed.payloads[0].mediaUrl).toBe("http://x.test/a.jpg"); expect(parsed.meta.durationMs).toBe(42); }); }); it("passes the message through as the agent prompt", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store); await agentCommand({ message: "ping", to: "+1333" }, runtime); const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; expect(callArgs?.prompt).toBe("ping"); }); }); it("passes through telegram accountId when delivering", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store, undefined, undefined, { botToken: "t-1" }); const deps = { sendMessageWhatsApp: vi.fn(), sendMessageTelegram: vi .fn() .mockResolvedValue({ messageId: "t1", chatId: "123" }), sendMessageDiscord: vi.fn(), sendMessageSignal: vi.fn(), sendMessageIMessage: vi.fn(), }; const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; process.env.TELEGRAM_BOT_TOKEN = ""; try { await agentCommand( { message: "hi", to: "123", deliver: true, provider: "telegram", }, runtime, deps, ); expect(deps.sendMessageTelegram).toHaveBeenCalledWith( "123", "ok", expect.objectContaining({ accountId: undefined, verbose: false }), ); } finally { if (prevTelegramToken === undefined) { delete process.env.TELEGRAM_BOT_TOKEN; } else { process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken; } } }); }); });