diff --git a/src/infra/exec-approval-session-target.test.ts b/src/infra/exec-approval-session-target.test.ts index bb62f8d48f0..57c13a3f177 100644 --- a/src/infra/exec-approval-session-target.test.ts +++ b/src/infra/exec-approval-session-target.test.ts @@ -1,8 +1,12 @@ import fs from "node:fs"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; +import { + parseRawSessionConversationRef, + parseThreadSessionSuffix, +} from "../sessions/session-key-utils.js"; import { withTempDirSync } from "../test-helpers/temp-dir.js"; import { doesApprovalRequestMatchChannelAccount, @@ -17,6 +21,36 @@ import { import type { ExecApprovalRequest } from "./exec-approvals.js"; import type { PluginApprovalRequest } from "./plugin-approvals.js"; +vi.mock("../channels/plugins/session-conversation.js", () => ({ + resolveSessionConversationRef(sessionKey: string | undefined | null) { + const raw = parseRawSessionConversationRef(sessionKey); + if (!raw) { + return null; + } + const parsed = parseThreadSessionSuffix(raw.rawId); + const id = (parsed.baseSessionKey ?? raw.rawId).trim(); + if (!id) { + return null; + } + return { + channel: raw.channel, + kind: raw.kind, + rawId: raw.rawId, + id, + threadId: parsed.threadId, + baseSessionKey: `${raw.prefix}:${id}`, + baseConversationId: id, + parentConversationCandidates: parsed.threadId ? [id] : [], + }; + }, +})); + +vi.mock("./outbound/targets.js", async () => { + return await vi.importActual( + "./outbound/targets-session.js", + ); +}); + const baseRequest: ExecApprovalRequest = { id: "req-1", request: { diff --git a/src/infra/provider-usage.load.test.ts b/src/infra/provider-usage.load.test.ts index 84836801532..8ec0364c0f7 100644 --- a/src/infra/provider-usage.load.test.ts +++ b/src/infra/provider-usage.load.test.ts @@ -10,41 +10,63 @@ import { type ProviderAuth = ProviderUsageAuth; const googleGeminiCliProvider = "google-gemini-cli" as unknown as ProviderAuth["provider"]; +const resolveProviderUsageSnapshotWithPluginMock = vi.fn(async () => null); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => ({}), +})); + +vi.mock("../plugins/provider-runtime.js", async () => { + const actual = await vi.importActual( + "../plugins/provider-runtime.js", + ); + return { + ...actual, + resolveProviderUsageSnapshotWithPlugin: (...args: unknown[]) => + resolveProviderUsageSnapshotWithPluginMock(...args), + }; +}); describe("provider-usage.load", () => { beforeEach(() => { vi.restoreAllMocks(); + resolveProviderUsageSnapshotWithPluginMock.mockReset(); + resolveProviderUsageSnapshotWithPluginMock.mockResolvedValue(null); }); it("loads snapshots for copilot gemini codex and xiaomi", async () => { - const mockFetch = createProviderUsageFetch(async (url) => { - if (url.includes("api.github.com/copilot_internal/user")) { - return makeResponse(200, { - quota_snapshots: { chat: { percent_remaining: 80 } }, - copilot_plan: "Copilot Pro", - }); + resolveProviderUsageSnapshotWithPluginMock.mockImplementation(async ({ provider }) => { + switch (provider) { + case "github-copilot": + return { + provider, + displayName: "GitHub Copilot", + windows: [{ label: "Chat", usedPercent: 20 }], + }; + case googleGeminiCliProvider: + return { + provider, + displayName: "Gemini CLI", + windows: [{ label: "Pro", usedPercent: 40 }], + }; + case "openai-codex": + return { + provider, + displayName: "Codex", + windows: [{ label: "3h", usedPercent: 12 }], + }; + case "xiaomi": + return { + provider, + displayName: "Xiaomi", + windows: [], + }; + default: + return null; } - if (url.includes("cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels")) { - return makeResponse(200, { - models: { - "gemini-2.5-pro": { - quotaInfo: { remainingFraction: 0.4, resetTime: "2026-01-08T01:00:00Z" }, - }, - }, - }); - } - if (url.includes("cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) { - return makeResponse(200, { - buckets: [{ modelId: "gemini-2.5-pro", remainingFraction: 0.6 }], - }); - } - if (url.includes("chatgpt.com/backend-api/wham/usage")) { - return makeResponse(200, { - rate_limit: { primary_window: { used_percent: 12, limit_window_seconds: 10800 } }, - plan_type: "Plus", - }); - } - return makeResponse(404, "not found"); + }); + const mockFetch = createProviderUsageFetch(async () => { + throw new Error("legacy fetch should not run"); }); const summary = await loadUsageWithAuth( @@ -77,6 +99,7 @@ describe("provider-usage.load", () => { expect(summary.providers.find((provider) => provider.provider === "xiaomi")?.windows).toEqual( [], ); + expect(mockFetch).not.toHaveBeenCalled(); }); it("returns empty provider list when auth resolves to none", async () => { @@ -97,11 +120,14 @@ describe("provider-usage.load", () => { }); it("filters errors that are marked as ignored", async () => { - const mockFetch = createProviderUsageFetch(async (url) => { - if (url.includes("api.anthropic.com/api/oauth/usage")) { - return makeResponse(500, "boom"); - } - return makeResponse(404, "not found"); + resolveProviderUsageSnapshotWithPluginMock.mockResolvedValueOnce({ + provider: "anthropic", + displayName: "Claude", + windows: [], + error: "HTTP 500", + }); + const mockFetch = createProviderUsageFetch(async () => { + throw new Error("legacy fetch should not run"); }); ignoredErrors.add("HTTP 500"); try { diff --git a/src/infra/provider-usage.test.ts b/src/infra/provider-usage.test.ts index e461d39964d..dc1aaa2e9b0 100644 --- a/src/infra/provider-usage.test.ts +++ b/src/infra/provider-usage.test.ts @@ -1,62 +1,36 @@ -import fs from "node:fs"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { withTempHome } from "../../test/helpers/temp-home.js"; -import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js"; -import { withEnvAsync } from "../test-utils/env.js"; -import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { formatUsageReportLines, formatUsageSummaryLine, loadProviderUsageSummary, type UsageSummary, } from "./provider-usage.js"; -import { loadUsageWithAuth, usageNow } from "./provider-usage.test-support.js"; +import { createProviderUsageFetch } from "../test-utils/provider-usage-fetch.js"; +import { loadUsageWithAuth } from "./provider-usage.test-support.js"; -const minimaxRemainsEndpoint = "api.minimaxi.com/v1/api/openplatform/coding_plan/remains"; +const resolveProviderUsageSnapshotWithPluginMock = vi.fn(async () => null); -function expectSingleAnthropicProvider(summary: UsageSummary) { - expect(summary.providers).toHaveLength(1); - const claude = summary.providers[0]; - expect(claude?.provider).toBe("anthropic"); - return claude; -} +vi.mock("../config/config.js", () => ({ + loadConfig: () => ({}), +})); -function createMinimaxOnlyFetch(payload: unknown) { - return createProviderUsageFetch(async (url) => { - if (url.includes(minimaxRemainsEndpoint)) { - return makeResponse(200, payload); - } - return makeResponse(404, "not found"); - }); -} - -async function expectMinimaxUsage( - payload: unknown, - expected: { - usedPercent: number; - plan?: string; - label?: string; - }, -) { - const mockFetch = createMinimaxOnlyFetch(payload); - - const summary = await loadUsageWithAuth( - loadProviderUsageSummary, - [{ provider: "minimax", token: "token-1b" }], - mockFetch, +vi.mock("../plugins/provider-runtime.js", async () => { + const actual = await vi.importActual( + "../plugins/provider-runtime.js", ); - - const minimax = summary.providers.find((p) => p.provider === "minimax"); - expect(minimax?.windows[0]?.usedPercent).toBe(expected.usedPercent); - expect(minimax?.windows[0]?.label).toBe(expected.label ?? "5h"); - if (expected.plan !== undefined) { - expect(minimax?.plan).toBe(expected.plan); - } - expect(mockFetch).toHaveBeenCalled(); -} + return { + ...actual, + resolveProviderUsageSnapshotWithPlugin: (...args: unknown[]) => + resolveProviderUsageSnapshotWithPluginMock(...args), + }; +}); describe("provider usage formatting", () => { + beforeEach(() => { + resolveProviderUsageSnapshotWithPluginMock.mockReset(); + resolveProviderUsageSnapshotWithPluginMock.mockResolvedValue(null); + }); + it("returns null when no usage is available", () => { const summary: UsageSummary = { updatedAt: 0, providers: [] }; expect(formatUsageSummaryLine(summary)).toBeNull(); @@ -117,42 +91,34 @@ describe("provider usage formatting", () => { describe("provider usage loading", () => { it("loads usage snapshots with injected auth", async () => { - const mockFetch = createProviderUsageFetch(async (url) => { - if (url.includes("api.anthropic.com")) { - return makeResponse(200, { - five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" }, - }); + resolveProviderUsageSnapshotWithPluginMock.mockImplementation(async ({ provider }) => { + switch (provider) { + case "anthropic": + return { + provider, + displayName: "Claude", + windows: [{ label: "5h", usedPercent: 20 }], + }; + case "minimax": + return { + provider, + displayName: "MiniMax", + windows: [{ label: "5h", usedPercent: 75 }], + plan: "Coding Plan", + }; + case "zai": + return { + provider, + displayName: "Z.ai", + windows: [{ label: "3h", usedPercent: 25 }], + plan: "Pro", + }; + default: + return null; } - if (url.includes("api.z.ai")) { - return makeResponse(200, { - success: true, - code: 200, - data: { - planName: "Pro", - limits: [ - { - type: "TOKENS_LIMIT", - percentage: 25, - unit: 3, - number: 6, - nextResetTime: "2026-01-07T06:00:00Z", - }, - ], - }, - }); - } - if (url.includes(minimaxRemainsEndpoint)) { - return makeResponse(200, { - base_resp: { status_code: 0, status_msg: "ok" }, - data: { - total: 200, - remain: 50, - reset_at: "2026-01-07T05:00:00Z", - plan_name: "Coding Plan", - }, - }); - } - return makeResponse(404, "not found"); + }); + const mockFetch = createProviderUsageFetch(async () => { + throw new Error("legacy fetch should not run"); }); const summary = await loadUsageWithAuth( @@ -172,179 +138,6 @@ describe("provider usage loading", () => { expect(claude?.windows[0]?.label).toBe("5h"); expect(minimax?.windows[0]?.usedPercent).toBe(75); expect(zai?.plan).toBe("Pro"); - expect(mockFetch).toHaveBeenCalled(); - }); - - it.each([ - { - name: "handles nested MiniMax usage payloads", - payload: { - base_resp: { status_code: 0, status_msg: "ok" }, - data: { - plan_name: "Coding Plan", - usage: { - prompt_limit: 200, - prompt_remain: 50, - next_reset_time: "2026-01-07T05:00:00Z", - }, - }, - }, - expected: { usedPercent: 75, plan: "Coding Plan" }, - }, - { - name: "prefers MiniMax count-based usage when percent looks inverted", - payload: { - base_resp: { status_code: 0, status_msg: "ok" }, - data: { - prompt_limit: 200, - prompt_remain: 150, - usage_percent: 75, - next_reset_time: "2026-01-07T05:00:00Z", - }, - }, - expected: { usedPercent: 25 }, - }, - { - name: "handles MiniMax model_remains usage payloads", - payload: { - base_resp: { status_code: 0, status_msg: "ok" }, - model_remains: [ - { - start_time: 1736217600, - end_time: 1736235600, - remains_time: 600, - current_interval_total_count: 120, - // API field is remaining quota, not consumed (MiniMax-M2#99). - current_interval_usage_count: 30, - model_name: "MiniMax-M2.5", - }, - ], - }, - expected: { usedPercent: 75 }, - }, - { - name: "keeps payload-level MiniMax plan metadata when the usage candidate is nested", - payload: { - base_resp: { status_code: 0, status_msg: "ok" }, - data: { - plan_name: "Payload Plan", - nested: { - usage_ratio: "0.4", - window_hours: 2, - next_reset_time: "2026-01-07T05:00:00Z", - }, - }, - }, - expected: { usedPercent: 40, plan: "Payload Plan", label: "2h" }, - }, - ])("$name", async ({ payload, expected }) => { - await expectMinimaxUsage(payload, expected); - }); - - it("discovers Claude usage from token auth profiles", async () => { - await withTempHome( - async (tempHome) => { - const agentDir = path.join( - process.env.OPENCLAW_STATE_DIR ?? path.join(tempHome, ".openclaw"), - "agents", - "main", - "agent", - ); - fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 }); - fs.writeFileSync( - path.join(agentDir, "auth-profiles.json"), - `${JSON.stringify( - { - version: 1, - order: { anthropic: ["anthropic:default"] }, - profiles: { - "anthropic:default": { - type: "token", - provider: "anthropic", - token: "token-1", - expires: Date.UTC(2100, 0, 1, 0, 0, 0), - }, - }, - }, - null, - 2, - )}\n`, - "utf8", - ); - const store = ensureAuthProfileStore(agentDir, { - allowKeychainPrompt: false, - }); - expect(listProfilesForProvider(store, "anthropic")).toContain("anthropic:default"); - - const mockFetch = createProviderUsageFetch(async (url, init) => { - if (url.includes("api.anthropic.com/api/oauth/usage")) { - const headers = (init?.headers ?? {}) as Record; - expect(headers.Authorization).toBe("Bearer token-1"); - return makeResponse(200, { - five_hour: { - utilization: 20, - resets_at: "2026-01-07T01:00:00Z", - }, - }); - } - return makeResponse(404, "not found"); - }); - - const summary = await loadProviderUsageSummary({ - now: usageNow, - providers: ["anthropic"], - agentDir, - fetch: mockFetch as unknown as typeof fetch, - config: {}, - }); - - const claude = expectSingleAnthropicProvider(summary); - expect(claude?.windows[0]?.label).toBe("5h"); - expect(mockFetch).toHaveBeenCalled(); - }, - { - env: { - OPENCLAW_STATE_DIR: (home) => path.join(home, ".openclaw"), - }, - prefix: "openclaw-provider-usage-", - }, - ); - }); - - it("falls back to claude.ai web usage when OAuth scope is missing", async () => { - await withEnvAsync({ CLAUDE_AI_SESSION_KEY: "sk-ant-web-1" }, async () => { - const mockFetch = createProviderUsageFetch(async (url) => { - if (url.includes("api.anthropic.com/api/oauth/usage")) { - return makeResponse(403, { - type: "error", - error: { - type: "permission_error", - message: "OAuth token does not meet scope requirement user:profile", - }, - }); - } - if (url.includes("claude.ai/api/organizations/org-1/usage")) { - return makeResponse(200, { - five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" }, - seven_day: { utilization: 40, resets_at: "2026-01-08T01:00:00Z" }, - seven_day_opus: { utilization: 5 }, - }); - } - if (url.includes("claude.ai/api/organizations")) { - return makeResponse(200, [{ uuid: "org-1", name: "Test" }]); - } - return makeResponse(404, "not found"); - }); - - const summary = await loadUsageWithAuth( - loadProviderUsageSummary, - [{ provider: "anthropic", token: "sk-ant-oauth-1" }], - mockFetch, - ); - - const claude = expectSingleAnthropicProvider(summary); - expect(claude?.windows.some((w) => w.label === "5h")).toBe(true); - expect(claude?.windows.some((w) => w.label === "Week")).toBe(true); - }); + expect(mockFetch).not.toHaveBeenCalled(); }); });