Files
openclaw/src/agents/agent-command.live-model-switch.test.ts
2026-04-04 22:42:30 +05:30

565 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LiveSessionModelSwitchError } from "./live-model-switch.js";
const state = vi.hoisted(() => ({
runWithModelFallbackMock: vi.fn(),
runAgentAttemptMock: vi.fn(),
emitAgentEventMock: vi.fn(),
registerAgentRunContextMock: vi.fn(),
clearAgentRunContextMock: vi.fn(),
updateSessionStoreAfterAgentRunMock: vi.fn(),
deliverAgentCommandResultMock: vi.fn(),
}));
vi.mock("./model-fallback.js", () => ({
runWithModelFallback: (params: unknown) => state.runWithModelFallbackMock(params),
}));
vi.mock("./command/attempt-execution.js", () => ({
buildAcpResult: vi.fn(),
createAcpVisibleTextAccumulator: vi.fn(),
emitAcpAssistantDelta: vi.fn(),
emitAcpLifecycleEnd: vi.fn(),
emitAcpLifecycleError: vi.fn(),
emitAcpLifecycleStart: vi.fn(),
persistAcpTurnTranscript: vi.fn(),
persistSessionEntry: vi.fn(),
prependInternalEventContext: (_body: string) => _body,
runAgentAttempt: (...args: unknown[]) => state.runAgentAttemptMock(...args),
sessionFileHasContent: vi.fn(async () => false),
}));
vi.mock("./command/delivery.js", () => ({
deliverAgentCommandResult: (...args: unknown[]) => state.deliverAgentCommandResultMock(...args),
}));
vi.mock("./command/run-context.js", () => ({
resolveAgentRunContext: () => ({
messageChannel: "test",
accountId: "acct",
groupId: undefined,
groupChannel: undefined,
groupSpace: undefined,
currentChannelId: undefined,
currentThreadTs: undefined,
replyToMode: undefined,
hasRepliedRef: { current: false },
}),
}));
vi.mock("./command/session-store.js", () => ({
updateSessionStoreAfterAgentRun: (...args: unknown[]) =>
state.updateSessionStoreAfterAgentRunMock(...args),
}));
vi.mock("./command/session.js", () => ({
resolveSession: () => ({
sessionId: "session-1",
sessionKey: "agent:main",
sessionEntry: { sessionId: "session-1", updatedAt: Date.now() },
sessionStore: {},
storePath: "/tmp/store.json",
isNewSession: true,
persistedThinking: undefined,
persistedVerbose: undefined,
}),
}));
vi.mock("./command/types.js", () => ({}));
vi.mock("../acp/policy.js", () => ({
resolveAcpAgentPolicyError: () => null,
resolveAcpDispatchPolicyError: () => null,
}));
vi.mock("../acp/runtime/errors.js", () => ({
toAcpRuntimeError: vi.fn(),
}));
vi.mock("../acp/runtime/session-identifiers.js", () => ({
resolveAcpSessionCwd: () => "/tmp",
}));
vi.mock("../auto-reply/thinking.js", () => ({
formatThinkingLevels: () => "low, medium, high",
formatXHighModelHint: () => "model-x",
normalizeThinkLevel: (v?: string) => v || undefined,
normalizeVerboseLevel: (v?: string) => v || undefined,
supportsXHighThinking: () => false,
}));
vi.mock("../cli/command-format.js", () => ({
formatCliCommand: (cmd: string) => cmd,
}));
vi.mock("../cli/command-secret-gateway.js", () => ({
resolveCommandSecretRefsViaGateway: async (params: { config: unknown }) => ({
resolvedConfig: params.config,
diagnostics: [],
}),
}));
vi.mock("../cli/command-secret-targets.js", () => ({
getAgentRuntimeCommandSecretTargetIds: () => [],
}));
vi.mock("../cli/deps.js", () => ({
createDefaultDeps: () => ({}),
}));
vi.mock("../config/config.js", () => ({
loadConfig: () => ({
agents: {
defaults: {
models: {
"anthropic/claude": {},
"openai/claude": {},
"openai/gpt-5.4": {},
},
},
},
}),
readConfigFileSnapshotForWrite: async () => ({
snapshot: { valid: false },
}),
setRuntimeConfigSnapshot: vi.fn(),
}));
vi.mock("../config/sessions.js", () => ({
resolveAgentIdFromSessionKey: () => "default",
mergeSessionEntry: (a: unknown, b: unknown) => ({ ...(a as object), ...(b as object) }),
updateSessionStore: vi.fn(
async (_path: string, fn: (store: Record<string, unknown>) => unknown) => {
const store: Record<string, unknown> = {};
return fn(store);
},
),
}));
vi.mock("../config/sessions/transcript.js", () => ({
resolveSessionTranscriptFile: async () => ({
sessionFile: "/tmp/session.jsonl",
sessionEntry: { sessionId: "session-1", updatedAt: Date.now() },
}),
}));
vi.mock("../infra/agent-events.js", () => ({
clearAgentRunContext: (...args: unknown[]) => state.clearAgentRunContextMock(...args),
emitAgentEvent: (...args: unknown[]) => state.emitAgentEventMock(...args),
registerAgentRunContext: (...args: unknown[]) => state.registerAgentRunContextMock(...args),
}));
vi.mock("../infra/outbound/session-context.js", () => ({
buildOutboundSessionContext: () => ({}),
}));
vi.mock("../infra/skills-remote.js", () => ({
getRemoteSkillEligibility: () => ({ eligible: false }),
}));
vi.mock("../logging/subsystem.js", () => ({
createSubsystemLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
vi.mock("../routing/session-key.js", () => ({
normalizeAgentId: (id: string) => id,
}));
vi.mock("../runtime.js", () => ({
defaultRuntime: {
error: vi.fn(),
log: vi.fn(),
},
}));
vi.mock("../sessions/level-overrides.js", () => ({
applyVerboseOverride: vi.fn(),
}));
vi.mock("../sessions/model-overrides.js", () => ({
applyModelOverrideToSessionEntry: () => ({ updated: false }),
}));
vi.mock("../sessions/send-policy.js", () => ({
resolveSendPolicy: () => "allow",
}));
vi.mock("../terminal/ansi.js", () => ({
sanitizeForLog: (s: string) => s,
}));
vi.mock("../utils/message-channel.js", () => ({
resolveMessageChannel: () => "test",
}));
const resolveEffectiveModelFallbacksMock = vi.fn().mockReturnValue(undefined);
vi.mock("./agent-scope.js", () => ({
listAgentIds: () => ["default"],
resolveAgentDir: () => "/tmp/agent",
resolveEffectiveModelFallbacks: resolveEffectiveModelFallbacksMock,
resolveSessionAgentId: () => "default",
resolveAgentSkillsFilter: () => undefined,
resolveAgentWorkspaceDir: () => "/tmp/workspace",
}));
vi.mock("./auth-profiles.js", () => ({
ensureAuthProfileStore: () => ({ profiles: {} }),
}));
vi.mock("./auth-profiles/session-override.js", () => ({
clearSessionAuthProfileOverride: vi.fn(),
}));
vi.mock("./defaults.js", () => ({
DEFAULT_MODEL: "claude",
DEFAULT_PROVIDER: "anthropic",
}));
vi.mock("./lanes.js", () => ({
AGENT_LANE_SUBAGENT: "subagent",
}));
vi.mock("./model-catalog.js", () => ({
loadModelCatalog: async () => [],
}));
vi.mock("./model-selection.js", () => ({
buildAllowedModelSet: () => ({
allowedKeys: new Set<string>(["anthropic/claude", "openai/claude", "openai/gpt-5.4"]),
allowedCatalog: [],
allowAny: false,
}),
modelKey: (p: string, m: string) => `${p}/${m}`,
normalizeModelRef: (p: string, m: string) => ({ provider: p, model: m }),
parseModelRef: (m: string, p: string) => ({ provider: p, model: m }),
resolveConfiguredModelRef: () => ({ provider: "anthropic", model: "claude" }),
resolveDefaultModelForAgent: () => ({ provider: "anthropic", model: "claude" }),
resolveThinkingDefault: () => "low",
}));
vi.mock("./skills.js", () => ({
buildWorkspaceSkillSnapshot: () => ({}),
}));
vi.mock("./skills/refresh.js", () => ({
getSkillsSnapshotVersion: () => 0,
}));
vi.mock("./spawned-context.js", () => ({
normalizeSpawnedRunMetadata: (meta: unknown) => meta ?? {},
}));
vi.mock("./timeout.js", () => ({
resolveAgentTimeoutMs: () => 30_000,
}));
vi.mock("./workspace.js", () => ({
ensureAgentWorkspace: async () => ({ dir: "/tmp/workspace" }),
}));
vi.mock("../acp/control-plane/manager.js", () => ({
getAcpSessionManager: () => ({
resolveSession: () => null,
}),
}));
async function getAgentCommand() {
return (await import("./agent-command.js")).agentCommand;
}
type FallbackRunnerParams = {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
};
function makeSuccessResult(provider: string, model: string) {
return {
payloads: [{ text: "ok" }],
meta: {
durationMs: 100,
aborted: false,
stopReason: "end_turn",
agentMeta: { provider, model },
},
};
}
describe("agentCommand LiveSessionModelSwitchError retry", () => {
beforeEach(() => {
vi.clearAllMocks();
state.deliverAgentCommandResultMock.mockResolvedValue(undefined);
state.updateSessionStoreAfterAgentRunMock.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("retries with the switched provider/model when LiveSessionModelSwitchError is thrown", async () => {
let invocation = 0;
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
invocation += 1;
if (invocation === 1) {
throw new LiveSessionModelSwitchError({
provider: "openai",
model: "gpt-5.4",
});
}
const result = await params.run(params.provider, params.model);
return {
result,
provider: params.provider,
model: params.model,
attempts: [],
};
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
const agentCommand = await getAgentCommand();
await agentCommand({
message: "hello",
to: "+1234567890",
senderIsOwner: true,
});
expect(state.runWithModelFallbackMock).toHaveBeenCalledTimes(2);
const secondCall = state.runWithModelFallbackMock.mock.calls[1]?.[0] as
| FallbackRunnerParams
| undefined;
expect(secondCall?.provider).toBe("openai");
expect(secondCall?.model).toBe("gpt-5.4");
});
it("propagates non-LiveSessionModelSwitchError errors without retrying", async () => {
state.runWithModelFallbackMock.mockRejectedValueOnce(new Error("some other failure"));
const agentCommand = await getAgentCommand();
await expect(
agentCommand({
message: "hello",
to: "+1234567890",
senderIsOwner: true,
}),
).rejects.toThrow("some other failure");
expect(state.runWithModelFallbackMock).toHaveBeenCalledTimes(1);
});
it("emits lifecycle error event for non-switch errors", async () => {
state.runWithModelFallbackMock.mockRejectedValueOnce(new Error("provider down"));
const agentCommand = await getAgentCommand();
await expect(
agentCommand({
message: "hello",
to: "+1234567890",
senderIsOwner: true,
}),
).rejects.toThrow("provider down");
const lifecycleErrorCalls = state.emitAgentEventMock.mock.calls.filter((call: unknown[]) => {
const arg = call[0] as { stream?: string; data?: { phase?: string } };
return arg?.stream === "lifecycle" && arg?.data?.phase === "error";
});
expect(lifecycleErrorCalls.length).toBeGreaterThanOrEqual(1);
});
it("resets lifecycleEnded flag between retry iterations", async () => {
let invocation = 0;
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
invocation += 1;
if (invocation === 1) {
throw new LiveSessionModelSwitchError({
provider: "openai",
model: "gpt-5.4",
});
}
const result = await params.run(params.provider, params.model);
return {
result,
provider: params.provider,
model: params.model,
attempts: [],
};
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
const agentCommand = await getAgentCommand();
await agentCommand({
message: "hello",
to: "+1234567890",
senderIsOwner: true,
});
const lifecycleEndCalls = state.emitAgentEventMock.mock.calls.filter((call: unknown[]) => {
const arg = call[0] as { stream?: string; data?: { phase?: string } };
return arg?.stream === "lifecycle" && arg?.data?.phase === "end";
});
expect(lifecycleEndCalls.length).toBeGreaterThanOrEqual(1);
});
it("propagates authProfileId from the switch error to the retried session entry", async () => {
let invocation = 0;
let capturedAuthProfileProvider: string | undefined;
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
invocation += 1;
if (invocation === 1) {
throw new LiveSessionModelSwitchError({
provider: "openai",
model: "gpt-5.4",
authProfileId: "profile-openai-prod",
authProfileIdSource: "user",
});
}
const result = await params.run(params.provider, params.model);
return {
result,
provider: params.provider,
model: params.model,
attempts: [],
};
});
state.runAgentAttemptMock.mockImplementation(async (...args: unknown[]) => {
const attemptParams = args[0] as { authProfileProvider?: string } | undefined;
capturedAuthProfileProvider = attemptParams?.authProfileProvider;
return makeSuccessResult("openai", "gpt-5.4");
});
const agentCommand = await getAgentCommand();
await agentCommand({
message: "hello",
to: "+1234567890",
senderIsOwner: true,
});
expect(capturedAuthProfileProvider).toBe("openai");
expect(state.runWithModelFallbackMock).toHaveBeenCalledTimes(2);
});
it("updates hasSessionModelOverride for fallback resolution after switch", async () => {
let invocation = 0;
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
invocation += 1;
if (invocation === 1) {
throw new LiveSessionModelSwitchError({
provider: "openai",
model: "gpt-5.4",
});
}
const result = await params.run(params.provider, params.model);
return {
result,
provider: params.provider,
model: params.model,
attempts: [],
};
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
resolveEffectiveModelFallbacksMock.mockClear();
const agentCommand = await getAgentCommand();
await agentCommand({
message: "hello",
to: "+1234567890",
senderIsOwner: true,
});
expect(resolveEffectiveModelFallbacksMock).toHaveBeenCalledTimes(2);
expect(resolveEffectiveModelFallbacksMock.mock.calls[0][0]).toMatchObject({
hasSessionModelOverride: false,
});
expect(resolveEffectiveModelFallbacksMock.mock.calls[1][0]).toMatchObject({
hasSessionModelOverride: true,
});
});
it("does not flip hasSessionModelOverride on auth-only switch with same model", async () => {
let invocation = 0;
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
invocation += 1;
if (invocation === 1) {
throw new LiveSessionModelSwitchError({
provider: "anthropic",
model: "claude",
authProfileId: "profile-99",
authProfileIdSource: "user",
});
}
const result = await params.run(params.provider, params.model);
return {
result,
provider: params.provider,
model: params.model,
attempts: [],
};
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude"));
resolveEffectiveModelFallbacksMock.mockClear();
const agentCommand = await getAgentCommand();
await agentCommand({
message: "hello",
to: "+1234567890",
senderIsOwner: true,
});
expect(resolveEffectiveModelFallbacksMock).toHaveBeenCalledTimes(2);
expect(resolveEffectiveModelFallbacksMock.mock.calls[0][0]).toMatchObject({
hasSessionModelOverride: false,
});
expect(resolveEffectiveModelFallbacksMock.mock.calls[1][0]).toMatchObject({
hasSessionModelOverride: false,
});
});
it("flips hasSessionModelOverride on provider-only switch with same model", async () => {
let invocation = 0;
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
invocation += 1;
if (invocation === 1) {
throw new LiveSessionModelSwitchError({
provider: "openai",
model: "claude",
});
}
const result = await params.run(params.provider, params.model);
return {
result,
provider: params.provider,
model: params.model,
attempts: [],
};
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "claude"));
resolveEffectiveModelFallbacksMock.mockClear();
const agentCommand = await getAgentCommand();
await agentCommand({
message: "hello",
to: "+1234567890",
senderIsOwner: true,
});
expect(resolveEffectiveModelFallbacksMock).toHaveBeenCalledTimes(2);
expect(resolveEffectiveModelFallbacksMock.mock.calls[0][0]).toMatchObject({
hasSessionModelOverride: false,
});
expect(resolveEffectiveModelFallbacksMock.mock.calls[1][0]).toMatchObject({
hasSessionModelOverride: true,
});
});
});