import fs from "node:fs"; import path from "node:path"; import { beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import "../cron/isolated-agent.mocks.js"; import * as cliRunnerModule from "../agents/cli-runner.js"; import { FailoverError } from "../agents/failover-error.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import * as modelSelectionModule from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import * as commandSecretGatewayModule from "../cli/command-secret-gateway.js"; import type { OpenClawConfig } from "../config/config.js"; import * as configModule from "../config/config.js"; import * as sessionsModule from "../config/sessions.js"; import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { RuntimeEnv } from "../runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; import { agentCommand, agentCommandFromIngress } from "./agent.js"; import * as agentDeliveryModule from "./agent/delivery.js"; vi.mock("../agents/auth-profiles.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })), }; }); vi.mock("../agents/workspace.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, ensureAgentWorkspace: vi.fn(async ({ dir }: { dir: string }) => ({ dir })), }; }); vi.mock("../agents/skills.js", () => ({ buildWorkspaceSkillSnapshot: vi.fn(() => undefined), })); vi.mock("../agents/skills/refresh.js", () => ({ getSkillsSnapshotVersion: vi.fn(() => 0), })); const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn(() => { throw new Error("exit"); }), }; const configSpy = vi.spyOn(configModule, "loadConfig"); const readConfigFileSnapshotForWriteSpy = vi.spyOn(configModule, "readConfigFileSnapshotForWrite"); const setRuntimeConfigSnapshotSpy = vi.spyOn(configModule, "setRuntimeConfigSnapshot"); const runCliAgentSpy = vi.spyOn(cliRunnerModule, "runCliAgent"); const deliverAgentCommandResultSpy = vi.spyOn(agentDeliveryModule, "deliverAgentCommandResult"); async function withTempHome(fn: (home: string) => Promise): Promise { return withTempHomeBase(fn, { prefix: "openclaw-agent-" }); } function mockConfig( home: string, storePath: string, agentOverrides?: Partial["defaults"]>>, telegramOverrides?: Partial["telegram"]>>, agentsList?: Array<{ id: string; default?: boolean }>, ) { configSpy.mockReturnValue({ agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" }, models: { "anthropic/claude-opus-4-5": {} }, workspace: path.join(home, "openclaw"), ...agentOverrides, }, list: agentsList, }, session: { store: storePath, mainKey: "main" }, channels: { telegram: telegramOverrides ? { ...telegramOverrides } : undefined, }, }); } async function runWithDefaultAgentConfig(params: { home: string; args: Parameters[0]; agentsList?: Array<{ id: string; default?: boolean }>; }) { const store = path.join(params.home, "sessions.json"); mockConfig(params.home, store, undefined, undefined, params.agentsList); await agentCommand(params.args, runtime); return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; } async function runEmbeddedWithTempConfig(params: { args: Parameters[0]; agentOverrides?: Partial["defaults"]>>; telegramOverrides?: Partial["telegram"]>>; agentsList?: Array<{ id: string; default?: boolean }>; }) { return withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store, params.agentOverrides, params.telegramOverrides, params.agentsList); await agentCommand(params.args, runtime); return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; }); } function writeSessionStoreSeed( storePath: string, sessions: Record>, ) { fs.mkdirSync(path.dirname(storePath), { recursive: true }); fs.writeFileSync(storePath, JSON.stringify(sessions, null, 2)); } function createDefaultAgentResult(params?: { payloads?: Array>; durationMs?: number; }) { return { payloads: params?.payloads ?? [{ text: "ok" }], meta: { durationMs: params?.durationMs ?? 5, agentMeta: { sessionId: "s", provider: "p", model: "m" }, }, }; } function getLastEmbeddedCall() { return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; } function expectLastRunProviderModel(provider: string, model: string): void { const callArgs = getLastEmbeddedCall(); expect(callArgs?.provider).toBe(provider); expect(callArgs?.model).toBe(model); } function readSessionStore(storePath: string): Record { return JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record; } async function withCrossAgentResumeFixture( run: (params: { home: string; storePattern: string; sessionId: string; sessionKey: string; }) => Promise, ): Promise { await withTempHome(async (home) => { const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json"); const execStore = path.join(home, "sessions", "exec", "sessions.json"); const sessionId = "session-exec-hook"; const sessionKey = "agent:exec:hook:gmail:thread-1"; writeSessionStoreSeed(execStore, { [sessionKey]: { sessionId, updatedAt: Date.now(), systemSent: true, }, }); mockConfig(home, storePattern, undefined, undefined, [ { id: "dev" }, { id: "exec", default: true }, ]); await agentCommand({ message: "resume me", sessionId }, runtime); await run({ home, storePattern, sessionId, sessionKey }); }); } async function expectPersistedSessionFile(params: { seedKey: string; sessionId: string; expectedPathFragment: string; }) { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); writeSessionStoreSeed(store, { [params.seedKey]: { sessionId: params.sessionId, updatedAt: Date.now(), }, }); mockConfig(home, store); await agentCommand({ message: "hi", sessionKey: params.seedKey }, runtime); const saved = readSessionStore<{ sessionId?: string; sessionFile?: string }>(store); const entry = saved[params.seedKey]; expect(entry?.sessionId).toBe(params.sessionId); expect(entry?.sessionFile).toContain(params.expectedPathFragment); expect(getLastEmbeddedCall()?.sessionFile).toBe(entry?.sessionFile); }); } async function runAgentWithSessionKey(sessionKey: string): Promise { await agentCommand({ message: "hi", sessionKey }, runtime); } async function expectDefaultThinkLevel(params: { agentOverrides?: Partial["defaults"]>>; catalogEntry: Record; expected: string; }) { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store, params.agentOverrides); vi.mocked(loadModelCatalog).mockResolvedValueOnce([params.catalogEntry as never]); await agentCommand({ message: "hi", to: "+1555" }, runtime); expect(getLastEmbeddedCall()?.thinkLevel).toBe(params.expected); }); } function createTelegramOutboundPlugin() { const sendWithTelegram = async ( ctx: { deps?: { sendTelegram?: ( to: string, text: string, opts: Record, ) => Promise<{ messageId: string; chatId: string; }>; }; to: string; text: string; accountId?: string | null; mediaUrl?: string; }, mediaUrl?: string, ) => { const sendTelegram = ctx.deps?.sendTelegram; if (!sendTelegram) { throw new Error("sendTelegram dependency missing"); } const result = await sendTelegram(ctx.to, ctx.text, { accountId: ctx.accountId ?? undefined, ...(mediaUrl ? { mediaUrl } : {}), verbose: false, }); return { channel: "telegram", messageId: result.messageId, chatId: result.chatId }; }; return createOutboundTestPlugin({ id: "telegram", outbound: { deliveryMode: "direct", sendText: async (ctx) => sendWithTelegram(ctx), sendMedia: async (ctx) => sendWithTelegram(ctx, ctx.mediaUrl), }, }); } beforeEach(() => { vi.clearAllMocks(); configModule.clearRuntimeConfigSnapshot(); runCliAgentSpy.mockResolvedValue(createDefaultAgentResult() as never); vi.mocked(runEmbeddedPiAgent).mockResolvedValue(createDefaultAgentResult()); vi.mocked(loadModelCatalog).mockResolvedValue([]); vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); readConfigFileSnapshotForWriteSpy.mockResolvedValue({ snapshot: { valid: false, resolved: {} as OpenClawConfig }, writeOptions: {}, } as Awaited>); }); describe("agentCommand", () => { it("sets runtime snapshots from source config before embedded agent run", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); const loadedConfig = { agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" }, models: { "anthropic/claude-opus-4-5": {} }, workspace: path.join(home, "openclaw"), }, }, session: { store, mainKey: "main" }, models: { providers: { openai: { baseUrl: "https://api.openai.com/v1", apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret models: [], }, }, }, } as unknown as OpenClawConfig; const sourceConfig = { ...loadedConfig, models: { providers: { openai: { baseUrl: "https://api.openai.com/v1", apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret models: [], }, }, }, } as unknown as OpenClawConfig; const resolvedConfig = { ...loadedConfig, models: { providers: { openai: { baseUrl: "https://api.openai.com/v1", apiKey: "sk-resolved-runtime", // pragma: allowlist secret models: [], }, }, }, } as unknown as OpenClawConfig; configSpy.mockReturnValue(loadedConfig); readConfigFileSnapshotForWriteSpy.mockResolvedValue({ snapshot: { valid: true, resolved: sourceConfig }, writeOptions: {}, } as Awaited>); const resolveSecretsSpy = vi .spyOn(commandSecretGatewayModule, "resolveCommandSecretRefsViaGateway") .mockResolvedValueOnce({ resolvedConfig, diagnostics: [], targetStatesByPath: {}, hadUnresolvedTargets: false, }); await agentCommand({ message: "hello", to: "+1555" }, runtime); expect(resolveSecretsSpy).toHaveBeenCalledWith({ config: loadedConfig, commandName: "agent", targetIds: expect.any(Set), }); expect(setRuntimeConfigSnapshotSpy).toHaveBeenCalledWith(resolvedConfig, sourceConfig); expect(vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.config).toBe(resolvedConfig); }); }); 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.each([ { name: "defaults senderIsOwner to true for local agent runs", args: { message: "hi", to: "+1555" }, expected: true, }, { name: "honors explicit senderIsOwner override", args: { message: "hi", to: "+1555", senderIsOwner: false }, expected: false, }, ])("$name", async ({ args, expected }) => { const callArgs = await runEmbeddedWithTempConfig({ args }); expect(callArgs?.senderIsOwner).toBe(expected); }); it("requires explicit senderIsOwner for ingress runs", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store); await expect( // Runtime guard for non-TS callers; TS callsites are statically typed. agentCommandFromIngress({ message: "hi", to: "+1555" } as never, runtime), ).rejects.toThrow("senderIsOwner must be explicitly set for ingress agent runs."); }); }); it("honors explicit senderIsOwner for ingress runs", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store); await agentCommandFromIngress({ message: "hi", to: "+1555", senderIsOwner: false }, runtime); const ingressCall = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; expect(ingressCall?.senderIsOwner).toBe(false); }); }); it("resumes when session-id is provided", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); writeSessionStoreSeed(store, { foo: { sessionId: "session-123", updatedAt: Date.now(), systemSent: true, }, }); 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 the resumed session agent scope when sessionId resolves to another agent store", async () => { await withCrossAgentResumeFixture(async ({ sessionKey }) => { const callArgs = getLastEmbeddedCall(); expect(callArgs?.sessionKey).toBe(sessionKey); expect(callArgs?.agentId).toBe("exec"); expect(callArgs?.agentDir).toContain(`${path.sep}agents${path.sep}exec${path.sep}agent`); }); }); it("forwards resolved outbound session context when resuming by sessionId", async () => { await withCrossAgentResumeFixture(async ({ sessionKey }) => { const deliverCall = deliverAgentCommandResultSpy.mock.calls.at(-1)?.[0]; expect(deliverCall?.opts.sessionKey).toBeUndefined(); expect(deliverCall?.outboundSession).toEqual( expect.objectContaining({ key: sessionKey, agentId: "exec", }), ); }); }); it("resolves resumed session transcript path from custom session store directory", async () => { await withTempHome(async (home) => { const customStoreDir = path.join(home, "custom-state"); const store = path.join(customStoreDir, "sessions.json"); writeSessionStoreSeed(store, {}); mockConfig(home, store); const resolveSessionFilePathSpy = vi.spyOn(sessionsModule, "resolveSessionFilePath"); await agentCommand({ message: "resume me", sessionId: "session-custom-123" }, runtime); const matchingCall = resolveSessionFilePathSpy.mock.calls.find( (call) => call[0] === "session-custom-123", ); expect(matchingCall?.[2]).toEqual( expect.objectContaining({ agentId: "main", sessionsDir: customStoreDir, }), ); }); }); it("does not duplicate agent events from embedded runs", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store); const assistantEvents: Array<{ runId: string; text?: string }> = []; const stop = onAgentEvent((evt) => { if (evt.stream !== "assistant") { return; } assistantEvents.push({ runId: evt.runId, text: typeof evt.data?.text === "string" ? evt.data.text : undefined, }); }); vi.mocked(runEmbeddedPiAgent).mockImplementationOnce(async (params) => { const runId = (params as { runId?: string } | undefined)?.runId ?? "run"; const data = { text: "hello", delta: "hello" }; ( params as { onAgentEvent?: (evt: { stream: string; data: Record }) => void; } ).onAgentEvent?.({ stream: "assistant", data }); emitAgentEvent({ runId, stream: "assistant", data }); return { payloads: [{ text: "hello" }], meta: { agentMeta: { provider: "p", model: "m" } }, } as never; }); await agentCommand({ message: "hi", to: "+1555" }, runtime); stop(); const matching = assistantEvents.filter((evt) => evt.text === "hello"); expect(matching).toHaveLength(1); }); }); it("uses provider/model from agents.defaults.model.primary", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store, { model: { primary: "openai/gpt-4.1-mini" }, models: { "anthropic/claude-opus-4-5": {}, "openai/gpt-4.1-mini": {}, }, }); await agentCommand({ message: "hi", to: "+1555" }, runtime); expectLastRunProviderModel("openai", "gpt-4.1-mini"); }); }); it("uses default fallback list for session model overrides", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); writeSessionStoreSeed(store, { "agent:main:subagent:test": { sessionId: "session-subagent", updatedAt: Date.now(), providerOverride: "anthropic", modelOverride: "claude-opus-4-5", }, }); mockConfig(home, store, { model: { primary: "openai/gpt-4.1-mini", fallbacks: ["openai/gpt-5.2"], }, models: { "anthropic/claude-opus-4-5": {}, "openai/gpt-4.1-mini": {}, "openai/gpt-5.2": {}, }, }); vi.mocked(loadModelCatalog).mockResolvedValueOnce([ { id: "claude-opus-4-5", name: "Opus", provider: "anthropic" }, { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, { id: "gpt-5.2", name: "GPT-5.2", provider: "openai" }, ]); vi.mocked(runEmbeddedPiAgent) .mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 })) .mockResolvedValueOnce({ payloads: [{ text: "ok" }], meta: { durationMs: 5, agentMeta: { sessionId: "session-subagent", provider: "openai", model: "gpt-5.2" }, }, }); await agentCommand( { message: "hi", sessionKey: "agent:main:subagent:test", }, runtime, ); const attempts = vi .mocked(runEmbeddedPiAgent) .mock.calls.map((call) => ({ provider: call[0]?.provider, model: call[0]?.model })); expect(attempts).toEqual([ { provider: "anthropic", model: "claude-opus-4-5" }, { provider: "openai", model: "gpt-5.2" }, ]); }); }); it("keeps stored session model override when models allowlist is empty", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); writeSessionStoreSeed(store, { "agent:main:subagent:allow-any": { sessionId: "session-allow-any", updatedAt: Date.now(), providerOverride: "openai", modelOverride: "gpt-custom-foo", }, }); mockConfig(home, store, { model: { primary: "anthropic/claude-opus-4-5" }, models: {}, }); vi.mocked(loadModelCatalog).mockResolvedValueOnce([ { id: "claude-opus-4-5", name: "Opus", provider: "anthropic" }, ]); await runAgentWithSessionKey("agent:main:subagent:allow-any"); const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; expect(callArgs?.provider).toBe("openai"); expect(callArgs?.model).toBe("gpt-custom-foo"); const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< string, { providerOverride?: string; modelOverride?: string } >; expect(saved["agent:main:subagent:allow-any"]?.providerOverride).toBe("openai"); expect(saved["agent:main:subagent:allow-any"]?.modelOverride).toBe("gpt-custom-foo"); }); }); it("persists cleared model and auth override fields when stored override falls back to default", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); writeSessionStoreSeed(store, { "agent:main:subagent:clear-overrides": { sessionId: "session-clear-overrides", updatedAt: Date.now(), providerOverride: "anthropic", modelOverride: "claude-opus-4-5", authProfileOverride: "profile-legacy", authProfileOverrideSource: "user", authProfileOverrideCompactionCount: 2, fallbackNoticeSelectedModel: "anthropic/claude-opus-4-5", fallbackNoticeActiveModel: "openai/gpt-4.1-mini", fallbackNoticeReason: "fallback", }, }); mockConfig(home, store, { model: { primary: "openai/gpt-4.1-mini" }, models: { "openai/gpt-4.1-mini": {}, }, }); vi.mocked(loadModelCatalog).mockResolvedValueOnce([ { id: "claude-opus-4-5", name: "Opus", provider: "anthropic" }, { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, ]); await runAgentWithSessionKey("agent:main:subagent:clear-overrides"); expectLastRunProviderModel("openai", "gpt-4.1-mini"); const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< string, { providerOverride?: string; modelOverride?: string; authProfileOverride?: string; authProfileOverrideSource?: string; authProfileOverrideCompactionCount?: number; fallbackNoticeSelectedModel?: string; fallbackNoticeActiveModel?: string; fallbackNoticeReason?: string; } >; const entry = saved["agent:main:subagent:clear-overrides"]; expect(entry?.providerOverride).toBeUndefined(); expect(entry?.modelOverride).toBeUndefined(); expect(entry?.authProfileOverride).toBeUndefined(); expect(entry?.authProfileOverrideSource).toBeUndefined(); expect(entry?.authProfileOverrideCompactionCount).toBeUndefined(); expect(entry?.fallbackNoticeSelectedModel).toBeUndefined(); expect(entry?.fallbackNoticeActiveModel).toBeUndefined(); expect(entry?.fallbackNoticeReason).toBeUndefined(); }); }); it("keeps explicit sessionKey even when sessionId exists elsewhere", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); writeSessionStoreSeed(store, { "agent:main:main": { sessionId: "sess-main", updatedAt: Date.now(), }, }); 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("persists resolved sessionFile for existing session keys", async () => { await expectPersistedSessionFile({ seedKey: "agent:main:subagent:abc", sessionId: "sess-main", expectedPathFragment: `${path.sep}agents${path.sep}main${path.sep}sessions${path.sep}sess-main.jsonl`, }); }); it("preserves topic transcript suffix when persisting missing sessionFile", async () => { await expectPersistedSessionFile({ seedKey: "agent:main:telegram:group:123:topic:456", sessionId: "sess-topic", expectedPathFragment: "sess-topic-topic-456.jsonl", }); }); it("derives session key from --agent when no routing target is provided", async () => { await withTempHome(async (home) => { const callArgs = await runWithDefaultAgentConfig({ home, args: { message: "hi", agentId: "ops" }, agentsList: [{ id: "ops" }], }); expect(callArgs?.sessionKey).toBe("agent:ops:main"); expect(callArgs?.sessionFile).toContain(`${path.sep}agents${path.sep}ops${path.sep}sessions`); }); }); it("clears stale Claude CLI legacy session IDs before retrying after session expiration", async () => { vi.mocked(modelSelectionModule.isCliProvider).mockImplementation( (provider) => provider.trim().toLowerCase() === "claude-cli", ); try { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); const sessionKey = "agent:main:subagent:cli-expired"; writeSessionStoreSeed(store, { [sessionKey]: { sessionId: "session-cli-123", updatedAt: Date.now(), providerOverride: "claude-cli", modelOverride: "opus", cliSessionIds: { "claude-cli": "stale-cli-session" }, claudeCliSessionId: "stale-legacy-session", }, }); mockConfig(home, store, { model: { primary: "claude-cli/opus", fallbacks: [] }, models: { "claude-cli/opus": {} }, }); runCliAgentSpy .mockRejectedValueOnce( new FailoverError("session expired", { reason: "session_expired", provider: "claude-cli", model: "opus", status: 410, }), ) .mockRejectedValue(new Error("retry failed")); await expect(agentCommand({ message: "hi", sessionKey }, runtime)).rejects.toThrow( "retry failed", ); expect(runCliAgentSpy).toHaveBeenCalledTimes(2); const firstCall = runCliAgentSpy.mock.calls[0]?.[0] as | { cliSessionId?: string } | undefined; const secondCall = runCliAgentSpy.mock.calls[1]?.[0] as | { cliSessionId?: string } | undefined; expect(firstCall?.cliSessionId).toBe("stale-cli-session"); expect(secondCall?.cliSessionId).toBeUndefined(); const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< string, { cliSessionIds?: Record; claudeCliSessionId?: string } >; const entry = saved[sessionKey]; expect(entry?.cliSessionIds?.["claude-cli"]).toBeUndefined(); expect(entry?.claudeCliSessionId).toBeUndefined(); }); } finally { vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); } }); it("rejects unknown agent overrides", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store); await expect(agentCommand({ message: "hi", agentId: "ghost" }, runtime)).rejects.toThrow( 'Unknown agent id "ghost"', ); }); }); it("defaults thinking to low for reasoning-capable models", async () => { await expectDefaultThinkLevel({ catalogEntry: { id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic", reasoning: true, }, expected: "low", }); }); it("defaults thinking to adaptive for Anthropic Claude 4.6 models", async () => { await expectDefaultThinkLevel({ agentOverrides: { model: { primary: "anthropic/claude-opus-4-6" }, models: { "anthropic/claude-opus-4-6": {} }, }, catalogEntry: { id: "claude-opus-4-6", name: "Opus 4.6", provider: "anthropic", reasoning: true, }, expected: "adaptive", }); }); it("prefers per-model thinking over global thinkingDefault", async () => { await expectDefaultThinkLevel({ agentOverrides: { thinkingDefault: "low", models: { "anthropic/claude-opus-4-5": { params: { thinking: "high" }, }, }, }, catalogEntry: { id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic", reasoning: true, }, expected: "high", }); }); it("prints JSON payload when requested", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockResolvedValue( createDefaultAgentResult({ payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }], durationMs: 42, }), ); const store = path.join(home, "sessions.json"); mockConfig(home, store); await agentCommand({ message: "hi", to: "+1999", json: true }, runtime); const logged = (runtime.log as unknown 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 () => { const callArgs = await runEmbeddedWithTempConfig({ args: { message: "ping", to: "+1333" }, }); 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, { botToken: "t-1" }); setActivePluginRegistry( createTestRegistry([ { pluginId: "telegram", plugin: createTelegramOutboundPlugin(), source: "test" }, ]), ); const deps = { sendMessageWhatsApp: vi.fn(), sendMessageTelegram: vi.fn().mockResolvedValue({ messageId: "t1", chatId: "123" }), sendMessageSlack: vi.fn(), 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, channel: "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; } } }); }); it("uses reply channel as the message channel context", async () => { const callArgs = await runEmbeddedWithTempConfig({ args: { message: "hi", agentId: "ops", replyChannel: "slack" }, agentsList: [{ id: "ops" }], }); expect(callArgs?.messageChannel).toBe("slack"); }); it("prefers runContext for embedded routing", async () => { const callArgs = await runEmbeddedWithTempConfig({ args: { message: "hi", to: "+1555", channel: "whatsapp", runContext: { messageChannel: "slack", accountId: "acct-2" }, }, }); expect(callArgs?.messageChannel).toBe("slack"); expect(callArgs?.agentAccountId).toBe("acct-2"); }); it("forwards accountId to embedded runs", async () => { const callArgs = await runEmbeddedWithTempConfig({ args: { message: "hi", to: "+1555", accountId: "kev" }, }); expect(callArgs?.agentAccountId).toBe("kev"); }); it("logs output when delivery is disabled", async () => { await withTempHome(async (home) => { await runWithDefaultAgentConfig({ home, args: { message: "hi", agentId: "ops" }, agentsList: [{ id: "ops" }], }); expect(runtime.log).toHaveBeenCalledWith("ok"); }); }); });