Files
openclaw/src/agents/agent-command.live-model-switch.test.ts
2026-04-28 05:08:34 +01:00

818 lines
26 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, 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<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: (...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<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("../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<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: (...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<unknown>;
onFallbackStep?: (step: Record<string, unknown>) => void | Promise<void>;
classifyResult?: (params: {
provider: string;
model: string;
result: unknown;
attempt: number;
total: number;
}) => 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 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);
});
});