mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-08 15:51:06 +00:00
565 lines
17 KiB
TypeScript
565 lines
17 KiB
TypeScript
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,
|
||
});
|
||
});
|
||
});
|