Download src/commands/agent.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 102 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/commands/agent.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/commands/agent.test.ts
-
curl -L -o agent.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/commands/agent.test.ts
102 kB
| // Agent command tests cover local agent runs, session routing, and command runtime behavior. | |
| import { execFileSync } from "node:child_process"; | |
| import fs from "node:fs"; | |
| import path from "node:path"; | |
| import { expectDefined } from "@openclaw/normalization-core"; | |
| import { withTempHome as withTempHomeBase } from "openclaw/plugin-sdk/test-env"; | |
| import { beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; | |
| // Register shared mocks before imports bind their production exports. | |
| import "./agent-command.test-mocks.js"; | |
| import { testing as acpManagerTesting } from "../acp/control-plane/manager.js"; | |
| import { executionIdentity } from "../agents/agent-command-execution-identity.js"; | |
| import { createHostWorkspaceWriteTool } from "../agents/agent-tools.read.js"; | |
| import * as authProfileStoreModule from "../agents/auth-profiles/store-runtime.js"; | |
| import * as attemptExecutionRuntime from "../agents/command/attempt-execution.runtime.js"; | |
| import { deliverAgentCommandResult } from "../agents/command/delivery.runtime.js"; | |
| import { prepareAgentCommandExecution } from "../agents/command/prepare.js"; | |
| import { runEmbeddedAgent } from "../agents/embedded-agent.js"; | |
| import { loadManifestModelCatalog } from "../agents/model-catalog.js"; | |
| import * as modelSelectionModule from "../agents/model-selection.js"; | |
| import { readPreparedModelCatalog } from "../agents/prepared-model-catalog.js"; | |
| import { | |
| createAgentRunDirectAbortError, | |
| createAgentRunRestartAbortError, | |
| isAgentRunDirectAbortReason, | |
| isAgentRunRestartAbortReason, | |
| } from "../agents/run-termination.js"; | |
| import { callInProcessGatewayTool } from "../agents/tools/in-process-gateway.js"; | |
| import { ensureAgentWorkspace } from "../agents/workspace.js"; | |
| import { managedWorktrees } from "../agents/worktrees/service.js"; | |
| import { BASE_THINKING_LEVELS } from "../auto-reply/thinking.shared.js"; | |
| import { | |
| readAgentRunTerminalError, | |
| readAgentRunTerminalOutcome, | |
| } from "../channels/turn/agent-run-terminal-outcome.js"; | |
| import * as runtimeSnapshotModule from "../config/runtime-snapshot.js"; | |
| import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; | |
| import { | |
| listSessionEntriesCore, | |
| loadSessionEntry, | |
| loadTranscriptEvents, | |
| replaceSessionEntry, | |
| replaceTranscriptEvents, | |
| } from "../config/sessions/session-accessor.js"; | |
| import { addSessionMember, listSessionMembers } from "../config/sessions/session-sharing-store.js"; | |
| import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js"; | |
| import type { InternalSessionEntry as SessionEntry } from "../config/sessions/types.js"; | |
| import type { OpenClawConfig } from "../config/types.openclaw.js"; | |
| import { getBootEchoContextForSession } from "../gateway/boot-echo-guard.js"; | |
| import { runBootOnce } from "../gateway/boot.js"; | |
| import { emitAgentEvent, onAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js"; | |
| import { buildOutboundBaseSessionKey } from "../infra/outbound/base-session-key.js"; | |
| import { loadEnabledClaudeBundleCommands } from "../plugins/bundle-commands.js"; | |
| import type { PluginProviderRegistration } from "../plugins/registry.test-fixtures.js"; | |
| import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; | |
| import type { RuntimeEnv } from "../runtime.js"; | |
| import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE } from "../sessions/agent-harness-session-key.js"; | |
| import { MODEL_SELECTION_LOCKED_MESSAGE } from "../sessions/model-overrides.js"; | |
| import { interruptSessionWorkAdmissions } from "../sessions/session-lifecycle-admission.js"; | |
| import { createDeferredCore } from "../shared/deferred.js"; | |
| import { resolveEffectiveAgentSkillFilter } from "../skills/discovery/agent-filter.js"; | |
| import { | |
| loadVisibleSkills, | |
| loadWorkspaceSkills, | |
| } from "../skills/loading/workspace-skill-loader.js"; | |
| import { resolveReusableWorkspaceSkillSnapshot } from "../skills/runtime/session-snapshot.js"; | |
| import type { SkillEntry } from "../skills/types.js"; | |
| import { | |
| createDirectOutboundTestAdapter, | |
| createOutboundTestPlugin, | |
| createTestRegistry, | |
| } from "../test-utils/channel-plugins.js"; | |
| import { | |
| deliveryContextFromSession, | |
| normalizeSessionDeliveryState, | |
| } from "../utils/delivery-context.shared.js"; | |
| import { agentCommand, agentCommandFromIngress } from "./agent.js"; | |
| import { createThrowingTestRuntime } from "./test-runtime-config-helpers.js"; | |
| const configIoMocks = vi.hoisted(() => ({ | |
| loadConfig: vi.fn(), | |
| readConfigFileSnapshotForWrite: vi.fn(), | |
| })); | |
| const attemptExecutionMocks = vi.hoisted(() => ({ | |
| useRealRunAgentAttempt: false, | |
| })); | |
| vi.mock("../config/io.js", () => ({ | |
| getRuntimeConfig: configIoMocks.loadConfig, | |
| loadConfig: configIoMocks.loadConfig, | |
| readConfigFileSnapshotForWrite: configIoMocks.readConfigFileSnapshotForWrite, | |
| })); | |
| vi.mock("../agents/auth-profiles/store.js", async (importOriginal) => { | |
| return { | |
| ...(await importOriginal<typeof import("../agents/auth-profiles/store.js")>()), | |
| hasAnyAuthProfileStoreSource: vi.fn(() => false), | |
| }; | |
| }); | |
| vi.mock("../agents/auth-profiles/store-runtime.js", () => { | |
| const createEmptyStore = () => ({ version: 1, profiles: {} }); | |
| return { | |
| ensureAuthProfileStore: vi.fn(createEmptyStore), | |
| ensureAuthProfileStoreForLocalUpdate: vi.fn(createEmptyStore), | |
| loadAuthProfileStore: vi.fn(createEmptyStore), | |
| loadAuthProfileStoreForRuntime: vi.fn(createEmptyStore), | |
| loadAuthProfileStoreForSecretsRuntime: vi.fn(createEmptyStore), | |
| loadAuthProfileStoreWithoutExternalProfiles: vi.fn(createEmptyStore), | |
| saveAuthProfileStore: vi.fn(), | |
| updateAuthProfileStoreWithLock: vi.fn(async () => createEmptyStore()), | |
| }; | |
| }); | |
| vi.mock("../agents/auth-profiles/source-check.js", () => ({ | |
| hasAnyAuthProfileStoreSource: vi.fn(() => false), | |
| })); | |
| vi.mock("../auto-reply/reply/session-stable-reply-mode.js", () => ({ | |
| // Session-stable policy has owner coverage in the reply resolver suite. This | |
| // command suite only owns forwarding its result into CLI binding facts. | |
| resolveSessionStableReplyMode: vi.fn(() => "automatic"), | |
| })); | |
| vi.mock("../auto-reply/reply/source-reply-delivery-mode.js", () => ({ | |
| // Source-reply policy has focused owner coverage. Command preparation only | |
| // needs to distinguish synthetic turns before forwarding stable facts. | |
| isSyntheticSourceReplyTurn: (params: { | |
| inputProvenance?: { kind?: string }; | |
| isHeartbeat?: boolean; | |
| }) => | |
| params.isHeartbeat === true || | |
| params.inputProvenance?.kind === "inter_session" || | |
| params.inputProvenance?.kind === "internal_system", | |
| })); | |
| vi.mock("../agents/harness/selection.js", () => ({ | |
| // Availability fallback has focused owner coverage in selection.test.ts. The | |
| // command suite only needs a stable policy for auth-profile validation. | |
| resolveAvailableAgentHarnessPolicy: vi.fn(() => ({ | |
| runtime: "openclaw", | |
| runtimeSource: "implicit", | |
| })), | |
| })); | |
| vi.mock("../agents/harness/hook-helpers.js", () => ({ | |
| // Tool and transcript hook dispatch are exercised by their integration | |
| // suites. No command fixture in this file registers either hook. | |
| runAgentHarnessAfterToolCallHook: vi.fn(async () => undefined), | |
| runAgentHarnessBeforeMessageWriteHook: ({ message }: { message: unknown }) => message, | |
| })); | |
| vi.mock("../agents/thinking-runtime.js", () => ({ | |
| // Runtime selection and catalog normalization have focused owner coverage in | |
| // thinking-runtime.test.ts. Command tests only need stable policy handoffs. | |
| hasResolvedThinkingCatalogEntry: (params: { | |
| catalog?: Array<{ id: string; provider: string; reasoning?: boolean }>; | |
| provider: string; | |
| model: string; | |
| }) => | |
| params.catalog?.some( | |
| (entry) => | |
| entry.provider.toLowerCase() === params.provider.toLowerCase() && | |
| entry.id === params.model && | |
| entry.reasoning !== undefined, | |
| ) ?? false, | |
| needsThinkHydration: ( | |
| catalog: Array<{ id: string; provider: string; reasoning?: boolean }> | undefined, | |
| provider: string, | |
| model: string, | |
| agentRuntime: string, | |
| ) => | |
| agentRuntime !== "openclaw" || | |
| !catalog?.some( | |
| (entry) => | |
| entry.provider.toLowerCase() === provider.toLowerCase() && | |
| entry.id === model && | |
| entry.reasoning !== undefined, | |
| ), | |
| normalizeThinkingCatalogProviders: <T extends { provider: string }>(catalog: T[]) => | |
| catalog.map((entry) => ({ ...entry, provider: entry.provider.toLowerCase() })), | |
| resolveCandidateThinkingLevel: ({ level }: { level?: string }) => level, | |
| resolveEffectiveAgentRuntime: () => "openclaw", | |
| })); | |
| vi.mock("../agents/main-session-recovery/main-session-recovery-store.js", () => ({ | |
| // Recovery-store fencing has dedicated store-backed coverage. None of these | |
| // command cases enters a persisted recovery cycle. | |
| claimMainSessionRecoveryOwner: vi.fn(async () => ({ kind: "not_required" })), | |
| commitMainSessionRecovery: vi.fn(async () => undefined), | |
| inspectMainSessionRecoveryRequired: vi.fn(async () => ({ kind: "not_required" })), | |
| refreshMainSessionRecoveryOwner: vi.fn(async () => undefined), | |
| releaseMainSessionRecoveryOwner: vi.fn(async () => undefined), | |
| })); | |
| vi.mock("../cli/command-secret-targets.js", () => ({ | |
| // Secret target discovery has dedicated owner coverage. These command | |
| // fixtures contain no SecretRefs and only need empty discovery results. | |
| getAgentRuntimeCommandSecretTargetIds: () => new Set<string>(), | |
| getAgentRuntimeOptionalCommandSecretPaths: () => new Set<string>(), | |
| getScopedChannelsCommandSecretTargets: () => ({ targetIds: new Set<string>() }), | |
| })); | |
| vi.mock("../infra/outbound/channel-bootstrap.runtime.js", () => ({ | |
| // Every channel fixture in this suite is already active. Bootstrap discovery | |
| // and its plugin-loader graph have focused owner coverage. | |
| bootstrapOutboundChannelPlugin: vi.fn(() => undefined), | |
| resetOutboundChannelBootstrapStateForTests: vi.fn(), | |
| })); | |
| vi.mock("../config/sessions/inbound.runtime.js", () => ({ | |
| // Explicit-recipient cases own route selection, not the downstream session | |
| // persistence exercised by outbound-session owner tests. | |
| resolveSessionStorePathCore: vi.fn(() => ""), | |
| updateSessionLastRoute: vi.fn(async () => null), | |
| })); | |
| vi.mock("../agents/command/assistant-transcript-repair.js", () => ({ | |
| // Repair persistence, replay, and failure barriers have a focused owner | |
| // suite. These command cases contain no pending transcript repair records. | |
| persistAssistantTranscriptRepairRecord: vi.fn(async () => undefined), | |
| repairPendingAssistantTranscriptTurns: vi.fn(async () => undefined), | |
| })); | |
| vi.mock("../agents/command/session-store.runtime.js", async () => { | |
| const accessor = await import("../config/sessions/session-accessor.js"); | |
| return { | |
| loadSessionEntry: accessor.loadSessionEntry, | |
| loadSessionEntryReadOnly: accessor.loadSessionEntryReadOnly, | |
| updateSessionStoreAfterAgentRun: vi.fn(async () => undefined), | |
| }; | |
| }); | |
| vi.mock("../agents/command/cli-compaction.js", () => { | |
| return { | |
| runCliTurnCompactionLifecycle: vi.fn( | |
| async (params: { sessionEntry?: unknown }) => params.sessionEntry, | |
| ), | |
| }; | |
| }); | |
| vi.mock("../agents/command/attempt-execution.runtime.js", () => { | |
| return { | |
| buildAcpResult: vi.fn(), | |
| createAcpToolLifecycleTracker: () => ({ | |
| active: new Map(), | |
| terminalToolCallIds: new Set(), | |
| saturated: false, | |
| }), | |
| createAcpVisibleTextAccumulator: vi.fn(), | |
| emitAcpAssistantDelta: vi.fn(), | |
| emitAcpLifecycleEnd: vi.fn(), | |
| emitAcpLifecycleError: vi.fn(), | |
| emitAcpLifecycleStart: vi.fn(), | |
| persistAcpTurnTranscript: vi.fn(async (params: { sessionEntry?: unknown }) => ({ | |
| kind: "persisted", | |
| sessionEntry: params.sessionEntry, | |
| })), | |
| persistCliTurnTranscript: vi.fn(async (params: { sessionEntry?: unknown }) => ({ | |
| kind: "persisted", | |
| sessionEntry: params.sessionEntry, | |
| })), | |
| runAgentAttempt: vi.fn(async (params: Record<string, unknown>) => { | |
| if (attemptExecutionMocks.useRealRunAgentAttempt) { | |
| const actual = await vi.importActual< | |
| typeof import("../agents/command/attempt-execution.js") | |
| >("../agents/command/attempt-execution.js"); | |
| return await actual.runAgentAttempt(params as never); | |
| } | |
| const opts = params.opts as Record<string, unknown>; | |
| const runContext = params.runContext as Record<string, unknown>; | |
| const sessionEntry = params.sessionEntry as | |
| | { | |
| authProfileOverride?: string; | |
| authProfileOverrideSource?: string; | |
| } | |
| | undefined; | |
| const providerOverride = params.providerOverride as string; | |
| const authProfileProvider = params.authProfileProvider as string; | |
| const authProfileId = | |
| providerOverride === authProfileProvider ? sessionEntry?.authProfileOverride : undefined; | |
| return await runEmbeddedAgent({ | |
| sessionId: params.sessionId, | |
| sessionKey: params.sessionKey, | |
| agentId: params.sessionAgentId, | |
| trigger: "user", | |
| messageChannel: params.messageChannel, | |
| agentAccountId: runContext.accountId, | |
| messageTo: opts.replyTo ?? opts.to, | |
| messageThreadId: opts.threadId, | |
| sessionFile: params.sessionFile, | |
| workspaceDir: params.workspaceDir, | |
| config: params.cfg, | |
| skillsSnapshot: params.skillsSnapshot, | |
| prompt: params.body, | |
| images: opts.images, | |
| imageOrder: opts.imageOrder, | |
| clientTools: opts.clientTools, | |
| provider: providerOverride, | |
| model: params.modelOverride, | |
| authProfileId, | |
| authProfileIdSource: authProfileId ? sessionEntry?.authProfileOverrideSource : undefined, | |
| thinkLevel: params.resolvedThinkLevel, | |
| fastMode: params.fastMode, | |
| verboseLevel: params.resolvedVerboseLevel, | |
| timeoutMs: params.timeoutMs, | |
| runId: params.runId, | |
| lane: opts.lane, | |
| abortSignal: opts.abortSignal, | |
| extraSystemPrompt: opts.extraSystemPrompt, | |
| bootstrapContextMode: opts.bootstrapContextMode, | |
| bootstrapContextRunKind: opts.bootstrapContextRunKind, | |
| internalEvents: opts.internalEvents, | |
| inputProvenance: opts.inputProvenance, | |
| streamParams: opts.streamParams, | |
| agentDir: params.agentDir, | |
| allowTransientCooldownProbe: params.allowTransientCooldownProbe, | |
| cleanupBundleMcpOnRunEnd: opts.cleanupBundleMcpOnRunEnd, | |
| cleanupCliLiveSessionOnRunEnd: opts.cleanupCliLiveSessionOnRunEnd, | |
| modelRun: opts.modelRun, | |
| promptMode: opts.promptMode, | |
| disableTools: opts.modelRun === true, | |
| onAgentEvent: params.onAgentEvent, | |
| } as never); | |
| }), | |
| sessionTranscriptHasContent: vi.fn(async () => false), | |
| }; | |
| }); | |
| vi.mock("../agents/command/delivery.runtime.js", () => { | |
| return { | |
| deliverAgentCommandResult: vi.fn( | |
| async (params: { | |
| cfg: OpenClawConfig; | |
| deps: { | |
| sendMessageTelegram?: ( | |
| to: string, | |
| text: string, | |
| opts: Record<string, unknown>, | |
| ) => Promise<unknown>; | |
| }; | |
| runtime: RuntimeEnv; | |
| opts: { | |
| channel?: string; | |
| deliver?: boolean; | |
| json?: boolean; | |
| to?: string; | |
| }; | |
| result: { meta?: Record<string, unknown> }; | |
| payloads?: Array<{ text?: string; mediaUrl?: string | null }>; | |
| }) => { | |
| const payloads = params.payloads ?? []; | |
| const deliveryResult = { payloads, meta: params.result.meta ?? {} }; | |
| if (params.opts.json) { | |
| params.runtime.log(JSON.stringify(deliveryResult)); | |
| return deliveryResult; | |
| } | |
| if (params.opts.deliver && params.opts.channel === "telegram" && params.opts.to) { | |
| for (const payload of payloads) { | |
| await params.deps.sendMessageTelegram?.(params.opts.to, payload.text ?? "", { | |
| ...(payload.mediaUrl ? { mediaUrl: payload.mediaUrl } : {}), | |
| accountId: undefined, | |
| verbose: false, | |
| }); | |
| } | |
| return deliveryResult; | |
| } | |
| for (const payload of payloads) { | |
| if (payload.text) { | |
| params.runtime.log(payload.text); | |
| } | |
| } | |
| return deliveryResult; | |
| }, | |
| ), | |
| }; | |
| }); | |
| vi.mock("../config/sessions/transcript-resolve.runtime.js", () => { | |
| return { | |
| resolveSessionTranscriptFile: vi.fn( | |
| async (params: { | |
| sessionId: string; | |
| sessionKey: string; | |
| sessionEntry?: { sessionFile?: string; sessionId?: string }; | |
| sessionStore?: Record<string, { sessionFile?: string; sessionId?: string }>; | |
| storePath?: string; | |
| agentId: string; | |
| threadId?: string | number; | |
| }) => { | |
| const sessionFile = | |
| params.sessionEntry?.sessionFile ?? | |
| `sqlite:${params.agentId}:${params.sessionId}:${params.storePath ?? ""}`; | |
| let sessionEntry = params.sessionEntry; | |
| if (params.sessionStore && params.sessionKey) { | |
| const existingEntry = params.sessionStore[params.sessionKey] ?? {}; | |
| sessionEntry = { | |
| ...existingEntry, | |
| sessionId: params.sessionId, | |
| sessionFile, | |
| }; | |
| params.sessionStore[params.sessionKey] = sessionEntry; | |
| } | |
| return { sessionFile, sessionEntry }; | |
| }, | |
| ), | |
| }; | |
| }); | |
| const runtime = createThrowingTestRuntime(); | |
| async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> { | |
| return withTempHomeBase(fn, { | |
| prefix: "openclaw-agent-", | |
| }); | |
| } | |
| function mockConfig( | |
| home: string, | |
| storePath: string, | |
| agentOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>>, | |
| telegramOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["telegram"]>>, | |
| agentsList?: NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>, | |
| ) { | |
| const cfg = { | |
| meta: { migrations: { modelPolicyAllowlist: true } }, | |
| agents: { | |
| defaults: { | |
| model: { primary: "anthropic/claude-opus-4-6" }, | |
| models: { "anthropic/claude-opus-4-6": {} }, | |
| workspace: path.join(home, "openclaw"), | |
| ...agentOverrides, | |
| }, | |
| list: agentsList, | |
| }, | |
| session: { store: storePath, mainKey: "main" }, | |
| channels: { | |
| telegram: telegramOverrides ? { ...telegramOverrides } : undefined, | |
| }, | |
| } as OpenClawConfig; | |
| configIoMocks.loadConfig.mockReturnValue(cfg); | |
| return cfg; | |
| } | |
| function mockUserInvocableSkills(params: { | |
| home: string; | |
| skills: Array<{ name: string; disableModelInvocation?: boolean }>; | |
| }) { | |
| const entries = params.skills.map(({ name, disableModelInvocation = false }) => { | |
| const baseDir = path.join(params.home, "openclaw", "skills", name); | |
| const filePath = path.join(baseDir, "SKILL.md"); | |
| return { | |
| skill: { | |
| name, | |
| description: `${name} instructions`, | |
| filePath, | |
| baseDir, | |
| source: "openclaw-workspace", | |
| sourceInfo: { | |
| path: filePath, | |
| source: "openclaw-workspace", | |
| scope: "project", | |
| origin: "top-level", | |
| }, | |
| disableModelInvocation, | |
| }, | |
| frontmatter: {}, | |
| invocation: { userInvocable: true, disableModelInvocation }, | |
| exposure: { | |
| includeInRuntimeRegistry: true, | |
| includeInAvailableSkillsPrompt: !disableModelInvocation, | |
| userInvocable: true, | |
| }, | |
| } satisfies SkillEntry; | |
| }); | |
| vi.mocked(loadVisibleSkills).mockImplementation((_workspaceDir, opts) => { | |
| const filter = opts?.skillFilter; | |
| return filter === undefined | |
| ? entries | |
| : entries.filter((entry) => filter.includes(entry.skill.name)); | |
| }); | |
| vi.mocked(loadWorkspaceSkills).mockReturnValue(entries); | |
| } | |
| async function writeSessionStoreSeed( | |
| storePath: string, | |
| sessions: Record<string, Record<string, unknown>>, | |
| ): Promise<void> { | |
| fs.mkdirSync(path.dirname(storePath), { recursive: true }); | |
| for (const [sessionKey, entry] of Object.entries(sessions)) { | |
| const sessionId = typeof entry.sessionId === "string" ? entry.sessionId : sessionKey; | |
| await replaceSessionEntry({ sessionKey, storePath }, { | |
| ...entry, | |
| sessionId, | |
| updatedAt: typeof entry.updatedAt === "number" ? entry.updatedAt : Date.now(), | |
| } as SessionEntry); | |
| } | |
| } | |
| function createDefaultAgentResult(params?: { | |
| payloads?: Array<Record<string, unknown>>; | |
| durationMs?: number; | |
| }) { | |
| return { | |
| payloads: params?.payloads ?? [{ text: "ok" }], | |
| meta: { | |
| durationMs: params?.durationMs ?? 5, | |
| agentMeta: { sessionId: "s", provider: "p", model: "m" }, | |
| }, | |
| }; | |
| } | |
| function getLastEmbeddedCall() { | |
| const calls = vi.mocked(runEmbeddedAgent).mock.calls; | |
| return calls[calls.length - 1]?.[0]; | |
| } | |
| function expectLastRunProviderModel(provider: string, model: string): void { | |
| const callArgs = getLastEmbeddedCall(); | |
| expect(callArgs?.provider).toBe(provider); | |
| expect(callArgs?.model).toBe(model); | |
| } | |
| function readSessionStore<T>(storePath: string): Record<string, T> { | |
| return Object.fromEntries( | |
| listSessionEntriesCore({ storePath }).map(({ entry, sessionKey }) => [sessionKey, entry as T]), | |
| ); | |
| } | |
| function expectSqliteSessionFileMarker(params: { | |
| agentId: string; | |
| sessionFile: string | undefined; | |
| sessionId?: string; | |
| storePath: string; | |
| }): void { | |
| const marker = parseSqliteSessionFileMarker(params.sessionFile); | |
| expect(marker?.agentId).toBe(params.agentId); | |
| if (params.sessionId) { | |
| expect(marker?.sessionId).toBe(params.sessionId); | |
| } else { | |
| expect(marker?.sessionId).toBeTruthy(); | |
| } | |
| expect(marker?.storePath).toBe(path.resolve(params.storePath)); | |
| } | |
| async function runAgentWithSessionKey(sessionKey: string): Promise<void> { | |
| await agentCommand({ message: "hi", sessionKey }, runtime); | |
| } | |
| function mockModelCatalogOnce(entries: ReturnType<typeof loadManifestModelCatalog>): void { | |
| vi.mocked(loadManifestModelCatalog).mockReturnValueOnce(entries); | |
| vi.mocked(readPreparedModelCatalog).mockResolvedValueOnce(entries); | |
| } | |
| function installThinkingTestProviders(channels: Parameters<typeof createTestRegistry>[0] = []) { | |
| const registry = createTestRegistry(channels); | |
| registry.providers = ["anthropic", "codex", "ollama", "openai", "openrouter"].map( | |
| (providerId): PluginProviderRegistration => ({ | |
| pluginId: providerId, | |
| source: "test", | |
| provider: { | |
| id: providerId, | |
| label: providerId, | |
| auth: [], | |
| resolveThinkingProfile: () => ({ | |
| levels: BASE_THINKING_LEVELS.map((id) => ({ id })), | |
| defaultLevel: "off", | |
| }), | |
| }, | |
| }), | |
| ); | |
| setActivePluginRegistry(registry); | |
| } | |
| function createOutboundSessionRouteFixture(params: { | |
| cfg: OpenClawConfig; | |
| agentId: string; | |
| channel: string; | |
| accountId?: string | null; | |
| peer: { kind: "direct" | "group" | "channel"; id: string }; | |
| chatType: "direct" | "group" | "channel"; | |
| from: string; | |
| to: string; | |
| }) { | |
| const baseSessionKey = buildOutboundBaseSessionKey(params); | |
| return { | |
| sessionKey: baseSessionKey, | |
| baseSessionKey, | |
| peer: params.peer, | |
| chatType: params.chatType, | |
| from: params.from, | |
| to: params.to, | |
| }; | |
| } | |
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| attemptExecutionMocks.useRealRunAgentAttempt = false; | |
| resetPluginRuntimeStateForTest(); | |
| installThinkingTestProviders(); | |
| clearSessionStoreCacheForTest(); | |
| resetAgentEventsForTest(); | |
| acpManagerTesting.resetAcpSessionManagerForTests(); | |
| runtimeSnapshotModule.clearRuntimeConfigSnapshot(); | |
| vi.mocked(runEmbeddedAgent).mockResolvedValue(createDefaultAgentResult()); | |
| vi.mocked(loadManifestModelCatalog).mockReturnValue([]); | |
| vi.mocked(readPreparedModelCatalog).mockResolvedValue([]); | |
| vi.mocked(loadEnabledClaudeBundleCommands).mockReturnValue([]); | |
| vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); | |
| configIoMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ | |
| snapshot: { valid: false, resolved: {} as OpenClawConfig }, | |
| writeOptions: {}, | |
| }); | |
| }); | |
| describe("agentCommand", () => { | |
| it("delivers a real local Gateway tool result through the normal CLI admission", async () => { | |
| await withTempHome(async (home) => { | |
| mockConfig(home, path.join(home, "sessions.json")); | |
| // The synthetic provider substitutes inference only; command admission and RPC stay real. | |
| vi.mocked(runEmbeddedAgent).mockImplementationOnce(async () => { | |
| const identity = await callInProcessGatewayTool<{ agentId: string }>("agent.identity.get", { | |
| agentId: "main", | |
| }); | |
| expect(identity).toMatchObject({ agentId: "main" }); | |
| return createDefaultAgentResult({ | |
| payloads: [{ text: `Local agent: ${identity.agentId}` }], | |
| }); | |
| }); | |
| const actualDelivery = await vi.importActual<typeof import("../agents/command/delivery.js")>( | |
| "../agents/command/delivery.js", | |
| ); | |
| vi.mocked(deliverAgentCommandResult).mockImplementationOnce( | |
| actualDelivery.deliverAgentCommandResult, | |
| ); | |
| const result = await agentCommand( | |
| { message: "Identify this local agent", agentId: "main" }, | |
| runtime, | |
| ); | |
| expect(result?.payloads).toEqual([{ text: "Local agent: main", mediaUrl: null }]); | |
| expect(runtime.log).toHaveBeenCalledWith("Local agent: main"); | |
| expect(readAgentRunTerminalOutcome(result)).toBe("completed"); | |
| }); | |
| }); | |
| it.each([false, true])( | |
| "runs BOOT.md with an existing SQLite boot session and cleans up after failure=%s", | |
| async (fail) => { | |
| await withTempHome(async (home) => { | |
| const storePath = path.join(home, "sessions.json"); | |
| const cfg = mockConfig(home, storePath, undefined, undefined, [ | |
| { id: "main", default: true }, | |
| ]); | |
| const workspaceDir = path.join(home, "openclaw"); | |
| fs.mkdirSync(workspaceDir, { recursive: true }); | |
| fs.writeFileSync(path.join(workspaceDir, "BOOT.md"), "Check status."); | |
| const priorScope = { storePath, sessionKey: "agent:main:boot", sessionId: "previous-boot" }; | |
| await replaceSessionEntry(priorScope, { | |
| sessionId: priorScope.sessionId, | |
| updatedAt: Date.now(), | |
| label: "Previous boot", | |
| visibility: "read-only", | |
| }); | |
| const transcript = [ | |
| { type: "session", id: priorScope.sessionId, cwd: workspaceDir }, | |
| { | |
| type: "message", | |
| id: "old-message", | |
| parentId: null, | |
| message: { role: "user", content: "Keep this history." }, | |
| }, | |
| ]; | |
| await replaceTranscriptEvents(priorScope, transcript); | |
| const priorEntry = loadSessionEntry(priorScope); | |
| const { member } = addSessionMember(priorScope, { | |
| identityId: "boot-history-reader", | |
| addedBy: "operator", | |
| }); | |
| const bootSessions = new Map<string, string>(); | |
| vi.mocked(runEmbeddedAgent).mockImplementation(async (params) => { | |
| const sessionKey = expectDefined(params.sessionKey, "boot session key"); | |
| expect(params.sessionId).not.toBe(priorScope.sessionId); | |
| expect(getBootEchoContextForSession(sessionKey)).toContain("Check status."); | |
| bootSessions.set(sessionKey, params.sessionId); | |
| await replaceTranscriptEvents({ storePath, sessionKey, sessionId: params.sessionId }, [ | |
| { type: "session", id: params.sessionId, cwd: workspaceDir }, | |
| ]); | |
| if (fail) { | |
| throw new Error("boot runtime failed"); | |
| } | |
| return createDefaultAgentResult(); | |
| }); | |
| for (let restart = 0; restart < 2; restart++) { | |
| const result = await runBootOnce({ cfg, deps: {}, workspaceDir }); | |
| expect(result).toEqual( | |
| fail | |
| ? { status: "failed", reason: "agent run failed: boot runtime failed" } | |
| : { status: "ran" }, | |
| ); | |
| expect(vi.mocked(runEmbeddedAgent).mock.calls).toHaveLength(restart + 1); | |
| expect(loadSessionEntry(priorScope)).toEqual(priorEntry); | |
| expect(await loadTranscriptEvents(priorScope)).toEqual(transcript); | |
| expect(listSessionMembers(priorScope)).toEqual([member]); | |
| expect(listSessionEntriesCore({ storePath }).map(({ sessionKey }) => sessionKey)).toEqual( | |
| [priorScope.sessionKey], | |
| ); | |
| for (const [sessionKey, sessionId] of bootSessions) { | |
| expect(getBootEchoContextForSession(sessionKey)).toBeUndefined(); | |
| expect(await loadTranscriptEvents({ storePath, sessionKey, sessionId })).toEqual([]); | |
| } | |
| } | |
| expect(bootSessions.size).toBe(2); | |
| }); | |
| }, | |
| ); | |
| it.each([ | |
| { name: "completed stop", meta: { stopReason: "stop" }, outcome: "completed" }, | |
| { | |
| name: "structured blocked result", | |
| meta: { | |
| replayInvalid: true, | |
| livenessState: "blocked" as const, | |
| finalAssistantVisibleText: "Prompt exceeds model context", | |
| finalAssistantRawText: "Prompt exceeds model context", | |
| error: { kind: "context_overflow" as const, message: "Prompt exceeds model context" }, | |
| }, | |
| outcome: "failed", | |
| }, | |
| { name: "cancelled result", meta: { aborted: true, stopReason: "stop" }, outcome: "failed" }, | |
| { | |
| name: "provider timeout", | |
| meta: { aborted: true, stopReason: "timeout", timeoutPhase: "provider" as const }, | |
| outcome: "failed", | |
| }, | |
| { name: "yielded turn", meta: { yielded: true }, outcome: "completed" }, | |
| { | |
| name: "exhausted fallback", | |
| meta: { | |
| error: { | |
| kind: "incomplete_turn" as const, | |
| message: "Incomplete terminal response", | |
| fallbackSafe: true, | |
| terminalPresentation: true, | |
| }, | |
| }, | |
| outcome: "failed", | |
| }, | |
| { name: "callback error", meta: {}, fault: "callback", outcome: "failed" }, | |
| { name: "late cancellation", meta: {}, fault: "abort", outcome: "failed" }, | |
| ])( | |
| "hands off the terminal outcome after real delivery projection: $name", | |
| async ({ meta, outcome, fault }) => { | |
| await withTempHome(async (home) => { | |
| mockConfig(home, path.join(home, "sessions.json")); | |
| const controller = new AbortController(); | |
| const secret = ["sk", "abcdefghijklmnopqrstuv"].join("-"); | |
| const text = meta.error?.message ?? "ok"; | |
| const rawResult = { | |
| ...createDefaultAgentResult(), | |
| payloads: [{ text, ...(meta.error ? { isError: true } : {}) }], | |
| meta: { ...createDefaultAgentResult().meta, ...meta }, | |
| }; | |
| vi.mocked(runEmbeddedAgent).mockImplementationOnce(async (params) => { | |
| if (fault === "callback") { | |
| await params.onAgentEvent?.({ | |
| stream: "lifecycle", | |
| data: { | |
| phase: "finishing", | |
| error: `Deferred provider failure. Authorization: Bearer ${secret}`, | |
| }, | |
| }); | |
| } | |
| return rawResult; | |
| }); | |
| const actualDelivery = await vi.importActual< | |
| typeof import("../agents/command/delivery.js") | |
| >("../agents/command/delivery.js"); | |
| vi.mocked(deliverAgentCommandResult).mockImplementationOnce(async (params) => { | |
| const projected = await actualDelivery.deliverAgentCommandResult(params); | |
| if (fault === "abort") { | |
| controller.abort(); | |
| } | |
| return projected; | |
| }); | |
| const result = await agentCommand( | |
| { message: "probe", agentId: "main", json: true, abortSignal: controller.signal }, | |
| runtime, | |
| ); | |
| expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); | |
| expect(result?.payloads).toEqual([ | |
| { text, mediaUrl: null, ...(meta.error ? { isError: true } : {}) }, | |
| ]); | |
| expect(vi.mocked(runtime.log).mock.calls.at(-1)?.[0]).toBe(JSON.stringify(result, null, 2)); | |
| expect(readAgentRunTerminalOutcome(rawResult)).toBeUndefined(); | |
| expect(readAgentRunTerminalError(rawResult)).toBeUndefined(); | |
| expect(readAgentRunTerminalOutcome(result)).toBe(outcome); | |
| if (fault === "callback") { | |
| expect(readAgentRunTerminalError(result)).toContain("Deferred provider failure."); | |
| expect(readAgentRunTerminalError(result)).not.toContain(secret); | |
| } else if (outcome === "completed") { | |
| expect(readAgentRunTerminalError(result)).toBeUndefined(); | |
| } | |
| }); | |
| }, | |
| ); | |
| it("keeps best-effort delivery failure separate from the completed run outcome", async () => { | |
| await withTempHome(async (home) => { | |
| mockConfig(home, path.join(home, "sessions.json")); | |
| const actualDelivery = await vi.importActual<typeof import("../agents/command/delivery.js")>( | |
| "../agents/command/delivery.js", | |
| ); | |
| vi.mocked(deliverAgentCommandResult).mockImplementationOnce( | |
| actualDelivery.deliverAgentCommandResult, | |
| ); | |
| const result = await agentCommand( | |
| { | |
| message: "probe", | |
| agentId: "main", | |
| json: true, | |
| deliver: true, | |
| channel: "webchat", | |
| bestEffortDeliver: true, | |
| }, | |
| runtime, | |
| ); | |
| expect(runEmbeddedAgent).toHaveBeenCalledOnce(); | |
| expect(result?.deliveryStatus).toMatchObject({ status: "failed", succeeded: false }); | |
| expect(readAgentRunTerminalOutcome(result)).toBe("completed"); | |
| }); | |
| }); | |
| it.each(["rejection", "cancellation"] as const)( | |
| "settles deferred cleanup %s before handing off the reply", | |
| async (fault) => { | |
| await withTempHome(async (home) => { | |
| mockConfig(home, path.join(home, "sessions.json")); | |
| const controller = new AbortController(); | |
| const failure = new Error("Deferred cleanup failed"); | |
| vi.mocked(attemptExecutionRuntime.runAgentAttempt).mockImplementationOnce( | |
| async (params) => { | |
| params.deferredLifecycle?.adopt({ | |
| beginRetryWait: () => undefined, | |
| complete: async () => { | |
| if (fault === "rejection") { | |
| throw failure; | |
| } | |
| controller.abort(); | |
| }, | |
| discard: () => {}, | |
| }); | |
| return createDefaultAgentResult(); | |
| }, | |
| ); | |
| const command = agentCommand( | |
| { message: "probe", agentId: "main", abortSignal: controller.signal }, | |
| runtime, | |
| ); | |
| if (fault === "rejection") { | |
| await expect(command).rejects.toBe(failure); | |
| } else { | |
| expect(readAgentRunTerminalOutcome(await command)).toBe("failed"); | |
| } | |
| expect(runtime.log).toHaveBeenCalledWith("ok"); | |
| }); | |
| }, | |
| ); | |
| it("carries an external cwd into the direct agent session skill snapshot", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const executionWorkspace = path.join(home, "external-repo"); | |
| mockConfig(home, store); | |
| await agentCommand( | |
| { | |
| message: "inspect this repo", | |
| agentId: "main", | |
| cwd: executionWorkspace, | |
| }, | |
| runtime, | |
| ); | |
| expect(resolveReusableWorkspaceSkillSnapshot).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| executionWorkspaceDir: executionWorkspace, | |
| }), | |
| ); | |
| }); | |
| }); | |
| it.each([ | |
| ["local", undefined, false], | |
| ["local", true, false], | |
| ["ingress", undefined, true], | |
| ["ingress", true, false], | |
| ] as const)( | |
| "owns skill watching for %s runs with oneShotCliRun=%s", | |
| async (entrypoint, oneShotCliRun, watch) => { | |
| await withTempHome(async (home) => { | |
| mockConfig(home, path.join(home, "sessions.json")); | |
| const opts = { message: "inspect skills", agentId: "main", oneShotCliRun }; | |
| if (entrypoint === "ingress") { | |
| await agentCommandFromIngress({ ...opts, allowModelOverride: false }, runtime); | |
| } else { | |
| await agentCommand(opts, runtime); | |
| } | |
| expect(resolveReusableWorkspaceSkillSnapshot).toHaveBeenCalledWith( | |
| expect.objectContaining({ watch }), | |
| ); | |
| }); | |
| }, | |
| ); | |
| it("does not scaffold an implicit ACP workspace when the command supplies cwd", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const repository = path.join(home, "repository"); | |
| const configuredWorkspace = path.join(repository, ".openclaw", "workspace"); | |
| fs.mkdirSync(repository, { recursive: true }); | |
| execFileSync("git", ["-C", repository, "init", "-b", "main"]); | |
| fs.writeFileSync(path.join(repository, "README.md"), "base\n"); | |
| execFileSync("git", ["-C", repository, "add", "README.md"]); | |
| mockConfig(home, store, { workspace: configuredWorkspace }, undefined, [ | |
| { id: "codex", runtime: { type: "acp", acp: { agent: "codex" } } }, | |
| ]); | |
| const actualWorkspace = | |
| await vi.importActual<typeof import("../agents/workspace.js")>("../agents/workspace.js"); | |
| vi.mocked(ensureAgentWorkspace).mockImplementationOnce((params) => | |
| actualWorkspace.ensureAgentWorkspace(params), | |
| ); | |
| const prepared = await prepareAgentCommandExecution( | |
| { | |
| message: "inspect this repo", | |
| agentId: "codex", | |
| sessionId: "explicit-cwd-acp", | |
| cwd: repository, | |
| }, | |
| runtime, | |
| ); | |
| expect(prepared.workspaceDir).toBe(configuredWorkspace); | |
| const implicitWorkspace = configuredWorkspace; | |
| expect(fs.existsSync(implicitWorkspace)).toBe(true); | |
| expect(fs.existsSync(path.join(implicitWorkspace, "AGENTS.md"))).toBe(false); | |
| expect(fs.existsSync(path.join(implicitWorkspace, ".git"))).toBe(false); | |
| expect(() => execFileSync("git", ["-C", repository, "add", "-A"])).not.toThrow(); | |
| }); | |
| }); | |
| it("uses the recorded canonical workspace for a managed-worktree skill snapshot", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const sessionKey = "agent:main:dashboard:managed-worktree"; | |
| const canonicalWorkspace = path.join(home, "project"); | |
| fs.mkdirSync(canonicalWorkspace, { recursive: true }); | |
| execFileSync("git", ["-C", canonicalWorkspace, "init", "-b", "main"]); | |
| execFileSync("git", ["-C", canonicalWorkspace, "config", "user.name", "OpenClaw Test"]); | |
| execFileSync("git", [ | |
| "-C", | |
| canonicalWorkspace, | |
| "config", | |
| "user.email", | |
| "openclaw-test@example.invalid", | |
| ]); | |
| fs.writeFileSync(path.join(canonicalWorkspace, "README.md"), "base\n"); | |
| execFileSync("git", ["-C", canonicalWorkspace, "add", "README.md"]); | |
| execFileSync("git", ["-C", canonicalWorkspace, "commit", "-m", "initial"]); | |
| mockConfig(home, store); | |
| const worktree = await managedWorktrees.create({ | |
| repoRoot: canonicalWorkspace, | |
| name: "managed", | |
| ownerKind: "session", | |
| ownerId: sessionKey, | |
| }); | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { | |
| sessionId: "managed-worktree-session", | |
| spawnedCwd: worktree.path, | |
| worktree: { | |
| id: worktree.id, | |
| branch: worktree.branch, | |
| repoRoot: worktree.repoRoot, | |
| canonicalWorkspaceDir: canonicalWorkspace, | |
| }, | |
| }, | |
| }); | |
| await agentCommandFromIngress( | |
| { | |
| message: "inspect this repo", | |
| sessionKey, | |
| allowModelOverride: false, | |
| }, | |
| runtime, | |
| ); | |
| expect(resolveReusableWorkspaceSkillSnapshot).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| executionWorkspaceDir: canonicalWorkspace, | |
| }), | |
| ); | |
| }); | |
| }); | |
| it.each(["Echo $PATH exactly.", String.raw`Keep \$release_notes literal.`])( | |
| "does not discover skills for literal dollar input: %s", | |
| async (message) => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| mockConfig(home, store); | |
| await agentCommandFromIngress( | |
| { message, agentId: "main", allowModelOverride: false }, | |
| runtime, | |
| ); | |
| expect(getLastEmbeddedCall()?.prompt).toBe(message); | |
| expect(loadVisibleSkills).not.toHaveBeenCalled(); | |
| expect(loadWorkspaceSkills).not.toHaveBeenCalled(); | |
| }); | |
| }, | |
| ); | |
| it("renders a Claude bundle command template on Gateway ingress", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| mockConfig(home, store); | |
| vi.mocked(loadEnabledClaudeBundleCommands).mockReturnValue([ | |
| { | |
| pluginId: "test-bundle", | |
| rawName: "workflows-review", | |
| description: "Review a workflow", | |
| promptTemplate: "Review this workflow carefully.\n\nFocus on:\n$ARGUMENTS", | |
| sourceFilePath: "/tmp/plugin/commands/workflows-review.md", | |
| }, | |
| ]); | |
| await agentCommandFromIngress( | |
| { | |
| message: "/workflows_review retries and cleanup", | |
| agentId: "main", | |
| allowModelOverride: false, | |
| }, | |
| runtime, | |
| ); | |
| expect(getLastEmbeddedCall()?.prompt).toBe( | |
| "Review this workflow carefully.\n\nFocus on:\nretries and cleanup", | |
| ); | |
| }); | |
| }); | |
| it.each([ | |
| { | |
| label: "dollar reference", | |
| message: "Review this with $release_notes.", | |
| request: "Review this with $release_notes.", | |
| }, | |
| { | |
| label: "leading slash invocation", | |
| message: "/release_notes summarize the changes", | |
| request: "/release_notes summarize the changes", | |
| }, | |
| ])("expands a skill $label on Gateway ingress", async ({ message, request }) => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| mockConfig(home, store); | |
| mockUserInvocableSkills({ home, skills: [{ name: "release-notes" }] }); | |
| await agentCommandFromIngress( | |
| { message, agentId: "main", allowModelOverride: false }, | |
| runtime, | |
| ); | |
| expect(getLastEmbeddedCall()?.prompt).toBe( | |
| [ | |
| "Use the following explicitly referenced skills for this request. Read each skill's SKILL.md before acting:", | |
| "- release-notes", | |
| "", | |
| "User request:", | |
| request, | |
| ].join("\n"), | |
| ); | |
| }); | |
| }); | |
| it("expands an explicitly referenced skill hidden from model invocation", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| mockConfig(home, store); | |
| mockUserInvocableSkills({ | |
| home, | |
| skills: [{ name: "release-notes", disableModelInvocation: true }], | |
| }); | |
| await agentCommandFromIngress( | |
| { | |
| message: "$release_notes draft the summary", | |
| agentId: "main", | |
| allowModelOverride: false, | |
| }, | |
| runtime, | |
| ); | |
| const skillFile = path.join(home, "openclaw", "skills", "release-notes", "SKILL.md"); | |
| expect(getLastEmbeddedCall()?.prompt).toContain(`- release-notes (SKILL.md: ${skillFile})`); | |
| }); | |
| }); | |
| it("rejects an explicitly referenced skill hidden by the agent allowlist", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const cfg = mockConfig(home, store); | |
| cfg.agents!.defaults!.skills = ["allowed-skill"]; | |
| vi.mocked(resolveEffectiveAgentSkillFilter).mockReturnValueOnce(["allowed-skill"]); | |
| mockUserInvocableSkills({ home, skills: [{ name: "hidden-skill" }] }); | |
| await expect( | |
| agentCommandFromIngress( | |
| { message: "$hidden_skill", agentId: "main", allowModelOverride: false }, | |
| runtime, | |
| ), | |
| ).rejects.toThrow( | |
| 'Skill "hidden-skill" is not available for this agent. Update the skill allowlist or choose an allowed skill.', | |
| ); | |
| expect(runEmbeddedAgent).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| it("expands an explicit reference when the skills prompt catalog is unavailable", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const cfg = mockConfig(home, store); | |
| cfg.skills = { limits: { maxSkillsPromptChars: 1 } }; | |
| mockUserInvocableSkills({ home, skills: [{ name: "release-notes" }] }); | |
| await agentCommandFromIngress( | |
| { | |
| message: "Use $release_notes despite the catalog cap.", | |
| agentId: "main", | |
| allowModelOverride: false, | |
| }, | |
| runtime, | |
| ); | |
| expect(getLastEmbeddedCall()?.skillsSnapshot?.prompt).toBe(""); | |
| expect(getLastEmbeddedCall()?.prompt).toContain("- release-notes"); | |
| }); | |
| }); | |
| it("enforces ingress model override authorization", async () => { | |
| await expect( | |
| // Runtime guard for non-TS callers; TS callsites are statically typed. | |
| agentCommandFromIngress( | |
| { | |
| message: "hi", | |
| to: "+1555", | |
| } as never, | |
| runtime, | |
| ), | |
| ).rejects.toThrow("allowModelOverride must be explicitly set for ingress agent runs."); | |
| }); | |
| it("strips private recovery identity from runtime-shaped public ingress", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| mockConfig(home, store); | |
| const prepare = vi.spyOn(executionIdentity, "prepare"); | |
| const inheritedAdmission = { | |
| token: { | |
| tokenVersion: 1 as const, | |
| contextId: "inherited-context", | |
| executionId: "inherited-execution", | |
| runId: "public-ingress-run", | |
| createdAt: 1, | |
| }, | |
| retryOnly: true, | |
| }; | |
| const priorDescriptor = Object.getOwnPropertyDescriptor( | |
| Object.prototype, | |
| "executionIdentityAdmission", | |
| ); | |
| // oxlint-disable-next-line no-extend-native -- Simulate a hostile JS plugin's prototype pollution. | |
| Object.defineProperty(Object.prototype, "executionIdentityAdmission", { | |
| configurable: true, | |
| value: inheritedAdmission, | |
| }); | |
| try { | |
| await agentCommandFromIngress( | |
| { | |
| message: "public plugin turn", | |
| agentId: "main", | |
| runId: "public-ingress-run", | |
| allowModelOverride: false, | |
| senderIsOwner: true, | |
| mainRestartRecoveryAdmitted: true, | |
| mainRestartRecoveryAttempt: 1, | |
| mainRestartRecoveryOwnerLease: { | |
| claimId: "forged-claim", | |
| cycleId: "forged-cycle", | |
| lifecycleGeneration: "forged-generation", | |
| ownerEpoch: 1, | |
| sessionId: "forged-session", | |
| sessionKey: "agent:main:main", | |
| storePath: store, | |
| }, | |
| executionIdentityAdmission: { | |
| token: { | |
| tokenVersion: 1, | |
| contextId: "forged-context", | |
| executionId: "forged-execution", | |
| runId: "public-ingress-run", | |
| createdAt: 1, | |
| }, | |
| retryOnly: true, | |
| }, | |
| } as never, | |
| runtime, | |
| ); | |
| expect(prepare).toHaveBeenCalledWith( | |
| expect.objectContaining({ admission: undefined, runId: "public-ingress-run" }), | |
| ); | |
| expect( | |
| vi.mocked(attemptExecutionRuntime.runAgentAttempt).mock.calls.at(-1)?.[0].opts, | |
| ).toMatchObject({ senderIsOwner: false }); | |
| } finally { | |
| prepare.mockRestore(); | |
| if (priorDescriptor) { | |
| // oxlint-disable-next-line no-extend-native -- Restore the exact pre-test prototype descriptor. | |
| Object.defineProperty(Object.prototype, "executionIdentityAdmission", priorDescriptor); | |
| } else { | |
| delete (Object.prototype as Record<string, unknown>).executionIdentityAdmission; | |
| } | |
| } | |
| }); | |
| }); | |
| it("rejects a missing harness-owned session before local CLI dispatch", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| mockConfig(home, store); | |
| await expect( | |
| agentCommand( | |
| { | |
| message: "do not squat", | |
| sessionKey: "agent:main:harness:codex:supervision:missing-local", | |
| }, | |
| runtime, | |
| ), | |
| ).rejects.toThrow(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE); | |
| expect(runEmbeddedAgent).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| it("rejects a missing harness-owned session through embedded ingress", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| mockConfig(home, store); | |
| await expect( | |
| agentCommandFromIngress( | |
| { | |
| message: "do not squat", | |
| sessionKey: "agent:main:harness:codex:supervision:missing-ingress", | |
| allowModelOverride: false, | |
| }, | |
| runtime, | |
| ), | |
| ).rejects.toThrow(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE); | |
| expect(runEmbeddedAgent).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| it("continues an existing locked harness-owned session", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const sessionKey = "agent:main:harness:openclaw:supervision:existing"; | |
| mockConfig(home, store); | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { | |
| sessionId: "existing-harness-session", | |
| updatedAt: Date.now(), | |
| agentHarnessId: "openclaw", | |
| modelSelectionLocked: true, | |
| }, | |
| }); | |
| await agentCommandFromIngress( | |
| { | |
| message: "continue safely", | |
| sessionKey, | |
| allowModelOverride: false, | |
| }, | |
| runtime, | |
| ); | |
| expect(runEmbeddedAgent).toHaveBeenCalledOnce(); | |
| expect(getLastEmbeddedCall()?.sessionId).toBe("existing-harness-session"); | |
| }); | |
| }); | |
| it("enforces stored workspace permissions through public agent ingress", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const sessionKey = "agent:main:dashboard:workspace-permission"; | |
| const sessionRoot = path.join(home, "worktree"); | |
| const outsidePath = path.join(home, "outside.txt"); | |
| const insidePath = path.join(sessionRoot, "inside.txt"); | |
| fs.mkdirSync(sessionRoot, { recursive: true }); | |
| mockConfig(home, store); | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { | |
| sessionId: "workspace-permission-session", | |
| updatedAt: Date.now(), | |
| spawnedCwd: sessionRoot, | |
| permissionMode: "workspace", | |
| sessionRoot, | |
| }, | |
| }); | |
| attemptExecutionMocks.useRealRunAgentAttempt = true; | |
| let outsideWriteError: unknown; | |
| vi.mocked(runEmbeddedAgent).mockImplementationOnce(async (opts) => { | |
| const codingRoot = opts.cwd ?? opts.workspaceDir; | |
| const writeTool = createHostWorkspaceWriteTool(codingRoot, { | |
| containmentRoot: opts.sessionRoot ?? codingRoot, | |
| workspaceOnly: opts.permissionMode !== undefined && opts.permissionMode !== "full", | |
| }); | |
| try { | |
| await writeTool.execute("outside-write", { | |
| path: outsidePath, | |
| content: "outside", | |
| }); | |
| } catch (error) { | |
| outsideWriteError = error; | |
| } | |
| await writeTool.execute("inside-write", { | |
| path: insidePath, | |
| content: "inside", | |
| }); | |
| return createDefaultAgentResult(); | |
| }); | |
| await agentCommandFromIngress( | |
| { | |
| message: "write inside the session worktree", | |
| sessionKey, | |
| allowModelOverride: false, | |
| }, | |
| runtime, | |
| ); | |
| expect(outsideWriteError).toBeInstanceOf(Error); | |
| expect(String(outsideWriteError)).toMatch(/(?:sandbox|workspace) root/i); | |
| expect(fs.existsSync(outsidePath)).toBe(false); | |
| expect(fs.readFileSync(insidePath, "utf8")).toBe("inside"); | |
| }); | |
| }); | |
| it("reuses a Discord voice session after one stale-session rollover", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const sessionKey = "agent:main:discord:channel:voice-1"; | |
| const staleStartedAt = Date.now() - 2 * 24 * 60 * 60_000; | |
| const cfg = mockConfig(home, store); | |
| cfg.session = { ...cfg.session, reset: { mode: "daily" } }; | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { | |
| sessionId: "stale-voice-session", | |
| updatedAt: staleStartedAt, | |
| sessionStartedAt: staleStartedAt, | |
| }, | |
| }); | |
| const runVoiceTurn = async (message: string) => | |
| await agentCommandFromIngress( | |
| { | |
| message, | |
| sessionKey, | |
| agentId: "main", | |
| messageChannel: "discord", | |
| messageProvider: "discord-voice", | |
| allowModelOverride: false, | |
| deliver: false, | |
| }, | |
| runtime, | |
| ); | |
| await runVoiceTurn("remember 42"); | |
| const firstSessionId = getLastEmbeddedCall()?.sessionId; | |
| expect(firstSessionId).toBeTruthy(); | |
| expect(firstSessionId).not.toBe("stale-voice-session"); | |
| const firstPersisted = readSessionStore<{ | |
| sessionId: string; | |
| sessionStartedAt?: number; | |
| }>(store)[sessionKey]; | |
| expect(firstPersisted?.sessionId).toBe(firstSessionId); | |
| expect(firstPersisted?.sessionStartedAt).toBeGreaterThan(staleStartedAt); | |
| await runVoiceTurn("what number?"); | |
| expect(getLastEmbeddedCall()?.sessionId).toBe(firstSessionId); | |
| const persisted = readSessionStore<{ sessionId: string; sessionStartedAt?: number }>(store)[ | |
| sessionKey | |
| ]; | |
| expect(persisted?.sessionId).toBe(firstSessionId); | |
| expect(persisted?.sessionStartedAt).toBeGreaterThan(staleStartedAt); | |
| }); | |
| }); | |
| it("rejects archived sessions selected by session id", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| mockConfig(home, store); | |
| await writeSessionStoreSeed(store, { | |
| "agent:main:subagent:archived": { | |
| sessionId: "archived-session-id", | |
| archivedAt: Date.now(), | |
| updatedAt: Date.now(), | |
| }, | |
| }); | |
| vi.mocked(runEmbeddedAgent).mockClear(); | |
| await expect( | |
| agentCommandFromIngress( | |
| { | |
| message: "blocked while archived", | |
| sessionId: "archived-session-id", | |
| allowModelOverride: false, | |
| }, | |
| runtime, | |
| ), | |
| ).rejects.toThrow( | |
| 'Session "agent:main:subagent:archived" is archived. Restore it before starting new work.', | |
| ); | |
| expect(runEmbeddedAgent).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| it("reloads archive state after asynchronous command preparation", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const sessionKey = "agent:main:subagent:archive-race"; | |
| const sessionId = "archive-race-session-id"; | |
| mockConfig(home, store); | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { sessionId, updatedAt: Date.now() }, | |
| }); | |
| vi.mocked(ensureAgentWorkspace).mockImplementationOnce(async (params) => { | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { | |
| sessionId, | |
| archivedAt: Date.now(), | |
| updatedAt: Date.now(), | |
| }, | |
| }); | |
| return { dir: params?.dir ?? "/tmp/openclaw-workspace" }; | |
| }); | |
| await expect( | |
| agentCommandFromIngress( | |
| { | |
| message: "blocked after preparation", | |
| sessionId, | |
| allowModelOverride: false, | |
| }, | |
| runtime, | |
| ), | |
| ).rejects.toThrow( | |
| `Session "${sessionKey}" is archived. Restore it before starting new work.`, | |
| ); | |
| expect(runEmbeddedAgent).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| it("keeps a restored session restored after asynchronous command preparation", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const sessionKey = "agent:main:subagent:restore-race"; | |
| const sessionId = "restore-race-session-id"; | |
| mockConfig(home, store); | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { | |
| sessionId, | |
| archivedAt: Date.now(), | |
| updatedAt: Date.now(), | |
| }, | |
| }); | |
| vi.mocked(ensureAgentWorkspace).mockImplementationOnce(async (params) => { | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { sessionId, updatedAt: Date.now() }, | |
| }); | |
| return { dir: params?.dir ?? "/tmp/openclaw-workspace" }; | |
| }); | |
| await agentCommandFromIngress( | |
| { | |
| message: "run after restore", | |
| sessionId, | |
| allowModelOverride: false, | |
| }, | |
| runtime, | |
| ); | |
| expect(runEmbeddedAgent).toHaveBeenCalled(); | |
| expect( | |
| readSessionStore<{ archivedAt?: number }>(store)[sessionKey]?.archivedAt, | |
| ).toBeUndefined(); | |
| }); | |
| }); | |
| it("excludes an initiating agent turn from its own lifecycle interruption", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const sessionKey = "agent:main:subagent:in-band-lifecycle"; | |
| const sessionId = "in-band-lifecycle-session-id"; | |
| mockConfig(home, store); | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { sessionId, updatedAt: Date.now() }, | |
| }); | |
| vi.mocked(runEmbeddedAgent).mockImplementationOnce(async () => { | |
| await expect( | |
| interruptSessionWorkAdmissions({ | |
| scope: store, | |
| identities: [sessionKey, sessionId], | |
| timeoutMs: 5, | |
| }), | |
| ).resolves.toBe(true); | |
| return createDefaultAgentResult(); | |
| }); | |
| await agentCommandFromIngress( | |
| { | |
| message: "run an in-band lifecycle command", | |
| sessionId, | |
| allowModelOverride: false, | |
| }, | |
| runtime, | |
| ); | |
| expect(runEmbeddedAgent).toHaveBeenCalledOnce(); | |
| }); | |
| }); | |
| it.each(["generic", "explicit restart", "terminal Stop"] as const)( | |
| "preserves lifecycle interruption semantics: %s", | |
| async (interruption) => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const sessionKey = "agent:main:subagent:lifecycle-restart"; | |
| const sessionId = "lifecycle-restart-session-id"; | |
| mockConfig(home, store); | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { sessionId, updatedAt: Date.now() }, | |
| }); | |
| let observedAbortReason: unknown; | |
| const entered = createDeferredCore(); | |
| const cleanup = new AbortController(); | |
| const reason = | |
| interruption === "terminal Stop" | |
| ? createAgentRunDirectAbortError() | |
| : interruption === "explicit restart" | |
| ? createAgentRunRestartAbortError() | |
| : undefined; | |
| vi.mocked(runEmbeddedAgent).mockImplementationOnce( | |
| async (opts) => | |
| await new Promise((resolve) => { | |
| entered.resolve(); | |
| const finish = () => { | |
| observedAbortReason = opts.abortSignal?.reason; | |
| resolve(createDefaultAgentResult()); | |
| }; | |
| if (opts.abortSignal?.aborted) { | |
| finish(); | |
| return; | |
| } | |
| opts.abortSignal?.addEventListener("abort", finish, { once: true }); | |
| }), | |
| ); | |
| const command = agentCommandFromIngress( | |
| { | |
| message: "interrupt this lifecycle run", | |
| sessionId, | |
| allowModelOverride: false, | |
| abortSignal: cleanup.signal, | |
| }, | |
| runtime, | |
| ).catch((error: unknown) => error); | |
| try { | |
| await Promise.race([ | |
| entered.promise, | |
| command.then((result) => { | |
| throw new Error("Command settled before embedded entry", { cause: result }); | |
| }), | |
| ]); | |
| expect(runEmbeddedAgent).toHaveBeenCalledOnce(); | |
| await interruptSessionWorkAdmissions({ | |
| scope: store, | |
| identities: [sessionKey, sessionId], | |
| reason, | |
| }); | |
| const commandResult = await command; | |
| if (interruption === "terminal Stop") { | |
| expect(observedAbortReason).toBe(reason); | |
| expect(isAgentRunDirectAbortReason(observedAbortReason)).toBe(true); | |
| } | |
| expect(isAgentRunRestartAbortReason(observedAbortReason)).toBe( | |
| interruption !== "terminal Stop", | |
| ); | |
| expect(isAgentRunRestartAbortReason(commandResult)).toBe( | |
| interruption !== "terminal Stop", | |
| ); | |
| } finally { | |
| cleanup.abort(createAgentRunDirectAbortError()); | |
| await command; | |
| } | |
| }); | |
| }, | |
| ); | |
| it("rejects a stale requested session id after command preparation", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const sessionKey = "agent:main:subagent:stale-request"; | |
| mockConfig(home, store); | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { sessionId: "current-session-id", updatedAt: Date.now() }, | |
| }); | |
| await expect( | |
| agentCommandFromIngress( | |
| { | |
| message: "do not enter the replacement session", | |
| sessionKey, | |
| sessionId: "stale-session-id", | |
| allowModelOverride: false, | |
| }, | |
| runtime, | |
| ), | |
| ).rejects.toMatchObject({ code: "SESSION_WORK_START_CHANGED" }); | |
| expect(runEmbeddedAgent).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| it("uses the selected agent thinkingDefault for fresh ingress runs", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| mockConfig( | |
| home, | |
| store, | |
| { | |
| thinkingDefault: "high", | |
| }, | |
| undefined, | |
| [{ id: "main", default: true, thinkingDefault: "off" }], | |
| ); | |
| await agentCommandFromIngress( | |
| { | |
| message: "ping", | |
| agentId: "main", | |
| allowModelOverride: false, | |
| }, | |
| runtime, | |
| ); | |
| expect(getLastEmbeddedCall()?.thinkLevel).toBe("off"); | |
| }); | |
| }); | |
| it("persists local overrides", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| mockConfig(home, store); | |
| vi.mocked(runEmbeddedAgent).mockResolvedValue( | |
| createDefaultAgentResult({ | |
| payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }], | |
| durationMs: 42, | |
| }), | |
| ); | |
| await agentCommand( | |
| { | |
| message: "ping", | |
| to: "+1222", | |
| accountId: "kev", | |
| thinking: "high", | |
| verbose: "on", | |
| json: true, | |
| }, | |
| runtime, | |
| ); | |
| const saved = readSessionStore<{ thinkingLevel?: string; verboseLevel?: string }>(store); | |
| const entry = expectDefined( | |
| Object.values(saved)[0], | |
| "Object.values(saved)[0] test invariant", | |
| ); | |
| expect(entry.thinkingLevel).toBe("high"); | |
| expect(entry.verboseLevel).toBe("on"); | |
| const callArgs = getLastEmbeddedCall(); | |
| expect(callArgs?.thinkLevel).toBe("high"); | |
| expect(callArgs?.verboseLevel).toBe("on"); | |
| expect(callArgs?.prompt).toBe("ping"); | |
| expect(callArgs?.agentAccountId).toBe("kev"); | |
| const logCalls = (runtime.log as unknown as MockInstance).mock.calls; | |
| const logged = logCalls[logCalls.length - 1]?.[0] as string; | |
| const parsed = JSON.parse(logged) as { | |
| payloads: Array<{ text: string; mediaUrl?: string | null }>; | |
| meta: { durationMs: number }; | |
| }; | |
| expect(expectDefined(parsed.payloads[0], "parsed.payloads[0] test invariant").text).toBe( | |
| "json-reply", | |
| ); | |
| expect(expectDefined(parsed.payloads[0], "parsed.payloads[0] test invariant").mediaUrl).toBe( | |
| "http://x.test/a.jpg", | |
| ); | |
| expect(parsed.meta.durationMs).toBe(42); | |
| }); | |
| }); | |
| it("delivers embedded replies without re-persisting them", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| mockConfig(home, store); | |
| installThinkingTestProviders([ | |
| { | |
| pluginId: "telegram", | |
| source: "test", | |
| plugin: createOutboundTestPlugin({ | |
| id: "telegram", | |
| outbound: createDirectOutboundTestAdapter({ channel: "telegram" }), | |
| messaging: { | |
| normalizeTarget: (target) => { | |
| const chatId = target.trim().replace(/^telegram:/i, ""); | |
| return chatId ? `telegram:${chatId}` : undefined; | |
| }, | |
| resolveOutboundSessionRoute: (params) => { | |
| const chatId = params.target.replace(/^telegram:/i, ""); | |
| return createOutboundSessionRouteFixture({ | |
| cfg: params.cfg, | |
| agentId: params.agentId, | |
| channel: "telegram", | |
| accountId: params.accountId, | |
| peer: { kind: "direct", id: chatId }, | |
| chatType: "direct", | |
| from: `telegram:${chatId}`, | |
| to: `telegram:${chatId}`, | |
| }); | |
| }, | |
| }, | |
| }), | |
| }, | |
| ]); | |
| const sendMessageTelegram = vi.fn(async () => undefined); | |
| const base = createDefaultAgentResult({ payloads: [{ text: "assistant-visible" }] }); | |
| vi.mocked(runEmbeddedAgent).mockResolvedValueOnce({ | |
| ...base, | |
| meta: { | |
| ...base.meta, | |
| finalAssistantVisibleText: "assistant-visible", | |
| }, | |
| }); | |
| await agentCommandFromIngress( | |
| { | |
| message: "call a tool then answer", | |
| agentId: "main", | |
| to: "+1222", | |
| channel: "telegram", | |
| messageChannel: "telegram", | |
| deliver: true, | |
| allowModelOverride: false, | |
| sessionEffects: "internal", | |
| }, | |
| runtime, | |
| { sendMessageTelegram }, | |
| ); | |
| expect(sendMessageTelegram).toHaveBeenCalledWith("telegram:+1222", "assistant-visible", { | |
| accountId: undefined, | |
| verbose: false, | |
| }); | |
| expect(vi.mocked(attemptExecutionRuntime.persistCliTurnTranscript)).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| it("does not load the full model catalog for trusted explicit overrides without an allowlist", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| mockConfig(home, store, { models: {} }); | |
| await agentCommand( | |
| { | |
| message: "ping", | |
| to: "+1222", | |
| model: "openrouter/auto", | |
| }, | |
| runtime, | |
| ); | |
| expect(readPreparedModelCatalog).not.toHaveBeenCalled(); | |
| expectLastRunProviderModel("openrouter", "openrouter/auto"); | |
| const thinkingDefaultCall = vi.mocked(modelSelectionModule.resolveThinkingDefault).mock | |
| .calls[0]?.[0]; | |
| expect(thinkingDefaultCall?.provider).toBe("openrouter"); | |
| expect(thinkingDefaultCall?.model).toBe("openrouter/auto"); | |
| expect(thinkingDefaultCall?.catalog).toBeUndefined(); | |
| }); | |
| }); | |
| it("bypasses ACP sessions for one-shot model runs", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const sessionKey = "agent:main:main"; | |
| mockConfig(home, store, { models: {} }); | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { | |
| sessionId: "acp-backed-session", | |
| updatedAt: Date.now(), | |
| }, | |
| }); | |
| const runTurn = vi.fn(); | |
| acpManagerTesting.setAcpSessionManagerForTests({ | |
| resolveSession: vi.fn(() => ({ | |
| kind: "ready", | |
| sessionKey, | |
| meta: { | |
| backend: "acpx", | |
| agent: "codex", | |
| runtimeSessionName: "runtime-1", | |
| mode: "persistent", | |
| state: "idle", | |
| lastActivityAt: Date.now(), | |
| }, | |
| })), | |
| runTurn, | |
| }); | |
| await agentCommand( | |
| { | |
| message: "Reply with exactly OPENCLAW-MODEL-OK", | |
| sessionKey, | |
| model: "openrouter/auto", | |
| modelRun: true, | |
| promptMode: "none", | |
| }, | |
| runtime, | |
| ); | |
| expect(runTurn).not.toHaveBeenCalled(); | |
| const callArgs = getLastEmbeddedCall(); | |
| expect(callArgs?.provider).toBe("openrouter"); | |
| expect(callArgs?.model).toBe("openrouter/auto"); | |
| expect(callArgs?.prompt).toBe("Reply with exactly OPENCLAW-MODEL-OK"); | |
| expect(callArgs?.modelRun).toBe(true); | |
| expect(callArgs?.promptMode).toBe("none"); | |
| expect(callArgs?.disableTools).toBe(true); | |
| }); | |
| }); | |
| it("borrows session lookup data without returning cached mutable store objects", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const sessionKey = "agent:main:cache-borrow"; | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { | |
| sessionId: "session-cache-borrow", | |
| updatedAt: Date.now(), | |
| thinkingLevel: "low", | |
| }, | |
| "agent:main:other": { | |
| sessionId: "session-other", | |
| updatedAt: Date.now(), | |
| }, | |
| }); | |
| mockConfig(home, store, { models: {} }); | |
| const prepared = await prepareAgentCommandExecution( | |
| { | |
| message: "prepare only", | |
| sessionKey, | |
| }, | |
| runtime, | |
| ); | |
| const cached = loadSessionEntry({ storePath: store, sessionKey, clone: false }); | |
| expect(prepared.sessionStore).not.toBe(cached); | |
| expect(prepared.sessionEntry).not.toBe(cached); | |
| expect(prepared).not.toHaveProperty("recoveryCandidateEntry"); | |
| expect(prepared.sessionStore?.[sessionKey]).toBe(prepared.sessionEntry); | |
| expect(prepared.sessionStore?.["agent:main:other"]).toBeUndefined(); | |
| }); | |
| }); | |
| it("keeps synthetic direct-DM delivery mode out of existing CLI binding facts", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const sessionKey = "agent:main:discord:direct:requester"; | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { | |
| sessionId: "requester-session", | |
| updatedAt: Date.now(), | |
| chatType: "direct", | |
| modelProvider: "anthropic", | |
| model: "claude-opus-4-6", | |
| cliSessionBindings: { | |
| "claude-cli": { | |
| sessionId: "native-claude-session", | |
| messageToolPolicyHash: "automatic-policy-hash", | |
| }, | |
| }, | |
| delivery: normalizeSessionDeliveryState({ | |
| context: { channel: "discord", to: "user:requester" }, | |
| origin: { provider: "discord", chatType: "direct", to: "user:requester" }, | |
| }), | |
| }, | |
| }); | |
| const cfg = mockConfig(home, store, { | |
| models: { | |
| "anthropic/claude-opus-4-6": { agentRuntime: { id: "claude-cli" } }, | |
| }, | |
| }); | |
| cfg.messages = { visibleReplies: "automatic" }; | |
| const prepared = await prepareAgentCommandExecution( | |
| { | |
| message: "child completed", | |
| sessionKey, | |
| sourceReplyDeliveryMode: "message_tool_only", | |
| inputProvenance: { | |
| kind: "inter_session", | |
| sourceSessionKey: "agent:main:subagent:child", | |
| sourceTool: "subagent_announce", | |
| }, | |
| }, | |
| runtime, | |
| ); | |
| expect(prepared.opts.sourceReplyDeliveryMode).toBe("message_tool_only"); | |
| expect(prepared.opts.cliSessionBindingFacts).toEqual({ | |
| sourceReplyDeliveryMode: "automatic", | |
| }); | |
| expect(prepared.sessionEntry?.cliSessionBindings?.["claude-cli"]).toMatchObject({ | |
| sessionId: "native-claude-session", | |
| messageToolPolicyHash: "automatic-policy-hash", | |
| }); | |
| }); | |
| }); | |
| it("passes resolved session-id resume files to embedded runs", async () => { | |
| await withTempHome(async (home) => { | |
| const resumeStore = path.join(home, "sessions-resume.json"); | |
| await writeSessionStoreSeed(resumeStore, { | |
| foo: { | |
| sessionId: "session-123", | |
| updatedAt: Date.now(), | |
| systemSent: true, | |
| }, | |
| }); | |
| mockConfig(home, resumeStore); | |
| await agentCommand( | |
| { message: "resume me", sessionId: "session-123", thinking: "low" }, | |
| runtime, | |
| ); | |
| const callArgs = getLastEmbeddedCall(); | |
| expect(callArgs?.sessionId).toBe("session-123"); | |
| expectSqliteSessionFileMarker({ | |
| agentId: "main", | |
| sessionFile: callArgs?.sessionFile, | |
| sessionId: "session-123", | |
| storePath: resumeStore, | |
| }); | |
| }); | |
| }); | |
| it("does not duplicate agent events from embedded runs", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| mockConfig(home, store); | |
| const assistantEvents: Array<{ runId: string; text?: string }> = []; | |
| const stop = onAgentEvent((evt) => { | |
| if (evt.stream !== "assistant") { | |
| return; | |
| } | |
| assistantEvents.push({ | |
| runId: evt.runId, | |
| text: typeof evt.data?.text === "string" ? evt.data.text : undefined, | |
| }); | |
| }); | |
| vi.mocked(runEmbeddedAgent).mockImplementationOnce(async (params) => { | |
| const runId = (params as { runId?: string } | undefined)?.runId ?? "run"; | |
| const data = { text: "hello", delta: "hello" }; | |
| ( | |
| params as { | |
| onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void; | |
| } | |
| ).onAgentEvent?.({ stream: "assistant", data }); | |
| emitAgentEvent({ runId, stream: "assistant", data }); | |
| return { | |
| payloads: [{ text: "hello" }], | |
| meta: { agentMeta: { provider: "p", model: "m" } }, | |
| } as never; | |
| }); | |
| await agentCommand({ message: "hi", to: "+1555", thinking: "low" }, runtime); | |
| stop(); | |
| const matching = assistantEvents.filter((evt) => evt.text === "hello"); | |
| expect(matching).toHaveLength(1); | |
| }); | |
| }); | |
| it("probes the configured primary first for origin-backed auto session model overrides", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| await writeSessionStoreSeed(store, { | |
| "agent:main:subagent:test": { | |
| sessionId: "session-subagent", | |
| updatedAt: Date.now(), | |
| providerOverride: "anthropic", | |
| modelOverride: "claude-opus-4-6", | |
| modelOverrideSource: "auto", | |
| modelOverrideFallbackOriginProvider: "openai", | |
| modelOverrideFallbackOriginModel: "gpt-4.1-mini", | |
| }, | |
| }); | |
| mockConfig(home, store, { | |
| model: { | |
| primary: "openai/gpt-4.1-mini", | |
| fallbacks: ["openai/gpt-5.4"], | |
| }, | |
| models: { | |
| "anthropic/claude-opus-4-6": {}, | |
| "openai/gpt-4.1-mini": {}, | |
| "openai/gpt-5.4": {}, | |
| }, | |
| }); | |
| mockModelCatalogOnce([ | |
| { id: "claude-opus-4-6", name: "Opus", provider: "anthropic" }, | |
| { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, | |
| { id: "gpt-5.4", name: "GPT-5.2", provider: "openai" }, | |
| ]); | |
| vi.mocked(runEmbeddedAgent) | |
| .mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 })) | |
| .mockResolvedValueOnce({ | |
| payloads: [{ text: "ok" }], | |
| meta: { | |
| durationMs: 5, | |
| agentMeta: { sessionId: "session-subagent", provider: "openai", model: "gpt-5.4" }, | |
| }, | |
| }); | |
| await agentCommand( | |
| { | |
| message: "hi", | |
| sessionKey: "agent:main:subagent:test", | |
| }, | |
| runtime, | |
| ); | |
| const attempts = vi | |
| .mocked(runEmbeddedAgent) | |
| .mock.calls.map((call) => ({ provider: call[0]?.provider, model: call[0]?.model })); | |
| expect(attempts).toEqual([ | |
| { provider: "openai", model: "gpt-4.1-mini" }, | |
| { provider: "openai", model: "gpt-5.4" }, | |
| ]); | |
| }); | |
| }); | |
| it("does not probe or fall back from a locked stored model", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions-locked-model.json"); | |
| const sessionKey = "agent:main:subagent:locked-model"; | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { | |
| sessionId: "session-locked-model", | |
| updatedAt: Date.now(), | |
| providerOverride: "anthropic", | |
| modelOverride: "claude-opus-4-6", | |
| modelOverrideSource: "auto", | |
| modelOverrideFallbackOriginProvider: "openai", | |
| modelOverrideFallbackOriginModel: "gpt-4.1-mini", | |
| modelSelectionLocked: true, | |
| }, | |
| }); | |
| mockConfig(home, store, { | |
| model: { | |
| primary: "openai/gpt-4.1-mini", | |
| fallbacks: ["openai/gpt-5.4"], | |
| }, | |
| models: { | |
| "anthropic/claude-opus-4-6": {}, | |
| "openai/gpt-4.1-mini": {}, | |
| "openai/gpt-5.4": {}, | |
| }, | |
| }); | |
| mockModelCatalogOnce([ | |
| { id: "claude-opus-4-6", name: "Opus", provider: "anthropic" }, | |
| { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, | |
| { id: "gpt-5.4", name: "GPT-5.4", provider: "openai" }, | |
| ]); | |
| vi.mocked(runEmbeddedAgent).mockRejectedValueOnce( | |
| Object.assign(new Error("rate limited"), { status: 429 }), | |
| ); | |
| await expect(runAgentWithSessionKey(sessionKey)).rejects.toThrow("rate limited"); | |
| const attempts = vi | |
| .mocked(runEmbeddedAgent) | |
| .mock.calls.map((call) => ({ provider: call[0]?.provider, model: call[0]?.model })); | |
| expect(attempts).toEqual([{ provider: "anthropic", model: "claude-opus-4-6" }]); | |
| }); | |
| }); | |
| it("clears legacy auto session model overrides without origin metadata", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions-legacy-auto-override.json"); | |
| await writeSessionStoreSeed(store, { | |
| "agent:main:subagent:legacy-auto": { | |
| sessionId: "session-legacy-auto", | |
| updatedAt: Date.now(), | |
| providerOverride: "anthropic", | |
| modelOverride: "claude-opus-4-6", | |
| modelOverrideSource: "auto", | |
| }, | |
| }); | |
| mockConfig(home, store, { | |
| model: { | |
| primary: "openai/gpt-4.1-mini", | |
| fallbacks: ["openai/gpt-5.4"], | |
| }, | |
| models: { | |
| "anthropic/claude-opus-4-6": {}, | |
| "openai/gpt-4.1-mini": {}, | |
| "openai/gpt-5.4": {}, | |
| }, | |
| }); | |
| mockModelCatalogOnce([ | |
| { id: "claude-opus-4-6", name: "Opus", provider: "anthropic" }, | |
| { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, | |
| { id: "gpt-5.4", name: "GPT-5.4", provider: "openai" }, | |
| ]); | |
| await agentCommand( | |
| { | |
| message: "hi", | |
| sessionKey: "agent:main:subagent:legacy-auto", | |
| }, | |
| runtime, | |
| ); | |
| const attempts = vi | |
| .mocked(runEmbeddedAgent) | |
| .mock.calls.map((call) => ({ provider: call[0]?.provider, model: call[0]?.model })); | |
| expect(attempts).toEqual([{ provider: "openai", model: "gpt-4.1-mini" }]); | |
| const cleared = readSessionStore<{ | |
| providerOverride?: string; | |
| modelOverride?: string; | |
| modelOverrideSource?: string; | |
| }>(store); | |
| const entry = cleared["agent:main:subagent:legacy-auto"]; | |
| expect(entry?.providerOverride).toBeUndefined(); | |
| expect(entry?.modelOverride).toBeUndefined(); | |
| expect(entry?.modelOverrideSource).toBeUndefined(); | |
| }); | |
| }); | |
| it("does not repair locked legacy auto session model overrides", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions-locked-legacy-auto-override.json"); | |
| await writeSessionStoreSeed(store, { | |
| "agent:main:subagent:locked-legacy-auto": { | |
| sessionId: "session-locked-legacy-auto", | |
| updatedAt: Date.now(), | |
| providerOverride: "anthropic", | |
| modelOverride: "claude-opus-4-6", | |
| modelOverrideSource: "auto", | |
| modelSelectionLocked: true, | |
| }, | |
| }); | |
| mockConfig(home, store, { | |
| model: { | |
| primary: "openai/gpt-4.1-mini", | |
| fallbacks: ["openai/gpt-5.4"], | |
| }, | |
| models: { | |
| "anthropic/claude-opus-4-6": {}, | |
| "openai/gpt-4.1-mini": {}, | |
| "openai/gpt-5.4": {}, | |
| }, | |
| }); | |
| mockModelCatalogOnce([ | |
| { id: "claude-opus-4-6", name: "Opus", provider: "anthropic" }, | |
| { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, | |
| { id: "gpt-5.4", name: "GPT-5.4", provider: "openai" }, | |
| ]); | |
| await agentCommand( | |
| { | |
| message: "hi", | |
| sessionKey: "agent:main:subagent:locked-legacy-auto", | |
| }, | |
| runtime, | |
| ); | |
| expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); | |
| expectLastRunProviderModel("anthropic", "claude-opus-4-6"); | |
| const persisted = readSessionStore<{ | |
| providerOverride?: string; | |
| modelOverride?: string; | |
| modelOverrideSource?: string; | |
| modelSelectionLocked?: boolean; | |
| }>(store)["agent:main:subagent:locked-legacy-auto"]; | |
| expect(persisted).toMatchObject({ | |
| providerOverride: "anthropic", | |
| modelOverride: "claude-opus-4-6", | |
| modelOverrideSource: "auto", | |
| modelSelectionLocked: true, | |
| }); | |
| }); | |
| }); | |
| it("does not use fallback list for user session model overrides", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions-user-override.json"); | |
| await writeSessionStoreSeed(store, { | |
| "agent:main:subagent:user-override": { | |
| sessionId: "session-user-override", | |
| updatedAt: Date.now(), | |
| providerOverride: "ollama", | |
| modelOverride: "qwen3.5:27b", | |
| modelOverrideSource: "user", | |
| }, | |
| }); | |
| mockConfig(home, store, { | |
| model: { | |
| primary: "openai/gpt-4.1-mini", | |
| fallbacks: ["openai/gpt-5.4"], | |
| }, | |
| models: { | |
| "ollama/qwen3.5:27b": {}, | |
| "openai/gpt-4.1-mini": {}, | |
| "openai/gpt-5.4": {}, | |
| }, | |
| }); | |
| mockModelCatalogOnce([ | |
| { id: "qwen3.5:27b", name: "Qwen 3.5", provider: "ollama" }, | |
| { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, | |
| { id: "gpt-5.4", name: "GPT-5.4", provider: "openai" }, | |
| ]); | |
| vi.mocked(runEmbeddedAgent).mockRejectedValueOnce(new Error("connect ECONNREFUSED")); | |
| await expect( | |
| agentCommand( | |
| { | |
| message: "hi", | |
| sessionKey: "agent:main:subagent:user-override", | |
| }, | |
| runtime, | |
| ), | |
| ).rejects.toThrow("connect ECONNREFUSED"); | |
| const attempts = vi | |
| .mocked(runEmbeddedAgent) | |
| .mock.calls.map((call) => ({ provider: call[0]?.provider, model: call[0]?.model })); | |
| expect(attempts).toEqual([{ provider: "ollama", model: "qwen3.5:27b" }]); | |
| }); | |
| }); | |
| it("clears disallowed stored override fields", async () => { | |
| await withTempHome(async (home) => { | |
| const clearStore = path.join(home, "sessions-clear-overrides.json"); | |
| await writeSessionStoreSeed(clearStore, { | |
| "agent:main:subagent:clear-overrides": { | |
| sessionId: "session-clear-overrides", | |
| updatedAt: Date.now(), | |
| providerOverride: "anthropic", | |
| modelOverride: "claude-opus-4-6", | |
| authProfileOverride: "profile-legacy", | |
| authProfileOverrideSource: "user", | |
| authProfileOverrideCompactionCount: 2, | |
| fallbackNotice: { | |
| kind: "active", | |
| selectedModel: "anthropic/claude-opus-4-6", | |
| activeModel: "openai/gpt-4.1-mini", | |
| reason: "fallback", | |
| }, | |
| }, | |
| }); | |
| mockConfig(home, clearStore, { | |
| model: { | |
| primary: "openai/gpt-4.1-mini", | |
| fallbacks: ["anthropic/claude-opus-4-6"], | |
| }, | |
| models: { | |
| "openai/gpt-4.1-mini": {}, | |
| }, | |
| modelPolicy: { allow: ["openai/gpt-4.1-mini"] }, | |
| }); | |
| mockModelCatalogOnce([ | |
| { id: "claude-opus-4-6", name: "Opus", provider: "anthropic" }, | |
| { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, | |
| ]); | |
| await runAgentWithSessionKey("agent:main:subagent:clear-overrides"); | |
| expectLastRunProviderModel("openai", "gpt-4.1-mini"); | |
| const cleared = readSessionStore<{ | |
| providerOverride?: string; | |
| modelOverride?: string; | |
| authProfileOverride?: string; | |
| authProfileOverrideSource?: string; | |
| authProfileOverrideCompactionCount?: number; | |
| fallbackNotice?: unknown; | |
| }>(clearStore); | |
| const entry = cleared["agent:main:subagent:clear-overrides"]; | |
| expect(entry?.providerOverride).toBeUndefined(); | |
| expect(entry?.modelOverride).toBeUndefined(); | |
| expect(entry?.authProfileOverride).toBeUndefined(); | |
| expect(entry?.authProfileOverrideSource).toBeUndefined(); | |
| expect(entry?.authProfileOverrideCompactionCount).toBeUndefined(); | |
| expect(entry?.fallbackNotice).toBeUndefined(); | |
| }); | |
| }); | |
| it("allows a locked disallowed stored override to run without clearing it", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions-locked-disallowed-override.json"); | |
| const sessionKey = "agent:main:subagent:locked-disallowed"; | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { | |
| sessionId: "session-locked-disallowed", | |
| updatedAt: Date.now(), | |
| providerOverride: "anthropic", | |
| modelOverride: "claude-opus-4-6", | |
| modelOverrideSource: "user", | |
| modelSelectionLocked: true, | |
| }, | |
| }); | |
| mockConfig(home, store, { | |
| model: { primary: "openai/gpt-4.1-mini" }, | |
| models: { | |
| "anthropic/claude-opus-4-6": {}, | |
| "openai/gpt-4.1-mini": {}, | |
| }, | |
| modelPolicy: { allow: ["openai/gpt-4.1-mini"] }, | |
| }); | |
| mockModelCatalogOnce([ | |
| { id: "claude-opus-4-6", name: "Opus", provider: "anthropic" }, | |
| { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, | |
| ]); | |
| await runAgentWithSessionKey(sessionKey); | |
| expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); | |
| expectLastRunProviderModel("anthropic", "claude-opus-4-6"); | |
| expect( | |
| readSessionStore<{ | |
| providerOverride?: string; | |
| modelOverride?: string; | |
| modelOverrideSource?: string; | |
| modelSelectionLocked?: boolean; | |
| }>(store)[sessionKey], | |
| ).toMatchObject({ | |
| providerOverride: "anthropic", | |
| modelOverride: "claude-opus-4-6", | |
| modelOverrideSource: "user", | |
| modelSelectionLocked: true, | |
| }); | |
| }); | |
| }); | |
| it("rejects one-off model overrides for locked sessions", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions-locked-one-off-override.json"); | |
| const sessionKey = "agent:main:subagent:locked-one-off"; | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { | |
| sessionId: "session-locked-one-off", | |
| updatedAt: Date.now(), | |
| providerOverride: "anthropic", | |
| modelOverride: "claude-opus-4-6", | |
| modelOverrideSource: "user", | |
| modelSelectionLocked: true, | |
| }, | |
| }); | |
| mockConfig(home, store, { | |
| model: { primary: "anthropic/claude-opus-4-6" }, | |
| models: { | |
| "anthropic/claude-opus-4-6": {}, | |
| "openai/gpt-4.1-mini": {}, | |
| }, | |
| }); | |
| await expect( | |
| agentCommand( | |
| { | |
| message: "hi", | |
| sessionKey, | |
| model: "openai/gpt-4.1-mini", | |
| allowModelOverride: true, | |
| }, | |
| runtime, | |
| ), | |
| ).rejects.toMatchObject({ | |
| name: "ModelSelectionLockedError", | |
| message: MODEL_SELECTION_LOCKED_MESSAGE, | |
| }); | |
| expect(runEmbeddedAgent).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| it("handles one-off provider/model overrides and validates override values", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| mockConfig(home, store, { | |
| models: { | |
| "anthropic/claude-opus-4-6": {}, | |
| "openai/gpt-4.1-mini": {}, | |
| }, | |
| }); | |
| await agentCommand( | |
| { | |
| message: "use the override", | |
| sessionKey: "agent:main:subagent:run-override", | |
| provider: "openai", | |
| model: "gpt-4.1-mini", | |
| }, | |
| runtime, | |
| ); | |
| expectLastRunProviderModel("openai", "gpt-4.1-mini"); | |
| const saved = readSessionStore<{ | |
| providerOverride?: string; | |
| modelOverride?: string; | |
| }>(store); | |
| expect(saved["agent:main:subagent:run-override"]?.providerOverride).toBeUndefined(); | |
| expect(saved["agent:main:subagent:run-override"]?.modelOverride).toBeUndefined(); | |
| await writeSessionStoreSeed(store, { | |
| "agent:main:subagent:temp-openai-run": { | |
| sessionId: "session-temp-openai-run", | |
| updatedAt: Date.now(), | |
| authProfileOverride: "anthropic:work", | |
| authProfileOverrideSource: "user", | |
| authProfileOverrideCompactionCount: 2, | |
| }, | |
| }); | |
| vi.mocked(authProfileStoreModule.ensureAuthProfileStore).mockReturnValue({ | |
| version: 1, | |
| profiles: { | |
| "anthropic:work": { | |
| provider: "anthropic", | |
| }, | |
| }, | |
| } as never); | |
| await agentCommand( | |
| { | |
| message: "use a different provider once", | |
| sessionKey: "agent:main:subagent:temp-openai-run", | |
| provider: "openai", | |
| model: "gpt-4.1-mini", | |
| }, | |
| runtime, | |
| ); | |
| expectLastRunProviderModel("openai", "gpt-4.1-mini"); | |
| expect(getLastEmbeddedCall()?.authProfileId).toBeUndefined(); | |
| const savedAuth = readSessionStore<{ | |
| authProfileOverride?: string; | |
| authProfileOverrideSource?: string; | |
| authProfileOverrideCompactionCount?: number; | |
| }>(store); | |
| expect(savedAuth["agent:main:subagent:temp-openai-run"]?.authProfileOverride).toBe( | |
| "anthropic:work", | |
| ); | |
| expect(savedAuth["agent:main:subagent:temp-openai-run"]?.authProfileOverrideSource).toBe( | |
| "user", | |
| ); | |
| expect( | |
| savedAuth["agent:main:subagent:temp-openai-run"]?.authProfileOverrideCompactionCount, | |
| ).toBe(2); | |
| await expect( | |
| agentCommand( | |
| { | |
| message: "use an invalid override", | |
| sessionKey: "agent:main:subagent:invalid-override", | |
| provider: "openai\u001b[31m", | |
| model: "gpt-4.1-mini", | |
| }, | |
| runtime, | |
| ), | |
| ).rejects.toThrow("Provider override contains invalid control characters."); | |
| const parseModelRefSpy = vi.spyOn(modelSelectionModule, "parseModelRef"); | |
| parseModelRefSpy.mockImplementationOnce(() => ({ | |
| provider: "anthropic\u001b[31m", | |
| model: "claude-haiku-4-5\u001b[32m", | |
| })); | |
| mockConfig(home, store, { | |
| model: { | |
| primary: "openai/gpt-4.1-mini", | |
| fallbacks: ["anthropic/claude-haiku-4-5"], | |
| }, | |
| models: { | |
| "openai/gpt-4.1-mini": {}, | |
| }, | |
| modelPolicy: { allow: ["openai/gpt-4.1-mini"] }, | |
| }); | |
| try { | |
| await expect( | |
| agentCommand( | |
| { | |
| message: "use disallowed override", | |
| sessionKey: "agent:main:subagent:sanitized-override-error", | |
| model: "claude-haiku-4-5", | |
| }, | |
| runtime, | |
| ), | |
| ).rejects.toThrow( | |
| 'Model override "anthropic/claude-haiku-4-5" is not allowed for agent "main" by agents.defaults.modelPolicy.allow. Add "anthropic/claude-haiku-4-5" or "anthropic/*" to agents.defaults.modelPolicy.allow, or remove/empty the list to allow any model.', | |
| ); | |
| } finally { | |
| parseModelRefSpy.mockRestore(); | |
| } | |
| const legacyCfg = mockConfig(home, store, { | |
| model: { | |
| primary: "openai/gpt-4.1-mini", | |
| fallbacks: ["external/sensitive"], | |
| }, | |
| models: { | |
| "openai/gpt-4.1-mini": {}, | |
| }, | |
| }); | |
| delete (legacyCfg as { meta?: unknown }).meta; | |
| await expect( | |
| agentCommand( | |
| { | |
| message: "use the configured fallback directly", | |
| sessionKey: "agent:main:subagent:legacy-fallback-override", | |
| model: "external/sensitive", | |
| }, | |
| runtime, | |
| ), | |
| ).rejects.toThrow( | |
| 'Model override "external/sensitive" is not allowed for agent "main" by agents.defaults.models. Add "external/sensitive" or "external/*" to agents.defaults.modelPolicy.allow, or remove/empty the list to allow any model.', | |
| ); | |
| }); | |
| }); | |
| it("passes routing context to embedded runs", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| mockConfig(home, store, undefined, undefined, [{ id: "ops" }]); | |
| await agentCommand( | |
| { message: "hi", agentId: "ops", replyChannel: "slack", thinking: "low" }, | |
| runtime, | |
| ); | |
| let callArgs = getLastEmbeddedCall(); | |
| expect(callArgs?.sessionKey).toBe("agent:ops:main"); | |
| expectSqliteSessionFileMarker({ | |
| agentId: "ops", | |
| sessionFile: callArgs?.sessionFile, | |
| storePath: store, | |
| }); | |
| expect(callArgs?.messageChannel).toBe("slack"); | |
| expect(runtime.log).toHaveBeenCalledWith("ok"); | |
| await agentCommand( | |
| { | |
| message: "hi", | |
| to: "+1555", | |
| channel: "whatsapp", | |
| thinking: "low", | |
| runContext: { messageChannel: "slack", accountId: "acct-2" }, | |
| }, | |
| runtime, | |
| ); | |
| callArgs = getLastEmbeddedCall(); | |
| expect(callArgs?.messageChannel).toBe("slack"); | |
| expect(callArgs?.agentAccountId).toBe("acct-2"); | |
| await expect(agentCommand({ message: "hi", agentId: "ghost" }, runtime)).rejects.toThrow( | |
| 'Unknown agent id "ghost"', | |
| ); | |
| }); | |
| }); | |
| it("routes explicit agent recipients through channel session contracts", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const cfg = mockConfig(home, store, undefined, undefined, [{ id: "ops" }]); | |
| installThinkingTestProviders([ | |
| { | |
| pluginId: "whatsapp", | |
| source: "test", | |
| plugin: createOutboundTestPlugin({ | |
| id: "whatsapp", | |
| outbound: createDirectOutboundTestAdapter({ channel: "whatsapp" }), | |
| messaging: { | |
| resolveOutboundSessionRoute: (params) => { | |
| const chatType = params.target.endsWith("@g.us") ? "group" : "direct"; | |
| return createOutboundSessionRouteFixture({ | |
| cfg: params.cfg, | |
| agentId: params.agentId, | |
| channel: "whatsapp", | |
| accountId: params.accountId, | |
| peer: { kind: chatType, id: params.target }, | |
| chatType, | |
| from: params.target, | |
| to: params.target, | |
| }); | |
| }, | |
| }, | |
| }), | |
| }, | |
| ]); | |
| cfg.session = { ...cfg.session, dmScope: "per-account-channel-peer" }; | |
| await agentCommand( | |
| { | |
| message: "hi", | |
| agentId: "ops", | |
| channel: "whatsapp", | |
| to: "+15551234567", | |
| accountId: "work", | |
| thinking: "low", | |
| }, | |
| runtime, | |
| ); | |
| let callArgs = getLastEmbeddedCall(); | |
| expect(callArgs?.sessionKey).toBe("agent:ops:whatsapp:work:direct:+15551234567"); | |
| await agentCommand( | |
| { | |
| message: "hi", | |
| agentId: "ops", | |
| channel: "whatsapp", | |
| to: "120363040000000000@g.us", | |
| thinking: "low", | |
| }, | |
| runtime, | |
| ); | |
| callArgs = getLastEmbeddedCall(); | |
| expect(callArgs?.sessionKey).toBe("agent:ops:whatsapp:group:120363040000000000@g.us"); | |
| cfg.session = { ...cfg.session, dmScope: "main", mainKey: "work" }; | |
| await agentCommand( | |
| { | |
| message: "hi", | |
| agentId: "ops", | |
| channel: "webchat", | |
| to: "+15551234567", | |
| thinking: "low", | |
| }, | |
| runtime, | |
| ); | |
| callArgs = getLastEmbeddedCall(); | |
| expect(callArgs?.sessionKey).toBe("agent:ops:work"); | |
| }); | |
| }); | |
| it("uses explicit session keys for embedded runs", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| mockConfig(home, store, undefined, undefined, [{ id: "main" }, { id: "ops" }]); | |
| await agentCommand({ message: "hi", sessionKey: "agent:ops:incident-42" }, runtime); | |
| let callArgs = getLastEmbeddedCall(); | |
| expect(callArgs?.agentId).toBe("ops"); | |
| expect(callArgs?.sessionKey).toBe("agent:ops:incident-42"); | |
| expectSqliteSessionFileMarker({ | |
| agentId: "ops", | |
| sessionFile: callArgs?.sessionFile, | |
| storePath: store, | |
| }); | |
| await agentCommand({ message: "hi", agentId: "ops", sessionKey: "incident-42" }, runtime); | |
| callArgs = getLastEmbeddedCall(); | |
| expect(callArgs?.agentId).toBe("ops"); | |
| expect(callArgs?.sessionKey).toBe("agent:ops:incident-42"); | |
| for (const sessionKey of ["global", "unknown"]) { | |
| await agentCommand({ message: "hi", agentId: "ops", sessionKey }, runtime); | |
| callArgs = getLastEmbeddedCall(); | |
| expect(callArgs?.agentId).toBe("ops"); | |
| expect(callArgs?.sessionKey).toBe(sessionKey); | |
| expectSqliteSessionFileMarker({ | |
| agentId: "ops", | |
| sessionFile: callArgs?.sessionFile, | |
| storePath: store, | |
| }); | |
| } | |
| }); | |
| }); | |
| it("rejects agent-scoped to session selectors that conflict with the requested agent", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const sessionKey = "agent:main:openclaw-weixin:direct:o9cq802hhmfc@im.wechat"; | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { sessionId: "wechat-session", updatedAt: Date.now() }, | |
| }); | |
| mockConfig(home, store, undefined, undefined, [{ id: "main" }, { id: "work" }]); | |
| await expect( | |
| agentCommand({ message: "hi", agentId: "work", to: sessionKey }, runtime), | |
| ).rejects.toThrow('Agent id "work" does not match session key agent "main".'); | |
| expect(runEmbeddedAgent).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| it("does not forward agent-scoped to session selectors as delivery targets", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| const sessionKey = "agent:main:openclaw-weixin:direct:o9cq802hhmfc@im.wechat"; | |
| await writeSessionStoreSeed(store, { | |
| [sessionKey]: { | |
| sessionId: "wechat-session", | |
| updatedAt: Date.now(), | |
| delivery: normalizeSessionDeliveryState({ | |
| context: { channel: "telegram", to: "+1555" }, | |
| }), | |
| }, | |
| }); | |
| mockConfig(home, store); | |
| installThinkingTestProviders([ | |
| { | |
| pluginId: "telegram", | |
| source: "test", | |
| plugin: createOutboundTestPlugin({ | |
| id: "telegram", | |
| outbound: createDirectOutboundTestAdapter({ channel: "telegram" }), | |
| }), | |
| }, | |
| ]); | |
| await agentCommand( | |
| { message: "hi", to: sessionKey, deliver: true, channel: "telegram" }, | |
| runtime, | |
| ); | |
| const deliveryCall = vi.mocked(deliverAgentCommandResult).mock.calls.at(-1)?.[0] as | |
| | { opts?: { to?: string }; sessionEntry?: Pick<SessionEntry, "delivery"> } | |
| | undefined; | |
| expect(deliveryCall?.opts?.to).toBeUndefined(); | |
| expect(deliveryContextFromSession(deliveryCall?.sessionEntry)?.to).toBe("+1555"); | |
| }); | |
| }); | |
| it("scopes bare explicit session keys to the default agent for embedded runs", async () => { | |
| await withTempHome(async (home) => { | |
| const store = path.join(home, "sessions.json"); | |
| mockConfig(home, store, undefined, undefined, [{ id: "ops", default: true }, { id: "main" }]); | |
| await agentCommand({ message: "hi", sessionKey: "incident-42" }, runtime); | |
| let callArgs = getLastEmbeddedCall(); | |
| expect(callArgs?.agentId).toBe("ops"); | |
| expect(callArgs?.sessionKey).toBe("agent:ops:incident-42"); | |
| await agentCommand({ message: "hi", sessionKey: "global" }, runtime); | |
| callArgs = getLastEmbeddedCall(); | |
| expect(callArgs?.agentId).toBe("ops"); | |
| expect(callArgs?.sessionKey).toBe("global"); | |
| expectSqliteSessionFileMarker({ | |
| agentId: "ops", | |
| sessionFile: callArgs?.sessionFile, | |
| storePath: store, | |
| }); | |
| await agentCommand({ message: "hi", sessionKey: "unknown" }, runtime); | |
| callArgs = getLastEmbeddedCall(); | |
| expect(callArgs?.agentId).toBe("ops"); | |
| expect(callArgs?.sessionKey).toBe("unknown"); | |
| expectSqliteSessionFileMarker({ | |
| agentId: "ops", | |
| sessionFile: callArgs?.sessionFile, | |
| storePath: store, | |
| }); | |
| }); | |
| }); | |
| }); | |
| /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ | |