test: stabilize trigger handling and hook e2e tests

This commit is contained in:
Peter Steinberger
2026-03-23 11:05:43 +00:00
parent b9efba1faf
commit 6f048f59cb
8 changed files with 508 additions and 405 deletions

View File

@@ -4,14 +4,12 @@ import path from "node:path";
import type { AssistantMessage } from "@mariozechner/pi-ai"; import type { AssistantMessage } from "@mariozechner/pi-ai";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { registerLogTransport, resetLogger, setLoggerOverride } from "../logging/logger.js";
import { redactIdentifier } from "../logging/redact-identifier.js"; import { redactIdentifier } from "../logging/redact-identifier.js";
import type { AuthProfileFailureReason } from "./auth-profiles.js"; import type { AuthProfileFailureReason } from "./auth-profiles.js";
import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js"; import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js";
const runEmbeddedAttemptMock = vi.fn<(params: unknown) => Promise<EmbeddedRunAttemptResult>>(); const runEmbeddedAttemptMock = vi.fn<(params: unknown) => Promise<EmbeddedRunAttemptResult>>();
const resolveCopilotApiTokenMock = vi.fn(); const resolveCopilotApiTokenMock = vi.fn();
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
const { computeBackoffMock, sleepWithAbortMock } = vi.hoisted(() => ({ const { computeBackoffMock, sleepWithAbortMock } = vi.hoisted(() => ({
computeBackoffMock: vi.fn( computeBackoffMock: vi.fn(
( (
@@ -22,63 +20,121 @@ const { computeBackoffMock, sleepWithAbortMock } = vi.hoisted(() => ({
sleepWithAbortMock: vi.fn(async (_ms: number, _abortSignal?: AbortSignal) => undefined), sleepWithAbortMock: vi.fn(async (_ms: number, _abortSignal?: AbortSignal) => undefined),
})); }));
vi.mock("./pi-embedded-runner/run/attempt.js", () => ({ const installRunEmbeddedMocks = () => {
runEmbeddedAttempt: (params: unknown) => runEmbeddedAttemptMock(params), vi.doMock("../plugins/hook-runner-global.js", () => ({
})); getGlobalHookRunner: vi.fn(() => undefined),
}));
vi.mock("../infra/backoff.js", () => ({ vi.doMock("../context-engine/index.js", () => ({
computeBackoff: ( ensureContextEnginesInitialized: vi.fn(),
policy: { initialMs: number; maxMs: number; factor: number; jitter: number }, resolveContextEngine: vi.fn(async () => ({
attempt: number, dispose: async () => undefined,
) => computeBackoffMock(policy, attempt), })),
sleepWithAbort: (ms: number, abortSignal?: AbortSignal) => sleepWithAbortMock(ms, abortSignal), }));
})); vi.doMock("./runtime-plugins.js", () => ({
ensureRuntimePluginsLoaded: vi.fn(),
vi.mock("../../extensions/github-copilot/token.js", () => ({ }));
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", vi.doMock("./pi-embedded-runner/model.js", () => ({
resolveCopilotApiToken: (...args: unknown[]) => resolveCopilotApiTokenMock(...args), resolveModelAsync: async (provider: string, modelId: string) => ({
})); model: {
id: modelId,
vi.mock("./pi-embedded-runner/compact.js", () => ({ name: modelId,
compactEmbeddedPiSessionDirect: vi.fn(async () => { api: "openai-responses",
throw new Error("compact should not run in auth profile rotation tests"); provider,
}), baseUrl:
})); provider === "github-copilot" ? "https://api.copilot.example" : "https://example.com",
reasoning: false,
vi.mock("./models-config.js", async (importOriginal) => { input: ["text"],
const mod = await importOriginal<typeof import("./models-config.js")>(); cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
return { contextWindow: 16_000,
...mod, maxTokens: 2048,
ensureOpenClawModelsJson: vi.fn(async () => ({ wrote: false })), },
}; error: undefined,
}); authStorage: {
setRuntimeApiKey: vi.fn(),
},
modelRegistry: {},
}),
}));
vi.doMock("./pi-embedded-runner/run/attempt.js", () => ({
runEmbeddedAttempt: (params: unknown) => runEmbeddedAttemptMock(params),
}));
vi.doMock("../plugins/provider-runtime.runtime.js", () => ({
prepareProviderRuntimeAuth: async (params: {
provider: string;
context: { apiKey: string };
}) => {
if (params.provider !== "github-copilot") {
return undefined;
}
const token = await resolveCopilotApiTokenMock(params.context.apiKey);
return {
apiKey: token.token,
baseUrl: token.baseUrl,
expiresAt: token.expiresAt,
};
},
}));
vi.doMock("../infra/backoff.js", () => ({
computeBackoff: (
policy: { initialMs: number; maxMs: number; factor: number; jitter: number },
attempt: number,
) => computeBackoffMock(policy, attempt),
sleepWithAbort: (ms: number, abortSignal?: AbortSignal) => sleepWithAbortMock(ms, abortSignal),
}));
vi.doMock("./pi-embedded-runner/compact.js", () => ({
compactEmbeddedPiSessionDirect: vi.fn(async () => {
throw new Error("compact should not run in auth profile rotation tests");
}),
}));
vi.doMock("./models-config.js", async (importOriginal) => {
const mod = await importOriginal<typeof import("./models-config.js")>();
return {
...mod,
ensureOpenClawModelsJson: vi.fn(async () => ({ wrote: false })),
};
});
};
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent; let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent;
let unregisterLogTransport: (() => void) | undefined; let unregisterLogTransport: (() => void) | undefined;
let registerLogTransportFn: typeof import("../logging/logger.js").registerLogTransport;
let resetLoggerFn: typeof import("../logging/logger.js").resetLogger;
let setLoggerOverrideFn: typeof import("../logging/logger.js").setLoggerOverride;
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
beforeAll(async () => { beforeAll(async () => {
vi.resetModules();
installRunEmbeddedMocks();
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js")); ({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js"));
({
registerLogTransport: registerLogTransportFn,
resetLogger: resetLoggerFn,
setLoggerOverride: setLoggerOverrideFn,
} = await import("../logging/logger.js"));
}); });
async function runEmbeddedPiAgentInline(
params: Parameters<typeof runEmbeddedPiAgent>[0],
): Promise<Awaited<ReturnType<typeof runEmbeddedPiAgent>>> {
return await runEmbeddedPiAgent({
...params,
enqueue: async (task) => await task(),
});
}
beforeEach(() => { beforeEach(() => {
vi.useRealTimers(); vi.useRealTimers();
runEmbeddedAttemptMock.mockClear(); runEmbeddedAttemptMock.mockReset();
runEmbeddedAttemptMock.mockImplementation(async () => {
throw new Error("unexpected extra runEmbeddedAttempt call");
});
resolveCopilotApiTokenMock.mockReset(); resolveCopilotApiTokenMock.mockReset();
resolveCopilotApiTokenMock.mockImplementation(async () => {
throw new Error("unexpected extra Copilot token refresh");
});
globalThis.fetch = vi.fn(async (input: string | URL | Request) => { globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url !== COPILOT_TOKEN_URL) { throw new Error(`Unexpected fetch in test: ${url}`);
throw new Error(`Unexpected fetch in test: ${url}`);
}
const token = await resolveCopilotApiTokenMock();
return {
ok: true,
status: 200,
json: async () => ({
token: token.token,
expires_at: Math.floor(token.expiresAt / 1000),
}),
} as Response;
}) as typeof fetch; }) as typeof fetch;
computeBackoffMock.mockClear(); computeBackoffMock.mockClear();
sleepWithAbortMock.mockClear(); sleepWithAbortMock.mockClear();
@@ -88,8 +144,8 @@ afterEach(() => {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
unregisterLogTransport?.(); unregisterLogTransport?.();
unregisterLogTransport = undefined; unregisterLogTransport = undefined;
setLoggerOverride(null); setLoggerOverrideFn(null);
resetLogger(); resetLoggerFn();
}); });
const baseUsage = { const baseUsage = {
@@ -324,7 +380,7 @@ async function runAutoPinnedOpenAiTurn(params: {
runId: string; runId: string;
authProfileId?: string; authProfileId?: string;
}) { }) {
await runEmbeddedPiAgent({ await runEmbeddedPiAgentInline({
sessionId: "session:test", sessionId: "session:test",
sessionKey: params.sessionKey, sessionKey: params.sessionKey,
sessionFile: path.join(params.workspaceDir, "session.jsonl"), sessionFile: path.join(params.workspaceDir, "session.jsonl"),
@@ -368,7 +424,7 @@ async function runAutoPinnedRotationCase(params: {
sessionKey: string; sessionKey: string;
runId: string; runId: string;
}) { }) {
runEmbeddedAttemptMock.mockClear(); runEmbeddedAttemptMock.mockReset();
return withAgentWorkspace(async ({ agentDir, workspaceDir }) => { return withAgentWorkspace(async ({ agentDir, workspaceDir }) => {
await writeAuthStore(agentDir); await writeAuthStore(agentDir);
mockFailedThenSuccessfulAttempt(params.errorMessage); mockFailedThenSuccessfulAttempt(params.errorMessage);
@@ -390,7 +446,7 @@ async function runAutoPinnedPromptErrorRotationCase(params: {
sessionKey: string; sessionKey: string;
runId: string; runId: string;
}) { }) {
runEmbeddedAttemptMock.mockClear(); runEmbeddedAttemptMock.mockReset();
return withAgentWorkspace(async ({ agentDir, workspaceDir }) => { return withAgentWorkspace(async ({ agentDir, workspaceDir }) => {
await writeAuthStore(agentDir); await writeAuthStore(agentDir);
mockPromptErrorThenSuccessfulAttempt(params.errorMessage); mockPromptErrorThenSuccessfulAttempt(params.errorMessage);
@@ -486,7 +542,7 @@ async function runTurnWithCooldownSeed(params: {
}); });
mockSingleSuccessfulAttempt(); mockSingleSuccessfulAttempt();
await runEmbeddedPiAgent({ await runEmbeddedPiAgentInline({
sessionId: "session:test", sessionId: "session:test",
sessionKey: params.sessionKey, sessionKey: params.sessionKey,
sessionFile: path.join(workspaceDir, "session.jsonl"), sessionFile: path.join(workspaceDir, "session.jsonl"),
@@ -518,7 +574,9 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
resolveCopilotApiTokenMock resolveCopilotApiTokenMock
.mockResolvedValueOnce({ .mockResolvedValueOnce({
token: "copilot-initial", token: "copilot-initial",
expiresAt: now + 2 * 60 * 1000, // Keep expiry beyond the runtime refresh margin so the test only
// exercises auth-error refresh, not the background scheduler.
expiresAt: now + 10 * 60 * 1000,
source: "mock", source: "mock",
baseUrl: "https://api.copilot.example", baseUrl: "https://api.copilot.example",
}) })
@@ -549,7 +607,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
}), }),
); );
await runEmbeddedPiAgent({ await runEmbeddedPiAgentInline({
sessionId: "session:test", sessionId: "session:test",
sessionKey: "agent:test:copilot-auth-error", sessionKey: "agent:test:copilot-auth-error",
sessionFile: path.join(workspaceDir, "session.jsonl"), sessionFile: path.join(workspaceDir, "session.jsonl"),
@@ -582,13 +640,14 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
resolveCopilotApiTokenMock resolveCopilotApiTokenMock
.mockResolvedValueOnce({ .mockResolvedValueOnce({
token: "copilot-initial", token: "copilot-initial",
expiresAt: now + 2 * 60 * 1000, // Avoid an immediate scheduled refresh racing the explicit auth retry.
expiresAt: now + 10 * 60 * 1000,
source: "mock", source: "mock",
baseUrl: "https://api.copilot.example", baseUrl: "https://api.copilot.example",
}) })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
token: "copilot-refresh-1", token: "copilot-refresh-1",
expiresAt: now + 4 * 60 * 1000, expiresAt: now + 10 * 60 * 1000,
source: "mock", source: "mock",
baseUrl: "https://api.copilot.example", baseUrl: "https://api.copilot.example",
}) })
@@ -633,7 +692,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
}), }),
); );
await runEmbeddedPiAgent({ await runEmbeddedPiAgentInline({
sessionId: "session:test", sessionId: "session:test",
sessionKey: "agent:test:copilot-auth-repeat", sessionKey: "agent:test:copilot-auth-repeat",
sessionFile: path.join(workspaceDir, "session.jsonl"), sessionFile: path.join(workspaceDir, "session.jsonl"),
@@ -647,7 +706,6 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
timeoutMs: 5_000, timeoutMs: 5_000,
runId: "run:copilot-auth-repeat", runId: "run:copilot-auth-repeat",
}); });
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(4); expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(4);
expect(resolveCopilotApiTokenMock).toHaveBeenCalledTimes(3); expect(resolveCopilotApiTokenMock).toHaveBeenCalledTimes(3);
} finally { } finally {
@@ -682,7 +740,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
}), }),
); );
const runPromise = runEmbeddedPiAgent({ const runPromise = runEmbeddedPiAgentInline({
sessionId: "session:test", sessionId: "session:test",
sessionKey: "agent:test:copilot-shutdown", sessionKey: "agent:test:copilot-shutdown",
sessionFile: path.join(workspaceDir, "session.jsonl"), sessionFile: path.join(workspaceDir, "session.jsonl"),
@@ -744,12 +802,12 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
it("logs structured failover decision metadata for overloaded assistant rotation", async () => { it("logs structured failover decision metadata for overloaded assistant rotation", async () => {
const records: Array<Record<string, unknown>> = []; const records: Array<Record<string, unknown>> = [];
setLoggerOverride({ setLoggerOverrideFn({
level: "trace", level: "trace",
consoleLevel: "silent", consoleLevel: "silent",
file: path.join(os.tmpdir(), `openclaw-auth-rotation-${Date.now()}.log`), file: path.join(os.tmpdir(), `openclaw-auth-rotation-${Date.now()}.log`),
}); });
unregisterLogTransport = registerLogTransport((record) => { unregisterLogTransport = registerLogTransportFn((record) => {
records.push(record); records.push(record);
}); });
@@ -858,7 +916,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
}), }),
); );
const result = await runEmbeddedPiAgent({ const result = await runEmbeddedPiAgentInline({
sessionId: "session:test", sessionId: "session:test",
sessionKey: "agent:test:compaction-timeout", sessionKey: "agent:test:compaction-timeout",
sessionFile: path.join(workspaceDir, "session.jsonl"), sessionFile: path.join(workspaceDir, "session.jsonl"),
@@ -887,7 +945,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
mockSingleErrorAttempt({ errorMessage: "rate limit" }); mockSingleErrorAttempt({ errorMessage: "rate limit" });
await runEmbeddedPiAgent({ await runEmbeddedPiAgentInline({
sessionId: "session:test", sessionId: "session:test",
sessionKey: "agent:test:user", sessionKey: "agent:test:user",
sessionFile: path.join(workspaceDir, "session.jsonl"), sessionFile: path.join(workspaceDir, "session.jsonl"),
@@ -935,7 +993,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
}), }),
); );
await runEmbeddedPiAgent({ await runEmbeddedPiAgentInline({
sessionId: "session:test", sessionId: "session:test",
sessionKey: "agent:test:mismatch", sessionKey: "agent:test:mismatch",
sessionFile: path.join(workspaceDir, "session.jsonl"), sessionFile: path.join(workspaceDir, "session.jsonl"),
@@ -977,7 +1035,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
}); });
await expect( await expect(
runEmbeddedPiAgent({ runEmbeddedPiAgentInline({
sessionId: "session:test", sessionId: "session:test",
sessionKey: "agent:test:cooldown-failover", sessionKey: "agent:test:cooldown-failover",
sessionFile: path.join(workspaceDir, "session.jsonl"), sessionFile: path.join(workspaceDir, "session.jsonl"),
@@ -1021,7 +1079,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
}), }),
); );
const result = await runEmbeddedPiAgent({ const result = await runEmbeddedPiAgentInline({
sessionId: "session:test", sessionId: "session:test",
sessionKey: "agent:test:cooldown-probe", sessionKey: "agent:test:cooldown-probe",
sessionFile: path.join(workspaceDir, "session.jsonl"), sessionFile: path.join(workspaceDir, "session.jsonl"),
@@ -1069,7 +1127,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
}), }),
); );
const result = await runEmbeddedPiAgent({ const result = await runEmbeddedPiAgentInline({
sessionId: "session:test", sessionId: "session:test",
sessionKey: "agent:test:overloaded-cooldown-probe", sessionKey: "agent:test:overloaded-cooldown-probe",
sessionFile: path.join(workspaceDir, "session.jsonl"), sessionFile: path.join(workspaceDir, "session.jsonl"),
@@ -1117,7 +1175,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
}), }),
); );
const result = await runEmbeddedPiAgent({ const result = await runEmbeddedPiAgentInline({
sessionId: "session:test", sessionId: "session:test",
sessionKey: "agent:test:billing-cooldown-probe-no-fallbacks", sessionKey: "agent:test:billing-cooldown-probe-no-fallbacks",
sessionFile: path.join(workspaceDir, "session.jsonl"), sessionFile: path.join(workspaceDir, "session.jsonl"),
@@ -1148,7 +1206,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
}); });
await expect( await expect(
runEmbeddedPiAgent({ runEmbeddedPiAgentInline({
sessionId: "session:test", sessionId: "session:test",
sessionKey: "agent:support:cooldown-failover", sessionKey: "agent:support:cooldown-failover",
sessionFile: path.join(workspaceDir, "session.jsonl"), sessionFile: path.join(workspaceDir, "session.jsonl"),
@@ -1193,7 +1251,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
}); });
await expect( await expect(
runEmbeddedPiAgent({ runEmbeddedPiAgentInline({
sessionId: "session:test", sessionId: "session:test",
sessionKey: "agent:test:disabled-failover", sessionKey: "agent:test:disabled-failover",
sessionFile: path.join(workspaceDir, "session.jsonl"), sessionFile: path.join(workspaceDir, "session.jsonl"),
@@ -1227,7 +1285,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
await fs.writeFile(authPath, JSON.stringify({ version: 1, profiles: {}, usageStats: {} })); await fs.writeFile(authPath, JSON.stringify({ version: 1, profiles: {}, usageStats: {} }));
await expect( await expect(
runEmbeddedPiAgent({ runEmbeddedPiAgentInline({
sessionId: "session:test", sessionId: "session:test",
sessionKey: "agent:test:auth-unavailable", sessionKey: "agent:test:auth-unavailable",
sessionFile: path.join(workspaceDir, "session.jsonl"), sessionFile: path.join(workspaceDir, "session.jsonl"),
@@ -1265,7 +1323,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
let thrown: unknown; let thrown: unknown;
try { try {
await runEmbeddedPiAgent({ await runEmbeddedPiAgentInline({
sessionId: "session:test", sessionId: "session:test",
sessionKey: "agent:test:billing-failover-active-model", sessionKey: "agent:test:billing-failover-active-model",
sessionFile: path.join(workspaceDir, "session.jsonl"), sessionFile: path.join(workspaceDir, "session.jsonl"),

View File

@@ -1,58 +1,71 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { resetDiagnosticSessionStateForTest } from "../logging/diagnostic-session-state.js"; import { resetDiagnosticSessionStateForTest } from "../logging/diagnostic-session-state.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { toClientToolDefinitions, toToolDefinitions } from "./pi-tool-definition-adapter.js";
import { wrapToolWithAbortSignal } from "./pi-tools.abort.js";
import { import {
__testing as beforeToolCallTesting, initializeGlobalHookRunner,
consumeAdjustedParamsForToolCall, resetGlobalHookRunner,
wrapToolWithBeforeToolCallHook, } from "../plugins/hook-runner-global.js";
} from "./pi-tools.before-tool-call.js"; import { createMockPluginRegistry } from "../plugins/hooks.test-helpers.js";
vi.mock("../plugins/hook-runner-global.js", async (importOriginal) => { type ToolDefinitionAdapterModule = typeof import("./pi-tool-definition-adapter.js");
const actual = await importOriginal<typeof import("../plugins/hook-runner-global.js")>(); type PiToolsAbortModule = typeof import("./pi-tools.abort.js");
return { type BeforeToolCallModule = typeof import("./pi-tools.before-tool-call.js");
...actual,
getGlobalHookRunner: vi.fn(), type ToClientToolDefinitions = ToolDefinitionAdapterModule["toClientToolDefinitions"];
}; type ToToolDefinitions = ToolDefinitionAdapterModule["toToolDefinitions"];
type WrapToolWithAbortSignal = PiToolsAbortModule["wrapToolWithAbortSignal"];
type BeforeToolCallTesting = BeforeToolCallModule["__testing"];
type ConsumeAdjustedParamsForToolCall = BeforeToolCallModule["consumeAdjustedParamsForToolCall"];
type WrapToolWithBeforeToolCallHook = BeforeToolCallModule["wrapToolWithBeforeToolCallHook"];
let toClientToolDefinitions!: ToClientToolDefinitions;
let toToolDefinitions!: ToToolDefinitions;
let wrapToolWithAbortSignal!: WrapToolWithAbortSignal;
let beforeToolCallTesting!: BeforeToolCallTesting;
let consumeAdjustedParamsForToolCall!: ConsumeAdjustedParamsForToolCall;
let wrapToolWithBeforeToolCallHook!: WrapToolWithBeforeToolCallHook;
beforeEach(async () => {
if (!wrapToolWithBeforeToolCallHook) {
({ toClientToolDefinitions, toToolDefinitions } =
await import("./pi-tool-definition-adapter.js"));
({ wrapToolWithAbortSignal } = await import("./pi-tools.abort.js"));
({
__testing: beforeToolCallTesting,
consumeAdjustedParamsForToolCall,
wrapToolWithBeforeToolCallHook,
} = await import("./pi-tools.before-tool-call.js"));
}
}); });
const mockGetGlobalHookRunner = vi.mocked(getGlobalHookRunner); type BeforeToolCallHandlerMock = ReturnType<typeof vi.fn>;
type HookRunnerMock = { function installBeforeToolCallHook(params?: {
hasHooks: ReturnType<typeof vi.fn>; enabled?: boolean;
runBeforeToolCall: ReturnType<typeof vi.fn>;
};
function installMockHookRunner(params?: {
hasHooksReturn?: boolean;
runBeforeToolCallImpl?: (...args: unknown[]) => unknown; runBeforeToolCallImpl?: (...args: unknown[]) => unknown;
}) { }): BeforeToolCallHandlerMock {
const hookRunner: HookRunnerMock = { resetGlobalHookRunner();
hasHooks: const handler = params?.runBeforeToolCallImpl
params?.hasHooksReturn === undefined ? vi.fn(params.runBeforeToolCallImpl)
? vi.fn() : vi.fn(async () => undefined);
: vi.fn(() => params.hasHooksReturn as boolean), if (params?.enabled === false) {
runBeforeToolCall: params?.runBeforeToolCallImpl return handler;
? vi.fn(params.runBeforeToolCallImpl) }
: vi.fn(), initializeGlobalHookRunner(createMockPluginRegistry([{ hookName: "before_tool_call", handler }]));
}; return handler;
// oxlint-disable-next-line typescript/no-explicit-any
mockGetGlobalHookRunner.mockReturnValue(hookRunner as any);
return hookRunner;
} }
describe("before_tool_call hook integration", () => { describe("before_tool_call hook integration", () => {
let hookRunner: HookRunnerMock; let beforeToolCallHook: BeforeToolCallHandlerMock;
beforeEach(() => { beforeEach(() => {
resetGlobalHookRunner();
resetDiagnosticSessionStateForTest(); resetDiagnosticSessionStateForTest();
beforeToolCallTesting.adjustedParamsByToolCallId.clear(); beforeToolCallTesting.adjustedParamsByToolCallId.clear();
hookRunner = installMockHookRunner(); beforeToolCallHook = installBeforeToolCallHook();
}); });
it("executes tool normally when no hook is registered", async () => { it("executes tool normally when no hook is registered", async () => {
hookRunner.hasHooks.mockReturnValue(false); beforeToolCallHook = installBeforeToolCallHook({ enabled: false });
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } }); const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
// oxlint-disable-next-line typescript/no-explicit-any // oxlint-disable-next-line typescript/no-explicit-any
const tool = wrapToolWithBeforeToolCallHook({ name: "Read", execute } as any, { const tool = wrapToolWithBeforeToolCallHook({ name: "Read", execute } as any, {
@@ -63,7 +76,7 @@ describe("before_tool_call hook integration", () => {
await tool.execute("call-1", { path: "/tmp/file" }, undefined, extensionContext); await tool.execute("call-1", { path: "/tmp/file" }, undefined, extensionContext);
expect(hookRunner.runBeforeToolCall).not.toHaveBeenCalled(); expect(beforeToolCallHook).not.toHaveBeenCalled();
expect(execute).toHaveBeenCalledWith( expect(execute).toHaveBeenCalledWith(
"call-1", "call-1",
{ path: "/tmp/file" }, { path: "/tmp/file" },
@@ -73,8 +86,9 @@ describe("before_tool_call hook integration", () => {
}); });
it("allows hook to modify parameters", async () => { it("allows hook to modify parameters", async () => {
hookRunner.hasHooks.mockReturnValue(true); beforeToolCallHook = installBeforeToolCallHook({
hookRunner.runBeforeToolCall.mockResolvedValue({ params: { mode: "safe" } }); runBeforeToolCallImpl: async () => ({ params: { mode: "safe" } }),
});
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } }); const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
// oxlint-disable-next-line typescript/no-explicit-any // oxlint-disable-next-line typescript/no-explicit-any
const tool = wrapToolWithBeforeToolCallHook({ name: "exec", execute } as any); const tool = wrapToolWithBeforeToolCallHook({ name: "exec", execute } as any);
@@ -91,10 +105,11 @@ describe("before_tool_call hook integration", () => {
}); });
it("blocks tool execution when hook returns block=true", async () => { it("blocks tool execution when hook returns block=true", async () => {
hookRunner.hasHooks.mockReturnValue(true); beforeToolCallHook = installBeforeToolCallHook({
hookRunner.runBeforeToolCall.mockResolvedValue({ runBeforeToolCallImpl: async () => ({
block: true, block: true,
blockReason: "blocked", blockReason: "blocked",
}),
}); });
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } }); const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
// oxlint-disable-next-line typescript/no-explicit-any // oxlint-disable-next-line typescript/no-explicit-any
@@ -108,8 +123,11 @@ describe("before_tool_call hook integration", () => {
}); });
it("continues execution when hook throws", async () => { it("continues execution when hook throws", async () => {
hookRunner.hasHooks.mockReturnValue(true); beforeToolCallHook = installBeforeToolCallHook({
hookRunner.runBeforeToolCall.mockRejectedValue(new Error("boom")); runBeforeToolCallImpl: async () => {
throw new Error("boom");
},
});
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } }); const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
// oxlint-disable-next-line typescript/no-explicit-any // oxlint-disable-next-line typescript/no-explicit-any
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any); const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any);
@@ -126,8 +144,9 @@ describe("before_tool_call hook integration", () => {
}); });
it("normalizes non-object params for hook contract", async () => { it("normalizes non-object params for hook contract", async () => {
hookRunner.hasHooks.mockReturnValue(true); beforeToolCallHook = installBeforeToolCallHook({
hookRunner.runBeforeToolCall.mockResolvedValue(undefined); runBeforeToolCallImpl: async () => undefined,
});
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } }); const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
// oxlint-disable-next-line typescript/no-explicit-any // oxlint-disable-next-line typescript/no-explicit-any
const tool = wrapToolWithBeforeToolCallHook({ name: "ReAd", execute } as any, { const tool = wrapToolWithBeforeToolCallHook({ name: "ReAd", execute } as any, {
@@ -140,7 +159,7 @@ describe("before_tool_call hook integration", () => {
await tool.execute("call-5", "not-an-object", undefined, extensionContext); await tool.execute("call-5", "not-an-object", undefined, extensionContext);
expect(hookRunner.runBeforeToolCall).toHaveBeenCalledWith( expect(beforeToolCallHook).toHaveBeenCalledWith(
{ {
toolName: "read", toolName: "read",
params: {}, params: {},
@@ -159,10 +178,12 @@ describe("before_tool_call hook integration", () => {
}); });
it("keeps adjusted params isolated per run when toolCallId collides", async () => { it("keeps adjusted params isolated per run when toolCallId collides", async () => {
hookRunner.hasHooks.mockReturnValue(true); beforeToolCallHook = installBeforeToolCallHook({
hookRunner.runBeforeToolCall runBeforeToolCallImpl: vi
.mockResolvedValueOnce({ params: { marker: "A" } }) .fn()
.mockResolvedValueOnce({ params: { marker: "B" } }); .mockResolvedValueOnce({ params: { marker: "A" } })
.mockResolvedValueOnce({ params: { marker: "B" } }),
});
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } }); const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
// oxlint-disable-next-line typescript/no-explicit-any // oxlint-disable-next-line typescript/no-explicit-any
const toolA = wrapToolWithBeforeToolCallHook({ name: "Read", execute } as any, { const toolA = wrapToolWithBeforeToolCallHook({ name: "Read", execute } as any, {
@@ -192,12 +213,12 @@ describe("before_tool_call hook integration", () => {
}); });
describe("before_tool_call hook deduplication (#15502)", () => { describe("before_tool_call hook deduplication (#15502)", () => {
let hookRunner: HookRunnerMock; let beforeToolCallHook: BeforeToolCallHandlerMock;
beforeEach(() => { beforeEach(() => {
resetGlobalHookRunner();
resetDiagnosticSessionStateForTest(); resetDiagnosticSessionStateForTest();
hookRunner = installMockHookRunner({ beforeToolCallHook = installBeforeToolCallHook({
hasHooksReturn: true,
runBeforeToolCallImpl: async () => undefined, runBeforeToolCallImpl: async () => undefined,
}); });
}); });
@@ -221,7 +242,7 @@ describe("before_tool_call hook deduplication (#15502)", () => {
extensionContext, extensionContext,
); );
expect(hookRunner.runBeforeToolCall).toHaveBeenCalledTimes(1); expect(beforeToolCallHook).toHaveBeenCalledTimes(1);
}); });
it("fires hook exactly once when tool goes through wrap + abort + toToolDefinitions", async () => { it("fires hook exactly once when tool goes through wrap + abort + toToolDefinitions", async () => {
@@ -246,21 +267,21 @@ describe("before_tool_call hook deduplication (#15502)", () => {
extensionContext, extensionContext,
); );
expect(hookRunner.runBeforeToolCall).toHaveBeenCalledTimes(1); expect(beforeToolCallHook).toHaveBeenCalledTimes(1);
}); });
}); });
describe("before_tool_call hook integration for client tools", () => { describe("before_tool_call hook integration for client tools", () => {
let hookRunner: HookRunnerMock;
beforeEach(() => { beforeEach(() => {
resetGlobalHookRunner();
resetDiagnosticSessionStateForTest(); resetDiagnosticSessionStateForTest();
hookRunner = installMockHookRunner(); installBeforeToolCallHook();
}); });
it("passes modified params to client tool callbacks", async () => { it("passes modified params to client tool callbacks", async () => {
hookRunner.hasHooks.mockReturnValue(true); installBeforeToolCallHook({
hookRunner.runBeforeToolCall.mockResolvedValue({ params: { extra: true } }); runBeforeToolCallImpl: async () => ({ params: { extra: true } }),
});
const onClientToolCall = vi.fn(); const onClientToolCall = vi.fn();
const [tool] = toClientToolDefinitions( const [tool] = toClientToolDefinitions(
[ [

View File

@@ -1,7 +1,6 @@
import { mkdir, readFile, writeFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveSessionKey } from "../config/sessions.js";
import { import {
getProviderUsageMocks, getProviderUsageMocks,
getRunEmbeddedPiAgentMock, getRunEmbeddedPiAgentMock,
@@ -28,191 +27,132 @@ function getReplyFromConfigNow(getReplyFromConfig: () => GetReplyFromConfig): Ge
return getReplyFromConfig(); return getReplyFromConfig();
} }
function seedUsageSummary(): void {
usageMocks.loadProviderUsageSummary.mockClear();
usageMocks.loadProviderUsageSummary.mockResolvedValue({
updatedAt: 0,
providers: [
{
provider: "anthropic",
displayName: "Anthropic",
windows: [
{
label: "5h",
usedPercent: 20,
},
],
},
],
});
}
export function registerTriggerHandlingUsageSummaryCases(params: { export function registerTriggerHandlingUsageSummaryCases(params: {
getReplyFromConfig: () => GetReplyFromConfig; getReplyFromConfig: () => GetReplyFromConfig;
}): void { }): void {
describe("usage and status command handling", () => { describe("usage and status command handling", () => {
it("handles status, usage cycles, and auth-profile status details", async () => { it("shows status without invoking the agent", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
const getReplyFromConfig = getReplyFromConfigNow(params.getReplyFromConfig); const getReplyFromConfig = getReplyFromConfigNow(params.getReplyFromConfig);
usageMocks.loadProviderUsageSummary.mockClear(); seedUsageSummary();
usageMocks.loadProviderUsageSummary.mockResolvedValue({
updatedAt: 0,
providers: [
{
provider: "anthropic",
displayName: "Anthropic",
windows: [
{
label: "5h",
usedPercent: 20,
},
],
},
],
});
{ const res = await getReplyFromConfig(
const res = await getReplyFromConfig( {
{ Body: "/status",
Body: "/status", From: "+1000",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
CommandAuthorized: true,
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model:");
expect(text).toContain("OpenClaw");
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
}
{
const cfg = makeCfg(home);
cfg.session = { ...cfg.session, store: join(home, "usage-cycle.sessions.json") };
const usageStorePath = requireSessionStorePath(cfg);
const r0 = await getReplyFromConfig(
{
Body: "/usage on",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
CommandAuthorized: true,
},
undefined,
cfg,
);
expect(String((Array.isArray(r0) ? r0[0]?.text : r0?.text) ?? "")).toContain(
"Usage footer: tokens",
);
const r1 = await getReplyFromConfig(
{
Body: "/usage",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
CommandAuthorized: true,
},
undefined,
cfg,
);
expect(String((Array.isArray(r1) ? r1[0]?.text : r1?.text) ?? "")).toContain(
"Usage footer: full",
);
const r2 = await getReplyFromConfig(
{
Body: "/usage",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
CommandAuthorized: true,
},
undefined,
cfg,
);
expect(String((Array.isArray(r2) ? r2[0]?.text : r2?.text) ?? "")).toContain(
"Usage footer: off",
);
const r3 = await getReplyFromConfig(
{
Body: "/usage",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
CommandAuthorized: true,
},
undefined,
cfg,
);
expect(String((Array.isArray(r3) ? r3[0]?.text : r3?.text) ?? "")).toContain(
"Usage footer: tokens",
);
const finalStore = await readSessionStore(usageStorePath);
expect(pickFirstStoreEntry<{ responseUsage?: string }>(finalStore)?.responseUsage).toBe(
"tokens",
);
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
}
{
runEmbeddedPiAgentMock.mockClear();
const cfg = makeCfg(home);
cfg.session = { ...cfg.session, store: join(home, "auth-profile-status.sessions.json") };
const agentDir = join(home, ".openclaw", "agents", "main", "agent");
await mkdir(agentDir, { recursive: true });
await writeFile(
join(agentDir, "auth-profiles.json"),
JSON.stringify(
{
version: 1,
profiles: {
"anthropic:work": {
type: "api_key",
provider: "anthropic",
key: "sk-test-1234567890abcdef",
},
},
lastGood: { anthropic: "anthropic:work" },
},
null,
2,
),
);
const sessionKey = resolveSessionKey("per-sender", {
From: "+1002",
To: "+2000", To: "+2000",
Provider: "whatsapp", Provider: "whatsapp",
} as Parameters<typeof resolveSessionKey>[1]); SenderE164: "+1000",
await writeFile( CommandAuthorized: true,
requireSessionStorePath(cfg), },
JSON.stringify( {},
{ makeCfg(home),
[sessionKey]: { );
sessionId: "session-auth",
updatedAt: Date.now(),
authProfileOverride: "anthropic:work",
},
},
null,
2,
),
);
const res = await getReplyFromConfig( const text = Array.isArray(res) ? res[0]?.text : res?.text;
{ expect(text).toContain("Model:");
Body: "/status", expect(text).toContain("OpenClaw");
From: "+1002", expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
To: "+2000", });
Provider: "whatsapp", });
SenderE164: "+1002",
CommandAuthorized: true, it("cycles usage footer modes and persists the final selection", async () => {
}, await withTempHome(async (home) => {
{}, const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
cfg, const getReplyFromConfig = getReplyFromConfigNow(params.getReplyFromConfig);
); const cfg = makeCfg(home);
const text = Array.isArray(res) ? res[0]?.text : res?.text; cfg.session = { ...cfg.session, store: join(home, "usage-cycle.sessions.json") };
expect(text).toContain("api-key"); const usageStorePath = requireSessionStorePath(cfg);
expect(text).not.toContain("sk-test");
expect(text).not.toContain("abcdef"); const r0 = await getReplyFromConfig(
expect(text).not.toContain("1234567890abcdef"); // pragma: allowlist secret {
expect(text).toContain("(anthropic:work)"); Body: "/usage on",
expect(text).not.toContain("mixed"); From: "+1000",
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); To: "+2000",
} Provider: "whatsapp",
SenderE164: "+1000",
CommandAuthorized: true,
},
undefined,
cfg,
);
expect(String((Array.isArray(r0) ? r0[0]?.text : r0?.text) ?? "")).toContain(
"Usage footer: tokens",
);
const r1 = await getReplyFromConfig(
{
Body: "/usage",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
CommandAuthorized: true,
},
undefined,
cfg,
);
expect(String((Array.isArray(r1) ? r1[0]?.text : r1?.text) ?? "")).toContain(
"Usage footer: full",
);
const r2 = await getReplyFromConfig(
{
Body: "/usage",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
CommandAuthorized: true,
},
undefined,
cfg,
);
expect(String((Array.isArray(r2) ? r2[0]?.text : r2?.text) ?? "")).toContain(
"Usage footer: off",
);
const r3 = await getReplyFromConfig(
{
Body: "/usage",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
CommandAuthorized: true,
},
undefined,
cfg,
);
expect(String((Array.isArray(r3) ? r3[0]?.text : r3?.text) ?? "")).toContain(
"Usage footer: tokens",
);
const finalStore = await readSessionStore(usageStorePath);
expect(pickFirstStoreEntry<{ responseUsage?: string }>(finalStore)?.responseUsage).toBe(
"tokens",
);
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
}); });
}); });
}); });

View File

@@ -2,7 +2,6 @@ import fs from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { loadSessionStore, resolveSessionKey } from "../config/sessions.js"; import { loadSessionStore, resolveSessionKey } from "../config/sessions.js";
import { getReplyFromConfig } from "./reply.js";
import { registerGroupIntroPromptCases } from "./reply.triggers.group-intro-prompts.cases.js"; import { registerGroupIntroPromptCases } from "./reply.triggers.group-intro-prompts.cases.js";
import { registerTriggerHandlingUsageSummaryCases } from "./reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.js"; import { registerTriggerHandlingUsageSummaryCases } from "./reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.js";
import { import {
@@ -10,7 +9,7 @@ import {
getAbortEmbeddedPiRunMock, getAbortEmbeddedPiRunMock,
getCompactEmbeddedPiSessionMock, getCompactEmbeddedPiSessionMock,
getRunEmbeddedPiAgentMock, getRunEmbeddedPiAgentMock,
installTriggerHandlingE2eTestHooks, installTriggerHandlingReplyHarness,
MAIN_SESSION_KEY, MAIN_SESSION_KEY,
makeCfg, makeCfg,
mockRunEmbeddedPiAgentOk, mockRunEmbeddedPiAgentOk,
@@ -21,6 +20,8 @@ import {
import { enqueueFollowupRun, getFollowupQueueDepth, type FollowupRun } from "./reply/queue.js"; import { enqueueFollowupRun, getFollowupQueueDepth, type FollowupRun } from "./reply/queue.js";
import { HEARTBEAT_TOKEN } from "./tokens.js"; import { HEARTBEAT_TOKEN } from "./tokens.js";
type GetReplyFromConfig = typeof import("./reply.js").getReplyFromConfig;
vi.mock("./reply/agent-runner.runtime.js", () => ({ vi.mock("./reply/agent-runner.runtime.js", () => ({
runReplyAgent: async (params: { runReplyAgent: async (params: {
commandBody: string; commandBody: string;
@@ -75,7 +76,10 @@ vi.mock("./reply/agent-runner.runtime.js", () => ({
}, },
})); }));
installTriggerHandlingE2eTestHooks(); let getReplyFromConfig!: GetReplyFromConfig;
installTriggerHandlingReplyHarness((impl) => {
getReplyFromConfig = impl;
});
const BASE_MESSAGE = { const BASE_MESSAGE = {
Body: "hello", Body: "hello",
@@ -83,7 +87,7 @@ const BASE_MESSAGE = {
To: "+2000", To: "+2000",
} as const; } as const;
function maybeReplyText(reply: Awaited<ReturnType<typeof getReplyFromConfig>>) { function maybeReplyText(reply: Awaited<ReturnType<GetReplyFromConfig>>) {
return Array.isArray(reply) ? reply[0]?.text : reply?.text; return Array.isArray(reply) ? reply[0]?.text : reply?.text;
} }

View File

@@ -3,7 +3,10 @@ import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterAll, afterEach, beforeAll, expect, vi } from "vitest"; import { afterAll, afterEach, beforeAll, expect, vi } from "vitest";
import { clearRuntimeAuthProfileStoreSnapshots } from "../agents/auth-profiles.js";
import { resetCliCredentialCachesForTest } from "../agents/cli-credentials.js";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { resetProviderRuntimeHookCacheForTest } from "../plugins/provider-runtime.js";
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). // Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
// oxlint-disable-next-line typescript/no-explicit-any // oxlint-disable-next-line typescript/no-explicit-any
@@ -107,6 +110,20 @@ const installModelCatalogMock = () =>
installModelCatalogMock(); installModelCatalogMock();
vi.doMock("../agents/model-catalog.runtime.js", () => ({
loadModelCatalog: (...args: unknown[]) => modelCatalogMocks.loadModelCatalog(...args),
}));
vi.doMock("../plugins/provider-runtime.runtime.js", () => ({
augmentModelCatalogWithProviderPlugins: async (params: { catalog?: unknown[] }) =>
params.catalog ?? [],
buildProviderAuthDoctorHintWithPlugin: () => undefined,
buildProviderMissingAuthMessageWithPlugin: () => undefined,
formatProviderAuthProfileApiKeyWithPlugin: (params: { apiKey?: string }) => params.apiKey,
prepareProviderRuntimeAuth: async () => undefined,
refreshProviderOAuthCredentialWithPlugin: async () => undefined,
}));
const modelFallbackMocks = getSharedMocks("openclaw.trigger-handling.model-fallback-mocks", () => ({ const modelFallbackMocks = getSharedMocks("openclaw.trigger-handling.model-fallback-mocks", () => ({
runWithModelFallback: vi.fn( runWithModelFallback: vi.fn(
async (params: { async (params: {
@@ -131,6 +148,10 @@ const installModelFallbackMock = () =>
installModelFallbackMock(); installModelFallbackMock();
vi.doMock("../infra/git-commit.js", () => ({
resolveCommitHash: vi.fn(() => "abcdef0"),
}));
const webSessionMocks = getSharedMocks("openclaw.trigger-handling.web-session-mocks", () => ({ const webSessionMocks = getSharedMocks("openclaw.trigger-handling.web-session-mocks", () => ({
webAuthExists: vi.fn().mockResolvedValue(true), webAuthExists: vi.fn().mockResolvedValue(true),
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000), getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
@@ -419,6 +440,9 @@ export async function runGreetingPromptForBareNewOrReset(params: {
export function installTriggerHandlingE2eTestHooks() { export function installTriggerHandlingE2eTestHooks() {
afterEach(() => { afterEach(() => {
clearRuntimeAuthProfileStoreSnapshots();
resetCliCredentialCachesForTest();
resetProviderRuntimeHookCacheForTest();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
} }

View File

@@ -29,6 +29,29 @@ import type { CommandContext } from "./commands-types.js";
import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js"; import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js";
import { resolveSubagentLabel } from "./subagents-utils.js"; import { resolveSubagentLabel } from "./subagents-utils.js";
// Some usage endpoints only work with CLI/session OAuth tokens, not API keys.
// Skip those probes when the active auth mode cannot satisfy the endpoint.
const USAGE_OAUTH_ONLY_PROVIDERS = new Set([
"anthropic",
"github-copilot",
"google-gemini-cli",
"openai-codex",
]);
function shouldLoadUsageSummary(params: {
provider?: string;
selectedModelAuth?: string;
}): boolean {
if (!params.provider) {
return false;
}
if (!USAGE_OAUTH_ONLY_PROVIDERS.has(params.provider)) {
return true;
}
const auth = params.selectedModelAuth?.trim().toLowerCase();
return Boolean(auth?.startsWith("oauth") || auth?.startsWith("token"));
}
export async function buildStatusReply(params: { export async function buildStatusReply(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
command: CommandContext; command: CommandContext;
@@ -78,6 +101,25 @@ export async function buildStatusReply(params: {
? resolveSessionAgentId({ sessionKey, config: cfg }) ? resolveSessionAgentId({ sessionKey, config: cfg })
: resolveDefaultAgentId(cfg); : resolveDefaultAgentId(cfg);
const statusAgentDir = resolveAgentDir(cfg, statusAgentId); const statusAgentDir = resolveAgentDir(cfg, statusAgentId);
const modelRefs = resolveSelectedAndActiveModel({
selectedProvider: provider,
selectedModel: model,
sessionEntry,
});
const selectedModelAuth = resolveModelAuthLabel({
provider,
cfg,
sessionEntry,
agentDir: statusAgentDir,
});
const activeModelAuth = modelRefs.activeDiffers
? resolveModelAuthLabel({
provider: modelRefs.active.provider,
cfg,
sessionEntry,
agentDir: statusAgentDir,
})
: selectedModelAuth;
const currentUsageProvider = (() => { const currentUsageProvider = (() => {
try { try {
return resolveUsageProviderId(provider); return resolveUsageProviderId(provider);
@@ -86,12 +128,32 @@ export async function buildStatusReply(params: {
} }
})(); })();
let usageLine: string | null = null; let usageLine: string | null = null;
if (currentUsageProvider) { if (
currentUsageProvider &&
shouldLoadUsageSummary({
provider: currentUsageProvider,
selectedModelAuth,
})
) {
try { try {
const usageSummary = await loadProviderUsageSummary({ const usageSummaryTimeoutMs = 3500;
timeoutMs: 3500, let usageTimeout: NodeJS.Timeout | undefined;
providers: [currentUsageProvider], const usageSummary = await Promise.race([
agentDir: statusAgentDir, loadProviderUsageSummary({
timeoutMs: usageSummaryTimeoutMs,
providers: [currentUsageProvider],
agentDir: statusAgentDir,
}),
new Promise<never>((_, reject) => {
usageTimeout = setTimeout(
() => reject(new Error("usage summary timeout")),
usageSummaryTimeoutMs,
);
}),
]).finally(() => {
if (usageTimeout) {
clearTimeout(usageTimeout);
}
}); });
const usageEntry = usageSummary.providers[0]; const usageEntry = usageSummary.providers[0];
if (usageEntry && !usageEntry.error && usageEntry.windows.length > 0) { if (usageEntry && !usageEntry.error && usageEntry.windows.length > 0) {
@@ -143,25 +205,6 @@ export async function buildStatusReply(params: {
const groupActivation = isGroup const groupActivation = isGroup
? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? defaultGroupActivation()) ? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? defaultGroupActivation())
: undefined; : undefined;
const modelRefs = resolveSelectedAndActiveModel({
selectedProvider: provider,
selectedModel: model,
sessionEntry,
});
const selectedModelAuth = resolveModelAuthLabel({
provider,
cfg,
sessionEntry,
agentDir: statusAgentDir,
});
const activeModelAuth = modelRefs.activeDiffers
? resolveModelAuthLabel({
provider: modelRefs.active.provider,
cfg,
sessionEntry,
agentDir: statusAgentDir,
})
: selectedModelAuth;
const agentDefaults = cfg.agents?.defaults ?? {}; const agentDefaults = cfg.agents?.defaults ?? {};
const effectiveFastMode = const effectiveFastMode =
resolvedFastMode ?? resolvedFastMode ??

View File

@@ -314,88 +314,100 @@ export async function handleDirectiveOnly(
directives.elevatedLevel !== undefined && directives.elevatedLevel !== undefined &&
elevatedEnabled && elevatedEnabled &&
elevatedAllowed; elevatedAllowed;
const shouldPersistSessionEntry =
(directives.hasThinkDirective && Boolean(directives.thinkLevel)) ||
(directives.hasFastDirective && directives.fastMode !== undefined) ||
(directives.hasVerboseDirective && Boolean(directives.verboseLevel)) ||
(directives.hasReasoningDirective && Boolean(directives.reasoningLevel)) ||
(directives.hasElevatedDirective && Boolean(directives.elevatedLevel)) ||
(directives.hasExecDirective && directives.hasExecOptions && allowInternalExecPersistence) ||
Boolean(modelSelection) ||
directives.hasQueueDirective ||
shouldDowngradeXHigh;
const fastModeChanged = const fastModeChanged =
directives.hasFastDirective && directives.hasFastDirective &&
directives.fastMode !== undefined && directives.fastMode !== undefined &&
directives.fastMode !== currentFastMode; directives.fastMode !== currentFastMode;
let reasoningChanged = let reasoningChanged =
directives.hasReasoningDirective && directives.reasoningLevel !== undefined; directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
if (directives.hasThinkDirective && directives.thinkLevel) { if (shouldPersistSessionEntry) {
sessionEntry.thinkingLevel = directives.thinkLevel; if (directives.hasThinkDirective && directives.thinkLevel) {
} sessionEntry.thinkingLevel = directives.thinkLevel;
if (directives.hasFastDirective && directives.fastMode !== undefined) {
sessionEntry.fastMode = directives.fastMode;
}
if (shouldDowngradeXHigh) {
sessionEntry.thinkingLevel = "high";
}
if (directives.hasVerboseDirective && directives.verboseLevel) {
applyVerboseOverride(sessionEntry, directives.verboseLevel);
}
if (directives.hasReasoningDirective && directives.reasoningLevel) {
if (directives.reasoningLevel === "off") {
// Persist explicit off so it overrides model-capability defaults.
sessionEntry.reasoningLevel = "off";
} else {
sessionEntry.reasoningLevel = directives.reasoningLevel;
} }
reasoningChanged = if (directives.hasFastDirective && directives.fastMode !== undefined) {
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined; sessionEntry.fastMode = directives.fastMode;
}
if (directives.hasElevatedDirective && directives.elevatedLevel) {
// Unlike other toggles, elevated defaults can be "on".
// Persist "off" explicitly so `/elevated off` actually overrides defaults.
sessionEntry.elevatedLevel = directives.elevatedLevel;
elevatedChanged =
elevatedChanged ||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
}
if (directives.hasExecDirective && directives.hasExecOptions && allowInternalExecPersistence) {
if (directives.execHost) {
sessionEntry.execHost = directives.execHost;
} }
if (directives.execSecurity) { if (shouldDowngradeXHigh) {
sessionEntry.execSecurity = directives.execSecurity; sessionEntry.thinkingLevel = "high";
} }
if (directives.execAsk) { if (directives.hasVerboseDirective && directives.verboseLevel) {
sessionEntry.execAsk = directives.execAsk; applyVerboseOverride(sessionEntry, directives.verboseLevel);
} }
if (directives.execNode) { if (directives.hasReasoningDirective && directives.reasoningLevel) {
sessionEntry.execNode = directives.execNode; if (directives.reasoningLevel === "off") {
// Persist explicit off so it overrides model-capability defaults.
sessionEntry.reasoningLevel = "off";
} else {
sessionEntry.reasoningLevel = directives.reasoningLevel;
}
reasoningChanged =
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
} }
} if (directives.hasElevatedDirective && directives.elevatedLevel) {
if (modelSelection) { // Unlike other toggles, elevated defaults can be "on".
applyModelOverrideToSessionEntry({ // Persist "off" explicitly so `/elevated off` actually overrides defaults.
entry: sessionEntry, sessionEntry.elevatedLevel = directives.elevatedLevel;
selection: modelSelection, elevatedChanged =
profileOverride, elevatedChanged ||
}); (directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
}
if (directives.hasQueueDirective && directives.queueReset) {
delete sessionEntry.queueMode;
delete sessionEntry.queueDebounceMs;
delete sessionEntry.queueCap;
delete sessionEntry.queueDrop;
} else if (directives.hasQueueDirective) {
if (directives.queueMode) {
sessionEntry.queueMode = directives.queueMode;
} }
if (typeof directives.debounceMs === "number") { if (directives.hasExecDirective && directives.hasExecOptions && allowInternalExecPersistence) {
sessionEntry.queueDebounceMs = directives.debounceMs; if (directives.execHost) {
sessionEntry.execHost = directives.execHost;
}
if (directives.execSecurity) {
sessionEntry.execSecurity = directives.execSecurity;
}
if (directives.execAsk) {
sessionEntry.execAsk = directives.execAsk;
}
if (directives.execNode) {
sessionEntry.execNode = directives.execNode;
}
} }
if (typeof directives.cap === "number") { if (modelSelection) {
sessionEntry.queueCap = directives.cap; applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection: modelSelection,
profileOverride,
});
} }
if (directives.dropPolicy) { if (directives.hasQueueDirective && directives.queueReset) {
sessionEntry.queueDrop = directives.dropPolicy; delete sessionEntry.queueMode;
delete sessionEntry.queueDebounceMs;
delete sessionEntry.queueCap;
delete sessionEntry.queueDrop;
} else if (directives.hasQueueDirective) {
if (directives.queueMode) {
sessionEntry.queueMode = directives.queueMode;
}
if (typeof directives.debounceMs === "number") {
sessionEntry.queueDebounceMs = directives.debounceMs;
}
if (typeof directives.cap === "number") {
sessionEntry.queueCap = directives.cap;
}
if (directives.dropPolicy) {
sessionEntry.queueDrop = directives.dropPolicy;
}
}
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
} }
}
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
} }
if (modelSelection) { if (modelSelection) {
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`; const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;

View File

@@ -37,6 +37,7 @@ function createToolHandlerCtx(params: {
sessionId: params.sessionId, sessionId: params.sessionId,
onBlockReplyFlush: params.onBlockReplyFlush, onBlockReplyFlush: params.onBlockReplyFlush,
}, },
hookRunner: hookMocks.runner,
state: { state: {
toolMetaById: new Map<string, string | undefined>(), toolMetaById: new Map<string, string | undefined>(),
...createBaseToolHandlerState(), ...createBaseToolHandlerState(),