mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
test(commands): isolate agent command owner boundaries (#122704)
Amp-Thread-ID: https://ampcode.com/threads/T-019fee8d-665d-707b-a380-23f2a6a1ce03 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
committed by
GitHub
parent
9594fe4623
commit
b9f5548b29
+117
-273
@@ -2,7 +2,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { buildChannelOutboundSessionRoute } from "openclaw/plugin-sdk/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.
|
||||
@@ -31,6 +30,7 @@ import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-s
|
||||
import type { InternalSessionEntry as SessionEntry } from "../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { emitAgentEvent, onAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js";
|
||||
import { buildOutboundBaseSessionKey } from "../infra/outbound/base-session-key.js";
|
||||
import type { PluginProviderRegistration } from "../plugins/registry.test-fixtures.js";
|
||||
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@@ -46,7 +46,6 @@ import {
|
||||
deliveryContextFromSession,
|
||||
normalizeSessionDeliveryState,
|
||||
} from "../utils/delivery-context.shared.js";
|
||||
import { getAgentHarnessPluginMocks } from "./agent-command-state.test-mocks.js";
|
||||
import { agentCommand, agentCommandFromIngress } from "./agent.js";
|
||||
import { createThrowingTestRuntime } from "./test-runtime-config-helpers.js";
|
||||
|
||||
@@ -54,7 +53,6 @@ const configIoMocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(),
|
||||
readConfigFileSnapshotForWrite: vi.fn(),
|
||||
}));
|
||||
const agentHarnessPluginMocks = getAgentHarnessPluginMocks();
|
||||
|
||||
vi.mock("../config/io.js", () => ({
|
||||
getRuntimeConfig: configIoMocks.loadConfig,
|
||||
@@ -81,6 +79,99 @@ 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,
|
||||
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 {
|
||||
@@ -386,6 +477,27 @@ function installThinkingTestProviders(channels: Parameters<typeof createTestRegi
|
||||
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();
|
||||
resetPluginRuntimeStateForTest();
|
||||
@@ -405,39 +517,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("agentCommand", () => {
|
||||
it("passes one-shot OpenAI model overrides to harness plugin preparation", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
const cfg = mockConfig(home, storePath, { models: undefined });
|
||||
|
||||
await agentCommand(
|
||||
{
|
||||
message: "hi",
|
||||
agentId: "main",
|
||||
model: "openai/gpt-5.2",
|
||||
allowModelOverride: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(agentHarnessPluginMocks.ensureSelectedAgentHarnessPlugin).toHaveBeenCalledTimes(2);
|
||||
const expectedPreparation = expect.objectContaining({
|
||||
config: cfg,
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.2",
|
||||
agentId: "main",
|
||||
workspaceDir: path.join(home, "openclaw"),
|
||||
});
|
||||
for (const callIndex of [1, 2] as const) {
|
||||
expect(agentHarnessPluginMocks.ensureSelectedAgentHarnessPlugin).toHaveBeenNthCalledWith(
|
||||
callIndex,
|
||||
expectedPreparation,
|
||||
);
|
||||
}
|
||||
expectLastRunProviderModel("openai", "gpt-5.2");
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces ingress model override authorization", async () => {
|
||||
await expect(
|
||||
// Runtime guard for non-TS callers; TS callsites are statically typed.
|
||||
@@ -872,133 +951,6 @@ describe("agentCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("installs a local gateway request scope for embedded agent dispatch", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store);
|
||||
const { getPluginRuntimeGatewayRequestScope } =
|
||||
await import("../plugins/runtime/gateway-request-scope.js");
|
||||
vi.mocked(attemptExecutionRuntime.runAgentAttempt).mockImplementationOnce(async () => {
|
||||
const scope = getPluginRuntimeGatewayRequestScope();
|
||||
expect(scope?.context?.getRuntimeConfig()).toMatchObject({
|
||||
session: { store },
|
||||
});
|
||||
return createDefaultAgentResult();
|
||||
});
|
||||
|
||||
await agentCommand({ message: "ping", agentId: "main" }, runtime);
|
||||
|
||||
expect(getPluginRuntimeGatewayRequestScope()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("runs direct ingress with a configured plugin-owned harness", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
const workspaceDir = path.join(home, "openclaw");
|
||||
const pluginDir = path.join(home, "plugins", "ingress-proof");
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "ingress-proof",
|
||||
name: "Ingress proof harness",
|
||||
activation: { onStartup: false, onAgentHarnesses: ["ingress-proof"] },
|
||||
configSchema: { type: "object", additionalProperties: false },
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "ingress-proof",
|
||||
version: "1.0.0",
|
||||
type: "module",
|
||||
openclaw: { extensions: ["./index.js"] },
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "index.js"),
|
||||
`export default {
|
||||
id: "ingress-proof",
|
||||
register(api) {
|
||||
api.registerAgentHarness({
|
||||
id: "ingress-proof",
|
||||
label: "Ingress proof harness",
|
||||
supports: () => ({ supported: true }),
|
||||
async runAttempt() { throw new Error("unused"); },
|
||||
});
|
||||
},
|
||||
};\n`,
|
||||
);
|
||||
const cfg = {
|
||||
meta: { migrations: { modelPolicyAllowlist: true } },
|
||||
plugins: {
|
||||
allow: ["ingress-proof"],
|
||||
entries: { "ingress-proof": { enabled: true } },
|
||||
load: { paths: [pluginDir] },
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
"ingress-proof": {
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://example.invalid/v1",
|
||||
models: [
|
||||
{
|
||||
id: "proof-model",
|
||||
name: "Proof model",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4096,
|
||||
agentRuntime: { id: "ingress-proof" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "ingress-proof/proof-model" },
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
},
|
||||
session: { store, mainKey: "main" },
|
||||
} as OpenClawConfig;
|
||||
configIoMocks.loadConfig.mockReturnValue(cfg);
|
||||
const actualRuntimePlugins = await vi.importActual<
|
||||
typeof import("../agents/runtime-plugins.js")
|
||||
>("../agents/runtime-plugins.js");
|
||||
const runtimePlugins = await import("../agents/runtime-plugins.js");
|
||||
vi.spyOn(runtimePlugins, "withAgentPluginRegistry").mockImplementationOnce(
|
||||
actualRuntimePlugins.withAgentPluginRegistry,
|
||||
);
|
||||
await agentCommandFromIngress(
|
||||
{
|
||||
message: "ping",
|
||||
agentId: "main",
|
||||
allowModelOverride: false,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(agentHarnessPluginMocks.ensureSelectedAgentHarnessPlugin).toHaveBeenCalledTimes(2);
|
||||
const harnessSelectionCalls = agentHarnessPluginMocks.ensureSelectedAgentHarnessPlugin.mock
|
||||
.calls as unknown as Array<
|
||||
[
|
||||
Parameters<
|
||||
typeof import("../agents/harness/runtime-plugin.js").ensureSelectedAgentHarnessPlugin
|
||||
>[0],
|
||||
]
|
||||
>;
|
||||
for (const [{ pluginRegistry }] of harnessSelectionCalls) {
|
||||
expect(
|
||||
pluginRegistry?.agentHarnesses.some((entry) => entry.harness.id === "ingress-proof"),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("persists local overrides", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
@@ -1094,7 +1046,7 @@ describe("agentCommand", () => {
|
||||
},
|
||||
resolveOutboundSessionRoute: (params) => {
|
||||
const chatId = params.target.replace(/^telegram:/i, "");
|
||||
return buildChannelOutboundSessionRoute({
|
||||
return createOutboundSessionRouteFixture({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "telegram",
|
||||
@@ -1146,25 +1098,6 @@ describe("agentCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes configured fast mode to embedded runs", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store, {
|
||||
model: "openai/gpt-5.5",
|
||||
models: {
|
||||
"openai/gpt-5.5": { params: { fastMode: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await agentCommand({ message: "ping", agentId: "main" }, runtime);
|
||||
|
||||
const callArgs = getLastEmbeddedCall();
|
||||
expect(callArgs?.provider).toBe("openai");
|
||||
expect(callArgs?.model).toBe("gpt-5.5");
|
||||
expect(callArgs?.fastMode).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -1189,31 +1122,6 @@ describe("agentCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses no-tools plain prompt mode for one-shot model runs", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store, { models: {} });
|
||||
|
||||
await agentCommand(
|
||||
{
|
||||
message: "Reply with exactly OPENCLAW-MODEL-OK",
|
||||
agentId: "main",
|
||||
model: "openrouter/auto",
|
||||
modelRun: true,
|
||||
promptMode: "none",
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
const callArgs = getLastEmbeddedCall();
|
||||
expect(callArgs?.provider).toBe("openrouter");
|
||||
expect(callArgs?.model).toBe("openrouter/auto");
|
||||
expect(callArgs?.modelRun).toBe(true);
|
||||
expect(callArgs?.promptMode).toBe("none");
|
||||
expect(callArgs?.disableTools).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("bypasses ACP sessions for one-shot model runs", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
@@ -1420,44 +1328,6 @@ describe("agentCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not publish Codex app-server events from the core command callback", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store);
|
||||
|
||||
const codexEvents: Array<{ runId: string; phase?: string }> = [];
|
||||
const stop = onAgentEvent((evt) => {
|
||||
if (evt.stream !== "codex_app_server.lifecycle") {
|
||||
return;
|
||||
}
|
||||
codexEvents.push({
|
||||
runId: evt.runId,
|
||||
phase: typeof evt.data?.phase === "string" ? evt.data.phase : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
vi.mocked(runEmbeddedAgent).mockImplementationOnce(async (params) => {
|
||||
(
|
||||
params as {
|
||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
||||
}
|
||||
).onAgentEvent?.({
|
||||
stream: "codex_app_server.lifecycle",
|
||||
data: { phase: "startup" },
|
||||
});
|
||||
return {
|
||||
payloads: [{ text: "hello" }],
|
||||
meta: { agentMeta: { provider: "p", model: "m" } },
|
||||
} as never;
|
||||
});
|
||||
|
||||
await agentCommand({ message: "hi", to: "+1555", thinking: "low" }, runtime);
|
||||
stop();
|
||||
|
||||
expect(codexEvents).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -2015,32 +1885,6 @@ describe("agentCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes resolved default thinking level to embedded runs", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store, {
|
||||
model: { primary: "openai/gpt-4.1-mini" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-6": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
});
|
||||
mockModelCatalogOnce([
|
||||
{
|
||||
id: "gpt-4.1-mini",
|
||||
name: "GPT-4.1 Mini",
|
||||
provider: "openai",
|
||||
reasoning: true,
|
||||
},
|
||||
]);
|
||||
|
||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
||||
|
||||
expect(getLastEmbeddedCall()?.thinkLevel).toBe("low");
|
||||
expectLastRunProviderModel("openai", "gpt-4.1-mini");
|
||||
});
|
||||
});
|
||||
|
||||
it("passes routing context to embedded runs", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
@@ -2095,7 +1939,7 @@ describe("agentCommand", () => {
|
||||
messaging: {
|
||||
resolveOutboundSessionRoute: (params) => {
|
||||
const chatType = params.target.endsWith("@g.us") ? "group" : "direct";
|
||||
return buildChannelOutboundSessionRoute({
|
||||
return createOutboundSessionRouteFixture({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "whatsapp",
|
||||
|
||||
Reference in New Issue
Block a user