988 lines
34 KiB
TypeScript
988 lines
34 KiB
TypeScript
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<typeof import("../agents/auth-profiles.js")>();
|
|
return {
|
|
...actual,
|
|
ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })),
|
|
};
|
|
});
|
|
|
|
vi.mock("../agents/workspace.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../agents/workspace.js")>();
|
|
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<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
|
return withTempHomeBase(fn, { prefix: "openclaw-agent-" });
|
|
}
|
|
|
|
function mockConfig(
|
|
home: string,
|
|
storePath: string,
|
|
agentOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>>,
|
|
telegramOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["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<typeof agentCommand>[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<typeof agentCommand>[0];
|
|
agentOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>>;
|
|
telegramOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["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<string, Record<string, unknown>>,
|
|
) {
|
|
fs.mkdirSync(path.dirname(storePath), { recursive: true });
|
|
fs.writeFileSync(storePath, JSON.stringify(sessions, null, 2));
|
|
}
|
|
|
|
function createDefaultAgentResult(params?: {
|
|
payloads?: Array<Record<string, unknown>>;
|
|
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<T>(storePath: string): Record<string, T> {
|
|
return JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record<string, T>;
|
|
}
|
|
|
|
async function withCrossAgentResumeFixture(
|
|
run: (params: {
|
|
home: string;
|
|
storePattern: string;
|
|
sessionId: string;
|
|
sessionKey: string;
|
|
}) => Promise<void>,
|
|
): Promise<void> {
|
|
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<void> {
|
|
await agentCommand({ message: "hi", sessionKey }, runtime);
|
|
}
|
|
|
|
async function expectDefaultThinkLevel(params: {
|
|
agentOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>>;
|
|
catalogEntry: Record<string, unknown>;
|
|
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<string, unknown>,
|
|
) => 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<ReturnType<typeof configModule.readConfigFileSnapshotForWrite>>);
|
|
});
|
|
|
|
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<ReturnType<typeof configModule.readConfigFileSnapshotForWrite>>);
|
|
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<string, unknown> }) => 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<string, string>; 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");
|
|
});
|
|
});
|
|
});
|