import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest" ;
import { LiveSessionModelSwitchError } from "./live-model-switch-error.js" ;
const state = vi.hoisted(() => ({
runWithModelFallbackMock: vi.fn(),
runAgentAttemptMock: vi.fn(),
resolveEffectiveModelFallbacksMock: vi.fn().mockReturnValue(undefined),
emitAgentEventMock: vi.fn(),
registerAgentRunContextMock: vi.fn(),
clearAgentRunContextMock: vi.fn(),
updateSessionStoreAfterAgentRunMock: vi.fn(),
deliverAgentCommandResultMock: vi.fn(),
clearSessionAuthProfileOverrideMock: vi.fn(),
authProfileStoreMock: { profiles: {} } as { profiles: Record<string, unknown> },
sessionEntryMock: undefined as unknown,
sessionStoreMock: undefined as unknown,
}));
vi.mock("./model-fallback.js" , () => ({
runWithModelFallback: (params: unknown) => state.runWithModelFallbackMock(params),
}));
vi.mock("./command/attempt-execution.runtime.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.runtime.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.runtime.js" , () => ({
updateSessionStoreAfterAgentRun: (...args: unknown[]) =>
state.updateSessionStoreAfterAgentRunMock(...args),
}));
vi.mock("./command/session.js" , () => ({
resolveSession: () => ({
sessionId: "session-1" ,
sessionKey: "agent:main" ,
sessionEntry: state.sessionEntryMock ?? {
sessionId: "session-1" ,
updatedAt: Date.now(),
skillsSnapshot: { prompt: "" , skills: [], version: 0 },
},
sessionStore: state.sessionStoreMock,
storePath: undefined,
isNewSession: false ,
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,
isThinkingLevelSupported: () => true ,
resolveSupportedThinkingLevel: ({ level }: { level?: string }) => level,
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/io.js" , () => ({
loadConfig: () => ({
agents: {
defaults: {
models: {
"anthropic/claude" : {},
"openai/claude" : {},
"openai/gpt-5.4" : {},
},
},
},
}),
readConfigFileSnapshotForWrite: async () => ({
snapshot: { valid: false },
}),
}));
vi.mock("./agent-runtime-config.js" , () => {
const cfg = {
agents: {
defaults: {
models: {
"anthropic/claude" : {},
"openai/claude" : {},
"openai/gpt-5.4" : {},
},
},
},
};
return {
resolveAgentRuntimeConfig: async () => ({
loadedRaw: cfg,
sourceConfig: cfg,
cfg,
}),
};
});
vi.mock("../config/runtime-snapshot.js" , () => ({
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-resolve.runtime.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),
onAgentEvent: vi.fn(),
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: () => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
raw: vi.fn(),
child: vi.fn(() => logger),
};
return logger;
},
}));
vi.mock("../routing/session-key.js" , () => ({
normalizeAgentId: (id: string) => id,
normalizeMainKey: (key?: string | null ) => key?.trim() || "main" ,
}));
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" ,
}));
vi.mock("./agent-scope.js" , () => ({
listAgentIds: () => ["default" ],
resolveAgentConfig: () => undefined,
resolveAgentDir: () => "/tmp/agent" ,
resolveEffectiveModelFallbacks: state.resolveEffectiveModelFallbacksMock,
resolveSessionAgentId: () => "default" ,
resolveAgentSkillsFilter: () => undefined,
resolveAgentWorkspaceDir: () => "/tmp/workspace" ,
}));
vi.mock("./auth-profiles.js" , () => ({
ensureAuthProfileStore: () => ({ profiles: {} }),
}));
vi.mock("./auth-profiles/store.js" , () => ({
ensureAuthProfileStore: () => state.authProfileStoreMock,
}));
vi.mock("./auth-profiles/session-override.js" , () => ({
clearSessionAuthProfileOverride: (...args: unknown[]) =>
state.clearSessionAuthProfileOverrideMock(...args),
}));
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" ,
"codex-cli/gpt-5.4" ,
"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("./provider-auth-aliases.js" , () => ({
resolveProviderAuthAliasMap: () => ({}),
resolveProviderIdForAuth: (provider: string) =>
provider.trim().toLowerCase() === "codex-cli" ? "openai-codex" : provider.trim().toLowerCase(),
}));
vi.mock("./skills.js" , () => ({
buildWorkspaceSkillSnapshot: () => ({}),
}));
vi.mock("./skills/filter.js" , () => ({
matchesSkillFilter: () => true ,
}));
vi.mock("./skills/refresh-state.js" , () => ({
getSkillsSnapshotVersion: () => 0 ,
shouldRefreshSnapshotForVersion: () => false ,
}));
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 ,
}),
}));
let agentCommand: typeof import ("./agent-command.js" ).agentCommand;
beforeAll(async () => {
agentCommand ??= (await import ("./agent-command.js" )).agentCommand;
});
type FallbackRunnerParams = {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
};
type ModelSwitchOptions = ConstructorParameters<typeof LiveSessionModelSwitchError>[0 ];
function makeSuccessResult(provider: string, model: string) {
return {
payloads: [{ text: "ok" }],
meta: {
durationMs: 100 ,
aborted: false ,
stopReason: "end_turn" ,
agentMeta: { provider, model },
},
};
}
function setupModelSwitchRetry(switchOptions: ModelSwitchOptions) {
let invocation = 0 ;
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
invocation += 1 ;
if (invocation === 1 ) {
throw new LiveSessionModelSwitchError(switchOptions);
}
const result = await params.run(params.provider, params.model);
return {
result,
provider: params.provider,
model: params.model,
attempts: [],
};
});
}
async function runBasicAgentCommand() {
await agentCommand({
message: "hello" ,
to: "+1234567890" ,
senderIsOwner: true ,
});
}
function expectFallbackOverrideCalls(first: boolean , second: boolean ) {
expect(state.resolveEffectiveModelFallbacksMock).toHaveBeenCalledTimes(2 );
expect(state.resolveEffectiveModelFallbacksMock.mock.calls[0 ][0 ]).toMatchObject({
hasSessionModelOverride: first,
});
expect(state.resolveEffectiveModelFallbacksMock.mock.calls[1 ][0 ]).toMatchObject({
hasSessionModelOverride: second,
});
}
describe("agentCommand – LiveSessionModelSwitchError retry" , () => {
beforeEach(() => {
vi.clearAllMocks();
state.authProfileStoreMock = { profiles: {} };
state.sessionEntryMock = undefined;
state.sessionStoreMock = undefined;
state.deliverAgentCommandResultMock.mockResolvedValue(undefined);
state.updateSessionStoreAfterAgentRunMock.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("retries with the switched provider/model when LiveSessionModelSwitchError is thrown" , async () => {
setupModelSwitchRetry({
provider: "openai" ,
model: "gpt-5.4" ,
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai" , "gpt-5.4" ));
await runBasicAgentCommand();
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" );
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 non-switch errors without retrying and emits lifecycle error" , async () => {
state.runWithModelFallbackMock.mockRejectedValueOnce(new Error("provider down" ));
await expect(
agentCommand({
message: "hello" ,
to: "+1234567890" ,
senderIsOwner: true ,
}),
).rejects.toThrow("provider down" );
expect(state.runWithModelFallbackMock).toHaveBeenCalledTimes(1 );
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("propagates authProfileId from the switch error to the retried session entry" , async () => {
let capturedAuthProfileProvider: string | undefined;
setupModelSwitchRetry({
provider: "openai" ,
model: "gpt-5.4" ,
authProfileId: "profile-openai-prod" ,
authProfileIdSource: "user" ,
});
state.runAgentAttemptMock.mockImplementation(async (...args: unknown[]) => {
const attemptParams = args[0 ] as { authProfileProvider?: string } | undefined;
capturedAuthProfileProvider = attemptParams?.authProfileProvider;
return makeSuccessResult("openai" , "gpt-5.4" );
});
await runBasicAgentCommand();
expect(capturedAuthProfileProvider).toBe("openai" );
expect(state.runWithModelFallbackMock).toHaveBeenCalledTimes(2 );
});
it("keeps aliased session auth profiles for codex-cli runs" , async () => {
let capturedAuthProfileProvider: string | undefined;
const sessionEntry = {
sessionId: "session-1" ,
updatedAt: Date.now(),
providerOverride: "codex-cli" ,
modelOverride: "gpt-5.4" ,
authProfileOverride: "openai-codex:work" ,
authProfileOverrideSource: "user" ,
skillsSnapshot: { prompt: "" , skills: [], version: 0 },
};
state.sessionEntryMock = sessionEntry;
state.authProfileStoreMock = {
profiles: {
"openai-codex:work" : {
type: "api_key" ,
provider: "openai-codex" ,
key: "sk-test" ,
},
},
};
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
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("codex-cli" , "gpt-5.4" );
});
await runBasicAgentCommand();
expect(capturedAuthProfileProvider).toBe("codex-cli" );
expect(state.clearSessionAuthProfileOverrideMock).not.toHaveBeenCalled();
});
it("updates hasSessionModelOverride for fallback resolution after switch" , async () => {
setupModelSwitchRetry({
provider: "openai" ,
model: "gpt-5.4" ,
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai" , "gpt-5.4" ));
state.resolveEffectiveModelFallbacksMock.mockClear();
await runBasicAgentCommand();
expectFallbackOverrideCalls(false , true );
});
it("does not flip hasSessionModelOverride on auth-only switch with same model" , async () => {
setupModelSwitchRetry({
provider: "anthropic" ,
model: "claude" ,
authProfileId: "profile-99" ,
authProfileIdSource: "user" ,
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic" , "claude" ));
state.resolveEffectiveModelFallbacksMock.mockClear();
await runBasicAgentCommand();
expectFallbackOverrideCalls(false , false );
});
it("flips hasSessionModelOverride on provider-only switch with same model" , async () => {
setupModelSwitchRetry({
provider: "openai" ,
model: "claude" ,
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai" , "claude" ));
state.resolveEffectiveModelFallbacksMock.mockClear();
await runBasicAgentCommand();
expectFallbackOverrideCalls(false , true );
});
});
Messung V0.5 in Prozent C=100 H=97 G=98
¤ Dauer der Verarbeitung: 0.11 Sekunden
(vorverarbeitet am 2026-05-26)
¤
*© Formatika GbR, Deutschland