diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts index 1a3a79cd19c..32f4697a301 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts @@ -4,14 +4,12 @@ import path from "node:path"; import type { AssistantMessage } from "@mariozechner/pi-ai"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { registerLogTransport, resetLogger, setLoggerOverride } from "../logging/logger.js"; import { redactIdentifier } from "../logging/redact-identifier.js"; import type { AuthProfileFailureReason } from "./auth-profiles.js"; import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js"; const runEmbeddedAttemptMock = vi.fn<(params: unknown) => Promise>(); const resolveCopilotApiTokenMock = vi.fn(); -const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"; const { computeBackoffMock, sleepWithAbortMock } = vi.hoisted(() => ({ computeBackoffMock: vi.fn( ( @@ -22,63 +20,121 @@ const { computeBackoffMock, sleepWithAbortMock } = vi.hoisted(() => ({ sleepWithAbortMock: vi.fn(async (_ms: number, _abortSignal?: AbortSignal) => undefined), })); -vi.mock("./pi-embedded-runner/run/attempt.js", () => ({ - runEmbeddedAttempt: (params: unknown) => runEmbeddedAttemptMock(params), -})); - -vi.mock("../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.mock("../../extensions/github-copilot/token.js", () => ({ - DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", - resolveCopilotApiToken: (...args: unknown[]) => resolveCopilotApiTokenMock(...args), -})); - -vi.mock("./pi-embedded-runner/compact.js", () => ({ - compactEmbeddedPiSessionDirect: vi.fn(async () => { - throw new Error("compact should not run in auth profile rotation tests"); - }), -})); - -vi.mock("./models-config.js", async (importOriginal) => { - const mod = await importOriginal(); - return { - ...mod, - ensureOpenClawModelsJson: vi.fn(async () => ({ wrote: false })), - }; -}); +const installRunEmbeddedMocks = () => { + vi.doMock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: vi.fn(() => undefined), + })); + vi.doMock("../context-engine/index.js", () => ({ + ensureContextEnginesInitialized: vi.fn(), + resolveContextEngine: vi.fn(async () => ({ + dispose: async () => undefined, + })), + })); + vi.doMock("./runtime-plugins.js", () => ({ + ensureRuntimePluginsLoaded: vi.fn(), + })); + vi.doMock("./pi-embedded-runner/model.js", () => ({ + resolveModelAsync: async (provider: string, modelId: string) => ({ + model: { + id: modelId, + name: modelId, + api: "openai-responses", + provider, + baseUrl: + provider === "github-copilot" ? "https://api.copilot.example" : "https://example.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 16_000, + maxTokens: 2048, + }, + 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(); + return { + ...mod, + ensureOpenClawModelsJson: vi.fn(async () => ({ wrote: false })), + }; + }); +}; let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent; 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; beforeAll(async () => { + vi.resetModules(); + installRunEmbeddedMocks(); ({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js")); + ({ + registerLogTransport: registerLogTransportFn, + resetLogger: resetLoggerFn, + setLoggerOverride: setLoggerOverrideFn, + } = await import("../logging/logger.js")); }); +async function runEmbeddedPiAgentInline( + params: Parameters[0], +): Promise>> { + return await runEmbeddedPiAgent({ + ...params, + enqueue: async (task) => await task(), + }); +} + beforeEach(() => { vi.useRealTimers(); - runEmbeddedAttemptMock.mockClear(); + runEmbeddedAttemptMock.mockReset(); + runEmbeddedAttemptMock.mockImplementation(async () => { + throw new Error("unexpected extra runEmbeddedAttempt call"); + }); resolveCopilotApiTokenMock.mockReset(); + resolveCopilotApiTokenMock.mockImplementation(async () => { + throw new Error("unexpected extra Copilot token refresh"); + }); globalThis.fetch = vi.fn(async (input: string | URL | Request) => { 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}`); - } - const token = await resolveCopilotApiTokenMock(); - return { - ok: true, - status: 200, - json: async () => ({ - token: token.token, - expires_at: Math.floor(token.expiresAt / 1000), - }), - } as Response; + throw new Error(`Unexpected fetch in test: ${url}`); }) as typeof fetch; computeBackoffMock.mockClear(); sleepWithAbortMock.mockClear(); @@ -88,8 +144,8 @@ afterEach(() => { globalThis.fetch = originalFetch; unregisterLogTransport?.(); unregisterLogTransport = undefined; - setLoggerOverride(null); - resetLogger(); + setLoggerOverrideFn(null); + resetLoggerFn(); }); const baseUsage = { @@ -324,7 +380,7 @@ async function runAutoPinnedOpenAiTurn(params: { runId: string; authProfileId?: string; }) { - await runEmbeddedPiAgent({ + await runEmbeddedPiAgentInline({ sessionId: "session:test", sessionKey: params.sessionKey, sessionFile: path.join(params.workspaceDir, "session.jsonl"), @@ -368,7 +424,7 @@ async function runAutoPinnedRotationCase(params: { sessionKey: string; runId: string; }) { - runEmbeddedAttemptMock.mockClear(); + runEmbeddedAttemptMock.mockReset(); return withAgentWorkspace(async ({ agentDir, workspaceDir }) => { await writeAuthStore(agentDir); mockFailedThenSuccessfulAttempt(params.errorMessage); @@ -390,7 +446,7 @@ async function runAutoPinnedPromptErrorRotationCase(params: { sessionKey: string; runId: string; }) { - runEmbeddedAttemptMock.mockClear(); + runEmbeddedAttemptMock.mockReset(); return withAgentWorkspace(async ({ agentDir, workspaceDir }) => { await writeAuthStore(agentDir); mockPromptErrorThenSuccessfulAttempt(params.errorMessage); @@ -486,7 +542,7 @@ async function runTurnWithCooldownSeed(params: { }); mockSingleSuccessfulAttempt(); - await runEmbeddedPiAgent({ + await runEmbeddedPiAgentInline({ sessionId: "session:test", sessionKey: params.sessionKey, sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -518,7 +574,9 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { resolveCopilotApiTokenMock .mockResolvedValueOnce({ 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", baseUrl: "https://api.copilot.example", }) @@ -549,7 +607,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedPiAgentInline({ sessionId: "session:test", sessionKey: "agent:test:copilot-auth-error", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -582,13 +640,14 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { resolveCopilotApiTokenMock .mockResolvedValueOnce({ 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", baseUrl: "https://api.copilot.example", }) .mockResolvedValueOnce({ token: "copilot-refresh-1", - expiresAt: now + 4 * 60 * 1000, + expiresAt: now + 10 * 60 * 1000, source: "mock", baseUrl: "https://api.copilot.example", }) @@ -633,7 +692,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedPiAgentInline({ sessionId: "session:test", sessionKey: "agent:test:copilot-auth-repeat", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -647,7 +706,6 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { timeoutMs: 5_000, runId: "run:copilot-auth-repeat", }); - expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(4); expect(resolveCopilotApiTokenMock).toHaveBeenCalledTimes(3); } finally { @@ -682,7 +740,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }), ); - const runPromise = runEmbeddedPiAgent({ + const runPromise = runEmbeddedPiAgentInline({ sessionId: "session:test", sessionKey: "agent:test:copilot-shutdown", 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 () => { const records: Array> = []; - setLoggerOverride({ + setLoggerOverrideFn({ level: "trace", consoleLevel: "silent", file: path.join(os.tmpdir(), `openclaw-auth-rotation-${Date.now()}.log`), }); - unregisterLogTransport = registerLogTransport((record) => { + unregisterLogTransport = registerLogTransportFn((record) => { records.push(record); }); @@ -858,7 +916,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedPiAgentInline({ sessionId: "session:test", sessionKey: "agent:test:compaction-timeout", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -887,7 +945,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { mockSingleErrorAttempt({ errorMessage: "rate limit" }); - await runEmbeddedPiAgent({ + await runEmbeddedPiAgentInline({ sessionId: "session:test", sessionKey: "agent:test:user", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -935,7 +993,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedPiAgentInline({ sessionId: "session:test", sessionKey: "agent:test:mismatch", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -977,7 +1035,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); await expect( - runEmbeddedPiAgent({ + runEmbeddedPiAgentInline({ sessionId: "session:test", sessionKey: "agent:test:cooldown-failover", 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", sessionKey: "agent:test:cooldown-probe", 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", sessionKey: "agent:test:overloaded-cooldown-probe", 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", sessionKey: "agent:test:billing-cooldown-probe-no-fallbacks", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -1148,7 +1206,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); await expect( - runEmbeddedPiAgent({ + runEmbeddedPiAgentInline({ sessionId: "session:test", sessionKey: "agent:support:cooldown-failover", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -1193,7 +1251,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); await expect( - runEmbeddedPiAgent({ + runEmbeddedPiAgentInline({ sessionId: "session:test", sessionKey: "agent:test:disabled-failover", 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 expect( - runEmbeddedPiAgent({ + runEmbeddedPiAgentInline({ sessionId: "session:test", sessionKey: "agent:test:auth-unavailable", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -1265,7 +1323,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { let thrown: unknown; try { - await runEmbeddedPiAgent({ + await runEmbeddedPiAgentInline({ sessionId: "session:test", sessionKey: "agent:test:billing-failover-active-model", sessionFile: path.join(workspaceDir, "session.jsonl"), diff --git a/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts b/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts index 216d982eb9f..40a2a7bee4b 100644 --- a/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts +++ b/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts @@ -1,58 +1,71 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; 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 { - __testing as beforeToolCallTesting, - consumeAdjustedParamsForToolCall, - wrapToolWithBeforeToolCallHook, -} from "./pi-tools.before-tool-call.js"; + initializeGlobalHookRunner, + resetGlobalHookRunner, +} from "../plugins/hook-runner-global.js"; +import { createMockPluginRegistry } from "../plugins/hooks.test-helpers.js"; -vi.mock("../plugins/hook-runner-global.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getGlobalHookRunner: vi.fn(), - }; +type ToolDefinitionAdapterModule = typeof import("./pi-tool-definition-adapter.js"); +type PiToolsAbortModule = typeof import("./pi-tools.abort.js"); +type BeforeToolCallModule = typeof import("./pi-tools.before-tool-call.js"); + +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; -type HookRunnerMock = { - hasHooks: ReturnType; - runBeforeToolCall: ReturnType; -}; - -function installMockHookRunner(params?: { - hasHooksReturn?: boolean; +function installBeforeToolCallHook(params?: { + enabled?: boolean; runBeforeToolCallImpl?: (...args: unknown[]) => unknown; -}) { - const hookRunner: HookRunnerMock = { - hasHooks: - params?.hasHooksReturn === undefined - ? vi.fn() - : vi.fn(() => params.hasHooksReturn as boolean), - runBeforeToolCall: params?.runBeforeToolCallImpl - ? vi.fn(params.runBeforeToolCallImpl) - : vi.fn(), - }; - // oxlint-disable-next-line typescript/no-explicit-any - mockGetGlobalHookRunner.mockReturnValue(hookRunner as any); - return hookRunner; +}): BeforeToolCallHandlerMock { + resetGlobalHookRunner(); + const handler = params?.runBeforeToolCallImpl + ? vi.fn(params.runBeforeToolCallImpl) + : vi.fn(async () => undefined); + if (params?.enabled === false) { + return handler; + } + initializeGlobalHookRunner(createMockPluginRegistry([{ hookName: "before_tool_call", handler }])); + return handler; } describe("before_tool_call hook integration", () => { - let hookRunner: HookRunnerMock; + let beforeToolCallHook: BeforeToolCallHandlerMock; beforeEach(() => { + resetGlobalHookRunner(); resetDiagnosticSessionStateForTest(); beforeToolCallTesting.adjustedParamsByToolCallId.clear(); - hookRunner = installMockHookRunner(); + beforeToolCallHook = installBeforeToolCallHook(); }); 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 } }); // oxlint-disable-next-line typescript/no-explicit-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); - expect(hookRunner.runBeforeToolCall).not.toHaveBeenCalled(); + expect(beforeToolCallHook).not.toHaveBeenCalled(); expect(execute).toHaveBeenCalledWith( "call-1", { path: "/tmp/file" }, @@ -73,8 +86,9 @@ describe("before_tool_call hook integration", () => { }); it("allows hook to modify parameters", async () => { - hookRunner.hasHooks.mockReturnValue(true); - hookRunner.runBeforeToolCall.mockResolvedValue({ params: { mode: "safe" } }); + beforeToolCallHook = installBeforeToolCallHook({ + runBeforeToolCallImpl: async () => ({ params: { mode: "safe" } }), + }); const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } }); // oxlint-disable-next-line typescript/no-explicit-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 () => { - hookRunner.hasHooks.mockReturnValue(true); - hookRunner.runBeforeToolCall.mockResolvedValue({ - block: true, - blockReason: "blocked", + beforeToolCallHook = installBeforeToolCallHook({ + runBeforeToolCallImpl: async () => ({ + block: true, + blockReason: "blocked", + }), }); const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } }); // 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 () => { - hookRunner.hasHooks.mockReturnValue(true); - hookRunner.runBeforeToolCall.mockRejectedValue(new Error("boom")); + beforeToolCallHook = installBeforeToolCallHook({ + runBeforeToolCallImpl: async () => { + throw new Error("boom"); + }, + }); const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } }); // oxlint-disable-next-line typescript/no-explicit-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 () => { - hookRunner.hasHooks.mockReturnValue(true); - hookRunner.runBeforeToolCall.mockResolvedValue(undefined); + beforeToolCallHook = installBeforeToolCallHook({ + runBeforeToolCallImpl: async () => undefined, + }); const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } }); // oxlint-disable-next-line typescript/no-explicit-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); - expect(hookRunner.runBeforeToolCall).toHaveBeenCalledWith( + expect(beforeToolCallHook).toHaveBeenCalledWith( { toolName: "read", params: {}, @@ -159,10 +178,12 @@ describe("before_tool_call hook integration", () => { }); it("keeps adjusted params isolated per run when toolCallId collides", async () => { - hookRunner.hasHooks.mockReturnValue(true); - hookRunner.runBeforeToolCall - .mockResolvedValueOnce({ params: { marker: "A" } }) - .mockResolvedValueOnce({ params: { marker: "B" } }); + beforeToolCallHook = installBeforeToolCallHook({ + runBeforeToolCallImpl: vi + .fn() + .mockResolvedValueOnce({ params: { marker: "A" } }) + .mockResolvedValueOnce({ params: { marker: "B" } }), + }); const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } }); // oxlint-disable-next-line typescript/no-explicit-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)", () => { - let hookRunner: HookRunnerMock; + let beforeToolCallHook: BeforeToolCallHandlerMock; beforeEach(() => { + resetGlobalHookRunner(); resetDiagnosticSessionStateForTest(); - hookRunner = installMockHookRunner({ - hasHooksReturn: true, + beforeToolCallHook = installBeforeToolCallHook({ runBeforeToolCallImpl: async () => undefined, }); }); @@ -221,7 +242,7 @@ describe("before_tool_call hook deduplication (#15502)", () => { extensionContext, ); - expect(hookRunner.runBeforeToolCall).toHaveBeenCalledTimes(1); + expect(beforeToolCallHook).toHaveBeenCalledTimes(1); }); 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, ); - expect(hookRunner.runBeforeToolCall).toHaveBeenCalledTimes(1); + expect(beforeToolCallHook).toHaveBeenCalledTimes(1); }); }); describe("before_tool_call hook integration for client tools", () => { - let hookRunner: HookRunnerMock; - beforeEach(() => { + resetGlobalHookRunner(); resetDiagnosticSessionStateForTest(); - hookRunner = installMockHookRunner(); + installBeforeToolCallHook(); }); it("passes modified params to client tool callbacks", async () => { - hookRunner.hasHooks.mockReturnValue(true); - hookRunner.runBeforeToolCall.mockResolvedValue({ params: { extra: true } }); + installBeforeToolCallHook({ + runBeforeToolCallImpl: async () => ({ params: { extra: true } }), + }); const onClientToolCall = vi.fn(); const [tool] = toClientToolDefinitions( [ diff --git a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts index a63479a0229..f067960cb6e 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts @@ -1,7 +1,6 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveSessionKey } from "../config/sessions.js"; import { getProviderUsageMocks, getRunEmbeddedPiAgentMock, @@ -28,191 +27,132 @@ function getReplyFromConfigNow(getReplyFromConfig: () => GetReplyFromConfig): Ge 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: { getReplyFromConfig: () => GetReplyFromConfig; }): void { 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) => { const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); const getReplyFromConfig = getReplyFromConfigNow(params.getReplyFromConfig); - usageMocks.loadProviderUsageSummary.mockClear(); - usageMocks.loadProviderUsageSummary.mockResolvedValue({ - updatedAt: 0, - providers: [ - { - provider: "anthropic", - displayName: "Anthropic", - windows: [ - { - label: "5h", - usedPercent: 20, - }, - ], - }, - ], - }); + seedUsageSummary(); - { - const res = await getReplyFromConfig( - { - Body: "/status", - 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", + const res = await getReplyFromConfig( + { + Body: "/status", + From: "+1000", To: "+2000", Provider: "whatsapp", - } as Parameters[1]); - await writeFile( - requireSessionStorePath(cfg), - JSON.stringify( - { - [sessionKey]: { - sessionId: "session-auth", - updatedAt: Date.now(), - authProfileOverride: "anthropic:work", - }, - }, - null, - 2, - ), - ); + SenderE164: "+1000", + CommandAuthorized: true, + }, + {}, + makeCfg(home), + ); - const res = await getReplyFromConfig( - { - Body: "/status", - From: "+1002", - To: "+2000", - Provider: "whatsapp", - SenderE164: "+1002", - CommandAuthorized: true, - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("api-key"); - expect(text).not.toContain("sk-test"); - expect(text).not.toContain("abcdef"); - expect(text).not.toContain("1234567890abcdef"); // pragma: allowlist secret - expect(text).toContain("(anthropic:work)"); - expect(text).not.toContain("mixed"); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); - } + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Model:"); + expect(text).toContain("OpenClaw"); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + }); + }); + + it("cycles usage footer modes and persists the final selection", async () => { + await withTempHome(async (home) => { + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + const getReplyFromConfig = getReplyFromConfigNow(params.getReplyFromConfig); + 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(); }); }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts index 416f636cd90..a1b30eee9c5 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts @@ -2,7 +2,6 @@ import fs from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { loadSessionStore, resolveSessionKey } from "../config/sessions.js"; -import { getReplyFromConfig } from "./reply.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 { @@ -10,7 +9,7 @@ import { getAbortEmbeddedPiRunMock, getCompactEmbeddedPiSessionMock, getRunEmbeddedPiAgentMock, - installTriggerHandlingE2eTestHooks, + installTriggerHandlingReplyHarness, MAIN_SESSION_KEY, makeCfg, mockRunEmbeddedPiAgentOk, @@ -21,6 +20,8 @@ import { import { enqueueFollowupRun, getFollowupQueueDepth, type FollowupRun } from "./reply/queue.js"; import { HEARTBEAT_TOKEN } from "./tokens.js"; +type GetReplyFromConfig = typeof import("./reply.js").getReplyFromConfig; + vi.mock("./reply/agent-runner.runtime.js", () => ({ runReplyAgent: async (params: { commandBody: string; @@ -75,7 +76,10 @@ vi.mock("./reply/agent-runner.runtime.js", () => ({ }, })); -installTriggerHandlingE2eTestHooks(); +let getReplyFromConfig!: GetReplyFromConfig; +installTriggerHandlingReplyHarness((impl) => { + getReplyFromConfig = impl; +}); const BASE_MESSAGE = { Body: "hello", @@ -83,7 +87,7 @@ const BASE_MESSAGE = { To: "+2000", } as const; -function maybeReplyText(reply: Awaited>) { +function maybeReplyText(reply: Awaited>) { return Array.isArray(reply) ? reply[0]?.text : reply?.text; } diff --git a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts index 7a1f4c64618..7a0c670648e 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts @@ -3,7 +3,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import { join } from "node:path"; 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 { resetProviderRuntimeHookCacheForTest } from "../plugins/provider-runtime.js"; // Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). // oxlint-disable-next-line typescript/no-explicit-any @@ -107,6 +110,20 @@ const 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", () => ({ runWithModelFallback: vi.fn( async (params: { @@ -131,6 +148,10 @@ const installModelFallbackMock = () => installModelFallbackMock(); +vi.doMock("../infra/git-commit.js", () => ({ + resolveCommitHash: vi.fn(() => "abcdef0"), +})); + const webSessionMocks = getSharedMocks("openclaw.trigger-handling.web-session-mocks", () => ({ webAuthExists: vi.fn().mockResolvedValue(true), getWebAuthAgeMs: vi.fn().mockReturnValue(120_000), @@ -419,6 +440,9 @@ export async function runGreetingPromptForBareNewOrReset(params: { export function installTriggerHandlingE2eTestHooks() { afterEach(() => { + clearRuntimeAuthProfileStoreSnapshots(); + resetCliCredentialCachesForTest(); + resetProviderRuntimeHookCacheForTest(); vi.clearAllMocks(); }); } diff --git a/src/auto-reply/reply/commands-status.ts b/src/auto-reply/reply/commands-status.ts index ce8aab97d2b..71769df5d51 100644 --- a/src/auto-reply/reply/commands-status.ts +++ b/src/auto-reply/reply/commands-status.ts @@ -29,6 +29,29 @@ import type { CommandContext } from "./commands-types.js"; import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.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: { cfg: OpenClawConfig; command: CommandContext; @@ -78,6 +101,25 @@ export async function buildStatusReply(params: { ? resolveSessionAgentId({ sessionKey, config: cfg }) : resolveDefaultAgentId(cfg); 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 = (() => { try { return resolveUsageProviderId(provider); @@ -86,12 +128,32 @@ export async function buildStatusReply(params: { } })(); let usageLine: string | null = null; - if (currentUsageProvider) { + if ( + currentUsageProvider && + shouldLoadUsageSummary({ + provider: currentUsageProvider, + selectedModelAuth, + }) + ) { try { - const usageSummary = await loadProviderUsageSummary({ - timeoutMs: 3500, - providers: [currentUsageProvider], - agentDir: statusAgentDir, + const usageSummaryTimeoutMs = 3500; + let usageTimeout: NodeJS.Timeout | undefined; + const usageSummary = await Promise.race([ + loadProviderUsageSummary({ + timeoutMs: usageSummaryTimeoutMs, + providers: [currentUsageProvider], + agentDir: statusAgentDir, + }), + new Promise((_, reject) => { + usageTimeout = setTimeout( + () => reject(new Error("usage summary timeout")), + usageSummaryTimeoutMs, + ); + }), + ]).finally(() => { + if (usageTimeout) { + clearTimeout(usageTimeout); + } }); const usageEntry = usageSummary.providers[0]; if (usageEntry && !usageEntry.error && usageEntry.windows.length > 0) { @@ -143,25 +205,6 @@ export async function buildStatusReply(params: { const groupActivation = isGroup ? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? defaultGroupActivation()) : 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 effectiveFastMode = resolvedFastMode ?? diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index 80e92b09588..42506cf7da6 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -314,88 +314,100 @@ export async function handleDirectiveOnly( directives.elevatedLevel !== undefined && elevatedEnabled && 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 = directives.hasFastDirective && directives.fastMode !== undefined && directives.fastMode !== currentFastMode; let reasoningChanged = directives.hasReasoningDirective && directives.reasoningLevel !== undefined; - 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; + if (shouldPersistSessionEntry) { + if (directives.hasThinkDirective && directives.thinkLevel) { + sessionEntry.thinkingLevel = directives.thinkLevel; } - reasoningChanged = - directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined; - } - 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.hasFastDirective && directives.fastMode !== undefined) { + sessionEntry.fastMode = directives.fastMode; } - if (directives.execSecurity) { - sessionEntry.execSecurity = directives.execSecurity; + if (shouldDowngradeXHigh) { + sessionEntry.thinkingLevel = "high"; } - if (directives.execAsk) { - sessionEntry.execAsk = directives.execAsk; + if (directives.hasVerboseDirective && directives.verboseLevel) { + applyVerboseOverride(sessionEntry, directives.verboseLevel); } - if (directives.execNode) { - sessionEntry.execNode = directives.execNode; + 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 = + directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined; } - } - if (modelSelection) { - applyModelOverrideToSessionEntry({ - entry: sessionEntry, - selection: modelSelection, - profileOverride, - }); - } - 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 (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 (typeof directives.debounceMs === "number") { - sessionEntry.queueDebounceMs = directives.debounceMs; + if (directives.hasExecDirective && directives.hasExecOptions && allowInternalExecPersistence) { + 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") { - sessionEntry.queueCap = directives.cap; + if (modelSelection) { + applyModelOverrideToSessionEntry({ + entry: sessionEntry, + selection: modelSelection, + profileOverride, + }); } - if (directives.dropPolicy) { - sessionEntry.queueDrop = directives.dropPolicy; + 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") { + 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) { const nextLabel = `${modelSelection.provider}/${modelSelection.model}`; diff --git a/src/plugins/wired-hooks-after-tool-call.e2e.test.ts b/src/plugins/wired-hooks-after-tool-call.e2e.test.ts index 147ca323a91..ceb61a94b21 100644 --- a/src/plugins/wired-hooks-after-tool-call.e2e.test.ts +++ b/src/plugins/wired-hooks-after-tool-call.e2e.test.ts @@ -37,6 +37,7 @@ function createToolHandlerCtx(params: { sessionId: params.sessionId, onBlockReplyFlush: params.onBlockReplyFlush, }, + hookRunner: hookMocks.runner, state: { toolMetaById: new Map(), ...createBaseToolHandlerState(),