Files
openclaw/src/commands/agent.test.ts
2026-05-09 21:41:24 +01:00

1131 lines
38 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { withTempHome as withTempHomeBase } from "openclaw/plugin-sdk/test-env";
import { beforeEach, describe, expect, it, type MockInstance, vi } from "vitest";
import "./agent-command.test-mocks.js";
import { __testing as acpManagerTesting } from "../acp/control-plane/manager.js";
import * as authProfileStoreModule from "../agents/auth-profiles/store.js";
import * as attemptExecutionRuntime from "../agents/command/attempt-execution.runtime.js";
import { loadManifestModelCatalog, loadModelCatalog } from "../agents/model-catalog.js";
import * as modelSelectionModule from "../agents/model-selection.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { BASE_THINKING_LEVELS } from "../auto-reply/thinking.shared.js";
import * as runtimeSnapshotModule from "../config/runtime-snapshot.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
emitAgentEvent,
onAgentEvent,
resetAgentEventsForTest,
resetAgentRunContextForTest,
} from "../infra/agent-events.js";
import type { PluginProviderRegistration } from "../plugins/registry.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import type { RuntimeEnv } from "../runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { agentCommand, agentCommandFromIngress } from "./agent.js";
import { createThrowingTestRuntime } from "./test-runtime-config-helpers.js";
const configIoMocks = vi.hoisted(() => ({
loadConfig: vi.fn(),
readConfigFileSnapshotForWrite: vi.fn(),
}));
const pluginRegistryMocks = vi.hoisted(() => ({
ensurePluginRegistryLoaded: vi.fn(),
}));
vi.mock("../config/io.js", () => ({
getRuntimeConfig: configIoMocks.loadConfig,
loadConfig: configIoMocks.loadConfig,
readConfigFileSnapshotForWrite: configIoMocks.readConfigFileSnapshotForWrite,
}));
vi.mock("../plugins/runtime/runtime-registry-loader.js", () => ({
ensurePluginRegistryLoaded: pluginRegistryMocks.ensurePluginRegistryLoaded,
}));
vi.mock("../agents/auth-profiles/store.js", () => {
const createEmptyStore = () => ({ version: 1, profiles: {} });
return {
clearRuntimeAuthProfileStoreSnapshots: vi.fn(),
ensureAuthProfileStore: vi.fn(createEmptyStore),
ensureAuthProfileStoreForLocalUpdate: vi.fn(createEmptyStore),
hasAnyAuthProfileStoreSource: vi.fn(() => false),
loadAuthProfileStore: vi.fn(createEmptyStore),
loadAuthProfileStoreForRuntime: vi.fn(createEmptyStore),
loadAuthProfileStoreForSecretsRuntime: vi.fn(createEmptyStore),
replaceRuntimeAuthProfileStoreSnapshots: vi.fn(),
saveAuthProfileStore: vi.fn(),
updateAuthProfileStoreWithLock: vi.fn(async () => createEmptyStore()),
};
});
vi.mock("../agents/command/session-store.runtime.js", () => {
return {
updateSessionStoreAfterAgentRun: vi.fn(async () => undefined),
};
});
vi.mock("../agents/command/attempt-execution.runtime.js", () => {
return {
buildAcpResult: vi.fn(),
createAcpVisibleTextAccumulator: vi.fn(),
emitAcpAssistantDelta: vi.fn(),
emitAcpLifecycleEnd: vi.fn(),
emitAcpLifecycleError: vi.fn(),
emitAcpLifecycleStart: vi.fn(),
persistAcpTurnTranscript: vi.fn(
async (params: { sessionEntry?: unknown }) => params.sessionEntry,
),
persistCliTurnTranscript: vi.fn(
async (params: { sessionEntry?: unknown }) => params.sessionEntry,
),
runAgentAttempt: vi.fn(async (params: Record<string, unknown>) => {
const opts = params.opts as Record<string, unknown>;
const runContext = params.runContext as Record<string, unknown>;
const sessionEntry = params.sessionEntry as
| {
authProfileOverride?: string;
authProfileOverrideSource?: string;
}
| undefined;
const providerOverride = params.providerOverride as string;
const authProfileProvider = params.authProfileProvider as string;
const authProfileId =
providerOverride === authProfileProvider ? sessionEntry?.authProfileOverride : undefined;
return await runEmbeddedPiAgent({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
agentId: params.sessionAgentId,
trigger: "user",
messageChannel: params.messageChannel,
agentAccountId: runContext.accountId,
messageTo: opts.replyTo ?? opts.to,
messageThreadId: opts.threadId,
senderIsOwner: opts.senderIsOwner,
sessionFile: params.sessionFile,
workspaceDir: params.workspaceDir,
config: params.cfg,
skillsSnapshot: params.skillsSnapshot,
prompt: params.body,
images: opts.images,
imageOrder: opts.imageOrder,
clientTools: opts.clientTools,
provider: providerOverride,
model: params.modelOverride,
authProfileId,
authProfileIdSource: authProfileId ? sessionEntry?.authProfileOverrideSource : undefined,
thinkLevel: params.resolvedThinkLevel,
fastMode: params.fastMode,
verboseLevel: params.resolvedVerboseLevel,
timeoutMs: params.timeoutMs,
runId: params.runId,
lane: opts.lane,
abortSignal: opts.abortSignal,
extraSystemPrompt: opts.extraSystemPrompt,
bootstrapContextMode: opts.bootstrapContextMode,
bootstrapContextRunKind: opts.bootstrapContextRunKind,
internalEvents: opts.internalEvents,
inputProvenance: opts.inputProvenance,
streamParams: opts.streamParams,
agentDir: params.agentDir,
allowTransientCooldownProbe: params.allowTransientCooldownProbe,
cleanupBundleMcpOnRunEnd: opts.cleanupBundleMcpOnRunEnd,
cleanupCliLiveSessionOnRunEnd: opts.cleanupCliLiveSessionOnRunEnd,
modelRun: opts.modelRun,
promptMode: opts.promptMode,
disableTools: opts.modelRun === true,
onAgentEvent: params.onAgentEvent,
} as never);
}),
sessionFileHasContent: vi.fn(async () => false),
};
});
vi.mock("../agents/command/delivery.runtime.js", () => {
return {
deliverAgentCommandResult: vi.fn(
async (params: {
cfg: OpenClawConfig;
deps: {
sendMessageTelegram?: (
to: string,
text: string,
opts: Record<string, unknown>,
) => Promise<unknown>;
};
runtime: RuntimeEnv;
opts: {
channel?: string;
deliver?: boolean;
json?: boolean;
to?: string;
};
result: { meta?: Record<string, unknown> };
payloads?: Array<{ text?: string; mediaUrl?: string | null }>;
}) => {
const payloads = params.payloads ?? [];
if (params.opts.json) {
params.runtime.log(JSON.stringify({ payloads, meta: params.result.meta ?? {} }));
return;
}
if (params.opts.deliver && params.opts.channel === "telegram" && params.opts.to) {
for (const payload of payloads) {
await params.deps.sendMessageTelegram?.(params.opts.to, payload.text ?? "", {
...(payload.mediaUrl ? { mediaUrl: payload.mediaUrl } : {}),
accountId: undefined,
verbose: false,
});
}
return;
}
for (const payload of payloads) {
if (payload.text) {
params.runtime.log(payload.text);
}
}
},
),
};
});
vi.mock("../config/sessions/transcript-resolve.runtime.js", () => {
const dirname = (filePath: string): string => {
const lastSlash = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
return lastSlash >= 0 ? filePath.slice(0, lastSlash) : ".";
};
const joinPath = (...parts: string[]): string => {
const separator = parts.some((part) => part.includes("\\")) ? "\\" : "/";
const normalizedParts: string[] = [];
for (const [index, part] of parts.entries()) {
const normalized =
index === 0 ? part.replace(/[\\/]+$/u, "") : part.replace(/^[\\/]+|[\\/]+$/gu, "");
if (normalized.length > 0) {
normalizedParts.push(normalized);
}
}
return normalizedParts.join(separator);
};
const resolveSessionFile = (sessionId: string, agentId: string, sessionsDir?: string): string =>
joinPath(sessionsDir ?? ".openclaw", "agents", agentId, "sessions", `${sessionId}.jsonl`);
return {
resolveSessionTranscriptFile: vi.fn(
async (params: {
sessionId: string;
sessionKey: string;
sessionEntry?: { sessionFile?: string; sessionId?: string };
sessionStore?: Record<string, { sessionFile?: string; sessionId?: string }>;
storePath?: string;
agentId: string;
threadId?: string | number;
}) => {
const sessionsDir = params.storePath ? dirname(params.storePath) : undefined;
const sessionFileFromStorePath =
params.sessionEntry?.sessionFile ??
resolveSessionFile(params.sessionId, params.agentId, sessionsDir);
const sessionFile = params.sessionEntry?.sessionFile
? sessionFileFromStorePath
: resolveSessionFile(params.sessionId, params.agentId, sessionsDir);
let sessionEntry = params.sessionEntry;
if (params.sessionStore && params.storePath && params.sessionKey) {
const existingEntry = params.sessionStore[params.sessionKey] ?? {};
sessionEntry = {
...existingEntry,
sessionId: params.sessionId,
sessionFile,
};
params.sessionStore[params.sessionKey] = sessionEntry;
fs.writeFileSync(params.storePath, JSON.stringify(params.sessionStore));
}
return { sessionFile, sessionEntry };
},
),
};
});
const runtime = createThrowingTestRuntime();
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, {
prefix: "openclaw-agent-",
skipHomeCleanup: true,
skipSessionCleanup: true,
});
}
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 }>,
) {
const cfg = {
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-6" },
models: { "anthropic/claude-opus-4-6": {} },
workspace: path.join(home, "openclaw"),
...agentOverrides,
},
list: agentsList,
},
session: { store: storePath, mainKey: "main" },
channels: {
telegram: telegramOverrides ? { ...telegramOverrides } : undefined,
},
} as OpenClawConfig;
configIoMocks.loadConfig.mockReturnValue(cfg);
return cfg;
}
function writeSessionStoreSeed(
storePath: string,
sessions: Record<string, Record<string, unknown>>,
) {
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(storePath, JSON.stringify(sessions));
}
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 runAgentWithSessionKey(sessionKey: string): Promise<void> {
await agentCommand({ message: "hi", sessionKey }, runtime);
}
function mockModelCatalogOnce(entries: ReturnType<typeof loadManifestModelCatalog>): void {
vi.mocked(loadManifestModelCatalog).mockReturnValueOnce(entries);
vi.mocked(loadModelCatalog).mockResolvedValueOnce(entries);
}
function installThinkingTestProviders() {
const registry = createTestRegistry();
registry.providers = ["anthropic", "codex", "ollama", "openai", "openrouter"].map(
(providerId): PluginProviderRegistration => ({
pluginId: providerId,
source: "test",
provider: {
id: providerId,
label: providerId,
auth: [],
resolveThinkingProfile: () => ({
levels: BASE_THINKING_LEVELS.map((id) => ({ id })),
defaultLevel: "off",
}),
},
}),
);
setActivePluginRegistry(registry);
}
beforeEach(() => {
vi.clearAllMocks();
resetPluginRuntimeStateForTest();
installThinkingTestProviders();
clearSessionStoreCacheForTest();
resetAgentEventsForTest();
resetAgentRunContextForTest();
acpManagerTesting.resetAcpSessionManagerForTests();
runtimeSnapshotModule.clearRuntimeConfigSnapshot();
vi.mocked(runEmbeddedPiAgent).mockResolvedValue(createDefaultAgentResult());
vi.mocked(loadManifestModelCatalog).mockReturnValue([]);
vi.mocked(loadModelCatalog).mockResolvedValue([]);
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false);
configIoMocks.readConfigFileSnapshotForWrite.mockResolvedValue({
snapshot: { valid: false, resolved: {} as OpenClawConfig },
writeOptions: {},
});
});
describe("agentCommand", () => {
it("enables the Codex runtime plugin for one-shot OpenAI model overrides", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
mockConfig(home, storePath, { models: undefined });
await agentCommand(
{
message: "hi",
agentId: "main",
model: "openai/gpt-5.2",
allowModelOverride: true,
},
runtime,
);
expect(pluginRegistryMocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({
scope: "all",
config: expect.any(Object),
activationSourceConfig: expect.any(Object),
workspaceDir: path.join(home, "openclaw"),
onlyPluginIds: ["codex"],
});
expectLastRunProviderModel("openai", "gpt-5.2");
});
});
it("does not enable Codex for one-shot OpenAI overrides when the provider forces PI", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const cfg = mockConfig(home, storePath, { models: undefined });
cfg.models = {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
agentRuntime: { id: "pi" },
models: [],
},
},
};
await agentCommand(
{
message: "hi",
agentId: "main",
model: "openai/gpt-5.2",
allowModelOverride: true,
},
runtime,
);
expect(pluginRegistryMocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
expectLastRunProviderModel("openai", "gpt-5.2");
});
});
it("enforces ingress trust flags", async () => {
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.");
await expect(
// Runtime guard for non-TS callers; TS callsites are statically typed.
agentCommandFromIngress(
{
message: "hi",
to: "+1555",
senderIsOwner: false,
} as never,
runtime,
),
).rejects.toThrow("allowModelOverride must be explicitly set for ingress agent runs.");
});
it("persists local overrides", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
vi.mocked(runEmbeddedPiAgent).mockResolvedValue(
createDefaultAgentResult({
payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }],
durationMs: 42,
}),
);
await agentCommand(
{
message: "ping",
to: "+1222",
accountId: "kev",
thinking: "high",
verbose: "on",
json: true,
},
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");
expect(callArgs?.senderIsOwner).toBe(true);
expect(callArgs?.prompt).toBe("ping");
expect(callArgs?.agentAccountId).toBe("kev");
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("persists embedded-runner turns to the session transcript", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
const base = createDefaultAgentResult({ payloads: [{ text: "assistant-visible" }] });
vi.mocked(runEmbeddedPiAgent).mockResolvedValueOnce({
...base,
meta: {
...base.meta,
executionTrace: { runner: "embedded" },
},
});
await agentCommand({ message: "hello from user", agentId: "main" }, runtime);
expect(vi.mocked(attemptExecutionRuntime.persistCliTurnTranscript)).toHaveBeenCalledTimes(1);
const persistArgs = vi.mocked(attemptExecutionRuntime.persistCliTurnTranscript).mock
.calls[0]?.[0];
expect(persistArgs?.embeddedAssistantGapFill).toBe(true);
expect(persistArgs?.body).toBe("hello from user");
expect(persistArgs?.result.meta?.executionTrace?.runner).toBe("embedded");
});
});
it("gap-fills Telegram-visible embedded replies without a runner trace", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
const sendMessageTelegram = vi.fn(async () => undefined);
const base = createDefaultAgentResult({ payloads: [{ text: "assistant-visible" }] });
vi.mocked(runEmbeddedPiAgent).mockResolvedValueOnce({
...base,
meta: {
...base.meta,
finalAssistantVisibleText: "assistant-visible",
},
});
await agentCommandFromIngress(
{
message: "call a tool then answer",
agentId: "main",
to: "+1222",
channel: "telegram",
messageChannel: "telegram",
deliver: true,
senderIsOwner: false,
allowModelOverride: false,
},
runtime,
{ sendMessageTelegram },
);
expect(sendMessageTelegram).toHaveBeenCalledWith(
"+1222",
"assistant-visible",
expect.objectContaining({ verbose: false }),
);
expect(vi.mocked(attemptExecutionRuntime.persistCliTurnTranscript)).toHaveBeenCalledTimes(1);
const persistArgs = vi.mocked(attemptExecutionRuntime.persistCliTurnTranscript).mock
.calls[0]?.[0];
expect(persistArgs?.embeddedAssistantGapFill).toBe(true);
expect(persistArgs?.body).toBe("call a tool then answer");
});
});
it("passes configured fast mode to embedded runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, {
model: "openai/gpt-5.5",
models: {
"openai/gpt-5.5": { params: { fastMode: true } },
},
});
await agentCommand({ message: "ping", agentId: "main" }, runtime);
const callArgs = getLastEmbeddedCall();
expect(callArgs).toEqual(
expect.objectContaining({
provider: "openai",
model: "gpt-5.5",
fastMode: true,
}),
);
});
});
it("does not load the full model catalog for trusted explicit overrides without an allowlist", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, { models: {} });
await agentCommand(
{
message: "ping",
to: "+1222",
model: "openrouter/auto",
},
runtime,
);
expect(loadModelCatalog).not.toHaveBeenCalled();
expectLastRunProviderModel("openrouter", "openrouter/auto");
expect(modelSelectionModule.resolveThinkingDefault).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openrouter",
model: "auto",
catalog: undefined,
}),
);
});
});
it("uses no-tools plain prompt mode for one-shot model runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, { models: {} });
await agentCommand(
{
message: "Reply with exactly OPENCLAW-MODEL-OK",
agentId: "main",
model: "openrouter/auto",
modelRun: true,
promptMode: "none",
},
runtime,
);
const callArgs = getLastEmbeddedCall();
expect(callArgs).toEqual(
expect.objectContaining({
provider: "openrouter",
model: "openrouter/auto",
modelRun: true,
promptMode: "none",
disableTools: true,
}),
);
});
});
it("bypasses ACP sessions for one-shot model runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
const sessionKey = "agent:main:main";
mockConfig(home, store, { models: {} });
writeSessionStoreSeed(store, {
[sessionKey]: {
sessionId: "acp-backed-session",
updatedAt: Date.now(),
},
});
const runTurn = vi.fn();
acpManagerTesting.setAcpSessionManagerForTests({
resolveSession: vi.fn(() => ({
kind: "ready",
sessionKey,
meta: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
})),
runTurn,
});
await agentCommand(
{
message: "Reply with exactly OPENCLAW-MODEL-OK",
sessionKey,
model: "openrouter/auto",
modelRun: true,
promptMode: "none",
},
runtime,
);
expect(runTurn).not.toHaveBeenCalled();
const callArgs = getLastEmbeddedCall();
expect(callArgs).toEqual(
expect.objectContaining({
provider: "openrouter",
model: "openrouter/auto",
prompt: "Reply with exactly OPENCLAW-MODEL-OK",
modelRun: true,
promptMode: "none",
disableTools: true,
}),
);
});
});
it("passes resolved session-id resume files to embedded runs", async () => {
await withTempHome(async (home) => {
const resumeStore = path.join(home, "sessions-resume.json");
writeSessionStoreSeed(resumeStore, {
foo: {
sessionId: "session-123",
updatedAt: Date.now(),
systemSent: true,
},
});
mockConfig(home, resumeStore);
await agentCommand(
{ message: "resume me", sessionId: "session-123", thinking: "low" },
runtime,
);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.sessionId).toBe("session-123");
expect(callArgs?.sessionFile).toContain(
`${path.dirname(resumeStore)}${path.sep}agents${path.sep}main${path.sep}sessions${path.sep}session-123.jsonl`,
);
});
});
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", thinking: "low" }, runtime);
stop();
const matching = assistantEvents.filter((evt) => evt.text === "hello");
expect(matching).toHaveLength(1);
});
});
it("does not publish Codex app-server events from the core command callback", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
const codexEvents: Array<{ runId: string; phase?: string }> = [];
const stop = onAgentEvent((evt) => {
if (evt.stream !== "codex_app_server.lifecycle") {
return;
}
codexEvents.push({
runId: evt.runId,
phase: typeof evt.data?.phase === "string" ? evt.data.phase : undefined,
});
});
vi.mocked(runEmbeddedPiAgent).mockImplementationOnce(async (params) => {
(
params as {
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
}
).onAgentEvent?.({
stream: "codex_app_server.lifecycle",
data: { phase: "startup" },
});
return {
payloads: [{ text: "hello" }],
meta: { agentMeta: { provider: "p", model: "m" } },
} as never;
});
await agentCommand({ message: "hi", to: "+1555", thinking: "low" }, runtime);
stop();
expect(codexEvents).toHaveLength(0);
});
});
it("uses default fallback list for auto 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-6",
modelOverrideSource: "auto",
},
});
mockConfig(home, store, {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: ["openai/gpt-5.4"],
},
models: {
"anthropic/claude-opus-4-6": {},
"openai/gpt-4.1-mini": {},
"openai/gpt-5.4": {},
},
});
mockModelCatalogOnce([
{ id: "claude-opus-4-6", name: "Opus", provider: "anthropic" },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
{ id: "gpt-5.4", 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.4" },
},
});
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-6" },
{ provider: "openai", model: "gpt-5.4" },
]);
});
});
it("does not use fallback list for user session model overrides", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions-user-override.json");
writeSessionStoreSeed(store, {
"agent:main:subagent:user-override": {
sessionId: "session-user-override",
updatedAt: Date.now(),
providerOverride: "ollama",
modelOverride: "qwen3.5:27b",
modelOverrideSource: "user",
},
});
mockConfig(home, store, {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: ["openai/gpt-5.4"],
},
models: {
"ollama/qwen3.5:27b": {},
"openai/gpt-4.1-mini": {},
"openai/gpt-5.4": {},
},
});
mockModelCatalogOnce([
{ id: "qwen3.5:27b", name: "Qwen 3.5", provider: "ollama" },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
{ id: "gpt-5.4", name: "GPT-5.4", provider: "openai" },
]);
vi.mocked(runEmbeddedPiAgent).mockRejectedValueOnce(new Error("connect ECONNREFUSED"));
await expect(
agentCommand(
{
message: "hi",
sessionKey: "agent:main:subagent:user-override",
},
runtime,
),
).rejects.toThrow("connect ECONNREFUSED");
const attempts = vi
.mocked(runEmbeddedPiAgent)
.mock.calls.map((call) => ({ provider: call[0]?.provider, model: call[0]?.model }));
expect(attempts).toEqual([{ provider: "ollama", model: "qwen3.5:27b" }]);
});
});
it("clears disallowed stored override fields", async () => {
await withTempHome(async (home) => {
const clearStore = path.join(home, "sessions-clear-overrides.json");
writeSessionStoreSeed(clearStore, {
"agent:main:subagent:clear-overrides": {
sessionId: "session-clear-overrides",
updatedAt: Date.now(),
providerOverride: "anthropic",
modelOverride: "claude-opus-4-6",
authProfileOverride: "profile-legacy",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 2,
fallbackNoticeSelectedModel: "anthropic/claude-opus-4-6",
fallbackNoticeActiveModel: "openai/gpt-4.1-mini",
fallbackNoticeReason: "fallback",
},
});
mockConfig(home, clearStore, {
model: { primary: "openai/gpt-4.1-mini" },
models: {
"openai/gpt-4.1-mini": {},
},
});
mockModelCatalogOnce([
{ id: "claude-opus-4-6", 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 cleared = readSessionStore<{
providerOverride?: string;
modelOverride?: string;
authProfileOverride?: string;
authProfileOverrideSource?: string;
authProfileOverrideCompactionCount?: number;
fallbackNoticeSelectedModel?: string;
fallbackNoticeActiveModel?: string;
fallbackNoticeReason?: string;
}>(clearStore);
const entry = cleared["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("handles one-off provider/model overrides and validates override values", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, {
models: {
"anthropic/claude-opus-4-6": {},
"openai/gpt-4.1-mini": {},
},
});
await agentCommand(
{
message: "use the override",
sessionKey: "agent:main:subagent:run-override",
provider: "openai",
model: "gpt-4.1-mini",
},
runtime,
);
expectLastRunProviderModel("openai", "gpt-4.1-mini");
const saved = readSessionStore<{
providerOverride?: string;
modelOverride?: string;
}>(store);
expect(saved["agent:main:subagent:run-override"]?.providerOverride).toBeUndefined();
expect(saved["agent:main:subagent:run-override"]?.modelOverride).toBeUndefined();
writeSessionStoreSeed(store, {
"agent:main:subagent:temp-openai-run": {
sessionId: "session-temp-openai-run",
updatedAt: Date.now(),
authProfileOverride: "anthropic:work",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 2,
},
});
vi.mocked(authProfileStoreModule.ensureAuthProfileStore).mockReturnValue({
version: 1,
profiles: {
"anthropic:work": {
provider: "anthropic",
},
},
} as never);
await agentCommand(
{
message: "use a different provider once",
sessionKey: "agent:main:subagent:temp-openai-run",
provider: "openai",
model: "gpt-4.1-mini",
},
runtime,
);
expectLastRunProviderModel("openai", "gpt-4.1-mini");
expect(getLastEmbeddedCall()?.authProfileId).toBeUndefined();
const savedAuth = readSessionStore<{
authProfileOverride?: string;
authProfileOverrideSource?: string;
authProfileOverrideCompactionCount?: number;
}>(store);
expect(savedAuth["agent:main:subagent:temp-openai-run"]?.authProfileOverride).toBe(
"anthropic:work",
);
expect(savedAuth["agent:main:subagent:temp-openai-run"]?.authProfileOverrideSource).toBe(
"user",
);
expect(
savedAuth["agent:main:subagent:temp-openai-run"]?.authProfileOverrideCompactionCount,
).toBe(2);
await expect(
agentCommand(
{
message: "use an invalid override",
sessionKey: "agent:main:subagent:invalid-override",
provider: "openai\u001b[31m",
model: "gpt-4.1-mini",
},
runtime,
),
).rejects.toThrow("Provider override contains invalid control characters.");
const parseModelRefSpy = vi.spyOn(modelSelectionModule, "parseModelRef");
parseModelRefSpy.mockImplementationOnce(() => ({
provider: "anthropic\u001b[31m",
model: "claude-haiku-4-5\u001b[32m",
}));
mockConfig(home, store, {
models: {
"openai/gpt-4.1-mini": {},
},
});
try {
await expect(
agentCommand(
{
message: "use disallowed override",
sessionKey: "agent:main:subagent:sanitized-override-error",
model: "claude-haiku-4-5",
},
runtime,
),
).rejects.toThrow(
'Model override "anthropic/claude-haiku-4-5" is not allowed for agent "main".',
);
} finally {
parseModelRefSpy.mockRestore();
}
});
});
it("passes resolved default thinking level to embedded runs", 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-6": {},
"openai/gpt-4.1-mini": {},
},
});
mockModelCatalogOnce([
{
id: "gpt-4.1-mini",
name: "GPT-4.1 Mini",
provider: "openai",
reasoning: true,
},
]);
await agentCommand({ message: "hi", to: "+1555" }, runtime);
expect(getLastEmbeddedCall()?.thinkLevel).toBe("low");
expectLastRunProviderModel("openai", "gpt-4.1-mini");
});
});
it("passes routing context to embedded runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, undefined, undefined, [{ id: "ops" }]);
await agentCommand(
{ message: "hi", agentId: "ops", replyChannel: "slack", thinking: "low" },
runtime,
);
let callArgs = getLastEmbeddedCall();
expect(callArgs?.sessionKey).toBe("agent:ops:main");
expect(callArgs?.sessionFile).toContain(`${path.sep}agents${path.sep}ops${path.sep}sessions`);
expect(callArgs?.messageChannel).toBe("slack");
expect(runtime.log).toHaveBeenCalledWith("ok");
await agentCommand(
{
message: "hi",
to: "+1555",
channel: "whatsapp",
thinking: "low",
runContext: { messageChannel: "slack", accountId: "acct-2" },
},
runtime,
);
callArgs = getLastEmbeddedCall();
expect(callArgs?.messageChannel).toBe("slack");
expect(callArgs?.agentAccountId).toBe("acct-2");
await expect(agentCommand({ message: "hi", agentId: "ghost" }, runtime)).rejects.toThrow(
'Unknown agent id "ghost"',
);
});
});
});