/** Tests live model switching behavior in active agent command sessions. */ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../config/sessions.js"; import { INTERNAL_RUNTIME_CONTEXT_BEGIN, INTERNAL_RUNTIME_CONTEXT_END } from "./internal-events.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), applyModelOverrideToSessionEntryMock: 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(), updateSessionStoreAfterAgentRunMock: vi.fn(), deliverAgentCommandResultMock: vi.fn(), resolveAgentDeliveryPlanMock: vi.fn(), resolveAgentOutboundTargetMock: vi.fn(), resolveMessageChannelSelectionMock: vi.fn(), trajectoryRecordEventMock: vi.fn(), trajectoryFlushMock: vi.fn(async () => undefined), persistSessionEntryMock: vi.fn(async (..._args: unknown[]): Promise => 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, })), prepareInternalSessionEffectsTranscriptMock: vi.fn(), removeInternalSessionEffectsTranscriptMock: vi.fn(), authProfileStoreMock: { profiles: {} } as { profiles: Record }, sessionEntryMock: undefined as SessionEntry | undefined, sessionStoreMock: undefined as unknown, storePathMock: undefined as string | undefined, resolvedSessionKeyMock: undefined as string | undefined, })); 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( "./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: ({ sessionKey }: { sessionKey: string }) => (state.sessionStoreMock as Record | undefined)?.[sessionKey], 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", () => ({})); vi.mock("./harness/runtime-plugin.js", () => ({ ensureSelectedAgentHarnessPlugin: vi.fn(async () => 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("../config/runtime-snapshot.js", () => ({ setRuntimeConfigSnapshot: vi.fn(), })); vi.mock("../config/sessions.js", () => ({ resolveAgentIdFromSessionKey: () => "default", mergeSessionEntry: (a: unknown, b: unknown) => ({ ...(a as object), ...(b as object) }), updateSessionStore: vi.fn( async (_path: string, fn: (store: Record) => unknown) => { const store: Record = {}; return fn(store); }, ), })); vi.mock("../config/sessions/transcript-resolve.runtime.js", () => ({ resolveSessionTranscriptFile: async () => ({ sessionFile: "/tmp/session.jsonl", sessionEntry: { sessionId: "session-1", updatedAt: Date.now() }, }), })); vi.mock("./internal-session-effects.js", () => ({ prepareInternalSessionEffectsTranscript: (...args: unknown[]) => state.prepareInternalSessionEffectsTranscriptMock(...args), removeInternalSessionEffectsTranscript: (...args: unknown[]) => state.removeInternalSessionEffectsTranscriptMock(...args), })); 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", onAgentEvent: 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), 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( "../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), repairProviderWrappedModelOverride: () => ({ updated: false }), })); vi.mock("../sessions/send-policy.js", () => ({ resolveSendPolicy: () => "allow", })); vi.mock("../terminal/ansi.js", () => ({ sanitizeForLog: (s: string) => s, })); vi.mock("../trajectory/runtime.js", () => ({ createTrajectoryRuntimeRecorder: () => ({ enabled: true, filePath: "/tmp/session.trajectory.jsonl", recordEvent: (...args: unknown[]) => state.trajectoryRecordEventMock(...args), flush: () => state.trajectoryFlushMock(), }), })); vi.mock("../utils/message-channel.js", () => ({ INTERNAL_MESSAGE_CHANNEL: "internal", isDeliverableMessageChannel: (value: string) => value !== "internal", normalizeMessageChannel: (value?: string | null) => value?.trim().toLowerCase() || undefined, resolveMessageChannel: (...values: Array) => 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", 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", })); vi.mock("./lanes.js", () => ({ AGENT_LANE_SUBAGENT: "subagent", })); vi.mock("./model-catalog.js", () => ({ loadManifestModelCatalog: state.loadManifestModelCatalogMock, })); vi.mock("./model-selection.js", () => { const normalizeProviderId = (provider: string) => provider.trim().toLowerCase(); const buildAllowedModelSet = ({ cfg, catalog, defaultProvider, defaultModel, }: { cfg?: unknown; catalog?: Array<{ provider: string; id: string }>; defaultProvider: string; defaultModel?: string; }) => { const modelMap = (cfg as { agents?: { defaults?: { models?: Record } } } | undefined)?.agents ?.defaults?.models ?? {}; const configuredCatalog = ( (cfg as { models?: { providers?: Record } } | undefined) ?.models?.providers ? Object.entries( (cfg as { models?: { providers?: Record } }).models! .providers!, ).flatMap(([provider, entry]) => Array.isArray(entry?.models) ? entry.models .filter( (model): model is Record => Boolean(model) && typeof model === "object", ) .map((model) => { const id = typeof model.id === "string" ? model.id : ""; return { provider, id, name: typeof model.name === "string" ? model.name : id, reasoning: typeof model.reasoning === "boolean" ? model.reasoning : undefined, compat: model.compat, }; }) .filter((model) => model.id) : [], ) : [] ) as Array<{ provider: string; id: string }>; const combinedCatalog = [...(catalog ?? []), ...configuredCatalog]; const allowedKeys = new Set( Object.keys(modelMap).map((ref) => { const [provider, ...modelParts] = ref.split("/"); return `${provider}/${modelParts.join("/")}`; }), ); if (defaultModel) { allowedKeys.add(`${defaultProvider}/${defaultModel}`); } if (Object.keys(modelMap).length === 0) { return { allowedKeys, allowedCatalog: combinedCatalog, allowAny: true, }; } return { allowedKeys, allowedCatalog: combinedCatalog.filter((entry) => allowedKeys.has(`${entry.provider}/${entry.id}`), ), allowAny: false, }; }; return { buildAllowedModelSet, createModelVisibilityPolicy: (params: { cfg?: unknown; catalog?: Array<{ provider: string; id: string }>; defaultProvider: string; defaultModel?: string; }) => { const allowed = buildAllowedModelSet(params); const allowsKey = (key: string) => { if (allowed.allowAny || allowed.allowedKeys.has(key)) { return true; } const slash = key.indexOf("/"); return slash > 0 && allowed.allowedKeys.has(`${key.slice(0, slash)}/*`); }; return { ...allowed, exactModelRefs: [], providerWildcards: new Set(), hasConfiguredEntries: !allowed.allowAny, hasProviderWildcards: [...allowed.allowedKeys].some((key) => key.endsWith("/*")), allowsKey, allows: ({ provider, model }: { provider: string; model: string }) => allowsKey(`${provider}/${model}`), resolveSelection: ({ provider, model }: { provider: string; model: string }) => { const key = `${provider}/${model}`; if (allowsKey(key)) { return { provider, model }; } const fallback = allowed.allowedCatalog[0]; return fallback ? { provider: fallback.provider, model: fallback.id } : null; }, visibleCatalog: ({ catalog }: { catalog: Array<{ provider: string; id: string }> }) => catalog, }; }, buildConfiguredModelCatalog: ({ cfg }: { cfg?: unknown }) => { const providers = (cfg as { models?: { providers?: Record } }) ?.models?.providers; if (!providers) { return []; } return Object.entries(providers).flatMap(([provider, entry]) => Array.isArray(entry?.models) ? entry.models .filter( (model): model is Record => Boolean(model) && typeof model === "object", ) .map((model) => { const id = typeof model.id === "string" ? model.id : ""; return { provider, id, name: typeof model.name === "string" ? model.name : id, reasoning: typeof model.reasoning === "boolean" ? model.reasoning : undefined, compat: model.compat, }; }) .filter((model) => model.id) : [], ); }, isModelKeyAllowedBySet: (allowedKeys: ReadonlySet, key: string) => { if (allowedKeys.has(key)) { return true; } const slash = key.indexOf("/"); return slash > 0 && allowedKeys.has(`${key.slice(0, slash)}/*`); }, resolveAllowedModelSelection: ({ provider, model, allowAny, allowedKeys, allowedCatalog, }: { provider: string; model: string; allowAny: boolean; allowedKeys: ReadonlySet; allowedCatalog: Array<{ provider: string; id: string }>; }) => { const key = `${provider}/${model}`; if ( allowAny || allowedKeys.has(key) || (key.includes("/") && allowedKeys.has(`${key.slice(0, key.indexOf("/"))}/*`)) ) { return { provider, model }; } const fallback = allowedCatalog[0]; return fallback ? { provider: fallback.provider, model: fallback.id } : null; }, buildModelAliasIndex: ({ cfg, }: { cfg?: { agents?: { defaults?: { models?: Record } } }; }) => { const byAlias = new Map< string, { alias: string; ref: { provider: string; model: string } } >(); const byKey = new Map(); for (const [ref, entry] of Object.entries(cfg?.agents?.defaults?.models ?? {})) { const alias = entry?.alias?.trim(); if (!alias) { continue; } const [provider, ...modelParts] = ref.split("/"); const model = modelParts.join("/"); byAlias.set(alias.toLowerCase(), { alias, ref: { provider, model } }); byKey.set(`${provider}/${model}`, [alias]); } return { byAlias, byKey }; }, modelKey: (p: string, m: string) => `${p}/${m}`, normalizeModelRef: (p: string, m: string) => ({ provider: normalizeProviderId(p), model: m }), normalizeProviderId, normalizeProviderIdForAuth: normalizeProviderId, parseModelRef: (m: string, p: string) => { const slash = m.indexOf("/"); return slash > 0 ? { provider: m.slice(0, slash), model: m.slice(slash + 1) } : { provider: p, model: m }; }, resolveModelRefFromString: ({ raw, defaultProvider, aliasIndex, }: { raw: string; defaultProvider: string; aliasIndex?: { byAlias: Map; }; }) => { const aliasMatch = aliasIndex?.byAlias.get(raw.trim().toLowerCase()); if (aliasMatch) { return { ref: aliasMatch.ref, alias: aliasMatch.alias }; } const slash = raw.indexOf("/"); return { ref: slash > 0 ? { provider: raw.slice(0, slash), model: raw.slice(slash + 1) } : { provider: defaultProvider, model: raw }, }; }, resolveConfiguredModelRef: ({ cfg }: { cfg?: unknown }) => { const raw = (cfg as { agents?: { defaults?: { model?: string | { primary?: string } } } }) ?.agents?.defaults?.model; const primary = typeof raw === "string" ? raw : raw?.primary; const [provider, ...modelParts] = (primary ?? "anthropic/claude").split("/"); return { provider, model: modelParts.join("/") || "claude" }; }, resolveDefaultModelForAgent: ({ cfg }: { cfg?: unknown }) => { const raw = (cfg as { agents?: { defaults?: { model?: string | { primary?: string } } } }) ?.agents?.defaults?.model; const primary = typeof raw === "string" ? raw : raw?.primary; const [provider, ...modelParts] = (primary ?? "anthropic/claude").split("/"); return { provider, model: modelParts.join("/") || "claude" }; }, resolveThinkingDefault: (args: unknown) => state.resolveThinkingDefaultMock(args), }; }); vi.mock("./model-visibility-policy.js", () => ({ createModelVisibilityPolicy: ({ cfg, catalog, defaultProvider, defaultModel, }: { cfg?: unknown; catalog?: Array<{ provider: string; id: string }>; defaultProvider: string; defaultModel?: string; }) => { const modelMap = (cfg as { agents?: { defaults?: { models?: Record } } } | undefined)?.agents ?.defaults?.models ?? {}; const allowedKeys = new Set( Object.keys(modelMap).map((ref) => { const [provider, ...modelParts] = ref.split("/"); return `${provider}/${modelParts.join("/")}`; }), ); if (defaultModel) { allowedKeys.add(`${defaultProvider}/${defaultModel}`); } const allowAny = Object.keys(modelMap).length === 0; const allowedCatalog = allowAny ? (catalog ?? []) : (catalog ?? []).filter((entry) => allowedKeys.has(`${entry.provider}/${entry.id}`)); const allowsKey = (key: string) => { if (allowAny || allowedKeys.has(key)) { return true; } const slash = key.indexOf("/"); return slash > 0 && allowedKeys.has(`${key.slice(0, slash)}/*`); }; return { allowAny, allowedKeys, allowedCatalog, exactModelRefs: [], providerWildcards: new Set(), hasConfiguredEntries: !allowAny, hasProviderWildcards: [...allowedKeys].some((key) => key.endsWith("/*")), allowsKey, allows: ({ provider, model }: { provider: string; model: string }) => allowsKey(`${provider}/${model}`), resolveSelection: ({ provider, model }: { provider: string; model: string }) => { const key = `${provider}/${model}`; if (allowsKey(key)) { return { provider, model }; } const fallback = allowedCatalog[0]; return fallback ? { provider: fallback.provider, model: fallback.id } : null; }, visibleCatalog: ({ catalog: catalogLocal, }: { catalog: Array<{ provider: string; id: string }>; }) => catalogLocal, }; }, })); 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; run: (provider: string, model: string) => Promise; onFallbackStep?: (step: Record) => void | Promise; classifyResult?: (params: { provider: string; model: string; result: unknown; attempt: number; total: number; }) => unknown; }; type ModelSwitchOptions = ConstructorParameters[0]; function makeSuccessResult(provider: string, model: string) { return { payloads: [{ text: "ok" }], meta: { durationMs: 100, aborted: false, stopReason: "end_turn", agentMeta: { provider, model }, }, }; } function makeEmptyResult(provider: string, model: string) { return { payloads: [], meta: { durationMs: 30_000, aborted: false, stopReason: "end_turn", agentHarnessResultClassification: "empty", agentMeta: { provider, model }, }, }; } function setupModelSwitchRetry(switchOptions: ModelSwitchOptions) { let invocation = 0; state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => { invocation += 1; if (invocation === 1) { throw new LiveSessionModelSwitchError(switchOptions); } const result = await params.run(params.provider, params.model); return { result, provider: params.provider, model: params.model, attempts: [], }; }); } 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 requireRecord(value: unknown, label: string): Record { if (!value || typeof value !== "object") { throw new Error(`expected ${label} to be an object`); } return value as Record; } 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, 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): 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 setupSessionTouchStore(): void { const sessionEntry: SessionEntry = { sessionId: "session-1", updatedAt: 1, skillsSnapshot: { prompt: "", skills: [], version: 0 }, }; state.sessionEntryMock = sessionEntry; state.sessionStoreMock = { "agent:main:main": sessionEntry }; state.storePathMock = "/tmp/openclaw-sessions.json"; } 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.applyModelOverrideToSessionEntryMock.mockReturnValue({ updated: false }); state.resolveAutoFallbackPrimaryProbeMock.mockReturnValue(undefined); state.resolveChannelModelOverrideMock.mockImplementation((params: unknown) => { const input = params as { cfg?: { channels?: { modelByChannel?: Record> } }; 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; 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.resolveAgentDeliveryPlanMock.mockImplementation( (params: { accountId?: string; explicitThreadId?: string | number; explicitTo?: string; requestedChannel?: string; sessionEntry?: SessionEntry; }) => { const context = params.sessionEntry?.deliveryContext; const channel = params.requestedChannel ?? context?.channel ?? params.sessionEntry?.lastChannel ?? "internal"; const to = params.explicitTo ?? context?.to ?? params.sessionEntry?.lastTo; const accountId = params.accountId ?? context?.accountId ?? params.sessionEntry?.lastAccountId; const threadId = params.explicitThreadId ?? context?.threadId ?? params.sessionEntry?.lastThreadId; return { baseDelivery: {}, resolvedChannel: channel, resolvedTo: to, resolvedAccountId: accountId, resolvedThreadId: threadId, deliveryTargetMode: params.explicitTo ? "explicit" : to ? "implicit" : undefined, }; }, ); state.updateSessionStoreAfterAgentRunMock.mockResolvedValue(undefined); state.trajectoryFlushMock.mockResolvedValue(undefined); state.prepareInternalSessionEffectsTranscriptMock.mockResolvedValue( "/tmp/openclaw-internal-run.jsonl", ); state.removeInternalSessionEffectsTranscriptMock.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("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("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 }) .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 () => { state.acpResolveSessionMock.mockReturnValue({ kind: "ready", meta: { agent: "claude", cwd: "/tmp/workspace", }, }); 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 () => { state.acpResolveSessionMock.mockReturnValue({ kind: "ready", meta: { agent: "claude", cwd: "/tmp/workspace", }, }); 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 () => { state.acpResolveSessionMock.mockReturnValue({ kind: "ready", meta: { agent: "claude", cwd: "/tmp/workspace", }, }); 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 () => { state.acpResolveSessionMock.mockReturnValue({ kind: "ready", meta: { agent: "claude", cwd: "/tmp/workspace", }, }); 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" }], mediaOnlyText: "[User sent media without caption]", }, }), ); }); it("keeps the initial session touch for local runs", async () => { setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4")); setupSessionTouchStore(); await runBasicAgentCommand(); const touchWrites = state.persistSessionEntryMock.mock.calls.filter((call) => { const entry = (call[0] as { entry?: Record } | 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")); setupSessionTouchStore(); 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("rejects unsupported explicit thinking for interactive subagent-key runs", async () => { setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude-fable-5")); state.resolvedSessionKeyMock = "agent:planner:subagent:00000000-0000-4000-8000-000000000000"; state.isThinkingLevelSupportedMock.mockReturnValue(false); await expect( agentCommand({ message: "hello", sessionKey: state.resolvedSessionKeyMock, thinking: "xhigh", }), ).rejects.toThrow(/is not supported/u); expect(state.runAgentAttemptMock).not.toHaveBeenCalled(); }); it("rejects unsupported explicit thinking for non-subagent sessions on the subagent lane", async () => { setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude-fable-5")); state.resolvedSessionKeyMock = "agent:main:main"; state.isThinkingLevelSupportedMock.mockReturnValue(false); await expect( agentCommand({ message: "hello", sessionKey: state.resolvedSessionKeyMock, thinking: "xhigh", lane: "subagent", }), ).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); await expect( agentCommand({ message: "hello", to: "+1234567890", thinking: "xhigh", }), ).rejects.toThrow(/is not supported/u); expect(state.runAgentAttemptMock).not.toHaveBeenCalled(); }); it("skips the initial session touch after gateway ingress already persisted activity", async () => { setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4")); setupSessionTouchStore(); await agentCommand({ message: "hello", to: "+1234567890", skipInitialSessionTouch: true, }); const touchWrites = state.persistSessionEntryMock.mock.calls.filter((call) => { const entry = (call[0] as { entry?: Record } | undefined)?.entry; return entry?.lastInteractionAt !== undefined; }); expect(touchWrites).toHaveLength(0); expect(state.updateSessionStoreAfterAgentRunMock).toHaveBeenCalledTimes(1); }); it("uses channel model override as the initial run model for channel-backed sessions", async () => { setupSingleAttemptFallback(); state.runtimeConfigMock = { agents: { defaults: { model: "anthropic/default-model", models: { "anthropic/default-model": {}, "openai/channel-model": {}, }, }, }, channels: { modelByChannel: { discord: { "channel-123": "openai/channel-model", }, }, }, }; state.sessionEntryMock = { sessionId: "session-1", updatedAt: 1, channel: "discord", groupId: "channel-123", skillsSnapshot: { prompt: "", skills: [], version: 0 }, }; state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "channel-model")); await runBasicAgentCommand(); expect(mockCallArg(state.resolveChannelModelOverrideMock)).toMatchObject({ channel: "discord", groupId: "channel-123", }); const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams; expect(fallbackParams.provider).toBe("openai"); expect(fallbackParams.model).toBe("channel-model"); expectRecordFields(mockCallArg(state.runAgentAttemptMock), { providerOverride: "openai", modelOverride: "channel-model", }); }); it("uses current run channel context when persisted session metadata is absent", async () => { setupSingleAttemptFallback(); state.runtimeConfigMock = { agents: { defaults: { model: "anthropic/default-model", models: { "anthropic/default-model": {}, "openai/channel-model": {}, }, }, }, channels: { modelByChannel: { discord: { "channel-123": "openai/channel-model", }, }, }, }; state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "channel-model")); await agentCommand({ message: "hello", channel: "discord", groupId: "channel-123", to: "discord:channel:channel-123", }); const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams; expect(fallbackParams.provider).toBe("openai"); expect(fallbackParams.model).toBe("channel-model"); }); it("keeps persisted channel model override when current run context is internal", async () => { setupSingleAttemptFallback(); state.runtimeConfigMock = { agents: { defaults: { model: "anthropic/default-model", models: { "anthropic/default-model": {}, "openai/channel-model": {}, }, }, }, channels: { modelByChannel: { discord: { "channel-123": "openai/channel-model", }, }, }, }; state.sessionEntryMock = { sessionId: "session-1", updatedAt: 1, channel: "discord", groupId: "channel-123", skillsSnapshot: { prompt: "", skills: [], version: 0 }, }; state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "channel-model")); await agentCommand({ message: "hello", channel: "internal", messageChannel: "internal", to: "internal", }); expect(mockCallArg(state.resolveChannelModelOverrideMock)).toMatchObject({ channel: "discord", groupId: "channel-123", }); const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams; expect(fallbackParams.provider).toBe("openai"); expect(fallbackParams.model).toBe("channel-model"); }); it("uses channel model override after ignoring stale legacy fallback overrides", async () => { setupSingleAttemptFallback(); state.hasLegacyAutoFallbackWithoutOriginMock.mockReturnValue(true); state.runtimeConfigMock = { agents: { defaults: { model: "anthropic/default-model", models: { "anthropic/default-model": {}, "openai/channel-model": {}, }, }, }, channels: { modelByChannel: { discord: { "channel-123": "openai/channel-model", }, }, }, }; state.sessionEntryMock = { sessionId: "session-1", updatedAt: 1, channel: "discord", groupId: "channel-123", providerOverride: "anthropic", modelOverride: "stale-fallback-model", skillsSnapshot: { prompt: "", skills: [], version: 0 }, }; state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "channel-model")); await runBasicAgentCommand(); 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 = { agents: { defaults: { model: "anthropic/default-model", models: { "anthropic/default-model": {}, "anthropic/fallback-model": {}, "openai/channel-model": {}, }, }, }, channels: { modelByChannel: { discord: { "channel-123": "openai/channel-model", }, }, }, }; state.sessionEntryMock = { 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 = { agents: { defaults: { model: "anthropic/default-model", models: { "anthropic/default-model": {}, "openai/parent-channel-model": {}, }, }, }, channels: { modelByChannel: { slack: { general: "openai/parent-channel-model", }, }, }, }; state.resolvedSessionKeyMock = "agent:main:slack:channel:general:thread:thread-1"; state.sessionEntryMock = { 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 = { agents: { defaults: { model: "anthropic/default-model", models: { "anthropic/default-model": {}, "openai/channel-model": {}, "anthropic/stored-model": {}, }, }, }, channels: { modelByChannel: { discord: { "channel-123": "openai/channel-model", }, }, }, }; state.sessionEntryMock = { 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 = { agents: { defaults: { model: "anthropic/default-model", models: { "anthropic/default-model": {}, "openai/channel-model": {}, "openai/explicit-model": {}, }, }, }, channels: { modelByChannel: { discord: { "channel-123": "openai/channel-model", }, }, }, }; state.sessionEntryMock = { 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(); setupSessionTouchStore(); 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 & { agentMeta: Record }; }; 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(); setupSessionTouchStore(); const result = makeSuccessResult("openai", "gpt-5.4") as ReturnType< typeof makeSuccessResult > & { meta: Record & { executionTrace: Record }; }; 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("does not treat backend CLI session id as OpenClaw session identity", async () => { setupSingleAttemptFallback(); setupSessionTouchStore(); const result = makeSuccessResult("openai", "gpt-5.4") as ReturnType< typeof makeSuccessResult > & { meta: Record & { agentMeta: Record }; }; 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")); setupSessionTouchStore(); 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 } | undefined)?.entry; return entry?.thinkingLevel === "medium"; })?.[0] as { entry?: Record } | undefined; expect(touchWrite?.entry?.lastInteractionAt).toBeDefined(); expect(state.updateSessionStoreAfterAgentRunMock).toHaveBeenCalledTimes(1); }); it("does not persist turn-local thinking fallback over a stored session override", async () => { setupSingleAttemptFallback(); const sessionEntry: SessionEntry = { sessionId: "session-1", updatedAt: 1, skillsSnapshot: { prompt: "", skills: [], version: 0 }, thinkingLevel: "high", }; const sessionStore: Record = { "agent:main:main": sessionEntry }; state.sessionEntryMock = sessionEntry; state.sessionStoreMock = sessionStore; state.storePathMock = "/tmp/openclaw-sessions.json"; 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("persists and clears current run delivery context for restart recovery", async () => { setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4")); const sessionEntry: SessionEntry = { sessionId: "session-1", updatedAt: 1, }; state.sessionEntryMock = sessionEntry; state.sessionStoreMock = { "agent:main:main": sessionEntry }; state.storePathMock = "/tmp/openclaw-sessions.json"; 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 stored = (state.sessionStoreMock as Record)["agent:main:main"]; expect(stored?.restartRecoveryDeliveryContext).toBeUndefined(); }); it("preserves parsed explicit target threads for restart recovery", async () => { setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4")); const sessionEntry: SessionEntry = { sessionId: "session-1", updatedAt: 1, }; state.sessionEntryMock = sessionEntry; state.sessionStoreMock = { "agent:main:main": sessionEntry }; state.storePathMock = "/tmp/openclaw-sessions.json"; 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 agentCommand({ message: "hello", channel: "discord", to: "discord:channel:general/thread:thread-1", accountId: "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", }); }); it("does not inherit a stale thread when restart recovery uses an explicit target", async () => { setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4")); const sessionEntry: SessionEntry = { sessionId: "session-1", updatedAt: 1, lastThreadId: "stale-thread", }; state.sessionEntryMock = sessionEntry; state.sessionStoreMock = { "agent:main:main": sessionEntry }; state.storePathMock = "/tmp/openclaw-sessions.json"; state.deliverAgentCommandResultMock.mockResolvedValue({ deliverySucceeded: true }); await agentCommand({ message: "hello", channel: "discord", to: "discord:dm:123", accountId: "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:dm:123", accountId: "main", }); }); it("persists implicit session delivery route for restart recovery", async () => { setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4")); const sessionEntry: SessionEntry = { sessionId: "session-1", updatedAt: 1, deliveryContext: { channel: "discord", to: "discord:channel:general", accountId: "main", threadId: "thread-1", }, }; state.sessionEntryMock = sessionEntry; state.sessionStoreMock = { "agent:main:main": sessionEntry }; state.storePathMock = "/tmp/openclaw-sessions.json"; 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({ deliveryContext: 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")); const sessionEntry: SessionEntry = { sessionId: "session-1", updatedAt: 1, }; state.sessionEntryMock = sessionEntry; state.sessionStoreMock = { "agent:main:main": sessionEntry }; state.storePathMock = "/tmp/openclaw-sessions.json"; 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("does not overwrite another active run's restart recovery context", async () => { setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeEmptyResult("openai", "gpt-5.4")); const staleEntry: SessionEntry = { sessionId: "session-1", updatedAt: 1, }; const laterRunEntry: SessionEntry = { sessionId: "session-1", updatedAt: 2, restartRecoveryDeliveryContext: { channel: "discord", to: "discord:dm:456", accountId: "main", }, restartRecoveryDeliveryRunId: "later-run", }; const sessionStore: Record = { "agent:main:main": laterRunEntry, }; state.sessionEntryMock = staleEntry; state.sessionStoreMock = sessionStore; state.storePathMock = "/tmp/openclaw-sessions.json"; await agentCommand({ message: "hello", sessionKey: "agent:main:main", channel: "discord", to: "discord:dm:123", accountId: "main", deliver: true, runId: "stale-run", }); expect(sessionStore["agent:main:main"]?.restartRecoveryDeliveryContext).toEqual( laterRunEntry.restartRecoveryDeliveryContext, ); expect(sessionStore["agent:main:main"]?.restartRecoveryDeliveryRunId).toBe("later-run"); }); it("does not clear another active run's restart recovery context", async () => { setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeEmptyResult("openai", "gpt-5.4")); const staleEntry: SessionEntry = { sessionId: "session-1", updatedAt: 1, }; const laterRunEntry: SessionEntry = { sessionId: "session-1", updatedAt: 2, restartRecoveryDeliveryContext: { channel: "discord", to: "discord:dm:456", accountId: "main", }, restartRecoveryDeliveryRunId: "later-run", }; const sessionStore: Record = { "agent:main:main": laterRunEntry, }; state.sessionEntryMock = staleEntry; state.sessionStoreMock = sessionStore; state.storePathMock = "/tmp/openclaw-sessions.json"; await agentCommand({ message: "hello", sessionKey: "agent:main:main", deliver: true, 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 sessionEntry: SessionEntry = { sessionId: "session-1", updatedAt: 1, }; const sessionStore: Record = { "agent:main:main": sessionEntry }; state.sessionEntryMock = sessionEntry; state.sessionStoreMock = sessionStore; state.storePathMock = "/tmp/openclaw-sessions.json"; state.deliverAgentCommandResultMock.mockImplementation(async () => { const current = sessionStore["agent:main:main"]; if (current) { current.abortedLastRun = true; } return { deliverySucceeded: false }; }); await agentCommand({ message: "hello", channel: "discord", to: "discord:dm:123", accountId: "main", deliver: true, }); 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 sessionEntry: SessionEntry = { sessionId: "session-1", updatedAt: 1, }; const sessionStore: Record = { "agent:main:main": sessionEntry }; state.sessionEntryMock = sessionEntry; state.sessionStoreMock = sessionStore; state.storePathMock = "/tmp/openclaw-sessions.json"; state.deliverAgentCommandResultMock.mockImplementation(async () => { delete sessionStore["agent:main:main"]; return { deliverySucceeded: true }; }); await agentCommand({ message: "hello", channel: "discord", to: "discord:dm:123", accountId: "main", deliver: true, }); 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 sessionEntry: SessionEntry = { sessionId: "session-1", updatedAt: 1, }; const rotatedEntry: SessionEntry = { sessionId: "session-2", updatedAt: 2, restartRecoveryDeliveryContext: { channel: "discord", to: "discord:dm:456", accountId: "main", }, }; const sessionStore: Record = { "agent:main:main": sessionEntry }; state.sessionEntryMock = sessionEntry; state.sessionStoreMock = sessionStore; state.storePathMock = "/tmp/openclaw-sessions.json"; state.deliverAgentCommandResultMock.mockImplementation(async () => { sessionStore["agent:main:main"] = rotatedEntry; return { deliverySucceeded: true }; }); await agentCommand({ message: "hello", channel: "discord", to: "discord:dm:123", accountId: "main", deliver: true, }); 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 sessionEntry: SessionEntry = { sessionId: "session-1", updatedAt: 1, }; const laterRunEntry: SessionEntry = { sessionId: "session-1", updatedAt: 2, restartRecoveryDeliveryContext: { channel: "discord", to: "discord:dm:456", accountId: "main", }, restartRecoveryDeliveryRunId: "later-run", }; const sessionStore: Record = { "agent:main:main": sessionEntry }; state.sessionEntryMock = sessionEntry; state.sessionStoreMock = sessionStore; state.storePathMock = "/tmp/openclaw-sessions.json"; state.deliverAgentCommandResultMock.mockImplementation(async () => { sessionStore["agent:main:main"] = laterRunEntry; return { deliverySucceeded: false }; }); await agentCommand({ message: "hello", channel: "discord", to: "discord:dm:123", accountId: "main", deliver: true, }); expect(sessionStore["agent:main:main"]).toEqual(laterRunEntry); }); it("stores pending final delivery with the current run delivery context", async () => { setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4")); const sessionEntry: SessionEntry = { sessionId: "session-1", updatedAt: 1, }; state.sessionEntryMock = sessionEntry; state.sessionStoreMock = { "agent:main:main": sessionEntry }; state.storePathMock = "/tmp/openclaw-sessions.json"; state.deliverAgentCommandResultMock.mockResolvedValue({ deliverySucceeded: false }); await agentCommand({ message: "hello", channel: "discord", to: "discord:dm:123", accountId: "main", deliver: true, }); 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: "discord:dm:123", accountId: "main", }, }), ); }); it("clears stale flag-only pending final delivery when there is no final payload", async () => { setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeEmptyResult("openai", "gpt-5.4")); const sessionEntry: SessionEntry = { sessionId: "session-1", updatedAt: 1, pendingFinalDelivery: true, pendingFinalDeliveryCreatedAt: 2, pendingFinalDeliveryLastAttemptAt: 3, pendingFinalDeliveryAttemptCount: 4, pendingFinalDeliveryLastError: "previous failure", pendingFinalDeliveryContext: { channel: "tui" }, pendingFinalDeliveryIntentId: "intent-1", }; state.sessionEntryMock = sessionEntry; state.sessionStoreMock = { "agent:main:main": sessionEntry }; state.storePathMock = "/tmp/openclaw-sessions.json"; state.deliverAgentCommandResultMock.mockResolvedValue(undefined); await agentCommand({ message: "hello", 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("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 = { "agent:main:main": visibleEntry }; state.sessionEntryMock = visibleEntry; state.sessionStoreMock = sessionStore; state.storePathMock = "/tmp/openclaw-session-store.json"; 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.prepareInternalSessionEffectsTranscriptMock).toHaveBeenCalledWith({ sessionFile: "/tmp/session.jsonl", runId: expect.any(String), }); expect(attemptCalls).toHaveLength(1); expect(attemptCalls[0]?.sessionFile).toBe("/tmp/openclaw-internal-run.jsonl"); expect(attemptCalls[0]?.sessionEntry).toStrictEqual(visibleEntry); 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, }), ); }); 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("validates explicit thinking against configured model compat without an allowlist", async () => { state.runtimeConfigMock = { agents: { defaults: { model: { primary: "gmn/gpt-5.4" }, }, }, models: { providers: { gmn: { models: [ { id: "gpt-5.4", name: "GPT 5.4 via GMN", reasoning: true, compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] }, }, ], }, }, }, }; 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("gmn", "gpt-5.4")); await agentCommand({ message: "hello", to: "+1234567890", thinking: "xhigh", }); 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("validates explicit thinking against allowlisted configured model compat when manifest catalog is empty", async () => { state.runtimeConfigMock = { agents: { defaults: { model: { primary: "gmn/gpt-5.4" }, models: { "gmn/gpt-5.4": {}, }, }, }, models: { providers: { gmn: { models: [ { id: "gpt-5.4", name: "GPT 5.4 via GMN", 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("gmn", "gpt-5.4")); await agentCommand({ message: "hello", to: "+1234567890", thinking: "xhigh", }); 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 & { executionTrace?: Record }; }; 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: "[User sent media without caption]", 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 = { "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 } | 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 = { "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("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; }; 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 }) => 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 }) .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 }) => 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 }) .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("updates hasSessionModelOverride for fallback resolution after switch", async () => { setupModelSwitchRetry({ provider: "openai", model: "gpt-5.4", }); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4")); state.resolveEffectiveModelFallbacksMock.mockClear(); await runBasicAgentCommand(); expectFallbackOverrideCalls(false, true); }); it("does not flip hasSessionModelOverride on auth-only switch with same model", async () => { setupModelSwitchRetry({ provider: "anthropic", model: "claude", authProfileId: "profile-99", authProfileIdSource: "user", }); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude")); state.resolveEffectiveModelFallbacksMock.mockClear(); await runBasicAgentCommand(); expectFallbackOverrideCalls(false, false); }); it("sends internal completion wakes to ACP sessions as plain prompt text", async () => { state.acpResolveSessionMock.mockReturnValue({ kind: "ready", meta: { agent: "claude", cwd: "/tmp/workspace", }, }); await agentCommand({ message: [ INTERNAL_RUNTIME_CONTEXT_BEGIN, "OpenClaw runtime context (internal):", "hidden task completion event", INTERNAL_RUNTIME_CONTEXT_END, ].join("\n"), sessionKey: "agent:main: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 () => { state.acpResolveSessionMock.mockReturnValue({ kind: "ready", meta: { agent: "claude", cwd: "/tmp/workspace", }, }); 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 () => { state.acpResolveSessionMock.mockReturnValue({ kind: "ready", meta: { agent: "claude", cwd: "/tmp/workspace", }, }); state.resolveAcpDispatchPolicyErrorMock.mockReturnValue( new Error("ACP dispatch is disabled by policy (`acp.dispatch.enabled=false`)."), ); await agentCommand({ message: "bootstrap ACP child", sessionKey: "agent:main: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 () => { state.acpResolveSessionMock.mockReturnValue({ kind: "ready", meta: { agent: "claude", cwd: "/tmp/workspace", }, }); state.resolveAcpDispatchPolicyErrorMock.mockReturnValue( new Error("ACP dispatch is disabled by policy (`acp.dispatch.enabled=false`)."), ); await expect( agentCommand({ message: "automatic ACP turn", sessionKey: "agent:main: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 () => { state.acpResolveSessionMock.mockReturnValue({ kind: "ready", meta: { agent: "claude", cwd: "/tmp/workspace", }, }); 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 }), ); }); it("flips hasSessionModelOverride on provider-only switch with same model", async () => { setupModelSwitchRetry({ provider: "openai", model: "claude", }); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "claude")); state.resolveEffectiveModelFallbacksMock.mockClear(); await runBasicAgentCommand(); expectFallbackOverrideCalls(false, true); }); });