diff --git a/src/commands/agent.cli-provider.test.ts b/src/commands/agent.cli-provider.test.ts new file mode 100644 index 00000000000..f600365a1a4 --- /dev/null +++ b/src/commands/agent.cli-provider.test.ts @@ -0,0 +1,285 @@ +import fs from "node:fs"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; +import "../cron/isolated-agent.mocks.js"; +import { __testing as acpManagerTesting } from "../acp/control-plane/manager.js"; +import * as cliRunnerModule from "../agents/cli-runner.js"; +import { FailoverError } from "../agents/failover-error.js"; +import { loadModelCatalog } from "../agents/model-catalog.js"; +import * as modelSelectionModule from "../agents/model-selection.js"; +import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import type { OpenClawConfig } from "../config/config.js"; +import * as configModule from "../config/config.js"; +import { clearSessionStoreCacheForTest } from "../config/sessions.js"; +import { resetAgentEventsForTest, resetAgentRunContextForTest } from "../infra/agent-events.js"; +import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { agentCommand } from "./agent.js"; + +vi.mock("../logging/subsystem.js", () => { + const createMockLogger = () => ({ + subsystem: "test", + isEnabled: vi.fn(() => true), + trace: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + fatal: vi.fn(), + raw: vi.fn(), + child: vi.fn(() => createMockLogger()), + }); + return { + createSubsystemLogger: vi.fn(() => createMockLogger()), + }; +}); + +vi.mock("../agents/workspace.js", () => ({ + DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/openclaw-workspace", + DEFAULT_AGENTS_FILENAME: "AGENTS.md", + DEFAULT_IDENTITY_FILENAME: "IDENTITY.md", + resolveDefaultAgentWorkspaceDir: () => "/tmp/openclaw-workspace", + ensureAgentWorkspace: vi.fn(async ({ dir }: { dir: string }) => ({ dir })), +})); + +vi.mock("../agents/command/session-store.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + updateSessionStoreAfterAgentRun: vi.fn(async () => undefined), + }; +}); + +vi.mock("../agents/skills.js", () => ({ + buildWorkspaceSkillSnapshot: vi.fn(() => undefined), + loadWorkspaceSkillEntries: vi.fn(() => []), +})); + +vi.mock("../agents/skills/refresh.js", () => ({ + getSkillsSnapshotVersion: vi.fn(() => 0), +})); + +const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), +}; + +const configSpy = vi.spyOn(configModule, "loadConfig"); +const readConfigFileSnapshotForWriteSpy = vi.spyOn(configModule, "readConfigFileSnapshotForWrite"); +const runCliAgentSpy = vi.spyOn(cliRunnerModule, "runCliAgent"); + +async function withTempHome(fn: (home: string) => Promise): Promise { + return withTempHomeBase(fn, { prefix: "openclaw-agent-cli-" }); +} + +function mockConfig( + home: string, + storePath: string, + agentOverrides?: Partial["defaults"]>>, +) { + const cfg = { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + models: { "anthropic/claude-opus-4-5": {} }, + workspace: path.join(home, "openclaw"), + ...agentOverrides, + }, + }, + session: { store: storePath, mainKey: "main" }, + } as OpenClawConfig; + configSpy.mockReturnValue(cfg); + return cfg; +} + +function writeSessionStoreSeed( + storePath: string, + sessions: Record>, +) { + fs.mkdirSync(path.dirname(storePath), { recursive: true }); + fs.writeFileSync(storePath, JSON.stringify(sessions, null, 2)); +} + +function readSessionStore(storePath: string): Record { + return JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record; +} + +function createDefaultAgentResult() { + return { + payloads: [{ text: "ok" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }; +} + +function expectLastEmbeddedProviderModel(provider: string, model: string): void { + const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; + expect(callArgs?.provider).toBe(provider); + expect(callArgs?.model).toBe(model); +} + +beforeEach(() => { + vi.clearAllMocks(); + clearSessionStoreCacheForTest(); + resetAgentEventsForTest(); + resetAgentRunContextForTest(); + resetPluginRuntimeStateForTest(); + acpManagerTesting.resetAcpSessionManagerForTests(); + configModule.clearRuntimeConfigSnapshot(); + runCliAgentSpy.mockResolvedValue(createDefaultAgentResult() as never); + vi.mocked(runEmbeddedPiAgent).mockResolvedValue(createDefaultAgentResult()); + vi.mocked(loadModelCatalog).mockResolvedValue([]); + vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); + readConfigFileSnapshotForWriteSpy.mockResolvedValue({ + snapshot: { valid: false, resolved: {} as OpenClawConfig }, + writeOptions: {}, + } as Awaited>); +}); + +describe("agentCommand CLI provider handling", () => { + it("rejects explicit CLI overrides that are outside the models allowlist", async () => { + vi.mocked(modelSelectionModule.isCliProvider).mockImplementation( + (provider) => provider.trim().toLowerCase() === "claude-cli", + ); + try { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store, { + models: { + "openai/gpt-4.1-mini": {}, + }, + }); + + await expect( + agentCommand( + { + message: "use disallowed cli override", + sessionKey: "agent:main:subagent:cli-override-error", + model: "claude-cli/opus", + }, + runtime, + ), + ).rejects.toThrow('Model override "claude-cli/opus" is not allowed for agent "main".'); + }); + } finally { + vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); + } + }); + + it("clears stored CLI overrides when they fall outside the models allowlist", async () => { + vi.mocked(modelSelectionModule.isCliProvider).mockImplementation( + (provider) => provider.trim().toLowerCase() === "claude-cli", + ); + try { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + writeSessionStoreSeed(store, { + "agent:main:subagent:clear-cli-overrides": { + sessionId: "session-clear-cli-overrides", + updatedAt: Date.now(), + providerOverride: "claude-cli", + modelOverride: "opus", + }, + }); + + mockConfig(home, store, { + model: { primary: "openai/gpt-4.1-mini" }, + models: { + "openai/gpt-4.1-mini": {}, + }, + }); + + vi.mocked(loadModelCatalog).mockResolvedValueOnce([ + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, + { id: "opus", name: "Opus", provider: "claude-cli" }, + ]); + + await agentCommand( + { + message: "hi", + sessionKey: "agent:main:subagent:clear-cli-overrides", + }, + runtime, + ); + + expectLastEmbeddedProviderModel("openai", "gpt-4.1-mini"); + + const saved = readSessionStore<{ + providerOverride?: string; + modelOverride?: string; + }>(store); + expect(saved["agent:main:subagent:clear-cli-overrides"]?.providerOverride).toBeUndefined(); + expect(saved["agent:main:subagent:clear-cli-overrides"]?.modelOverride).toBeUndefined(); + }); + } finally { + vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); + } + }); + + it("clears stale Claude CLI legacy session IDs before retrying after session expiration", async () => { + vi.mocked(modelSelectionModule.isCliProvider).mockImplementation( + (provider) => provider.trim().toLowerCase() === "claude-cli", + ); + try { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + const sessionKey = "agent:main:subagent:cli-expired"; + writeSessionStoreSeed(store, { + [sessionKey]: { + sessionId: "session-cli-123", + updatedAt: Date.now(), + providerOverride: "claude-cli", + modelOverride: "opus", + cliSessionIds: { "claude-cli": "stale-cli-session" }, + claudeCliSessionId: "stale-legacy-session", + }, + }); + + mockConfig(home, store, { + model: { primary: "claude-cli/opus", fallbacks: [] }, + models: { "claude-cli/opus": {} }, + }); + + runCliAgentSpy + .mockRejectedValueOnce( + new FailoverError("session expired", { + reason: "session_expired", + provider: "claude-cli", + model: "opus", + status: 410, + }), + ) + .mockRejectedValue(new Error("retry failed")); + + await expect(agentCommand({ message: "hi", sessionKey }, runtime)).rejects.toThrow( + "retry failed", + ); + + expect(runCliAgentSpy).toHaveBeenCalledTimes(2); + const firstCall = runCliAgentSpy.mock.calls[0]?.[0] as + | { cliSessionId?: string } + | undefined; + const secondCall = runCliAgentSpy.mock.calls[1]?.[0] as + | { cliSessionId?: string } + | undefined; + expect(firstCall?.cliSessionId).toBe("stale-cli-session"); + expect(secondCall?.cliSessionId).toBeUndefined(); + + const saved = readSessionStore<{ + cliSessionIds?: Record; + claudeCliSessionId?: string; + }>(store); + expect(saved[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined(); + expect(saved[sessionKey]?.claudeCliSessionId).toBeUndefined(); + }); + } finally { + vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); + } + }); +}); diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 3cdc89dca17..48b58fedfdf 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -8,7 +8,6 @@ import { resolveAgentDir, resolveSessionAgentId } from "../agents/agent-scope.js import * as authProfilesModule from "../agents/auth-profiles.js"; import * as cliRunnerModule from "../agents/cli-runner.js"; import { resolveSession } from "../agents/command/session.js"; -import { FailoverError } from "../agents/failover-error.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import * as modelSelectionModule from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; @@ -1043,66 +1042,6 @@ describe("agentCommand", () => { }); }); - it("clears stale Claude CLI legacy session IDs before retrying after session expiration", async () => { - vi.mocked(modelSelectionModule.isCliProvider).mockImplementation( - (provider) => provider.trim().toLowerCase() === "claude-cli", - ); - try { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - const sessionKey = "agent:main:subagent:cli-expired"; - writeSessionStoreSeed(store, { - [sessionKey]: { - sessionId: "session-cli-123", - updatedAt: Date.now(), - providerOverride: "claude-cli", - modelOverride: "opus", - cliSessionIds: { "claude-cli": "stale-cli-session" }, - claudeCliSessionId: "stale-legacy-session", - }, - }); - mockConfig(home, store, { - model: { primary: "claude-cli/opus", fallbacks: [] }, - models: { "claude-cli/opus": {} }, - }); - runCliAgentSpy - .mockRejectedValueOnce( - new FailoverError("session expired", { - reason: "session_expired", - provider: "claude-cli", - model: "opus", - status: 410, - }), - ) - .mockRejectedValue(new Error("retry failed")); - - await expect(agentCommand({ message: "hi", sessionKey }, runtime)).rejects.toThrow( - "retry failed", - ); - - expect(runCliAgentSpy).toHaveBeenCalledTimes(2); - const firstCall = runCliAgentSpy.mock.calls[0]?.[0] as - | { cliSessionId?: string } - | undefined; - const secondCall = runCliAgentSpy.mock.calls[1]?.[0] as - | { cliSessionId?: string } - | undefined; - expect(firstCall?.cliSessionId).toBe("stale-cli-session"); - expect(secondCall?.cliSessionId).toBeUndefined(); - - const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< - string, - { cliSessionIds?: Record; claudeCliSessionId?: string } - >; - const entry = saved[sessionKey]; - expect(entry?.cliSessionIds?.["claude-cli"]).toBeUndefined(); - expect(entry?.claudeCliSessionId).toBeUndefined(); - }); - } finally { - vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); - } - }); - it("rejects unknown agent overrides", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json");