Files
openclaw/src/agents/agent-command.live-model-switch.test.ts
T
Peter Steinberger 73bba03e4c refactor: canonicalize session delivery state (#113225)
* refactor: canonicalize session delivery state

* test: canonicalize reply persistence fixtures

* test: canonicalize talk delivery fixtures

* test: canonicalize voice session routes

* test: canonicalize attachment delivery fixtures

* test: migrate gateway delivery fixtures

* fix: skip invalid session delivery rows

* test: align delivery SDK surface gates

* fix: preserve legacy delivery precedence

* test: canonicalize heartbeat delivery fixtures

* fix: preserve delivery route prompt identity

* test: canonicalize session delivery fixtures

* fix: preserve recoverable legacy delivery routes

* fix: canonicalize remaining session state

* fix: preserve canonical session classification

* style: format delivery state changes

* test: refresh plugin SDK delivery baseline

* test: avoid mutating session fixture input

* style: simplify delivery identity check

* style: simplify delivery origin spread

* fix: preserve fresh delivery route metadata

* test: assert canonical surface route switch

* fix: canonicalize doctor file-store imports

* fix: preserve transitional delivery migration state

* fix: satisfy canonical delivery CI gates

* ci: scope GitHub App token permissions

* test: infer canonical delivery projections

* test: canonicalize ACP requester delivery fixtures

* test: canonicalize harness rollback fixture

* style: apply pinned formatter
2026-07-24 01:01:19 -07:00

4258 lines
151 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.
/** Tests live model switching behavior in active agent command sessions. */
import { expectDefined } from "@openclaw/normalization-core";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../config/sessions.js";
import {
deliveryContextFromSession,
normalizeSessionDeliveryState,
} from "../utils/delivery-context.shared.js";
import {
buildTestAllowedModelSet,
type CommandSessionEntryFixture,
buildTestConfiguredModelCatalog,
buildTestModelAliasIndex,
createChannelModelRuntimeConfig,
createCommandSessionEntry,
createCommandSessionFixture,
createConfiguredModelCompatRuntimeConfig,
createTestModelVisibilityPolicy,
isTestModelKeyAllowed,
normalizeTestProviderId,
resolveTestConfiguredModelRef,
resolveTestDefaultModelForAgent,
resolveTestModelRefFromString,
} from "./agent-command.live-model-switch.test-helpers.js";
import {
INTERNAL_RUNTIME_CONTEXT_BEGIN,
INTERNAL_RUNTIME_CONTEXT_END,
} from "./internal-runtime-context.js";
import { LiveSessionModelSwitchError } from "./live-model-switch-error.js";
import {
createAgentRunDirectAbortError,
createAgentRunRestartAbortError,
} from "./run-termination.js";
const state = vi.hoisted(() => ({
defaultRuntimeConfig: {
agents: {
defaults: {
models: {
"anthropic/claude": {},
"openai/claude": {},
"openai/gpt-5.4": {},
},
},
},
},
runtimeConfigMock: undefined as unknown,
acpResolveSessionMock: vi.fn((..._args: unknown[]): unknown => null),
acpRunTurnMock: vi.fn((..._args: unknown[]): unknown => undefined),
buildAcpResultMock: vi.fn(),
createAcpVisibleTextAccumulatorMock: vi.fn(),
emitAcpLifecycleEndMock: vi.fn(),
emitAcpLifecycleErrorMock: vi.fn(),
persistCliTurnTranscriptMock: vi.fn(),
persistAcpTurnTranscriptMock: vi.fn(),
runCliTurnCompactionLifecycleMock: vi.fn(),
resolveAcpAgentPolicyErrorMock: vi.fn(),
resolveAcpDispatchPolicyErrorMock: vi.fn(),
resolveAcpExplicitTurnPolicyErrorMock: vi.fn(),
runWithModelFallbackMock: vi.fn(),
runAgentAttemptMock: vi.fn(),
resolveAgentSkillsFilterMock: vi.fn(
(_cfg?: unknown, _agentId?: string): string[] | undefined => undefined,
),
resolveEffectiveModelFallbacksMock: vi.fn().mockReturnValue(undefined),
hasLegacyAutoFallbackWithoutOriginMock: vi.fn((_entry: unknown) => false),
isModelSelectionLockedMock: vi.fn(
(entry: unknown) =>
(entry as { modelSelectionLocked?: boolean } | undefined)?.modelSelectionLocked === true,
),
applyModelOverrideToSessionEntryMock: vi.fn((_params: unknown) => ({ updated: false })),
repairProviderWrappedModelOverrideMock: vi.fn((_params: unknown) => ({ updated: false })),
resolveAutoFallbackPrimaryProbeMock: vi.fn((_params: unknown) => undefined as unknown),
resolveChannelModelOverrideMock: vi.fn((_params: unknown) => null as unknown),
assertLifecycleCurrentMock: vi.fn(),
emitAgentEventMock: vi.fn(),
registerAgentRunContextMock: vi.fn(),
clearAgentRunContextMock: vi.fn(),
loadSessionEntryMock: vi.fn(),
updateSessionStoreAfterAgentRunMock: vi.fn(),
deliverAgentCommandResultMock: vi.fn(),
resolveAgentDeliveryPlanMock: vi.fn(),
resolveAgentDeliveryPlanWithSessionRouteMock: vi.fn(),
resolveAgentOutboundTargetMock: vi.fn(),
resolveMessageChannelSelectionMock: vi.fn(),
createTrajectoryRuntimeRecorderMock: vi.fn(),
trajectoryRecordEventMock: vi.fn(),
trajectoryFlushMock: vi.fn(async () => undefined),
persistSessionEntryMock: vi.fn(async (..._args: unknown[]): Promise<unknown> => undefined),
clearSessionAuthProfileOverrideMock: vi.fn(),
isThinkingLevelSupportedMock: vi.fn((_args: unknown) => true),
resolveSupportedThinkingLevelMock: vi.fn(({ level }: { level?: string }) => level),
resolveThinkingDefaultMock: vi.fn((_args: unknown) => "low"),
loadManifestModelCatalogMock: vi.fn(() => []),
buildWorkspaceSkillSnapshotMock: vi.fn((..._args: unknown[]): unknown => ({
prompt: "",
skills: [],
resolvedSkills: [],
version: 0,
})),
prepareInternalSessionEffectsSessionMock: vi.fn(),
removeInternalSessionEffectsSessionMock: vi.fn(),
authProfileStoreMock: { profiles: {} } as { profiles: Record<string, unknown> },
sessionEntryMock: undefined as SessionEntry | undefined,
sessionStoreMock: undefined as unknown,
storePathMock: undefined as string | undefined,
resolvedSessionKeyMock: undefined as string | undefined,
trajectoryRecorderParamsMock: vi.fn(),
}));
vi.mock("./model-fallback.js", () => ({
runWithModelFallback: (params: unknown) => state.runWithModelFallbackMock(params),
}));
vi.mock("./command/attempt-execution.runtime.js", () => ({
buildAcpResult: (...args: unknown[]) => state.buildAcpResultMock(...args),
createAcpToolLifecycleTracker: () => ({
active: new Map(),
terminalToolCallIds: new Set(),
saturated: false,
}),
createAcpVisibleTextAccumulator: () => state.createAcpVisibleTextAccumulatorMock(),
emitAcpAssistantDelta: vi.fn(),
emitAcpLifecycleEnd: (...args: unknown[]) => state.emitAcpLifecycleEndMock(...args),
emitAcpLifecycleError: (...args: unknown[]) => state.emitAcpLifecycleErrorMock(...args),
emitAcpLifecycleStart: vi.fn(),
emitAcpRuntimeEvent: vi.fn(),
persistCliTurnTranscript: (...args: unknown[]) => state.persistCliTurnTranscriptMock(...args),
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/attempt-execution.shared.js", async () => {
const actual = await vi.importActual<typeof import("./command/attempt-execution.shared.js")>(
"./command/attempt-execution.shared.js",
);
return {
...actual,
persistSessionEntry: (...args: unknown[]) => state.persistSessionEntryMock(...args),
};
});
vi.mock("./command/delivery.runtime.js", () => ({
deliverAgentCommandResult: (...args: unknown[]) => state.deliverAgentCommandResultMock(...args),
}));
vi.mock("./command/cli-compaction.js", () => ({
runCliTurnCompactionLifecycle: (...args: unknown[]) =>
state.runCliTurnCompactionLifecycleMock(...args),
}));
vi.mock("./command/run-context.js", () => ({
resolveAgentRunContext: (opts: {
accountId?: string;
channel?: string;
groupChannel?: string | null;
groupId?: string | null;
groupSpace?: string | null;
messageChannel?: string;
replyChannel?: string;
runContext?: {
accountId?: string;
currentChannelId?: string;
currentThreadTs?: string;
groupChannel?: string | null;
groupId?: string | null;
groupSpace?: string | null;
messageChannel?: string;
replyToMode?: "off" | "first" | "all" | "batched";
};
threadId?: string | number;
to?: string;
}) => ({
messageChannel:
opts.runContext?.messageChannel ?? opts.messageChannel ?? opts.replyChannel ?? opts.channel,
accountId: opts.runContext?.accountId ?? opts.accountId ?? "acct",
groupId: opts.runContext?.groupId ?? opts.groupId,
groupChannel: opts.runContext?.groupChannel ?? opts.groupChannel,
groupSpace: opts.runContext?.groupSpace ?? opts.groupSpace,
currentChannelId: undefined,
currentThreadTs:
opts.runContext?.currentThreadTs ??
(opts.threadId == null ? undefined : String(opts.threadId)),
replyToMode: opts.runContext?.replyToMode,
hasRepliedRef: { current: false },
}),
}));
vi.mock("./command/session-store.runtime.js", () => ({
loadSessionEntry: (...args: unknown[]) => state.loadSessionEntryMock(...args),
loadSessionEntryReadOnly: (...args: unknown[]) => state.loadSessionEntryMock(...args),
updateSessionStoreAfterAgentRun: (...args: unknown[]) =>
state.updateSessionStoreAfterAgentRunMock(...args),
}));
vi.mock("./command/session.js", () => ({
resolveSession: () => {
const sessionEntry: SessionEntry = state.sessionEntryMock ?? {
sessionId: "session-1",
updatedAt: Date.now(),
skillsSnapshot: { prompt: "", skills: [], version: 0 },
};
return {
sessionId: "session-1",
sessionKey: state.resolvedSessionKeyMock ?? "agent:main:main",
sessionEntry,
sessionStore: state.sessionStoreMock,
storePath: state.storePathMock,
isNewSession: false,
persistedThinking:
typeof sessionEntry.thinkingLevel === "string" ? sessionEntry.thinkingLevel : undefined,
persistedVerbose: undefined,
};
},
}));
vi.mock("./command/types.js", () => ({}));
// Recovery ownership has dedicated store-backed coverage. This command suite
// uses an intentionally synthetic session resolver with no durable store path.
vi.mock("./main-session-recovery-store.js", () => ({
claimMainSessionRecoveryOwner: vi.fn(async () => ({ kind: "not_required" })),
inspectMainSessionRecoveryRequired: vi.fn(async () => ({ kind: "not_required" })),
releaseMainSessionRecoveryOwner: vi.fn(async () => undefined),
validateMainSessionRecoveryOwner: vi.fn(async () => true),
}));
vi.mock("./harness/runtime-plugin.js", () => ({
ensureSelectedAgentHarnessPlugin: vi.fn(async () => undefined),
}));
// Harness selection has dedicated coverage; this command suite registers no auto harnesses.
vi.mock("./harness/support.js", () => ({
resolveAutoAgentHarnessId: () => undefined,
}));
vi.mock("../acp/policy.js", () => ({
isAcpEnabledByPolicy: () => true,
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("@openclaw/acp-core/runtime/session-identifiers", () => ({
resolveAcpSessionCwd: () => "/tmp",
}));
vi.mock("../auto-reply/thinking.js", () => ({
formatThinkingLevels: () => "low, medium, high",
normalizeThinkLevel: (v?: string) => v || undefined,
normalizeVerboseLevel: (v?: string) => v || undefined,
isThinkingLevelSupported: (args: unknown) => state.isThinkingLevelSupportedMock(args),
resolveSupportedThinkingLevel: (args: { level?: string }) =>
state.resolveSupportedThinkingLevelMock(args),
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: () => state.runtimeConfigMock ?? state.defaultRuntimeConfig,
readConfigFileSnapshotForWrite: async () => ({
snapshot: { valid: false },
}),
}));
vi.mock("./agent-runtime-config.js", () => {
return {
resolveAgentRuntimeConfig: async () => ({
loadedRaw: state.runtimeConfigMock ?? state.defaultRuntimeConfig,
sourceConfig: state.runtimeConfigMock ?? state.defaultRuntimeConfig,
cfg: state.runtimeConfigMock ?? state.defaultRuntimeConfig,
}),
};
});
vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
isPluginMetadataSnapshotCompatible: () => false,
resolvePluginMetadataSnapshot: () => ({ plugins: [] }),
}));
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 (params: { sessionEntry?: SessionEntry }) => ({
sessionFile: params.sessionEntry?.sessionFile ?? "/tmp/session.jsonl",
sessionEntry: params.sessionEntry ?? { sessionId: "session-1", updatedAt: Date.now() },
}),
}));
vi.mock("./internal-session-effects.js", () => ({
prepareInternalSessionEffectsSession: (...args: unknown[]) =>
state.prepareInternalSessionEffectsSessionMock(...args),
removeInternalSessionEffectsSession: (...args: unknown[]) =>
state.removeInternalSessionEffectsSessionMock(...args),
resolveInternalSessionEffectsTarget: ({ agentId, runId, storePath }: Record<string, string>) => ({
agentId,
sessionId: `internal-${runId}`,
sessionKey: `agent:${agentId}:internal-session-effects:${runId}`,
storePath,
}),
}));
vi.mock("../infra/agent-events.js", () => ({
assertAgentRunLifecycleGenerationCurrent: (...args: unknown[]) =>
state.assertLifecycleCurrentMock(...args),
captureAgentRunLifecycleGeneration: () => "test-generation",
clearAgentRunContext: (...args: unknown[]) => state.clearAgentRunContextMock(...args),
emitAgentEvent: (...args: unknown[]) => state.emitAgentEventMock(...args),
getAgentEventLifecycleGeneration: () => "test-generation",
isAgentEventLifecycleGenerationCurrent: (generation: string) => generation === "test-generation",
onAgentEvent: vi.fn(),
registerAgentEventLifecycleRotationHandler: vi.fn(),
registerAgentRunContext: (...args: unknown[]) => state.registerAgentRunContextMock(...args),
withAgentRunLifecycleGeneration: (_generation: string, run: () => unknown) => run(),
}));
vi.mock("../infra/outbound/session-context.js", () => ({
buildOutboundSessionContext: () => ({}),
}));
vi.mock("../infra/outbound/agent-delivery.js", () => ({
resolveAgentDeliveryPlan: (...args: unknown[]) => state.resolveAgentDeliveryPlanMock(...args),
resolveAgentDeliveryPlanWithSessionRoute: (...args: unknown[]) =>
state.resolveAgentDeliveryPlanWithSessionRouteMock(...args),
resolveAgentOutboundTarget: (...args: unknown[]) => state.resolveAgentOutboundTargetMock(...args),
}));
vi.mock("../infra/outbound/channel-selection.js", () => ({
resolveMessageChannelSelection: (...args: unknown[]) =>
state.resolveMessageChannelSelectionMock(...args),
}));
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;
},
}));
afterAll(() => {
// This suite runs in a shared worker; do not leak its module-level logger
// mock into later files that verify real warning diagnostics.
vi.doUnmock("../logging/subsystem.js");
});
vi.mock("../channels/model-overrides.js", () => ({
resolveChannelModelOverride: (params: unknown) => state.resolveChannelModelOverrideMock(params),
}));
vi.mock("../routing/session-key.js", async () => {
const actual = await vi.importActual<typeof import("../routing/session-key.js")>(
"../routing/session-key.js",
);
return {
...actual,
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: (params: unknown) =>
state.applyModelOverrideToSessionEntryMock(params),
isModelSelectionLocked: (entry: unknown) => state.isModelSelectionLockedMock(entry),
MODEL_SELECTION_LOCKED_MESSAGE: "Model selection is locked for this session.",
ModelSelectionLockedError: class ModelSelectionLockedError extends Error {
constructor() {
super("Model selection is locked for this session.");
this.name = "ModelSelectionLockedError";
}
},
repairProviderWrappedModelOverride: (params: unknown) =>
state.repairProviderWrappedModelOverrideMock(params),
}));
vi.mock("../sessions/send-policy.js", () => ({
resolveSendPolicy: () => "allow",
}));
vi.mock("../terminal/ansi.js", () => ({
sanitizeForLog: (s: string) => s,
}));
vi.mock("../trajectory/runtime.js", () => ({
createTrajectoryRuntimeRecorder: (params: unknown) => {
state.createTrajectoryRuntimeRecorderMock(params);
state.trajectoryRecorderParamsMock(params);
return {
enabled: true,
filePath: "/tmp/session.trajectory.jsonl",
recordEvent: (...args: unknown[]) => state.trajectoryRecordEventMock(...args),
flush: () => state.trajectoryFlushMock(),
};
},
}));
vi.mock("../utils/message-channel.js", () => ({
INTERNAL_MESSAGE_CHANNEL: "internal",
isDeliverableMessageChannel: (value: string) => value !== "internal",
normalizeMessageChannel: (value?: string | null) => value?.trim().toLowerCase() || undefined,
resolveMessageChannel: (...values: Array<string | null | undefined>) =>
values
.find((value) => value?.trim())
?.trim()
.toLowerCase(),
}));
vi.mock("./agent-scope.js", () => ({
clearAutoFallbackPrimaryProbeSelection: vi.fn(),
entryMatchesAutoFallbackPrimaryProbe: () => true,
hasLegacyAutoFallbackWithoutOrigin: (entry: unknown) =>
state.hasLegacyAutoFallbackWithoutOriginMock(entry),
hasSessionAutoModelFallbackProvenance: () => false,
listAgentEntries: () => [],
listAgentIds: () => ["default"],
markAutoFallbackPrimaryProbe: vi.fn(),
resolveAutoFallbackPrimaryProbe: (params: unknown) =>
state.resolveAutoFallbackPrimaryProbeMock(params),
resolveAgentConfig: () => undefined,
resolveAgentDir: () => "/tmp/agent",
resolveAgentEffectiveModelPrimary: (cfg: unknown) => {
const raw = (cfg as { agents?: { defaults?: { model?: string | { primary?: string } } } })
?.agents?.defaults?.model;
return typeof raw === "string" ? raw : raw?.primary;
},
resolveDefaultAgentId: () => "default",
resolveEffectiveModelFallbacks: state.resolveEffectiveModelFallbacksMock,
resolveSessionAgentIds: () => ({ defaultAgentId: "default", sessionAgentId: "default" }),
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",
}));
// Exec eligibility is outside model-switch scope; avoid loading its policy graph.
vi.mock("./exec-defaults.js", () => ({
resolveNodeExecEligibility: () => ({ canExec: false }),
}));
vi.mock("./lanes.js", () => ({
AGENT_LANE_SUBAGENT: "subagent",
}));
vi.mock("./model-catalog.js", () => ({
loadManifestModelCatalog: state.loadManifestModelCatalogMock,
}));
vi.mock("./model-selection.js", () => ({
buildAllowedModelSet: buildTestAllowedModelSet,
createModelVisibilityPolicy: createTestModelVisibilityPolicy,
buildConfiguredModelCatalog: ({ cfg }: { cfg?: unknown }) => buildTestConfiguredModelCatalog(cfg),
isModelKeyAllowedBySet: isTestModelKeyAllowed,
buildModelAliasIndex: buildTestModelAliasIndex,
modelKey: (provider: string, model: string) => `${provider}/${model}`,
normalizeModelRef: (provider: string, model: string) => ({
provider: normalizeTestProviderId(provider),
model,
}),
normalizeProviderId: normalizeTestProviderId,
normalizeProviderIdForAuth: normalizeTestProviderId,
parseModelRef: (model: string, provider: string) => {
const slash = model.indexOf("/");
return slash > 0
? { provider: model.slice(0, slash), model: model.slice(slash + 1) }
: { provider, model };
},
resolveModelRefFromString: resolveTestModelRefFromString,
resolveConfiguredModelRef: resolveTestConfiguredModelRef,
resolveDefaultModelForAgent: resolveTestDefaultModelForAgent,
resolveThinkingDefault: (args: unknown) => state.resolveThinkingDefaultMock(args),
}));
vi.mock("./model-visibility-policy.js", () => ({
createModelVisibilityPolicy: createTestModelVisibilityPolicy,
}));
vi.mock("./provider-auth-aliases.js", () => ({
resolveProviderAuthAliasMap: () => ({}),
resolveProviderIdForAuth: (provider: string) =>
provider.trim().toLowerCase() === "codex-cli" ? "openai" : provider.trim().toLowerCase(),
}));
vi.mock("../skills/discovery/agent-filter.js", () => ({
resolveEffectiveAgentSkillFilter: (_cfg: unknown, agentId: string) =>
state.resolveAgentSkillsFilterMock(_cfg, agentId),
}));
vi.mock("../skills/runtime/remote.js", () => ({
getRemoteSkillEligibility: () => ({ eligible: false }),
}));
vi.mock("../skills/runtime/session-snapshot.js", () => ({
resolveReusableWorkspaceSkillSnapshot: (params: {
workspaceDir: string;
existingSnapshot?: { resolvedSkills?: unknown };
skillFilter?: string[];
}) => {
if (params.skillFilter !== undefined && params.skillFilter.length === 0) {
return {
snapshot: {
prompt: "",
skills: [],
resolvedSkills: [],
skillFilter: params.skillFilter,
version: 0,
},
shouldRefresh: !params.existingSnapshot,
snapshotVersion: 0,
};
}
if (params.existingSnapshot?.resolvedSkills !== undefined) {
return {
snapshot: params.existingSnapshot,
shouldRefresh: false,
snapshotVersion: 0,
};
}
const rebuilt = state.buildWorkspaceSkillSnapshotMock(params.workspaceDir, params) as {
resolvedSkills?: unknown;
};
return {
snapshot: params.existingSnapshot
? { ...params.existingSnapshot, resolvedSkills: rebuilt?.resolvedSkills }
: rebuilt,
shouldRefresh: !params.existingSnapshot,
snapshotVersion: 0,
};
},
}));
vi.mock("./spawned-context.js", () => ({
normalizeSpawnedRunMetadata: (meta: unknown) => meta ?? {},
}));
vi.mock("./timeout.js", () => ({
resolveAgentTimeoutMs: ({ overrideSeconds }: { overrideSeconds?: number | null }) =>
typeof overrideSeconds === "number" && Number.isFinite(overrideSeconds)
? overrideSeconds === 0
? 2_147_483_647
: Math.max(overrideSeconds * 1000, 1)
: 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;
let agentCommandTesting: typeof import("./agent-command.js").testing;
beforeAll(async () => {
const mod = await import("./agent-command.js");
agentCommand ??= mod.agentCommand;
agentCommandTesting ??= mod.testing;
});
type FallbackRunnerParams = {
provider: string;
model: string;
sessionId?: string;
fallbacksOverride?: string[];
resolveAgentHarnessRuntimeOverride?: (provider: string, model: string) => string | undefined;
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: [],
};
});
}
function setupSingleAttemptFallback() {
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
const result = await params.run(params.provider, params.model);
return {
result,
provider: params.provider,
model: params.model,
attempts: [],
};
});
}
function setupSuccessfulAttempt(provider = "openai", model = "gpt-5.4"): void {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult(provider, model));
}
function setupAcpSession(): void {
state.acpResolveSessionMock.mockReturnValue({
kind: "ready",
meta: { agent: "claude", cwd: "/tmp/workspace" },
});
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object") {
throw new Error(`expected ${label} to be an object`);
}
return value as Record<string, unknown>;
}
function requireArray(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) {
throw new Error(`expected ${label} to be an array`);
}
return value;
}
function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0): unknown {
const call = mock.mock.calls[callIndex] as unknown[] | undefined;
if (!call) {
throw new Error(`expected mock call ${callIndex}`);
}
return call[argIndex];
}
function expectRecordFields(value: unknown, expected: Record<string, unknown>): void {
const actual = requireRecord(value, "record");
for (const [key, expectedValue] of Object.entries(expected)) {
expect(actual[key]).toEqual(expectedValue);
}
}
async function runBasicAgentCommand() {
await agentCommand({
message: "hello",
to: "+1234567890",
});
}
function runDiscordDelivery(overrides: Partial<Parameters<typeof agentCommand>[0]> = {}) {
return agentCommand({
message: "hello",
channel: "discord",
to: "discord:dm:123",
accountId: "main",
deliver: true,
...overrides,
});
}
function runInternalModelCommand(runId: string) {
return agentCommand({
message: "probe",
to: "+1234567890",
runId,
modelRun: true,
promptMode: "none",
sessionEffects: "internal",
});
}
function setupStoredSession(
overrides: CommandSessionEntryFixture = {},
storePath = "/tmp/openclaw-sessions.json",
sessionKey = "agent:main:main",
): { entry: SessionEntry; store: Record<string, SessionEntry> } {
const fixture = createCommandSessionFixture(overrides, sessionKey);
state.sessionEntryMock = fixture.entry;
state.sessionStoreMock = fixture.store;
state.storePathMock = storePath;
return fixture;
}
function setupBareStoredSession(
overrides: CommandSessionEntryFixture = {},
storePath = "/tmp/openclaw-sessions.json",
sessionKey = "agent:main:main",
): { entry: SessionEntry; store: Record<string, SessionEntry> } {
const entry = createCommandSessionEntry(overrides);
const store = { [sessionKey]: entry };
state.sessionEntryMock = entry;
state.sessionStoreMock = store;
state.storePathMock = storePath;
return { entry, store };
}
function expectFallbackOverrideCalls(first: boolean, second: boolean) {
expect(state.resolveEffectiveModelFallbacksMock).toHaveBeenCalledTimes(2);
expectRecordFields(mockCallArg(state.resolveEffectiveModelFallbacksMock, 0), {
hasSessionModelOverride: first,
});
expectRecordFields(mockCallArg(state.resolveEffectiveModelFallbacksMock, 1), {
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.runtimeConfigMock = undefined;
delete (state.defaultRuntimeConfig.agents as { list?: unknown }).list;
state.isThinkingLevelSupportedMock.mockReturnValue(true);
state.resolveSupportedThinkingLevelMock.mockImplementation(
({ level }: { level?: string }) => level,
);
state.resolveThinkingDefaultMock.mockReturnValue("low");
state.resolveAgentSkillsFilterMock.mockReturnValue(undefined);
state.loadManifestModelCatalogMock.mockReturnValue([]);
state.hasLegacyAutoFallbackWithoutOriginMock.mockReturnValue(false);
state.isModelSelectionLockedMock.mockImplementation(
(entry: unknown) =>
(entry as { modelSelectionLocked?: boolean } | undefined)?.modelSelectionLocked === true,
);
state.applyModelOverrideToSessionEntryMock.mockReturnValue({ updated: false });
state.repairProviderWrappedModelOverrideMock.mockReturnValue({ updated: false });
state.resolveAutoFallbackPrimaryProbeMock.mockReturnValue(undefined);
state.resolveChannelModelOverrideMock.mockImplementation((params: unknown) => {
const input = params as {
cfg?: { channels?: { modelByChannel?: Record<string, Record<string, string>> } };
channel?: string;
groupId?: string;
parentSessionKey?: string;
};
const channel = input.channel?.trim().toLowerCase();
const entries = channel ? input.cfg?.channels?.modelByChannel?.[channel] : undefined;
if (!entries) {
return null;
}
const direct = input.groupId ? entries[input.groupId] : undefined;
if (direct) {
return { channel, model: direct, matchKey: input.groupId };
}
const parentChannel = input.parentSessionKey?.match(/:channel:([^:]+)/u)?.[1];
const parent = parentChannel ? entries[parentChannel] : undefined;
return parent ? { channel, model: parent, matchKey: parentChannel } : 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.persistCliTurnTranscriptMock.mockImplementation(
async (params: { sessionEntry?: unknown }) => ({
kind: "persisted",
sessionEntry: params.sessionEntry,
}),
);
state.persistAcpTurnTranscriptMock.mockImplementation(
async (params: { sessionEntry?: unknown }) => ({
kind: "persisted",
sessionEntry: params.sessionEntry,
}),
);
state.runCliTurnCompactionLifecycleMock.mockImplementation(
async (params: { sessionEntry?: unknown }) => params.sessionEntry,
);
state.authProfileStoreMock = { profiles: {} };
state.sessionEntryMock = undefined;
state.sessionStoreMock = undefined;
state.storePathMock = undefined;
state.resolvedSessionKeyMock = undefined;
state.persistSessionEntryMock.mockImplementation(async (...args: unknown[]) => {
const params = args[0] as {
sessionStore?: Record<string, unknown>;
sessionKey?: string;
entry?: unknown;
shouldPersist?: (entry: unknown) => boolean;
};
const current =
params.sessionStore && params.sessionKey
? params.sessionStore[params.sessionKey]
: undefined;
if (params.shouldPersist && !params.shouldPersist(current)) {
if (current === undefined && params.sessionStore && params.sessionKey) {
delete params.sessionStore[params.sessionKey];
}
return current;
}
if (params.sessionStore && params.sessionKey && params.entry) {
params.sessionStore[params.sessionKey] = params.entry;
return params.entry;
}
return current;
});
state.buildWorkspaceSkillSnapshotMock.mockReturnValue({
prompt: "",
skills: [],
resolvedSkills: [],
version: 0,
});
state.deliverAgentCommandResultMock.mockResolvedValue(undefined);
state.resolveAgentOutboundTargetMock.mockImplementation(
(params: { plan?: { resolvedTo?: string }; targetMode?: string }) => ({
resolvedTarget: null,
resolvedTo: params.plan?.resolvedTo,
targetMode: params.targetMode ?? "implicit",
}),
);
state.resolveMessageChannelSelectionMock.mockRejectedValue(new Error("channel required"));
state.loadSessionEntryMock.mockReset().mockImplementation((params: { sessionKey?: string }) => {
const sessionKey = params.sessionKey ?? state.resolvedSessionKeyMock ?? "agent:main:main";
return (state.sessionStoreMock as Record<string, SessionEntry> | undefined)?.[sessionKey];
});
state.resolveAgentDeliveryPlanMock.mockImplementation(
(params: {
accountId?: string;
explicitThreadId?: string | number;
explicitTo?: string;
requestedChannel?: string;
sessionEntry?: SessionEntry;
}) => {
const context = deliveryContextFromSession(params.sessionEntry);
const channel = params.requestedChannel ?? context?.channel ?? "internal";
const to = params.explicitTo ?? context?.to;
const accountId = params.accountId ?? context?.accountId;
const threadId = params.explicitThreadId ?? context?.threadId;
return {
baseDelivery: {},
resolvedChannel: channel,
resolvedTo: to,
resolvedAccountId: accountId,
resolvedThreadId: threadId,
deliveryTargetMode: params.explicitTo ? "explicit" : to ? "implicit" : undefined,
};
},
);
state.resolveAgentDeliveryPlanWithSessionRouteMock.mockImplementation((params: unknown) =>
state.resolveAgentDeliveryPlanMock(params),
);
state.updateSessionStoreAfterAgentRunMock.mockResolvedValue(undefined);
state.trajectoryFlushMock.mockResolvedValue(undefined);
state.prepareInternalSessionEffectsSessionMock.mockResolvedValue({
agentId: "default",
sessionId: "internal-session",
sessionKey: "agent:default:internal-session-effects:run",
storePath: "/tmp/openclaw-session-store.json",
sessionFile: "sqlite:default:internal-session:/tmp/openclaw-session-store.json",
sessionEntry: { sessionId: "internal-session", updatedAt: 1 },
});
state.removeInternalSessionEffectsSessionMock.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 = mockCallArg(state.runWithModelFallbackMock, 1) as FallbackRunnerParams;
expect(secondCall.provider).toBe("openai");
expect(secondCall.model).toBe("gpt-5.4");
expect(secondCall.sessionId).toBe("session-1");
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);
const lifecycleFinishingCalls = state.emitAgentEventMock.mock.calls.filter(
(call: unknown[]) => {
const arg = call[0] as { stream?: string; data?: { phase?: string } };
return arg?.stream === "lifecycle" && arg?.data?.phase === "finishing";
},
);
expect(lifecycleFinishingCalls.length).toBeGreaterThanOrEqual(1);
expectRecordFields(mockCallArg(state.runAgentAttemptMock), {
deferTerminalLifecycle: true,
});
const firstFinishingIndex = state.emitAgentEventMock.mock.calls.findIndex((call: unknown[]) => {
const arg = call[0] as { stream?: string; data?: { phase?: string } };
return arg?.stream === "lifecycle" && arg?.data?.phase === "finishing";
});
const lastEndIndex = state.emitAgentEventMock.mock.calls.findLastIndex((call: unknown[]) => {
const arg = call[0] as { stream?: string; data?: { phase?: string } };
return arg?.stream === "lifecycle" && arg?.data?.phase === "end";
});
expect(state.deliverAgentCommandResultMock).toHaveBeenCalledTimes(1);
const deliveryOrder = state.deliverAgentCommandResultMock.mock.invocationCallOrder[0] ?? 0;
expect(
state.emitAgentEventMock.mock.invocationCallOrder[firstFinishingIndex] ?? 0,
).toBeLessThan(deliveryOrder);
expect(deliveryOrder).toBeLessThan(
state.emitAgentEventMock.mock.invocationCallOrder[lastEndIndex] ?? 0,
);
});
it("forwards the auth profile bound to the configured default model", async () => {
state.runtimeConfigMock = {
agents: {
defaults: {
model: { primary: "anthropic/claude@anthropic:verified" },
models: { "anthropic/claude": {} },
},
},
};
state.sessionEntryMock = createCommandSessionEntry({
sessionId: "session-1",
updatedAt: Date.now(),
authProfileOverride: "anthropic:stale-auto",
authProfileOverrideSource: "auto",
skillsSnapshot: { prompt: "", skills: [], version: 0 },
});
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude"));
await runBasicAgentCommand();
expectRecordFields(mockCallArg(state.runAgentAttemptMock), {
providerOverride: "anthropic",
modelOverride: "claude",
configuredAuthProfileId: "anthropic:verified",
});
});
it("retries a same-model switch with the runtime carried by the error", async () => {
const sessionEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: 1,
agentRuntimeOverride: "openclaw",
};
state.sessionEntryMock = sessionEntry;
state.sessionStoreMock = { "agent:main:main": sessionEntry };
state.storePathMock = "/tmp/openclaw-sessions.json";
setupModelSwitchRetry({
provider: "openai",
model: "gpt-5.4",
agentRuntimeOverride: "codex",
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
await runBasicAgentCommand();
const retry = mockCallArg(state.runWithModelFallbackMock, 1) as FallbackRunnerParams;
expect(retry.resolveAgentHarnessRuntimeOverride?.("openai", "gpt-5.4")).toBe("codex");
expectRecordFields(mockCallArg(state.runAgentAttemptMock), {
providerOverride: "openai",
modelOverride: "gpt-5.4",
agentHarnessRuntimeOverride: "codex",
});
});
it("rejects live model switches for locked sessions without retrying", async () => {
setupModelSwitchRetry({
provider: "openai",
model: "gpt-5.4",
});
state.sessionEntryMock = createCommandSessionEntry({
sessionId: "session-1",
updatedAt: 1,
modelSelectionLocked: true,
skillsSnapshot: { prompt: "", skills: [], version: 0 },
});
state.isModelSelectionLockedMock.mockReturnValue(true);
await expect(runBasicAgentCommand()).rejects.toMatchObject({
name: "ModelSelectionLockedError",
message: "Model selection is locked for this session.",
});
expect(state.runWithModelFallbackMock).toHaveBeenCalledTimes(1);
expect(state.runAgentAttemptMock).not.toHaveBeenCalled();
expect(state.trajectoryFlushMock).toHaveBeenCalledTimes(1);
});
it("pins catalog-adopted direct runs before fallback preflight", async () => {
setupSingleAttemptFallback();
state.runtimeConfigMock = {
agents: {
defaults: {
models: state.defaultRuntimeConfig.agents.defaults.models,
},
},
};
state.sessionEntryMock = createCommandSessionEntry({
sessionId: "session-1",
updatedAt: 1,
agentHarnessId: "codex",
modelSelectionLocked: true,
pluginExtensions: {
codex: {
supervision: {
sourceThreadId: "019f-codex-thread",
modelLocked: true,
},
},
},
skillsSnapshot: { prompt: "", skills: [], version: 0 },
});
state.isModelSelectionLockedMock.mockReturnValue(true);
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude"));
await runBasicAgentCommand();
const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams;
expect(fallbackParams.resolveAgentHarnessRuntimeOverride?.("anthropic", "claude")).toBe(
"codex",
);
expect(fallbackParams.fallbacksOverride).toEqual([]);
expectRecordFields(mockCallArg(state.runAgentAttemptMock), {
providerOverride: "anthropic",
modelOverride: "claude",
agentHarnessRuntimeOverride: "codex",
sessionEntry: expect.objectContaining({
agentHarnessId: "codex",
modelSelectionLocked: true,
}),
});
});
it("skips legacy override repair when continuing an ordinary locked harness session", async () => {
setupSingleAttemptFallback();
state.resolvedSessionKeyMock = "agent:main:plugin-owned";
state.hasLegacyAutoFallbackWithoutOriginMock.mockReturnValue(true);
state.sessionEntryMock = createCommandSessionEntry({
sessionId: "session-1",
updatedAt: 1,
agentHarnessId: "codex",
modelSelectionLocked: true,
providerOverride: "openai",
modelOverride: "stale-fallback-model",
modelOverrideSource: "auto",
skillsSnapshot: { prompt: "", skills: [], version: 0 },
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude"));
await runBasicAgentCommand();
expect(state.applyModelOverrideToSessionEntryMock).not.toHaveBeenCalled();
expect(state.repairProviderWrappedModelOverrideMock).not.toHaveBeenCalled();
expectRecordFields(mockCallArg(state.runAgentAttemptMock), {
agentHarnessRuntimeOverride: "codex",
sessionEntry: expect.objectContaining({
agentHarnessId: "codex",
modelSelectionLocked: true,
providerOverride: "openai",
modelOverride: "stale-fallback-model",
modelOverrideSource: "auto",
}),
});
});
it("keeps the fast mode cutoff timestamp across live model switch retries", async () => {
let invocation = 0;
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
invocation++;
const result = await params.run(params.provider, params.model);
if (invocation === 1) {
throw new LiveSessionModelSwitchError({
provider: "openai",
model: "gpt-5.4",
});
}
return {
result,
provider: params.provider,
model: params.model,
attempts: [],
};
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
await runBasicAgentCommand();
const firstAttempt = mockCallArg(state.runAgentAttemptMock, 0) as {
fastModeStartedAtMs?: number;
};
const secondAttempt = mockCallArg(state.runAgentAttemptMock, 1) as {
fastModeStartedAtMs?: number;
};
expect(firstAttempt.fastModeStartedAtMs).toBe(secondAttempt.fastModeStartedAtMs);
});
it("reuses durable user-turn proof across live model switch retries", async () => {
let fallbackInvocation = 0;
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
fallbackInvocation += 1;
const result = await params.run(params.provider, params.model);
if (fallbackInvocation === 1) {
throw new LiveSessionModelSwitchError({
provider: "openai",
model: "gpt-5.4",
});
}
return {
result,
provider: params.provider,
model: params.model,
attempts: [],
};
});
state.runAgentAttemptMock.mockImplementation(async (attemptParams: unknown) => {
const attempt = attemptParams as {
userTurnTranscriptRecorder?: {
markRuntimePersisted: (message: { role: "user"; content: string }) => void;
};
};
if (state.runAgentAttemptMock.mock.calls.length === 1) {
attempt.userTurnTranscriptRecorder?.markRuntimePersisted({
role: "user",
content: "hello",
});
}
return makeSuccessResult("openai", "gpt-5.4");
});
await runBasicAgentCommand();
const firstAttempt = mockCallArg(state.runAgentAttemptMock, 0) as {
suppressPromptPersistenceOnRetry?: boolean;
userTurnTranscriptRecorder?: unknown;
};
const secondAttempt = mockCallArg(state.runAgentAttemptMock, 1) as {
suppressPromptPersistenceOnRetry?: boolean;
userTurnTranscriptRecorder?: unknown;
};
expect(secondAttempt.userTurnTranscriptRecorder).toBe(firstAttempt.userTurnTranscriptRecorder);
expect(firstAttempt.suppressPromptPersistenceOnRetry).toBe(false);
expect(secondAttempt.suppressPromptPersistenceOnRetry).toBe(true);
});
it("uses an embedded queue rebound generation for terminal lifecycle and cleanup", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockImplementation(async (attemptParams: unknown) => {
(
attemptParams as {
onLifecycleGenerationChanged?: (lifecycleGeneration: string) => void;
}
).onLifecycleGenerationChanged?.("post-restart-generation");
return makeSuccessResult("openai", "gpt-5.4");
});
await runBasicAgentCommand();
const lifecycleEnd = state.emitAgentEventMock.mock.calls
.map(
(call) =>
call[0] as {
stream?: string;
data?: { phase?: string };
lifecycleGeneration?: string;
},
)
.find((event) => event.stream === "lifecycle" && event.data?.phase === "end");
expect(lifecycleEnd?.lifecycleGeneration).toBe("post-restart-generation");
expect(state.clearAgentRunContextMock).toHaveBeenCalledWith(
expect.any(String),
"post-restart-generation",
);
});
it("uses an embedded queue rebound generation for cleanup when the attempt fails", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockImplementation(async (attemptParams: unknown) => {
(
attemptParams as {
onLifecycleGenerationChanged?: (lifecycleGeneration: string) => void;
}
).onLifecycleGenerationChanged?.("post-restart-generation");
throw new Error("attempt failed after queue rebound");
});
await expect(runBasicAgentCommand()).rejects.toThrow("attempt failed after queue rebound");
expect(state.clearAgentRunContextMock).toHaveBeenCalledWith(
expect.any(String),
"post-restart-generation",
);
});
it("preserves restart ownership when an aborted attempt resolves normally", async () => {
setupSingleAttemptFallback();
const controller = new AbortController();
state.runAgentAttemptMock.mockImplementation(async () => {
controller.abort(createAgentRunRestartAbortError());
return {
payloads: [],
meta: {
durationMs: 100,
aborted: true,
stopReason: "end_turn",
agentMeta: { provider: "anthropic", model: "claude" },
},
};
});
await expect(
agentCommand({
message: "hello",
to: "+1234567890",
abortSignal: controller.signal,
}),
).rejects.toThrow("agent run aborted for restart");
const lifecycleEvents = state.emitAgentEventMock.mock.calls
.map((call) => call[0] as { stream?: string; data?: Record<string, unknown> })
.filter((event) => event.stream === "lifecycle");
expect(lifecycleEvents).toEqual(
expect.arrayContaining([
expect.objectContaining({
data: expect.objectContaining({
phase: "error",
aborted: true,
stopReason: "restart",
}),
}),
]),
);
expect(state.deliverAgentCommandResultMock).not.toHaveBeenCalled();
});
it("preserves restart ownership when an aborted ACP turn resolves normally", async () => {
setupAcpSession();
const controller = new AbortController();
controller.abort(createAgentRunRestartAbortError());
await expect(
agentCommand({
message: "hello",
sessionKey: "agent:main:main",
abortSignal: controller.signal,
}),
).rejects.toThrow("agent run aborted for restart");
expect(state.emitAcpLifecycleEndMock).not.toHaveBeenCalled();
expect(state.deliverAgentCommandResultMock).not.toHaveBeenCalled();
});
it("suppresses ACP delivery when restart begins during transcript persistence", async () => {
setupAcpSession();
const controller = new AbortController();
state.persistAcpTurnTranscriptMock.mockImplementation(
async (params: { sessionEntry?: unknown }) => {
controller.abort(createAgentRunRestartAbortError());
return { kind: "persisted", sessionEntry: params.sessionEntry };
},
);
await expect(
agentCommand({
message: "hello",
sessionKey: "agent:main:main",
abortSignal: controller.signal,
}),
).rejects.toThrow("agent run aborted for restart");
expect(state.emitAcpLifecycleEndMock).not.toHaveBeenCalled();
expect(state.emitAcpLifecycleErrorMock).toHaveBeenCalledWith(
expect.objectContaining({
runId: "session-1",
sessionKey: "agent:main:main",
}),
);
const lifecycleError = state.emitAcpLifecycleErrorMock.mock.calls[0]?.[0] as
| { abortSignal?: AbortSignal }
| undefined;
expect(lifecycleError?.abortSignal?.aborted).toBe(true);
expect(lifecycleError?.abortSignal?.reason).toBe(controller.signal.reason);
expect(state.persistAcpTurnTranscriptMock).toHaveBeenCalledTimes(1);
expect(state.buildAcpResultMock).not.toHaveBeenCalled();
expect(state.deliverAgentCommandResultMock).not.toHaveBeenCalled();
});
it("threads lifecycle ownership into ACP delivery", async () => {
setupAcpSession();
await agentCommand({
message: "hello",
sessionKey: "agent:main:main",
});
const deliveryParams = requireRecord(
mockCallArg(state.deliverAgentCommandResultMock),
"ACP delivery params",
);
expect(deliveryParams.assertDeliveryCurrent).toBeTypeOf("function");
(deliveryParams.assertDeliveryCurrent as () => void)();
expect(state.assertLifecycleCurrentMock).toHaveBeenLastCalledWith("test-generation");
});
it("persists structured transcript media for ACP turns", async () => {
setupAcpSession();
await agentCommand({
message: "[media attached: media://inbound/image-1]",
transcriptMessage: "",
transcriptMedia: [{ path: "/media/inbound/image-1.png", contentType: "image/png" }],
sessionKey: "agent:main:main",
});
expect(state.persistAcpTurnTranscriptMock).toHaveBeenCalledWith(
expect.objectContaining({
transcriptBody: "",
userInput: {
text: "",
media: [{ path: "/media/inbound/image-1.png", contentType: "image/png" }],
},
}),
);
});
it("keeps the initial session touch for local runs", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
setupStoredSession();
await runBasicAgentCommand();
const touchWrites = state.persistSessionEntryMock.mock.calls.filter((call) => {
const entry = (call[0] as { entry?: Record<string, unknown> } | undefined)?.entry;
return entry?.lastInteractionAt !== undefined;
});
expect(touchWrites).toHaveLength(1);
expect(state.updateSessionStoreAfterAgentRunMock).toHaveBeenCalledTimes(1);
});
it("threads lifecycle ownership into normal delivery", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
setupStoredSession();
await runBasicAgentCommand();
const deliveryParams = requireRecord(
mockCallArg(state.deliverAgentCommandResultMock),
"delivery params",
);
expect(deliveryParams.assertDeliveryCurrent).toBeTypeOf("function");
(deliveryParams.assertDeliveryCurrent as () => void)();
expect(state.assertLifecycleCurrentMock).toHaveBeenLastCalledWith("test-generation");
});
it("passes explicit timeout overrides into agent attempts", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
await agentCommand({
message: "hello",
to: "+1234567890",
timeout: "600",
});
expectRecordFields(mockCallArg(state.runAgentAttemptMock), {
timeoutMs: 600_000,
runTimeoutOverrideMs: 600_000,
});
});
it("clamps unsupported explicit thinking for subagent spawns instead of throwing", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude-fable-5"));
state.resolvedSessionKeyMock = "agent:planner:subagent:00000000-0000-4000-8000-000000000000";
state.isThinkingLevelSupportedMock.mockReturnValue(false);
state.resolveSupportedThinkingLevelMock.mockReturnValue("high");
await agentCommand({
message: "hello",
sessionKey: state.resolvedSessionKeyMock,
thinking: "xhigh",
lane: "subagent",
});
expect(state.resolveSupportedThinkingLevelMock).toHaveBeenCalled();
expectRecordFields(mockCallArg(state.runAgentAttemptMock), {
resolvedThinkLevel: "high",
});
});
it.each([
{
name: "rejects unsupported explicit thinking for interactive subagent-key runs",
sessionKey: "agent:planner:subagent:00000000-0000-4000-8000-000000000000",
lane: undefined,
},
{
name: "rejects unsupported explicit thinking for non-subagent sessions on the subagent lane",
sessionKey: "agent:main:main",
lane: "subagent" as const,
},
])("$name", async ({ sessionKey, lane }) => {
setupSuccessfulAttempt("anthropic", "claude-fable-5");
state.resolvedSessionKeyMock = sessionKey;
state.isThinkingLevelSupportedMock.mockReturnValue(false);
await expect(
agentCommand({
message: "hello",
sessionKey: state.resolvedSessionKeyMock,
thinking: "xhigh",
lane,
}),
).rejects.toThrow(/is not supported/u);
expect(state.runAgentAttemptMock).not.toHaveBeenCalled();
});
it("rejects unsupported explicit thinking for direct interactive runs", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude-fable-5"));
state.resolvedSessionKeyMock = "agent:main:main";
state.isThinkingLevelSupportedMock.mockReturnValue(false);
const { store } = setupStoredSession({ thinkingLevel: "low" });
await expect(
agentCommand({
message: "hello",
to: "+1234567890",
thinking: "ultra",
}),
).rejects.toThrow(/is not supported/u);
expect(state.runAgentAttemptMock).not.toHaveBeenCalled();
expect(store["agent:main:main"]?.thinkingLevel).toBe("low");
expect(state.persistSessionEntryMock).not.toHaveBeenCalledWith(
expect.objectContaining({
entry: expect.objectContaining({ thinkingLevel: "ultra" }),
}),
);
});
it("skips the initial session touch after gateway ingress already persisted activity", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
setupStoredSession();
await agentCommand({
message: "hello",
to: "+1234567890",
skipInitialSessionTouch: true,
});
const touchWrites = state.persistSessionEntryMock.mock.calls.filter((call) => {
const entry = (call[0] as { entry?: Record<string, unknown> } | undefined)?.entry;
return entry?.lastInteractionAt !== undefined;
});
expect(touchWrites).toHaveLength(0);
expect(state.updateSessionStoreAfterAgentRunMock).toHaveBeenCalledTimes(1);
});
it.each([
{
name: "uses channel model override as the initial run model for channel-backed sessions",
sessionEntry: createCommandSessionFixture({ channel: "discord", groupId: "channel-123" })
.entry,
command: undefined,
legacyFallback: false,
expectResolverContext: true,
expectAttemptModel: true,
},
{
name: "uses current run channel context when persisted session metadata is absent",
sessionEntry: undefined,
command: {
message: "hello",
channel: "discord",
groupId: "channel-123",
to: "discord:channel:channel-123",
},
legacyFallback: false,
expectResolverContext: false,
expectAttemptModel: false,
},
{
name: "keeps persisted channel model override when current run context is internal",
sessionEntry: createCommandSessionFixture({ channel: "discord", groupId: "channel-123" })
.entry,
command: {
message: "hello",
channel: "internal",
messageChannel: "internal",
to: "internal",
},
legacyFallback: false,
expectResolverContext: true,
expectAttemptModel: false,
},
{
name: "uses channel model override after ignoring stale legacy fallback overrides",
sessionEntry: createCommandSessionFixture({
channel: "discord",
groupId: "channel-123",
providerOverride: "anthropic",
modelOverride: "stale-fallback-model",
}).entry,
command: undefined,
legacyFallback: true,
expectResolverContext: false,
expectAttemptModel: false,
},
])(
"$name",
async ({
sessionEntry,
command,
legacyFallback,
expectResolverContext,
expectAttemptModel,
}) => {
setupSuccessfulAttempt("openai", "channel-model");
state.runtimeConfigMock = createChannelModelRuntimeConfig();
state.sessionEntryMock = sessionEntry;
state.hasLegacyAutoFallbackWithoutOriginMock.mockReturnValue(legacyFallback);
await (command ? agentCommand(command) : runBasicAgentCommand());
if (expectResolverContext) {
expect(mockCallArg(state.resolveChannelModelOverrideMock)).toMatchObject({
channel: "discord",
groupId: "channel-123",
});
}
if (expectAttemptModel) {
expectRecordFields(mockCallArg(state.runAgentAttemptMock), {
providerOverride: "openai",
modelOverride: "channel-model",
});
}
const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams;
expect(fallbackParams.provider).toBe("openai");
expect(fallbackParams.model).toBe("channel-model");
},
);
it("uses a concurrent user override adopted during legacy fallback repair", async () => {
setupSingleAttemptFallback();
state.applyModelOverrideToSessionEntryMock.mockImplementation((params: unknown) => {
const { entry } = params as { entry: SessionEntry };
delete entry.providerOverride;
delete entry.modelOverride;
delete entry.modelOverrideSource;
return { updated: true };
});
state.hasLegacyAutoFallbackWithoutOriginMock.mockImplementation(
(entry: unknown) =>
(entry as SessionEntry | undefined)?.modelOverride === "stale-fallback-model",
);
state.runtimeConfigMock = {
agents: {
defaults: {
model: "anthropic/default-model",
models: {
"anthropic/default-model": {},
"anthropic/stale-fallback-model": {},
"google/gemini-3-pro": {},
},
},
},
};
const sessionEntry = {
sessionId: "session-1",
updatedAt: 1,
providerOverride: "anthropic",
modelOverride: "stale-fallback-model",
modelOverrideSource: "auto",
skillsSnapshot: { prompt: "", skills: [], version: 0 },
} satisfies SessionEntry;
state.sessionEntryMock = sessionEntry;
state.sessionStoreMock = { "agent:main:main": sessionEntry };
state.storePathMock = "/tmp/openclaw-session-store.json";
state.persistSessionEntryMock.mockImplementation(async (...args: unknown[]) => {
const params = args[0] as { entry?: SessionEntry };
if (params.entry?.modelOverride === "stale-fallback-model") {
return params.entry;
}
return {
...sessionEntry,
updatedAt: 2,
providerOverride: "google",
modelOverride: "gemini-3-pro",
modelOverrideSource: "user",
};
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("google", "gemini-3-pro"));
await runBasicAgentCommand();
const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams;
expect(fallbackParams.provider).toBe("google");
expect(fallbackParams.model).toBe("gemini-3-pro");
});
it("probes the channel primary when a session is pinned to an auto fallback", async () => {
setupSingleAttemptFallback();
state.resolveAutoFallbackPrimaryProbeMock.mockReturnValue({
provider: "openai",
model: "channel-model",
fallbackProvider: "anthropic",
fallbackModel: "fallback-model",
});
state.runtimeConfigMock = createChannelModelRuntimeConfig({
additionalModels: { "anthropic/fallback-model": {} },
});
state.sessionEntryMock = createCommandSessionEntry({
sessionId: "session-1",
updatedAt: 1,
channel: "discord",
groupId: "channel-123",
providerOverride: "anthropic",
modelOverride: "fallback-model",
modelOverrideSource: "auto",
modelOverrideFallbackOriginProvider: "openai",
modelOverrideFallbackOriginModel: "channel-model",
skillsSnapshot: { prompt: "", skills: [], version: 0 },
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "channel-model"));
await runBasicAgentCommand();
expectRecordFields(mockCallArg(state.resolveAutoFallbackPrimaryProbeMock), {
primaryProvider: "openai",
primaryModel: "channel-model",
});
const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams;
expect(fallbackParams.provider).toBe("openai");
expect(fallbackParams.model).toBe("channel-model");
});
it("uses current threaded session key for parent channel model overrides", async () => {
setupSingleAttemptFallback();
state.runtimeConfigMock = createChannelModelRuntimeConfig({
channel: "slack",
matchKey: "general",
model: "openai/parent-channel-model",
});
state.resolvedSessionKeyMock = "agent:main:slack:channel:general:thread:thread-1";
state.sessionEntryMock = createCommandSessionEntry({
sessionId: "session-1",
updatedAt: 1,
channel: "slack",
groupId: "thread-1",
skillsSnapshot: { prompt: "", skills: [], version: 0 },
});
state.runAgentAttemptMock.mockResolvedValue(
makeSuccessResult("openai", "parent-channel-model"),
);
await runBasicAgentCommand();
const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams;
expect(fallbackParams.provider).toBe("openai");
expect(fallbackParams.model).toBe("parent-channel-model");
});
it("keeps stored session model overrides ahead of channel model overrides", async () => {
setupSingleAttemptFallback();
state.runtimeConfigMock = createChannelModelRuntimeConfig({
additionalModels: { "anthropic/stored-model": {} },
});
state.sessionEntryMock = createCommandSessionEntry({
sessionId: "session-1",
updatedAt: 1,
channel: "discord",
groupId: "channel-123",
providerOverride: "anthropic",
modelOverride: "stored-model",
modelOverrideSource: "user",
skillsSnapshot: { prompt: "", skills: [], version: 0 },
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "stored-model"));
await runBasicAgentCommand();
const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams;
expect(fallbackParams.provider).toBe("anthropic");
expect(fallbackParams.model).toBe("stored-model");
});
it("keeps explicit run model overrides ahead of channel model overrides", async () => {
setupSingleAttemptFallback();
state.runtimeConfigMock = createChannelModelRuntimeConfig({
additionalModels: { "openai/explicit-model": {} },
});
state.sessionEntryMock = createCommandSessionEntry({
sessionId: "session-1",
updatedAt: 1,
channel: "discord",
groupId: "channel-123",
skillsSnapshot: { prompt: "", skills: [], version: 0 },
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "explicit-model"));
await agentCommand({
message: "hello",
to: "+1234567890",
model: "openai/explicit-model",
allowModelOverride: true,
});
const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams;
expect(fallbackParams.provider).toBe("openai");
expect(fallbackParams.model).toBe("explicit-model");
});
it("uses rotated session identity for all post-run session persistence", async () => {
setupSingleAttemptFallback();
setupStoredSession();
const rotatedEntry: SessionEntry = {
sessionId: "rotated-session",
sessionFile: "/tmp/rotated-session.jsonl",
updatedAt: 2,
skillsSnapshot: { prompt: "", skills: [], version: 0 },
};
const result = makeSuccessResult("openai", "gpt-5.4") as ReturnType<
typeof makeSuccessResult
> & {
meta: Record<string, unknown> & { agentMeta: Record<string, unknown> };
};
result.meta.executionTrace = {
runner: "embedded",
fallbackUsed: false,
winnerProvider: "openai",
winnerModel: "gpt-5.4",
};
result.meta.finalAssistantVisibleText = "ok";
result.meta.agentMeta = {
...result.meta.agentMeta,
sessionId: "rotated-session",
sessionFile: "/tmp/rotated-session.jsonl",
};
state.runAgentAttemptMock.mockResolvedValue(result);
state.updateSessionStoreAfterAgentRunMock.mockImplementation(async () => {
state.sessionStoreMock = { "agent:main:main": rotatedEntry };
});
state.persistCliTurnTranscriptMock.mockResolvedValue({
kind: "persisted",
sessionEntry: rotatedEntry,
});
state.runCliTurnCompactionLifecycleMock.mockResolvedValue(rotatedEntry);
await runBasicAgentCommand();
expectRecordFields(mockCallArg(state.updateSessionStoreAfterAgentRunMock), {
sessionId: "rotated-session",
});
expectRecordFields(mockCallArg(state.persistCliTurnTranscriptMock), {
sessionId: "rotated-session",
sessionKey: "agent:main:main",
});
expectRecordFields(mockCallArg(state.runCliTurnCompactionLifecycleMock), {
sessionId: "rotated-session",
sessionKey: "agent:main:main",
});
expectRecordFields(mockCallArg(state.deliverAgentCommandResultMock), {
expectedSessionIdForFreshDelivery: "rotated-session",
});
});
it("skips post-run persistence after the session is deleted", async () => {
setupSingleAttemptFallback();
setupStoredSession();
const result = makeSuccessResult("openai", "gpt-5.4") as ReturnType<
typeof makeSuccessResult
> & {
meta: Record<string, unknown> & { executionTrace: Record<string, unknown> };
};
result.meta.executionTrace = {
runner: "cli",
fallbackUsed: false,
winnerProvider: "openai",
winnerModel: "gpt-5.4",
};
state.runAgentAttemptMock.mockResolvedValue(result);
state.persistCliTurnTranscriptMock.mockResolvedValue({
kind: "session-rebound",
sessionEntry: undefined,
});
await runBasicAgentCommand();
expect(state.persistCliTurnTranscriptMock).toHaveBeenCalledTimes(1);
expect(state.runCliTurnCompactionLifecycleMock).not.toHaveBeenCalled();
expect(state.deliverAgentCommandResultMock).toHaveBeenCalledTimes(1);
});
it("preserves restart recovery ownership when delivery fails after a session rebound", async () => {
setupSingleAttemptFallback();
setupStoredSession();
const sessionStore = state.sessionStoreMock as Record<string, SessionEntry>;
const claimedEntry = expectDefined(
sessionStore["agent:main:main"],
"preclaimed restart recovery session",
);
claimedEntry.restartRecoveryDeliveryRunId = "session-1";
claimedEntry.restartRecoveryDeliveryContext = {
channel: "discord",
to: "discord:dm:123",
accountId: "main",
};
const result = makeSuccessResult("openai", "gpt-5.4") as ReturnType<
typeof makeSuccessResult
> & {
meta: Record<string, unknown> & { executionTrace: Record<string, unknown> };
};
result.meta.executionTrace = {
runner: "cli",
fallbackUsed: false,
winnerProvider: "openai",
winnerModel: "gpt-5.4",
};
state.runAgentAttemptMock.mockResolvedValue(result);
state.persistCliTurnTranscriptMock.mockResolvedValue({
kind: "session-rebound",
sessionEntry: undefined,
});
state.deliverAgentCommandResultMock.mockRejectedValue(new Error("delivery failed"));
await expect(
agentCommand({
message: "hello",
channel: "discord",
to: "discord:dm:123",
accountId: "main",
deliver: true,
}),
).rejects.toThrow("delivery failed");
expect(sessionStore["agent:main:main"]?.restartRecoveryDeliveryRunId).toBe("session-1");
});
it.each([
{
name: "persists only the CLI assistant reply after the runner persists the current user turn",
runner: "cli",
canonicalUserRecorder: true,
embeddedAssistantGapFill: false,
},
{
name: "persists the full embedded turn when no canonical user recorder exists",
runner: "embedded",
canonicalUserRecorder: false,
embeddedAssistantGapFill: true,
},
])("$name", async ({ runner, canonicalUserRecorder, embeddedAssistantGapFill }) => {
type AttemptCall = {
onUserMessagePersisted?: () => void;
};
setupSingleAttemptFallback();
setupStoredSession();
const result = makeSuccessResult("openai", "gpt-5.4") as ReturnType<
typeof makeSuccessResult
> & {
meta: Record<string, unknown> & { executionTrace: Record<string, unknown> };
};
result.meta.executionTrace = {
runner,
fallbackUsed: false,
winnerProvider: "openai",
winnerModel: "gpt-5.4",
};
state.runAgentAttemptMock.mockImplementation(async (attemptParams: AttemptCall) => {
attemptParams.onUserMessagePersisted?.();
return result;
});
state.persistCliTurnTranscriptMock.mockResolvedValue({
kind: "persisted",
sessionEntry: state.sessionEntryMock,
});
await (canonicalUserRecorder
? agentCommand({
message: "hello",
to: "+1234567890",
userTurnTranscriptRecorder: {} as NonNullable<
Parameters<typeof agentCommand>[0]["userTurnTranscriptRecorder"]
>,
})
: runBasicAgentCommand());
expectRecordFields(mockCallArg(state.persistCliTurnTranscriptMock), {
embeddedAssistantGapFill,
});
});
it("does not treat backend CLI session id as OpenClaw session identity", async () => {
setupSingleAttemptFallback();
setupStoredSession();
const result = makeSuccessResult("openai", "gpt-5.4") as ReturnType<
typeof makeSuccessResult
> & {
meta: Record<string, unknown> & { agentMeta: Record<string, unknown> };
};
result.meta.agentMeta = {
...result.meta.agentMeta,
sessionId: "backend-cli-session",
};
state.runAgentAttemptMock.mockResolvedValue(result);
await runBasicAgentCommand();
expectRecordFields(mockCallArg(state.updateSessionStoreAfterAgentRunMock), {
sessionId: "session-1",
});
expectRecordFields(mockCallArg(state.deliverAgentCommandResultMock), {
expectedSessionIdForFreshDelivery: "session-1",
});
});
it("scopes explicit-agent sentinel store keys before command routing", () => {
expect(
agentCommandTesting.resolveExplicitAgentCommandSessionKey({
rawExplicitSessionKey: "global",
agentIdOverride: "work",
cfg: {},
}),
).toBe("agent:work:global");
expect(
agentCommandTesting.resolveExplicitAgentCommandSessionKey({
rawExplicitSessionKey: "main",
agentIdOverride: "work",
cfg: {},
}),
).toBe("agent:work:main");
});
it("persists explicit overrides even when ingress skips the initial touch", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
setupStoredSession();
await agentCommand({
message: "hello",
to: "+1234567890",
thinking: "medium",
skipInitialSessionTouch: true,
});
const touchWrite = state.persistSessionEntryMock.mock.calls.find((call) => {
const entry = (call[0] as { entry?: Record<string, unknown> } | undefined)?.entry;
return entry?.thinkingLevel === "medium";
})?.[0] as { entry?: Record<string, unknown> } | undefined;
expect(touchWrite?.entry?.lastInteractionAt).toBeDefined();
expect(state.updateSessionStoreAfterAgentRunMock).toHaveBeenCalledTimes(1);
});
it("forwards an explicit OpenClaw runtime override into fallback and attempt execution", async () => {
setupSingleAttemptFallback();
state.runtimeConfigMock = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.4" },
models: { "openai/gpt-5.4": {} },
},
},
};
setupStoredSession({ agentRuntimeOverride: "openclaw", agentHarnessId: "codex" });
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
await runBasicAgentCommand();
const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams;
expect(fallbackParams.resolveAgentHarnessRuntimeOverride?.("openai", "gpt-5.4")).toBe(
"openclaw",
);
expectRecordFields(mockCallArg(state.runAgentAttemptMock), {
providerOverride: "openai",
modelOverride: "gpt-5.4",
agentHarnessRuntimeOverride: "openclaw",
});
});
it("does not persist turn-local thinking fallback over a stored session override", async () => {
setupSingleAttemptFallback();
const { entry: sessionEntry, store: sessionStore } = setupStoredSession({
thinkingLevel: "high",
});
state.isThinkingLevelSupportedMock.mockReturnValue(false);
state.resolveSupportedThinkingLevelMock.mockReturnValue("off");
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
await runBasicAgentCommand();
expectRecordFields(mockCallArg(state.runAgentAttemptMock), {
resolvedThinkLevel: "off",
});
expect(sessionEntry.thinkingLevel).toBe("high");
expect(sessionStore["agent:main:main"]?.thinkingLevel).toBe("high");
expect(state.persistSessionEntryMock).not.toHaveBeenCalledWith(
expect.objectContaining({
entry: expect.objectContaining({ thinkingLevel: "off" }),
}),
);
});
it("revalidates immutable Ultra for each model fallback without persisting the remap", async () => {
const { entry: sessionEntry } = setupStoredSession({ thinkingLevel: "ultra" });
state.runtimeConfigMock = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-luna" },
models: {
"openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } },
"openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } },
},
},
},
};
state.isThinkingLevelSupportedMock.mockImplementation((args: unknown) => {
const { model, level } = args as { model?: string; level?: string };
return model !== "gpt-5.6-luna" || level !== "ultra";
});
state.resolveSupportedThinkingLevelMock.mockImplementation(
({ level, model }: { level?: string; model?: string }) =>
model === "gpt-5.6-luna" && level === "ultra" ? "max" : level,
);
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
await params.run(params.provider, params.model);
const result = await params.run("openai", "gpt-5.6-sol");
return {
result,
provider: "openai",
model: "gpt-5.6-sol",
attempts: [],
};
});
state.runAgentAttemptMock.mockImplementation(
async (params: { providerOverride: string; modelOverride: string }) =>
makeSuccessResult(params.providerOverride, params.modelOverride),
);
await runBasicAgentCommand();
expectRecordFields(mockCallArg(state.runAgentAttemptMock, 0), {
modelOverride: "gpt-5.6-luna",
resolvedThinkLevel: "max",
});
expectRecordFields(mockCallArg(state.runAgentAttemptMock, 1), {
modelOverride: "gpt-5.6-sol",
resolvedThinkLevel: "ultra",
});
expect(state.resolveSupportedThinkingLevelMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
model: "gpt-5.6-luna",
level: "ultra",
agentRuntime: "codex",
}),
);
expect(sessionEntry.thinkingLevel).toBe("ultra");
expect(state.persistSessionEntryMock).not.toHaveBeenCalledWith(
expect.objectContaining({
entry: expect.objectContaining({ thinkingLevel: "max" }),
}),
);
});
it("recomputes a model-derived thinking default for each fallback candidate", async () => {
setupStoredSession();
state.runtimeConfigMock = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-sol" },
models: {
"openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } },
"openai/gpt-5.6-terra": { agentRuntime: { id: "codex" } },
},
},
},
};
state.resolveThinkingDefaultMock.mockImplementation((args: unknown) => {
const { model } = args as { model?: string };
return model === "gpt-5.6-terra" ? "medium" : "low";
});
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
await params.run(params.provider, params.model);
const result = await params.run("openai", "gpt-5.6-terra");
return {
result,
provider: "openai",
model: "gpt-5.6-terra",
attempts: [],
};
});
state.runAgentAttemptMock.mockImplementation(
async (params: { providerOverride: string; modelOverride: string }) =>
makeSuccessResult(params.providerOverride, params.modelOverride),
);
await runBasicAgentCommand();
expectRecordFields(mockCallArg(state.runAgentAttemptMock, 0), {
modelOverride: "gpt-5.6-sol",
resolvedThinkLevel: "low",
});
expectRecordFields(mockCallArg(state.runAgentAttemptMock, 1), {
modelOverride: "gpt-5.6-terra",
resolvedThinkLevel: "medium",
});
});
it("persists and clears current run delivery context for restart recovery", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
setupBareStoredSession();
state.deliverAgentCommandResultMock.mockResolvedValue({ deliverySucceeded: true });
await agentCommand({
message: "hello",
channel: "discord",
to: "discord:dm:123",
accountId: "main",
threadId: "reply-1",
deliver: true,
});
const persistedContexts = state.persistSessionEntryMock.mock.calls.map((call) => {
const params = call[0] as { entry?: SessionEntry };
return params.entry?.restartRecoveryDeliveryContext;
});
expect(persistedContexts).toContainEqual({
channel: "discord",
to: "discord:dm:123",
accountId: "main",
threadId: "reply-1",
});
const cleanupParams = state.persistSessionEntryMock.mock.calls.at(-1)?.[0] as
| { sessionStore?: Record<string, SessionEntry> }
| undefined;
const stored = cleanupParams?.sessionStore?.["agent:main:main"];
expect(stored?.restartRecoveryDeliveryContext).toBeUndefined();
});
it("records generated-media delivery runs as durable terminal sources", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
setupBareStoredSession();
state.deliverAgentCommandResultMock.mockImplementation(async (params: unknown) => {
const onDeliveryResult = (params as { onDeliveryResult?: (result: unknown) => void })
.onDeliveryResult;
const deliveryResult = {
payloads: [{ isReasoning: true }, { text: "ready", mediaUrls: ["/tmp/payload.png"] }],
meta: {},
deliverySucceeded: true,
deliveryStatus: {
status: "partial_failed",
payloadOutcomes: [
{ index: 0, status: "suppressed" },
{ index: 1, status: "failed", sentBeforeError: false },
],
},
messagingToolSentTargets: [
{
provider: "discord",
to: "discord:dm:123",
threadId: 42,
mediaUrls: ["/tmp/proof.png"],
},
],
};
onDeliveryResult?.(deliveryResult);
return deliveryResult;
});
await agentCommand({
message: "generated image ready",
channel: "discord",
to: "discord:dm:123",
deliver: false,
runId: "image:task-1:agent-loop",
sourceReplyDeliveryMode: "automatic",
disableMessageTool: true,
forceRestartSafeTools: true,
internalDeliveryMediaUrls: ["/tmp/payload.png"],
inputProvenance: {
kind: "inter_session",
sourceChannel: "webchat",
sourceTool: "image_generate",
},
});
const persistedSourceRunIds = state.persistSessionEntryMock.mock.calls.map((call) => {
const params = call[0] as { entry?: SessionEntry };
return params.entry?.restartRecoveryDeliverySourceRunId;
});
expect(persistedSourceRunIds).toContain("image:task-1:agent-loop");
expect(
state.persistSessionEntryMock.mock.calls.some((call) => {
const params = call[0] as { entry?: SessionEntry };
return params.entry?.restartRecoverySourceReplyDeliveryMode === "automatic";
}),
).toBe(true);
const cleanupParams = state.persistSessionEntryMock.mock.calls.at(-1)?.[0] as
| { sessionStore?: Record<string, SessionEntry> }
| undefined;
const stored = cleanupParams?.sessionStore?.["agent:main:main"];
expect(stored?.restartRecoveryDeliverySourceRunId).toBeUndefined();
expect(stored?.restartRecoverySourceReplyDeliveryMode).toBeUndefined();
expect(stored?.restartRecoveryTerminalRunIds).toEqual(["image:task-1:agent-loop"]);
expect(stored?.restartRecoveryTerminalDeliveryEvidence).toEqual([
{
runId: "image:task-1:agent-loop",
captured: true,
payloads: [{ visible: false }, { mediaUrls: ["/tmp/payload.png"], visible: true }],
deliveryStatus: {
status: "partial_failed",
payloadOutcomes: [
{ index: 0, status: "suppressed" },
{ index: 1, status: "failed", sentBeforeError: false },
],
},
messagingToolSentTargets: [
{
provider: "discord",
to: "discord:dm:123",
threadId: "42",
mediaUrls: ["/tmp/proof.png"],
visible: true,
},
],
restartUnsafeSideEffectsDetected: true,
},
]);
});
it("does not make an unconstrained message-tool completion replayable", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
setupBareStoredSession();
await agentCommand({
message: "generated image ready",
sessionKey: "agent:main:main",
deliver: false,
runId: "image:unsafe-message-tool:agent-loop",
sourceReplyDeliveryMode: "message_tool_only",
inputProvenance: {
kind: "inter_session",
sourceChannel: "webchat",
sourceTool: "image_generate",
},
});
expect(
state.persistSessionEntryMock.mock.calls.some((call) => {
const params = call[0] as { entry?: SessionEntry };
return params.entry?.restartRecoveryDeliverySourceRunId !== undefined;
}),
).toBe(false);
});
it("constrains recovery delivery to host-owned media before persistence and send", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue({
...makeSuccessResult("openai", "gpt-5.4"),
payloads: [{ text: "ready", mediaUrls: ["/tmp/already-delivered.png"] }],
});
await agentCommand({
message: "deliver only missing generated media",
channel: "discord",
to: "channel:123",
deliver: true,
sourceReplyDeliveryMode: "automatic",
disableMessageTool: true,
forceRestartSafeTools: true,
internalDeliveryMediaUrls: ["/tmp/missing.png"],
runId: "image:task-policy:agent-loop",
inputProvenance: {
kind: "inter_session",
sourceChannel: "webchat",
sourceTool: "image_generate",
},
});
const deliveryParams = requireRecord(
mockCallArg(state.deliverAgentCommandResultMock),
"delivery params",
);
expect(requireRecord(deliveryParams.result, "delivery result").payloads).toEqual([
{ text: "ready" },
{ mediaUrls: ["/tmp/missing.png"], trustedLocalMedia: true },
]);
expect(deliveryParams.payloads).toEqual([
{ text: "ready" },
{ mediaUrls: ["/tmp/missing.png"], trustedLocalMedia: true },
]);
expect(
state.persistSessionEntryMock.mock.calls.some((call) => {
const params = call[0] as { entry?: SessionEntry };
return (
params.entry?.restartRecoveryDeliveryMediaUrls?.[0] === "/tmp/missing.png" &&
params.entry.restartRecoveryDisableMessageTool === true &&
params.entry.restartRecoveryForceSafeTools === true
);
}),
).toBe(true);
});
it("restores the exact generated-media policy for a preclaimed recovery run", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue({
...makeSuccessResult("openai", "gpt-5.4"),
payloads: [{ text: "ready", mediaUrls: ["/tmp/model-selected.png"] }],
});
setupBareStoredSession({
restartRecoveryDeliveryRunId: "recovery-run",
restartRecoveryDeliverySourceRunId: "image:task-policy:agent-loop",
restartRecoveryDeliveryContext: { channel: "discord", to: "channel:123" },
restartRecoveryDeliveryMediaUrls: ["/tmp/missing.png"],
restartRecoveryDisableMessageTool: true,
restartRecoverySuppressTextDelivery: true,
restartRecoverySourceReplyDeliveryMode: "automatic",
restartRecoveryForceSafeTools: true,
});
await agentCommand({
message: "continue generated media delivery",
sessionKey: "agent:main:main",
channel: "discord",
to: "channel:123",
deliver: true,
runId: "recovery-run",
});
const deliveryParams = requireRecord(
mockCallArg(state.deliverAgentCommandResultMock),
"delivery params",
);
expect(requireRecord(deliveryParams.result, "delivery result").payloads).toEqual([
{ mediaUrls: ["/tmp/missing.png"], trustedLocalMedia: true },
]);
const persistedPolicies = state.persistSessionEntryMock.mock.calls.map((call) => {
const params = call[0] as { entry?: SessionEntry };
return {
mediaUrls: params.entry?.restartRecoveryDeliveryMediaUrls,
disableMessageTool: params.entry?.restartRecoveryDisableMessageTool,
forceSafeTools: params.entry?.restartRecoveryForceSafeTools,
suppressText: params.entry?.restartRecoverySuppressTextDelivery,
};
});
expect(persistedPolicies).toContainEqual({
mediaUrls: undefined,
disableMessageTool: undefined,
forceSafeTools: undefined,
suppressText: undefined,
});
});
it("rejects host-owned media constraints without the scoped recovery policy", async () => {
await expect(
agentCommand({
message: "unsafe delivery constraint",
sessionKey: "agent:main:main",
internalDeliveryMediaUrls: ["/tmp/proof.png"],
}),
).rejects.toThrow(
"internal delivery media constraints require automatic delivery with restart-safe tools and no message tool",
);
expect(state.runAgentAttemptMock).not.toHaveBeenCalled();
});
it("retains and clears a preclaimed transcript-only recovery run", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
setupBareStoredSession({
restartRecoveryDeliveryRunId: "recovery-run",
restartRecoveryDeliverySourceRunId: "control-ui-run",
});
await agentCommand({
message: "continue after restart",
sessionKey: "agent:main:main",
deliver: false,
runId: "recovery-run",
});
const persistedClaims = state.persistSessionEntryMock.mock.calls.map((call) => {
const params = call[0] as { entry?: SessionEntry };
return {
context: params.entry?.restartRecoveryDeliveryContext,
runId: params.entry?.restartRecoveryDeliveryRunId,
sourceRunId: params.entry?.restartRecoveryDeliverySourceRunId,
};
});
expect(persistedClaims).toContainEqual({
context: undefined,
runId: "recovery-run",
sourceRunId: "control-ui-run",
});
expect(persistedClaims.at(-1)).toEqual({
context: undefined,
runId: undefined,
sourceRunId: undefined,
});
const cleanupParams = state.persistSessionEntryMock.mock.calls.at(-1)?.[0] as
| {
sessionStore?: Record<string, SessionEntry>;
}
| undefined;
const stored = cleanupParams?.sessionStore?.["agent:main:main"];
expect(stored?.restartRecoveryDeliveryContext).toBeUndefined();
expect(stored?.restartRecoveryDeliveryRunId).toBeUndefined();
expect(stored?.restartRecoveryDeliverySourceRunId).toBeUndefined();
expect(stored?.restartRecoveryTerminalRunIds).toEqual(["control-ui-run", "recovery-run"]);
});
it("refreshes delivery session entries through the session accessor", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
setupBareStoredSession();
const freshEntry = createCommandSessionEntry({
sessionId: "session-1",
updatedAt: 2,
deliveryContext: {
channel: "discord",
to: "discord:dm:sqlite",
accountId: "main",
},
});
state.loadSessionEntryMock.mockReturnValue(freshEntry);
state.deliverAgentCommandResultMock.mockImplementation(async (params: unknown) => {
const resolver = (
params as {
resolveFreshSessionEntryForDelivery?: () => Promise<SessionEntry | undefined>;
}
).resolveFreshSessionEntryForDelivery;
const resolved = await resolver?.();
expect(resolved).toEqual(freshEntry);
return { deliverySucceeded: true };
});
await runDiscordDelivery();
expect(state.loadSessionEntryMock).toHaveBeenCalledWith({
storePath: "/tmp/openclaw-sessions.json",
sessionKey: "agent:main:main",
readConsistency: "latest",
clone: false,
});
});
it("preserves parsed explicit target threads for restart recovery", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
setupBareStoredSession();
state.deliverAgentCommandResultMock.mockResolvedValue({ deliverySucceeded: true });
state.resolveAgentDeliveryPlanMock.mockReturnValueOnce({
baseDelivery: {
mode: "explicit",
threadId: "thread-1",
threadIdSource: "explicit",
},
resolvedChannel: "discord",
resolvedTo: "discord:channel:general",
resolvedAccountId: "main",
resolvedThreadId: "thread-1",
deliveryTargetMode: "explicit",
});
await runDiscordDelivery({ to: "discord:channel:general/thread:thread-1" });
const persistedContexts = state.persistSessionEntryMock.mock.calls.map((call) => {
const params = call[0] as { entry?: SessionEntry };
return params.entry?.restartRecoveryDeliveryContext;
});
expect(persistedContexts).toContainEqual({
channel: "discord",
to: "discord:channel:general",
accountId: "main",
threadId: "thread-1",
});
});
it("does not inherit a stale thread when restart recovery uses an explicit target", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
setupBareStoredSession({
lastThreadId: "stale-thread",
});
state.deliverAgentCommandResultMock.mockResolvedValue({ deliverySucceeded: true });
await runDiscordDelivery();
const persistedContexts = state.persistSessionEntryMock.mock.calls.map((call) => {
const params = call[0] as { entry?: SessionEntry };
return params.entry?.restartRecoveryDeliveryContext;
});
expect(persistedContexts).toContainEqual({
channel: "discord",
to: "discord:dm:123",
accountId: "main",
});
});
it("persists implicit session delivery route for restart recovery", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
setupBareStoredSession({
delivery: normalizeSessionDeliveryState({
context: {
channel: "discord",
to: "discord:channel:general",
accountId: "main",
threadId: "thread-1",
},
}),
});
state.deliverAgentCommandResultMock.mockResolvedValue({ deliverySucceeded: true });
await agentCommand({
message: "hello",
sessionKey: "agent:main:main",
deliver: true,
});
const persistedContexts = state.persistSessionEntryMock.mock.calls.map((call) => {
const params = call[0] as { entry?: SessionEntry };
return params.entry?.restartRecoveryDeliveryContext;
});
expect(persistedContexts).toContainEqual({
channel: "discord",
to: "discord:channel:general",
accountId: "main",
threadId: "thread-1",
});
expect(state.resolveAgentDeliveryPlanMock).toHaveBeenCalledWith(
expect.objectContaining({
explicitTo: undefined,
requestedChannel: undefined,
sessionEntry: expect.objectContaining({
delivery: expect.objectContaining({
context: expect.objectContaining({ to: "discord:channel:general" }),
}),
}),
wantsDelivery: true,
}),
);
});
it("persists default target delivery route for restart recovery", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
setupBareStoredSession();
state.deliverAgentCommandResultMock.mockResolvedValue({ deliverySucceeded: true });
state.resolveMessageChannelSelectionMock.mockResolvedValue({
channel: "discord",
configured: ["discord"],
source: "single-configured",
});
state.resolveAgentOutboundTargetMock.mockReturnValue({
resolvedTarget: { ok: true, to: "discord:channel:default" },
resolvedTo: "discord:channel:default",
targetMode: "implicit",
});
await agentCommand({
message: "hello",
sessionKey: "agent:main:main",
deliver: true,
});
const persistedContexts = state.persistSessionEntryMock.mock.calls.map((call) => {
const params = call[0] as { entry?: SessionEntry };
return params.entry?.restartRecoveryDeliveryContext;
});
expect(persistedContexts).toContainEqual({
channel: "discord",
to: "discord:channel:default",
});
});
it.each([
{
name: "does not overwrite another active run's restart recovery context",
attemptResult: makeEmptyResult("openai", "gpt-5.4"),
},
{
name: "does not clear another active run's restart recovery context",
attemptResult: makeEmptyResult("openai", "gpt-5.4"),
},
])("$name", async ({ attemptResult }) => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(attemptResult);
const staleEntry = createCommandSessionEntry();
const laterRunEntry = createCommandSessionEntry({
updatedAt: 2,
restartRecoveryDeliveryContext: {
channel: "discord",
to: "discord:dm:456",
accountId: "main",
},
restartRecoveryDeliveryRunId: "later-run",
});
const sessionStore = { "agent:main:main": laterRunEntry };
state.sessionEntryMock = staleEntry;
state.sessionStoreMock = sessionStore;
state.storePathMock = "/tmp/openclaw-sessions.json";
await runDiscordDelivery({ sessionKey: "agent:main:main", runId: "stale-run" });
expect(sessionStore["agent:main:main"]?.restartRecoveryDeliveryContext).toEqual(
laterRunEntry.restartRecoveryDeliveryContext,
);
expect(sessionStore["agent:main:main"]?.restartRecoveryDeliveryRunId).toBe("later-run");
});
it("keeps current run delivery context when restart marker wins the cleanup race", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
const { store: sessionStore } = setupBareStoredSession();
state.deliverAgentCommandResultMock.mockImplementation(async () => {
const current = sessionStore["agent:main:main"];
if (current) {
current.abortedLastRun = true;
}
return { deliverySucceeded: false };
});
await runDiscordDelivery();
expect(sessionStore["agent:main:main"]?.abortedLastRun).toBe(true);
expect(state.persistSessionEntryMock).toHaveBeenCalledWith(
expect.objectContaining({
entry: expect.objectContaining({
restartRecoveryDeliveryContext: {
channel: "discord",
to: "discord:dm:123",
accountId: "main",
},
restartRecoveryDeliveryRunId: "session-1",
}),
}),
);
});
it("does not recreate a deleted session entry during restart recovery cleanup", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeEmptyResult("openai", "gpt-5.4"));
const { store: sessionStore } = setupBareStoredSession();
state.deliverAgentCommandResultMock.mockImplementation(async () => {
delete sessionStore["agent:main:main"];
return { deliverySucceeded: true };
});
await runDiscordDelivery();
expect(sessionStore["agent:main:main"]).toBeUndefined();
});
it("does not clear restart recovery context from a rotated session entry", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeEmptyResult("openai", "gpt-5.4"));
const { store: sessionStore } = setupBareStoredSession();
const rotatedEntry = createCommandSessionEntry({
sessionId: "session-2",
updatedAt: 2,
restartRecoveryDeliveryContext: {
channel: "discord",
to: "discord:dm:456",
accountId: "main",
},
});
state.deliverAgentCommandResultMock.mockImplementation(async () => {
sessionStore["agent:main:main"] = rotatedEntry;
return { deliverySucceeded: true };
});
await runDiscordDelivery();
expect(sessionStore["agent:main:main"]).toEqual(rotatedEntry);
});
it("does not clear restart recovery context from another active run in the same session", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeEmptyResult("openai", "gpt-5.4"));
const { store: sessionStore } = setupBareStoredSession();
const laterRunEntry = createCommandSessionEntry({
updatedAt: 2,
restartRecoveryDeliveryContext: {
channel: "discord",
to: "discord:dm:456",
accountId: "main",
},
restartRecoveryDeliveryRunId: "later-run",
});
state.deliverAgentCommandResultMock.mockImplementation(async () => {
sessionStore["agent:main:main"] = laterRunEntry;
return { deliverySucceeded: false };
});
await runDiscordDelivery();
expect(sessionStore["agent:main:main"]).toEqual(laterRunEntry);
});
it("stores and delivers with the prepared canonical current-run target", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
setupBareStoredSession();
state.deliverAgentCommandResultMock.mockResolvedValue({ deliverySucceeded: false });
state.resolveAgentDeliveryPlanWithSessionRouteMock.mockResolvedValueOnce({
baseDelivery: {},
resolvedChannel: "discord",
resolvedTo: "channel:1524410080953634829",
resolvedAccountId: "main",
deliveryTargetMode: "explicit",
});
await runDiscordDelivery({ to: "channel:general" });
const pendingEntries = state.persistSessionEntryMock.mock.calls
.map((call) => (call[0] as { entry?: SessionEntry }).entry)
.filter((entry): entry is SessionEntry => entry?.pendingFinalDelivery === true);
expect(pendingEntries).toContainEqual(
expect.objectContaining({
pendingFinalDeliveryText: "ok",
pendingFinalDeliveryContext: {
channel: "discord",
to: "channel:1524410080953634829",
accountId: "main",
},
}),
);
expect(state.deliverAgentCommandResultMock).toHaveBeenCalledWith(
expect.objectContaining({
opts: expect.objectContaining({
replyChannel: "discord",
replyTo: "channel:1524410080953634829",
replyAccountId: "main",
deliveryTargetMode: "explicit",
}),
}),
);
});
it("rejects a strict delivery target before the model run", async () => {
setupSingleAttemptFallback();
const targetError = new Error('Unknown Discord target "channel:missing"');
state.resolveAgentDeliveryPlanWithSessionRouteMock.mockResolvedValueOnce({
baseDelivery: {},
resolvedChannel: "discord",
resolvedTo: "channel:missing",
deliveryTargetMode: "explicit",
targetResolutionError: targetError,
});
await expect(
agentCommand({
message: "hello",
channel: "discord",
to: "channel:missing",
deliver: true,
}),
).rejects.toBe(targetError);
expect(state.runAgentAttemptMock).not.toHaveBeenCalled();
expect(state.deliverAgentCommandResultMock).not.toHaveBeenCalled();
});
it("downgrades a rejected best-effort target to session-only before the model run", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
state.resolveAgentDeliveryPlanWithSessionRouteMock.mockResolvedValueOnce({
baseDelivery: {},
resolvedChannel: "discord",
resolvedTo: "channel:missing",
deliveryTargetMode: "explicit",
targetResolutionError: new Error('Unknown Discord target "channel:missing"'),
});
await expect(
agentCommand({
message: "hello",
channel: "discord",
to: "channel:missing",
deliver: true,
bestEffortDeliver: true,
}),
).resolves.toBeUndefined();
expect(state.runAgentAttemptMock).toHaveBeenCalled();
expect(state.deliverAgentCommandResultMock).toHaveBeenCalledWith(
expect.objectContaining({ opts: expect.objectContaining({ deliver: false }) }),
);
});
it("clears stale flag-only pending final delivery when there is no final payload", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeEmptyResult("openai", "gpt-5.4"));
setupBareStoredSession({
pendingFinalDelivery: true,
pendingFinalDeliveryCreatedAt: 2,
pendingFinalDeliveryLastAttemptAt: 3,
pendingFinalDeliveryAttemptCount: 4,
pendingFinalDeliveryLastError: "previous failure",
pendingFinalDeliveryContext: { channel: "tui" },
pendingFinalDeliveryIntentId: "intent-1",
});
state.deliverAgentCommandResultMock.mockResolvedValue(undefined);
await agentCommand({
message: "hello",
channel: "whatsapp",
to: "+1234567890",
deliver: true,
});
expect(state.persistSessionEntryMock).toHaveBeenCalledWith(
expect.objectContaining({
entry: expect.objectContaining({
pendingFinalDelivery: undefined,
pendingFinalDeliveryText: undefined,
pendingFinalDeliveryCreatedAt: undefined,
pendingFinalDeliveryLastAttemptAt: undefined,
pendingFinalDeliveryAttemptCount: undefined,
pendingFinalDeliveryLastError: undefined,
pendingFinalDeliveryContext: undefined,
pendingFinalDeliveryIntentId: undefined,
}),
}),
);
});
it("passes SQLite transcript markers to visible agent attempts", async () => {
setupSingleAttemptFallback();
const visibleEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: 1,
sessionFile: "sqlite:default:session-1:/tmp/openclaw-session-store.json",
};
const sessionStore: Record<string, SessionEntry> = { "agent:main:main": visibleEntry };
state.sessionEntryMock = visibleEntry;
state.sessionStoreMock = sessionStore;
state.storePathMock = "/tmp/openclaw-session-store.json";
state.loadSessionEntryMock.mockReturnValue(visibleEntry);
const attemptCalls: Array<{ sessionFile?: string; sessionEntry?: SessionEntry }> = [];
state.runAgentAttemptMock.mockImplementation(async (params) => {
attemptCalls.push(params as { sessionFile?: string; sessionEntry?: SessionEntry });
return makeSuccessResult("openai", "gpt-5.4");
});
await agentCommand({
message: "visible run",
to: "+1234567890",
});
expect(attemptCalls).toHaveLength(1);
expect(attemptCalls[0]?.sessionFile).toBe(
"sqlite:default:session-1:/tmp/openclaw-session-store.json",
);
});
it("keeps internal session-effect CLI runs out of visible session state", async () => {
setupSingleAttemptFallback();
const visibleEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: 1,
sessionFile: "/tmp/session.jsonl",
providerOverride: "anthropic",
modelOverride: "claude",
modelOverrideSource: "user",
skillsSnapshot: { prompt: "visible", skills: [{ name: "existing" }], version: 1 },
};
const sessionStore: Record<string, SessionEntry> = { "agent:main:main": visibleEntry };
state.sessionEntryMock = visibleEntry;
state.sessionStoreMock = sessionStore;
state.storePathMock = "/tmp/openclaw-session-store.json";
state.loadSessionEntryMock.mockReturnValue(visibleEntry);
const attemptCalls: Array<{ sessionFile?: string; sessionEntry?: SessionEntry }> = [];
state.runAgentAttemptMock.mockImplementation(async (params) => {
attemptCalls.push(params as { sessionFile?: string; sessionEntry?: SessionEntry });
return makeSuccessResult("openai", "gpt-5.4");
});
await agentCommand({
message: "internal resume",
to: "+1234567890",
sessionEffects: "internal",
suppressPromptPersistence: true,
});
expect(state.prepareInternalSessionEffectsSessionMock).toHaveBeenCalledWith({
agentId: "default",
cwd: "/tmp/workspace",
runId: "session-1",
source: {
agentId: "default",
sessionId: "session-1",
sessionKey: "agent:main:main",
storePath: "/tmp/openclaw-session-store.json",
},
storePath: "/tmp/openclaw-session-store.json",
});
expect(attemptCalls).toHaveLength(1);
expect(attemptCalls[0]?.sessionFile).toBe(
"sqlite:default:internal-session:/tmp/openclaw-session-store.json",
);
expect(attemptCalls[0]?.sessionEntry).toStrictEqual(visibleEntry);
expect(state.trajectoryRecorderParamsMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionFile: "sqlite:default:internal-session:/tmp/openclaw-session-store.json",
}),
);
expect(state.persistSessionEntryMock).not.toHaveBeenCalled();
expect(state.updateSessionStoreAfterAgentRunMock).not.toHaveBeenCalled();
expect(sessionStore["agent:main:main"]).toBe(visibleEntry);
expect(state.registerAgentRunContextMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
sessionKey: "agent:main:main",
sessionId: "session-1",
isControlUiVisible: false,
}),
);
expect(state.removeInternalSessionEffectsSessionMock).not.toHaveBeenCalled();
});
it.each([
{
name: "rejects session-id-resolved model runs for harness-owned sessions",
sessionKey: "agent:main:harness:codex:supervision:native-thread",
},
{
name: "rejects one-shot model runs for locked harness sessions with ordinary keys",
sessionKey: "agent:main:plugin-owned",
},
])("$name", async ({ sessionKey }) => {
state.resolvedSessionKeyMock = sessionKey;
state.sessionEntryMock = createCommandSessionEntry({
agentHarnessId: "codex",
modelSelectionLocked: true,
});
await expect(
agentCommand({
message: "probe",
sessionId: "session-1",
modelRun: true,
promptMode: "none",
sessionEffects: "internal",
}),
).rejects.toThrow("Agent harness-owned sessions cannot be used for one-shot model runs.");
expect(state.runAgentAttemptMock).not.toHaveBeenCalled();
});
it("continues a grandfathered unlocked harness-prefixed session as an ordinary run", async () => {
setupSingleAttemptFallback();
state.resolvedSessionKeyMock = "agent:main:harness:notes";
state.sessionEntryMock = {
agentHarnessId: "codex",
modelSelectionLocked: false,
sessionId: "session-1",
sessionFile: "/tmp/legacy-session.jsonl",
updatedAt: 1,
};
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude"));
await runBasicAgentCommand();
expect(state.runAgentAttemptMock).toHaveBeenCalledTimes(1);
expectRecordFields(mockCallArg(state.runAgentAttemptMock), {
agentHarnessRuntimeOverride: undefined,
sessionEntry: expect.objectContaining({
agentHarnessId: "codex",
modelSelectionLocked: false,
sessionId: "session-1",
}),
});
});
it("rejects a locked harness row with the wrong owner before transcript or model work", async () => {
state.resolvedSessionKeyMock = "agent:main:harness:codex:supervision:native-thread";
state.sessionEntryMock = {
agentHarnessId: "other",
modelSelectionLocked: true,
sessionId: "session-1",
sessionFile: "/tmp/native-session.jsonl",
updatedAt: 1,
};
await expect(agentCommand({ message: "continue", sessionId: "session-1" })).rejects.toThrow(
"Session key namespace is reserved for agent harness-owned sessions.",
);
expect(state.prepareInternalSessionEffectsSessionMock).not.toHaveBeenCalled();
expect(state.runAgentAttemptMock).not.toHaveBeenCalled();
});
it("removes the one-shot internal model-run SQLite session after success", async () => {
setupSingleAttemptFallback();
const result = makeSuccessResult("openai", "gpt-5.4");
state.runAgentAttemptMock.mockResolvedValue({
...result,
meta: {
...result.meta,
executionTrace: {
runner: "embedded",
fallbackUsed: false,
winnerProvider: "openai",
winnerModel: "gpt-5.4",
},
finalAssistantVisibleText: "ok",
agentMeta: {
provider: "openai",
model: "gpt-5.4",
sessionId: "rotated-model-run-session",
sessionFile: "sqlite:default:rotated-model-run-session:/tmp/openclaw-session-store.json",
},
},
});
await runInternalModelCommand("model-run-success");
expect(state.createTrajectoryRuntimeRecorderMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionFile: "sqlite:default:internal-session:/tmp/openclaw-session-store.json",
}),
);
expect(state.persistCliTurnTranscriptMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "rotated-model-run-session",
sessionKey: "agent:default:internal-session-effects:run",
storePath: "/tmp/openclaw-session-store.json",
}),
);
expect(state.removeInternalSessionEffectsSessionMock).toHaveBeenCalledWith({
agentId: "default",
sessionId: "rotated-model-run-session",
sessionKey: "agent:default:internal-session-effects:run",
storePath: "/tmp/openclaw-session-store.json",
sessionFile: "sqlite:default:internal-session:/tmp/openclaw-session-store.json",
sessionEntry: { sessionId: "internal-session", updatedAt: 1 },
});
const deliveryOrder = state.deliverAgentCommandResultMock.mock.invocationCallOrder[0] ?? 0;
const cleanupOrder =
state.removeInternalSessionEffectsSessionMock.mock.invocationCallOrder[0] ?? 0;
expect(deliveryOrder).toBeLessThan(cleanupOrder);
});
it("removes the one-shot internal model-run SQLite session after provider failure", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockRejectedValueOnce(new Error("probe failed"));
await expect(runInternalModelCommand("model-run-failure")).rejects.toThrow("probe failed");
expect(state.removeInternalSessionEffectsSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "internal-session",
sessionKey: "agent:default:internal-session-effects:run",
storePath: "/tmp/openclaw-session-store.json",
}),
);
});
it("cleans the deterministic model-run session when preparation fails", async () => {
state.storePathMock = "/tmp/openclaw-session-store.json";
state.loadSessionEntryMock.mockReturnValue({ sessionId: "session-1", updatedAt: 1 });
state.prepareInternalSessionEffectsSessionMock.mockRejectedValueOnce(
new Error("session preparation failed"),
);
await expect(runInternalModelCommand("model-run-prepare-failure")).rejects.toThrow(
"session preparation failed",
);
expect(state.removeInternalSessionEffectsSessionMock).toHaveBeenCalledWith({
agentId: "default",
sessionId: "internal-model-run-prepare-failure",
sessionKey: "agent:default:internal-session-effects:model-run-prepare-failure",
storePath: "/tmp/openclaw-session-store.json",
});
});
it("does not replace a completed model-run result with a SQLite cleanup failure", async () => {
setupSingleAttemptFallback();
state.storePathMock = "/tmp/openclaw-session-store.json";
state.loadSessionEntryMock.mockReturnValue({ sessionId: "session-1", updatedAt: 1 });
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
state.removeInternalSessionEffectsSessionMock.mockRejectedValue(
new Error("database is locked"),
);
await expect(runInternalModelCommand("model-run-cleanup-failure")).resolves.toBeUndefined();
expect(state.removeInternalSessionEffectsSessionMock).toHaveBeenCalled();
});
it("does not duplicate finishing lifecycle when an attempt already emitted finishing", async () => {
setupModelSwitchRetry({
provider: "openai",
model: "gpt-5.4",
});
state.runAgentAttemptMock.mockImplementation(async (attemptParams: unknown) => {
state.emitAgentEventMock({
runId: "run-live-switch",
stream: "lifecycle",
data: { phase: "finishing" },
});
(attemptParams as { onAgentEvent?: (evt: unknown) => void }).onAgentEvent?.({
stream: "lifecycle",
data: { phase: "finishing" },
});
return makeSuccessResult("openai", "gpt-5.4");
});
await runBasicAgentCommand();
const lifecycleFinishingCalls = state.emitAgentEventMock.mock.calls.filter(
(call: unknown[]) => {
const arg = call[0] as { stream?: string; data?: { phase?: string } };
return arg?.stream === "lifecycle" && arg?.data?.phase === "finishing";
},
);
expect(lifecycleFinishingCalls).toHaveLength(1);
});
it.each([
{
name: "validates explicit thinking against configured model compat without an allowlist",
allowlisted: false,
},
{
name: "validates explicit thinking against allowlisted configured model compat when manifest catalog is empty",
allowlisted: true,
},
])("$name", async ({ allowlisted }) => {
state.runtimeConfigMock = createConfiguredModelCompatRuntimeConfig(allowlisted);
if (allowlisted) {
state.loadManifestModelCatalogMock.mockReturnValue([]);
}
setupSuccessfulAttempt("gmn", "gpt-5.4");
await agentCommand({
message: "hello",
to: "+1234567890",
thinking: "xhigh",
});
if (allowlisted) {
expect(state.loadManifestModelCatalogMock).toHaveBeenCalledTimes(1);
}
const thinkingArgs = requireRecord(
mockCallArg(state.isThinkingLevelSupportedMock),
"thinking args",
);
expect(thinkingArgs.provider).toBe("gmn");
expect(thinkingArgs.model).toBe("gpt-5.4");
expect(thinkingArgs.level).toBe("xhigh");
const catalog = requireArray(thinkingArgs.catalog, "thinking catalog");
expectRecordFields(catalog[0], {
provider: "gmn",
id: "gpt-5.4",
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] },
});
});
it("resolves explicit model aliases before thinking validation", async () => {
state.runtimeConfigMock = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.4" },
models: {
"openai/*": {},
"codex/gpt-5.5": {
alias: "code",
},
},
},
},
models: {
providers: {
codex: {
models: [
{
id: "gpt-5.5",
name: "GPT 5.5 Codex",
reasoning: true,
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] },
},
],
},
},
},
};
state.loadManifestModelCatalogMock.mockReturnValue([]);
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.mockResolvedValue(makeSuccessResult("codex", "gpt-5.5"));
await agentCommand({
message: "hello",
to: "+1234567890",
model: "code",
thinking: "xhigh",
allowModelOverride: true,
});
const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams;
expect(fallbackParams.provider).toBe("codex");
expect(fallbackParams.model).toBe("gpt-5.5");
const thinkingArgs = requireRecord(
mockCallArg(state.isThinkingLevelSupportedMock),
"thinking args",
);
expect(thinkingArgs.provider).toBe("codex");
expect(thinkingArgs.model).toBe("gpt-5.5");
expect(thinkingArgs.level).toBe("xhigh");
});
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).toHaveBeenCalledTimes(1);
expect(mockCallArg(state.trajectoryRecordEventMock, 0, 0)).toBe("model.fallback_step");
expectRecordFields(mockCallArg(state.trajectoryRecordEventMock, 0, 1), {
fallbackStepType: "fallback_step",
fallbackStepFromModel: "ollama/llama3",
fallbackStepToModel: "openai/gpt-5.4",
fallbackStepFromFailureReason: "overloaded",
fallbackStepChainPosition: 1,
fallbackStepFinalOutcome: "next_fallback",
});
expect(state.trajectoryFlushMock).toHaveBeenCalledTimes(1);
});
it("suppresses duplicate user persistence only after the current turn has flushed", async () => {
type AttemptCall = {
onUserMessagePersisted?: () => void;
suppressPromptPersistenceOnRetry?: boolean;
};
const attemptCalls: AttemptCall[] = [];
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
const first = await params.run(params.provider, params.model);
const result = await params.run(params.provider, params.model);
return {
result,
provider: params.provider,
model: params.model,
attempts: [first],
};
});
state.runAgentAttemptMock.mockImplementation(async (attemptParams: AttemptCall) => {
const firstAttempt = attemptCalls.length === 0;
attemptCalls.push(attemptParams);
if (firstAttempt) {
if (!attemptParams.onUserMessagePersisted) {
throw new Error("expected retry persistence callback on first attempt");
}
attemptParams.onUserMessagePersisted();
} else {
attemptParams.onUserMessagePersisted?.();
}
return makeSuccessResult("openai", "gpt-5.4");
});
await runBasicAgentCommand();
expect(attemptCalls).toHaveLength(2);
expect(attemptCalls[0]?.suppressPromptPersistenceOnRetry).not.toBe(true);
expect(attemptCalls[1]?.suppressPromptPersistenceOnRetry).toBe(true);
});
it("keeps a hook-blocked user turn suppressed across model fallback", async () => {
type AttemptCall = {
suppressPromptPersistenceOnRetry?: boolean;
userTurnTranscriptRecorder?: {
markBlocked: () => void;
};
};
const attemptCalls: AttemptCall[] = [];
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
const first = await params.run(params.provider, params.model);
const result = await params.run(params.provider, params.model);
return {
result,
provider: params.provider,
model: params.model,
attempts: [first],
};
});
state.runAgentAttemptMock.mockImplementation(async (attemptParams: AttemptCall) => {
attemptCalls.push(attemptParams);
if (attemptCalls.length === 1) {
attemptParams.userTurnTranscriptRecorder?.markBlocked();
}
return makeSuccessResult("openai", "gpt-5.4");
});
await runBasicAgentCommand();
expect(attemptCalls).toHaveLength(2);
expect(attemptCalls[1]?.userTurnTranscriptRecorder).toBe(
attemptCalls[0]?.userTurnTranscriptRecorder,
);
expect(attemptCalls[0]?.suppressPromptPersistenceOnRetry).toBe(false);
expect(attemptCalls[1]?.suppressPromptPersistenceOnRetry).toBe(true);
});
it("suppresses prompt persistence for internal handoffs on every fallback attempt", async () => {
type AttemptCall = {
suppressPromptPersistenceOnRetry?: boolean;
};
const attemptCalls: AttemptCall[] = [];
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
const first = await params.run(params.provider, params.model);
const result = await params.run(params.provider, params.model);
return {
result,
provider: params.provider,
model: params.model,
attempts: [first],
};
});
state.runAgentAttemptMock.mockImplementation(async (attemptParams: AttemptCall) => {
attemptCalls.push(attemptParams);
const result = makeSuccessResult("openai", "gpt-5.4") as ReturnType<
typeof makeSuccessResult
> & {
meta: Record<string, unknown> & { executionTrace?: Record<string, unknown> };
};
result.meta.executionTrace = {
runner: "cli",
fallbackUsed: false,
winnerProvider: "openai",
winnerModel: "gpt-5.4",
};
return result;
});
await agentCommand({
message: "internal handoff",
to: "+1234567890",
suppressPromptPersistence: true,
});
expect(attemptCalls).toHaveLength(2);
expect(attemptCalls[0]?.suppressPromptPersistenceOnRetry).toBe(true);
expect(attemptCalls[1]?.suppressPromptPersistenceOnRetry).toBe(true);
expectRecordFields(mockCallArg(state.persistCliTurnTranscriptMock), {
skipUserTurn: true,
});
});
it("preserves an explicit empty transcript message as user-turn omission", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
await agentCommand({
message: "synthetic announce prompt",
transcriptMessage: "",
to: "+1234567890",
});
const attempt = mockCallArg(state.runAgentAttemptMock) as {
suppressPromptPersistenceOnRetry?: boolean;
userTurnTranscriptRecorder?: { message?: unknown };
};
expect(attempt.suppressPromptPersistenceOnRetry).toBe(true);
expect(attempt.userTurnTranscriptRecorder?.message).toBeUndefined();
});
it("uses a tracker-only recorder for text plus image turns", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
await agentCommand({
message: "inspect this image",
transcriptMessage: "canonical image caption",
images: [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }],
to: "+1234567890",
});
const attempt = mockCallArg(state.runAgentAttemptMock) as {
transcriptBody?: string;
suppressPromptPersistenceOnRetry?: boolean;
userTurnTranscriptRecorder?: { message?: unknown };
};
expect(attempt.transcriptBody).toBe("canonical image caption");
expect(attempt.suppressPromptPersistenceOnRetry).toBe(false);
expect(attempt.userTurnTranscriptRecorder?.message).toMatchObject({
role: "user",
content: "canonical image caption",
});
});
it("persists structured transcript media without a caption", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
await agentCommand({
message: "[media attached: media://inbound/image-1]",
transcriptMessage: "",
transcriptMedia: [{ path: "/media/inbound/image-1.png", contentType: "image/png" }],
images: [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }],
to: "+1234567890",
});
const attempt = mockCallArg(state.runAgentAttemptMock) as {
suppressPromptPersistenceOnRetry?: boolean;
userTurnTranscriptRecorder?: { message?: unknown };
};
expect(attempt.suppressPromptPersistenceOnRetry).toBe(false);
expect(attempt.userTurnTranscriptRecorder?.message).toMatchObject({
role: "user",
content: "",
MediaPath: "/media/inbound/image-1.png",
MediaPaths: ["/media/inbound/image-1.png"],
MediaType: "image/png",
MediaTypes: ["image/png"],
});
});
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",
}),
).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("marks lifecycle errors aborted when cancellation reaches post-turn handling", async () => {
const abortController = new AbortController();
state.runWithModelFallbackMock.mockImplementationOnce(async () => {
abortController.abort();
throw new Error("request aborted");
});
await expect(
agentCommand({
message: "hello",
to: "+1234567890",
abortSignal: abortController.signal,
}),
).rejects.toThrow("request aborted");
expect(
state.emitAgentEventMock.mock.calls.some(([event]) => {
const candidate = event as {
stream?: string;
data?: { phase?: string; aborted?: boolean };
};
return (
candidate.stream === "lifecycle" &&
candidate.data?.phase === "error" &&
candidate.data.aborted === true
);
}),
).toBe(true);
});
it("marks direct active-run cancellation aborted without a caller signal", async () => {
state.runWithModelFallbackMock.mockRejectedValueOnce(createAgentRunDirectAbortError());
await expect(
agentCommand({
message: "hello",
to: "+1234567890",
}),
).rejects.toThrow("agent run aborted");
expect(
state.emitAgentEventMock.mock.calls.some(([event]) => {
const candidate = event as {
stream?: string;
data?: { phase?: string; aborted?: boolean; stopReason?: string };
};
return (
candidate.stream === "lifecycle" &&
candidate.data?.phase === "error" &&
candidate.data.aborted === true &&
candidate.data.stopReason === "aborted"
);
}),
).toBe(true);
});
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("does not persist a user live switch as an auto fallback probe result", async () => {
const sessionEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: Date.now(),
providerOverride: "openai",
modelOverride: "claude",
modelOverrideSource: "auto",
modelOverrideFallbackOriginProvider: "anthropic",
modelOverrideFallbackOriginModel: "claude",
skillsSnapshot: { prompt: "", skills: [], version: 0 },
};
state.sessionEntryMock = sessionEntry;
const sessionStore: Record<string, SessionEntry> = { "agent:main:main": sessionEntry };
state.sessionStoreMock = sessionStore;
state.storePathMock = "/tmp/openclaw-session-store.json";
setupModelSwitchRetry({
provider: "openai",
model: "gpt-5.4",
authProfileId: "openai:primary",
authProfileIdSource: "user",
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
await runBasicAgentCommand();
const autoPinnedSwitchWrites = state.persistSessionEntryMock.mock.calls.filter((call) => {
const entry = (call[0] as { entry?: Record<string, unknown> } | undefined)?.entry;
return (
entry?.providerOverride === "openai" &&
entry?.modelOverride === "gpt-5.4" &&
entry?.modelOverrideSource === "auto" &&
entry?.modelOverrideFallbackOriginProvider === "anthropic"
);
});
expect(autoPinnedSwitchWrites).toHaveLength(0);
expectRecordFields(mockCallArg(state.updateSessionStoreAfterAgentRunMock), {
fallbackProvider: "openai",
fallbackModel: "gpt-5.4",
});
});
it("does not overwrite a concurrent user model switch after a primary probe", async () => {
const sessionEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: Date.now(),
providerOverride: "openai",
modelOverride: "claude",
modelOverrideSource: "auto",
modelOverrideFallbackOriginProvider: "anthropic",
modelOverrideFallbackOriginModel: "claude",
skillsSnapshot: { prompt: "", skills: [], version: 0 },
};
state.sessionEntryMock = sessionEntry;
const sessionStore: Record<string, SessionEntry> = { "agent:main:main": sessionEntry };
state.sessionStoreMock = sessionStore;
state.storePathMock = "/tmp/openclaw-session-store.json";
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => {
const result = await params.run(params.provider, params.model);
sessionStore["agent:main:main"] = {
sessionId: "session-1",
updatedAt: Date.now(),
providerOverride: "google",
modelOverride: "gemini-3-pro",
modelOverrideSource: "user",
skillsSnapshot: { prompt: "", skills: [], version: 0 },
};
return {
result,
provider: params.provider,
model: params.model,
attempts: [],
};
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude"));
await runBasicAgentCommand();
expectRecordFields(sessionStore["agent:main:main"], {
providerOverride: "google",
modelOverride: "gemini-3-pro",
modelOverrideSource: "user",
});
});
it("does not persist an automatic probe result after the session becomes locked", async () => {
const sessionEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: Date.now(),
providerOverride: "openai",
modelOverride: "claude",
modelOverrideSource: "auto",
modelOverrideFallbackOriginProvider: "anthropic",
modelOverrideFallbackOriginModel: "claude",
skillsSnapshot: { prompt: "", skills: [], version: 0 },
};
state.sessionEntryMock = sessionEntry;
state.sessionStoreMock = { "agent:main:main": sessionEntry };
state.storePathMock = "/tmp/openclaw-session-store.json";
state.resolveAutoFallbackPrimaryProbeMock.mockReturnValue({
provider: "anthropic",
model: "claude",
fallbackProvider: "openai",
fallbackModel: "claude",
});
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => {
state.persistSessionEntryMock.mockClear();
const result = await params.run("openai", "claude");
const currentEntry = expectDefined(
(state.sessionStoreMock as Record<string, SessionEntry>)["agent:main:main"],
'(state.sessionStoreMock as Record<string, SessionEntry>)[ "agent:main... test invariant',
);
currentEntry.modelSelectionLocked = true;
state.isModelSelectionLockedMock.mockReturnValue(true);
return {
result,
provider: "openai",
model: "claude",
attempts: [],
};
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "claude"));
await runBasicAgentCommand();
const autoProbeWrites = state.persistSessionEntryMock.mock.calls.filter((call) => {
const entry = (call[0] as { entry?: SessionEntry } | undefined)?.entry;
return entry?.modelOverrideSource === "auto" && entry?.modelOverride === "claude";
});
expect(autoProbeWrites).toHaveLength(0);
});
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:work",
authProfileOverrideSource: "user",
skillsSnapshot: { prompt: "", skills: [], version: 0 },
} satisfies SessionEntry;
state.sessionEntryMock = sessionEntry;
state.runtimeConfigMock = {
agents: {
defaults: {
models: {
"codex-cli/gpt-5.4": {},
},
},
},
};
state.authProfileStoreMock = {
profiles: {
"openai:work": {
type: "api_key",
provider: "openai",
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("hydrates stripped persisted skill snapshots before running the CLI path", async () => {
const persistedSnapshot = {
prompt: "persisted prompt",
skills: [{ name: "cli-skill" }],
skillFilter: ["cli-skill"],
version: 0,
};
const rebuiltSkills = [
{
name: "cli-skill",
description: "CLI skill",
filePath: "/tmp/workspace/skills/cli-skill/SKILL.md",
baseDir: "/tmp/workspace/skills/cli-skill",
source: "# CLI skill",
},
];
state.sessionEntryMock = {
sessionId: "session-1",
updatedAt: Date.now(),
skillsSnapshot: persistedSnapshot,
};
state.buildWorkspaceSkillSnapshotMock.mockReturnValue({
prompt: "rebuilt prompt",
skills: [{ name: "different-skill" }],
resolvedSkills: rebuiltSkills,
version: 99,
});
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.mockResolvedValue(makeSuccessResult("anthropic", "claude"));
await runBasicAgentCommand();
const attemptParams = mockCallArg(state.runAgentAttemptMock) as {
skillsSnapshot?: Record<string, unknown>;
};
expectRecordFields(attemptParams?.skillsSnapshot, {
prompt: "persisted prompt",
skills: [{ name: "cli-skill" }],
skillFilter: ["cli-skill"],
version: 0,
resolvedSkills: rebuiltSkills,
});
expect(state.buildWorkspaceSkillSnapshotMock).toHaveBeenCalledTimes(1);
});
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,
error: "empty result",
reason: "format",
code: "empty_result",
},
],
};
});
state.runAgentAttemptMock
.mockResolvedValueOnce(makeEmptyResult("anthropic", "claude"))
.mockResolvedValueOnce(makeSuccessResult("openai", "gpt-5.4"));
await runBasicAgentCommand();
expectRecordFields(observedClassification, {
reason: "format",
code: "empty_result",
});
expect(state.runAgentAttemptMock).toHaveBeenCalledTimes(2);
expectRecordFields(mockCallArg(state.runAgentAttemptMock, 1), {
providerOverride: "openai",
modelOverride: "gpt-5.4",
isFallbackRetry: true,
});
const deliveryParams = requireRecord(
mockCallArg(state.deliverAgentCommandResultMock),
"delivery params",
);
const result = requireRecord(deliveryParams.result, "delivery result");
const meta = requireRecord(result.meta, "delivery result meta");
const agentMeta = requireRecord(meta.agentMeta, "delivery agent meta");
const fallbackAttempts = requireArray(agentMeta.fallbackAttempts, "fallback attempts");
expectRecordFields(fallbackAttempts[0], {
provider: "anthropic",
model: "claude",
reason: "format",
});
});
it("emits a failure lifecycle after delivering a preserved exhausted result", async () => {
const exhaustedResult = {
payloads: [{ text: "Terminal tool summary", isError: true }],
meta: {
durationMs: 100,
aborted: false,
stopReason: "end_turn",
error: {
kind: "incomplete_turn",
message: "All fallback candidates ended incomplete",
fallbackSafe: true,
terminalPresentation: true,
},
agentMeta: { provider: "anthropic", model: "claude" },
},
};
state.runAgentAttemptMock.mockImplementationOnce(async (attemptParams: unknown) => {
const params = attemptParams as {
deferTerminalLifecycle?: boolean;
onAgentEvent?: (event: { stream: string; data: Record<string, unknown> }) => void;
};
expect(params.deferTerminalLifecycle).toBe(true);
params.onAgentEvent?.({
stream: "lifecycle",
data: {
phase: "finishing",
error: "All fallback candidates ended incomplete",
},
});
return exhaustedResult;
});
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
outcome: "exhausted",
result: await params.run("anthropic", "claude"),
provider: "anthropic",
model: "claude",
attempts: [
{
provider: "anthropic",
model: "claude",
error: "All fallback candidates ended incomplete",
reason: "format",
},
],
}));
await runBasicAgentCommand();
expect(state.deliverAgentCommandResultMock).toHaveBeenCalledTimes(1);
const lifecycleEvents = state.emitAgentEventMock.mock.calls
.map((call) => call[0] as { stream?: string; data?: Record<string, unknown> })
.filter((event) => event.stream === "lifecycle");
expect(lifecycleEvents.some((event) => event.data?.phase === "finishing")).toBe(false);
expect(lifecycleEvents.some((event) => event.data?.phase === "end")).toBe(false);
expect(lifecycleEvents).toEqual(
expect.arrayContaining([
expect.objectContaining({
data: expect.objectContaining({
phase: "error",
error: "All fallback candidates ended incomplete",
fallbackExhaustedFailure: true,
}),
}),
]),
);
});
it("emits a failure lifecycle for completed non-fallbackable error results", async () => {
const terminalErrorResult = {
payloads: [{ text: "Command may have changed state", isError: true }],
meta: {
durationMs: 100,
aborted: false,
stopReason: "end_turn",
replayInvalid: true,
error: {
kind: "incomplete_turn",
message: "raw provider detail should stay private",
fallbackSafe: false,
},
agentMeta: { provider: "anthropic", model: "claude" },
},
};
state.runAgentAttemptMock.mockImplementationOnce(async (attemptParams: unknown) => {
const params = attemptParams as {
onAgentEvent?: (event: { stream: string; data: Record<string, unknown> }) => void;
};
params.onAgentEvent?.({
stream: "lifecycle",
data: {
phase: "finishing",
error: "Command may have changed state",
replayInvalid: true,
},
});
return terminalErrorResult;
});
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
outcome: "completed",
result: await params.run("anthropic", "claude"),
provider: "anthropic",
model: "claude",
attempts: [],
}));
await runBasicAgentCommand();
expect(state.deliverAgentCommandResultMock).toHaveBeenCalledTimes(1);
const lifecycleEvents = state.emitAgentEventMock.mock.calls
.map((call) => call[0] as { stream?: string; data?: Record<string, unknown> })
.filter((event) => event.stream === "lifecycle");
expect(lifecycleEvents).toEqual(
expect.arrayContaining([
expect.objectContaining({
data: expect.objectContaining({
phase: "error",
error: "Command may have changed state",
replayInvalid: true,
}),
}),
]),
);
expect(
lifecycleEvents.some(
(event) => event.data?.phase === "end" || event.data?.fallbackExhaustedFailure === true,
),
).toBe(false);
expect(JSON.stringify(lifecycleEvents)).not.toContain("raw provider detail");
});
it.each([
{
name: "updates hasSessionModelOverride for fallback resolution after switch",
switchOptions: { provider: "openai", model: "gpt-5.4" },
expectedOverride: true,
},
{
name: "does not flip hasSessionModelOverride on auth-only switch with same model",
switchOptions: {
provider: "anthropic",
model: "claude",
authProfileId: "profile-99",
authProfileIdSource: "user" as const,
},
expectedOverride: false,
},
{
name: "flips hasSessionModelOverride on provider-only switch with same model",
switchOptions: { provider: "openai", model: "claude" },
expectedOverride: true,
},
])("$name", async ({ switchOptions, expectedOverride }) => {
setupModelSwitchRetry(switchOptions);
state.runAgentAttemptMock.mockResolvedValue(
makeSuccessResult(switchOptions.provider, switchOptions.model),
);
state.resolveEffectiveModelFallbacksMock.mockClear();
await runBasicAgentCommand();
expectFallbackOverrideCalls(false, expectedOverride);
});
it("sends internal completion wakes to ACP sessions as plain prompt text", async () => {
setupAcpSession();
await agentCommand({
message: [
INTERNAL_RUNTIME_CONTEXT_BEGIN,
"OpenClaw runtime context (internal):",
"hidden task completion event",
INTERNAL_RUNTIME_CONTEXT_END,
].join("\n"),
sessionKey: "agent:main:main",
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 = mockCallArg(state.acpRunTurnMock) 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 = mockCallArg(state.persistAcpTurnTranscriptMock) 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("keeps session provenance for internal ACP turns", async () => {
setupAcpSession();
await agentCommand({
message: "internal ACP turn",
sessionKey: "agent:main:main",
sessionEffects: "internal",
});
expect(state.registerAgentRunContextMock).toHaveBeenCalledWith(
"session-1",
expect.objectContaining({
sessionKey: "agent:main:main",
sessionId: "session-1",
isControlUiVisible: false,
}),
);
});
it("allows manual ACP spawn turns when ACP dispatch is disabled", async () => {
setupAcpSession();
state.resolveAcpDispatchPolicyErrorMock.mockReturnValue(
new Error("ACP dispatch is disabled by policy (`acp.dispatch.enabled=false`)."),
);
await agentCommand({
message: "bootstrap ACP child",
sessionKey: "agent:main:main",
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 () => {
setupAcpSession();
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:main",
}),
).rejects.toThrow("ACP dispatch is disabled");
expect(state.resolveAcpExplicitTurnPolicyErrorMock).not.toHaveBeenCalled();
expect(state.resolveAcpDispatchPolicyErrorMock).toHaveBeenCalledTimes(1);
expect(state.acpRunTurnMock).not.toHaveBeenCalled();
expect(state.emitAcpLifecycleErrorMock).toHaveBeenCalledWith(
expect.objectContaining({ terminalOutcome: "blocked" }),
);
});
it("preserves ACP cancelled results without a stop reason", async () => {
setupAcpSession();
state.acpRunTurnMock.mockImplementationOnce(async (params: unknown) => {
const onEvent = (params as { onEvent?: (event: unknown) => void }).onEvent;
onEvent?.({ type: "done", status: "cancelled" });
});
await agentCommand({
message: "cancelled ACP turn",
sessionKey: "agent:main:main",
});
expect(state.emitAcpLifecycleEndMock).toHaveBeenCalledWith(
expect.objectContaining({ resultStatus: "cancelled", stopReason: undefined }),
);
expect(state.buildAcpResultMock).toHaveBeenCalledWith(
expect.objectContaining({ resultStatus: "cancelled", stopReason: undefined }),
);
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */