import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { INTERNAL_RUNTIME_CONTEXT_BEGIN, INTERNAL_RUNTIME_CONTEXT_END } from "./internal-events.js"; import { LiveSessionModelSwitchError } from "./live-model-switch-error.js"; const state = vi.hoisted(() => ({ acpResolveSessionMock: vi.fn((..._args: unknown[]): unknown => null), acpRunTurnMock: vi.fn((..._args: unknown[]): unknown => undefined), buildAcpResultMock: vi.fn(), createAcpVisibleTextAccumulatorMock: vi.fn(), persistAcpTurnTranscriptMock: vi.fn(), resolveAcpAgentPolicyErrorMock: vi.fn(), resolveAcpDispatchPolicyErrorMock: vi.fn(), resolveAcpExplicitTurnPolicyErrorMock: vi.fn(), 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(), trajectoryRecordEventMock: vi.fn(), trajectoryFlushMock: vi.fn(async () => undefined), clearSessionAuthProfileOverrideMock: vi.fn(), authProfileStoreMock: { profiles: {} } as { profiles: Record }, 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: (...args: unknown[]) => state.buildAcpResultMock(...args), createAcpVisibleTextAccumulator: () => state.createAcpVisibleTextAccumulatorMock(), emitAcpAssistantDelta: vi.fn(), emitAcpLifecycleEnd: vi.fn(), emitAcpLifecycleError: vi.fn(), emitAcpLifecycleStart: vi.fn(), persistAcpTurnTranscript: (...args: unknown[]) => state.persistAcpTurnTranscriptMock(...args), 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: (...args: unknown[]) => state.resolveAcpAgentPolicyErrorMock(...args), resolveAcpDispatchPolicyError: (...args: unknown[]) => state.resolveAcpDispatchPolicyErrorMock(...args), resolveAcpExplicitTurnPolicyError: (...args: unknown[]) => state.resolveAcpExplicitTurnPolicyErrorMock(...args), })); vi.mock("../acp/runtime/errors.js", () => ({ toAcpRuntimeError: ({ error }: { error: unknown }) => error instanceof Error ? error : new Error(String(error)), })); 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", () => ({ getRuntimeConfig: () => ({ 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) => unknown) => { const store: Record = {}; 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("../trajectory/runtime.js", () => ({ createTrajectoryRuntimeRecorder: () => ({ enabled: true, filePath: "/tmp/session.trajectory.jsonl", recordEvent: (...args: unknown[]) => state.trajectoryRecordEventMock(...args), flush: () => state.trajectoryFlushMock(), }), })); 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([ "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: (...args: unknown[]) => state.acpResolveSessionMock(...args), runTurn: (...args: unknown[]) => state.acpRunTurnMock(...args), }), })); 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; onFallbackStep?: (step: Record) => void | Promise; classifyResult?: (params: { provider: string; model: string; result: unknown; attempt: number; total: number; }) => unknown; }; type ModelSwitchOptions = ConstructorParameters[0]; function makeSuccessResult(provider: string, model: string) { return { payloads: [{ text: "ok" }], meta: { durationMs: 100, aborted: false, stopReason: "end_turn", agentMeta: { provider, model }, }, }; } function makeEmptyResult(provider: string, model: string) { return { payloads: [], meta: { durationMs: 30_000, aborted: false, stopReason: "end_turn", agentHarnessResultClassification: "empty", 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.acpResolveSessionMock.mockReturnValue(null); state.resolveAcpAgentPolicyErrorMock.mockReturnValue(null); state.resolveAcpDispatchPolicyErrorMock.mockReturnValue(null); state.resolveAcpExplicitTurnPolicyErrorMock.mockReturnValue(null); state.acpRunTurnMock.mockImplementation(async (params: unknown) => { const onEvent = (params as { onEvent?: (event: unknown) => void }).onEvent; onEvent?.({ type: "text_delta", stream: "output", text: "done" }); onEvent?.({ type: "done", stopReason: "end_turn" }); }); state.createAcpVisibleTextAccumulatorMock.mockImplementation(() => { let text = ""; return { consume(chunk: string) { text += chunk; return { text, delta: chunk }; }, finalizeRaw: () => text, finalize: () => text, }; }); state.buildAcpResultMock.mockImplementation((params: { payloadText?: string }) => ({ payloads: params.payloadText ? [{ text: params.payloadText }] : [], meta: { durationMs: 0, stopReason: "end_turn" }, })); state.persistAcpTurnTranscriptMock.mockImplementation( async (params: { sessionEntry?: unknown }) => params.sessionEntry, ); state.authProfileStoreMock = { profiles: {} }; state.sessionEntryMock = undefined; state.sessionStoreMock = undefined; state.deliverAgentCommandResultMock.mockResolvedValue(undefined); state.updateSessionStoreAfterAgentRunMock.mockResolvedValue(undefined); state.trajectoryFlushMock.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("records fallback steps to the session trajectory runtime", async () => { state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => { await params.onFallbackStep?.({ fallbackStepType: "fallback_step", fallbackStepFromModel: "ollama/llama3", fallbackStepToModel: "openai/gpt-5.4", fallbackStepFromFailureReason: "overloaded", fallbackStepChainPosition: 1, fallbackStepFinalOutcome: "next_fallback", }); 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")); await runBasicAgentCommand(); expect(state.trajectoryRecordEventMock).toHaveBeenCalledWith( "model.fallback_step", expect.objectContaining({ fallbackStepType: "fallback_step", fallbackStepFromModel: "ollama/llama3", fallbackStepToModel: "openai/gpt-5.4", fallbackStepFromFailureReason: "overloaded", fallbackStepChainPosition: 1, fallbackStepFinalOutcome: "next_fallback", }), ); expect(state.trajectoryFlushMock).toHaveBeenCalled(); }); 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("classifies empty embedded run results before model fallback accepts them", async () => { let observedClassification: unknown; state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => { const primaryResult = await params.run(params.provider, params.model); observedClassification = await params.classifyResult?.({ provider: params.provider, model: params.model, result: primaryResult, attempt: 1, total: 2, }); const fallbackResult = await params.run("openai", "gpt-5.4"); return { result: fallbackResult, provider: "openai", model: "gpt-5.4", attempts: [ { provider: params.provider, model: params.model, reason: "format", code: "empty_result", }, ], }; }); state.runAgentAttemptMock .mockResolvedValueOnce(makeEmptyResult("anthropic", "claude")) .mockResolvedValueOnce(makeSuccessResult("openai", "gpt-5.4")); await runBasicAgentCommand(); expect(observedClassification).toMatchObject({ reason: "format", code: "empty_result", }); expect(state.runAgentAttemptMock).toHaveBeenCalledTimes(2); expect(state.runAgentAttemptMock.mock.calls[1]?.[0]).toMatchObject({ providerOverride: "openai", modelOverride: "gpt-5.4", isFallbackRetry: true, }); }); 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("sends internal completion wakes to ACP sessions as plain prompt text", async () => { state.acpResolveSessionMock.mockReturnValue({ kind: "ready", meta: { agent: "claude", cwd: "/tmp/workspace", }, }); await agentCommand({ message: [ INTERNAL_RUNTIME_CONTEXT_BEGIN, "OpenClaw runtime context (internal):", "hidden task completion event", INTERNAL_RUNTIME_CONTEXT_END, ].join("\n"), sessionKey: "agent:main", senderIsOwner: true, internalEvents: [ { type: "task_completion", source: "subagent", childSessionKey: "agent:main:subagent:child", childSessionId: "child-session-id", announceType: "subagent task", taskLabel: "inspect ACP delivery", status: "ok", statusLabel: "completed successfully", result: "child output", replyInstruction: "Summarize the result for the user.", }, ], }); expect(state.acpRunTurnMock).toHaveBeenCalledTimes(1); const runTurnParams = state.acpRunTurnMock.mock.calls[0]?.[0] as { text?: string }; expect(runTurnParams.text).toContain("A background task completed."); expect(runTurnParams.text).toContain("inspect ACP delivery"); expect(runTurnParams.text).toContain("child output"); expect(runTurnParams.text).not.toContain(INTERNAL_RUNTIME_CONTEXT_BEGIN); expect(runTurnParams.text).not.toContain(INTERNAL_RUNTIME_CONTEXT_END); expect(state.persistAcpTurnTranscriptMock).toHaveBeenCalledTimes(1); const transcriptParams = state.persistAcpTurnTranscriptMock.mock.calls[0]?.[0] as { body?: string; transcriptBody?: string; }; expect(transcriptParams.body).toBe(runTurnParams.text); expect(transcriptParams.transcriptBody).toContain("A background task completed."); expect(transcriptParams.transcriptBody).not.toContain(INTERNAL_RUNTIME_CONTEXT_BEGIN); expect(transcriptParams.transcriptBody).not.toContain(INTERNAL_RUNTIME_CONTEXT_END); }); it("allows manual ACP spawn turns when ACP dispatch is disabled", async () => { state.acpResolveSessionMock.mockReturnValue({ kind: "ready", meta: { agent: "claude", cwd: "/tmp/workspace", }, }); state.resolveAcpDispatchPolicyErrorMock.mockReturnValue( new Error("ACP dispatch is disabled by policy (`acp.dispatch.enabled=false`)."), ); await agentCommand({ message: "bootstrap ACP child", sessionKey: "agent:main", senderIsOwner: true, acpTurnSource: "manual_spawn", }); expect(state.resolveAcpExplicitTurnPolicyErrorMock).toHaveBeenCalledTimes(1); expect(state.resolveAcpDispatchPolicyErrorMock).not.toHaveBeenCalled(); expect(state.acpRunTurnMock).toHaveBeenCalledTimes(1); }); it("keeps ordinary ACP turns blocked when ACP dispatch is disabled", async () => { state.acpResolveSessionMock.mockReturnValue({ kind: "ready", meta: { agent: "claude", cwd: "/tmp/workspace", }, }); state.resolveAcpDispatchPolicyErrorMock.mockReturnValue( new Error("ACP dispatch is disabled by policy (`acp.dispatch.enabled=false`)."), ); await expect( agentCommand({ message: "automatic ACP turn", sessionKey: "agent:main", senderIsOwner: true, }), ).rejects.toThrow("ACP dispatch is disabled"); expect(state.resolveAcpExplicitTurnPolicyErrorMock).not.toHaveBeenCalled(); expect(state.resolveAcpDispatchPolicyErrorMock).toHaveBeenCalledTimes(1); expect(state.acpRunTurnMock).not.toHaveBeenCalled(); }); 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); }); });