diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index 66ea8b9ba..9590727e4 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -1,5 +1,9 @@ import type { PluginRuntime } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { downloadMSTeamsAttachments } from "./attachments/download.js"; +import { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js"; +import { buildMSTeamsAttachmentPlaceholder } from "./attachments/html.js"; +import { buildMSTeamsMediaPayload } from "./attachments/payload.js"; import { setMSTeamsRuntime } from "./runtime.js"; /** Mock DNS resolver that always returns a public IP (for anti-SSRF validation in tests). */ @@ -49,10 +53,6 @@ const runtimeStub = { } as unknown as PluginRuntime; describe("msteams attachments", () => { - const load = async () => { - return await import("./attachments.js"); - }; - beforeEach(() => { detectMimeMock.mockClear(); saveMediaBufferMock.mockClear(); @@ -62,13 +62,11 @@ describe("msteams attachments", () => { describe("buildMSTeamsAttachmentPlaceholder", () => { it("returns empty string when no attachments", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); expect(buildMSTeamsAttachmentPlaceholder(undefined)).toBe(""); expect(buildMSTeamsAttachmentPlaceholder([])).toBe(""); }); it("returns image placeholder for image attachments", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); expect( buildMSTeamsAttachmentPlaceholder([ { contentType: "image/png", contentUrl: "https://x/img.png" }, @@ -83,7 +81,6 @@ describe("msteams attachments", () => { }); it("treats Teams file.download.info image attachments as images", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); expect( buildMSTeamsAttachmentPlaceholder([ { @@ -95,7 +92,6 @@ describe("msteams attachments", () => { }); it("returns document placeholder for non-image attachments", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); expect( buildMSTeamsAttachmentPlaceholder([ { contentType: "application/pdf", contentUrl: "https://x/x.pdf" }, @@ -110,7 +106,6 @@ describe("msteams attachments", () => { }); it("counts inline images in text/html attachments", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); expect( buildMSTeamsAttachmentPlaceholder([ { @@ -132,7 +127,6 @@ describe("msteams attachments", () => { describe("downloadMSTeamsAttachments", () => { it("downloads and stores image contentUrl attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(async () => { return new Response(Buffer.from("png"), { status: 200, @@ -155,7 +149,6 @@ describe("msteams attachments", () => { }); it("supports Teams file.download.info downloadUrl attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(async () => { return new Response(Buffer.from("png"), { status: 200, @@ -181,7 +174,6 @@ describe("msteams attachments", () => { }); it("downloads non-image file attachments (PDF)", async () => { - const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(async () => { return new Response(Buffer.from("pdf"), { status: 200, @@ -209,7 +201,6 @@ describe("msteams attachments", () => { }); it("downloads inline image URLs from html attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(async () => { return new Response(Buffer.from("png"), { status: 200, @@ -235,7 +226,6 @@ describe("msteams attachments", () => { }); it("stores inline data:image base64 payloads", async () => { - const { downloadMSTeamsAttachments } = await load(); const base64 = Buffer.from("png").toString("base64"); const media = await downloadMSTeamsAttachments({ attachments: [ @@ -253,7 +243,6 @@ describe("msteams attachments", () => { }); it("retries with auth when the first request is unauthorized", async () => { - const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => { const headers = new Headers(opts?.headers); const hasAuth = Boolean(headers.get("Authorization")); @@ -281,7 +270,6 @@ describe("msteams attachments", () => { }); it("skips auth retries when the host is not in auth allowlist", async () => { - const { downloadMSTeamsAttachments } = await load(); const tokenProvider = { getAccessToken: vi.fn(async () => "token") }; const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => { const headers = new Headers(opts?.headers); @@ -313,7 +301,6 @@ describe("msteams attachments", () => { }); it("skips urls outside the allowlist", async () => { - const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(); const media = await downloadMSTeamsAttachments({ attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }], @@ -329,7 +316,6 @@ describe("msteams attachments", () => { describe("buildMSTeamsGraphMessageUrls", () => { it("builds channel message urls", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); const urls = buildMSTeamsGraphMessageUrls({ conversationType: "channel", conversationId: "19:thread@thread.tacv2", @@ -340,7 +326,6 @@ describe("msteams attachments", () => { }); it("builds channel reply urls when replyToId is present", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); const urls = buildMSTeamsGraphMessageUrls({ conversationType: "channel", messageId: "reply-id", @@ -353,7 +338,6 @@ describe("msteams attachments", () => { }); it("builds chat message urls", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); const urls = buildMSTeamsGraphMessageUrls({ conversationType: "groupChat", conversationId: "19:chat@thread.v2", @@ -365,7 +349,6 @@ describe("msteams attachments", () => { describe("downloadMSTeamsGraphMedia", () => { it("downloads hostedContents images", async () => { - const { downloadMSTeamsGraphMedia } = await load(); const base64 = Buffer.from("png").toString("base64"); const fetchMock = vi.fn(async (url: string) => { if (url.endsWith("/hostedContents")) { @@ -401,7 +384,6 @@ describe("msteams attachments", () => { }); it("merges SharePoint reference attachments with hosted content", async () => { - const { downloadMSTeamsGraphMedia } = await load(); const hostedBase64 = Buffer.from("png").toString("base64"); const shareUrl = "https://contoso.sharepoint.com/site/file"; const fetchMock = vi.fn(async (url: string) => { @@ -469,7 +451,6 @@ describe("msteams attachments", () => { }); it("blocks SharePoint redirects to hosts outside allowHosts", async () => { - const { downloadMSTeamsGraphMedia } = await load(); const shareUrl = "https://contoso.sharepoint.com/site/file"; const escapedUrl = "https://evil.example/internal.pdf"; fetchRemoteMediaMock.mockImplementationOnce(async (params) => { @@ -553,7 +534,6 @@ describe("msteams attachments", () => { describe("buildMSTeamsMediaPayload", () => { it("returns single and multi-file fields", async () => { - const { buildMSTeamsMediaPayload } = await load(); const payload = buildMSTeamsMediaPayload([ { path: "/tmp/a.png", contentType: "image/png" }, { path: "/tmp/b.png", contentType: "image/png" }, diff --git a/src/agents/live-auth-keys.test.ts b/src/agents/live-auth-keys.test.ts deleted file mode 100644 index 4c8895982..000000000 --- a/src/agents/live-auth-keys.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isAnthropicBillingError } from "./live-auth-keys.js"; - -describe("isAnthropicBillingError", () => { - it("does not false-positive on plain 'a 402' prose", () => { - const samples = [ - "Use a 402 stainless bolt", - "Book a 402 room", - "There is a 402 near me", - "The building at 402 Main Street", - ]; - - for (const sample of samples) { - expect(isAnthropicBillingError(sample)).toBe(false); - } - }); - - it("matches real 402 billing payload contexts including JSON keys", () => { - const samples = [ - "HTTP 402 Payment Required", - "status: 402", - "error code 402", - '{"status":402,"type":"error"}', - '{"code":402,"message":"payment required"}', - '{"error":{"code":402,"message":"billing hard limit reached"}}', - "got a 402 from the API", - "returned 402", - "received a 402 response", - ]; - - for (const sample of samples) { - expect(isAnthropicBillingError(sample)).toBe(true); - } - }); -}); diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index d6f3066ae..071f9cc92 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -2,6 +2,8 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; import { isModernModelRef } from "./live-model-filter.js"; import { normalizeModelCompat } from "./model-compat.js"; +import { resolveForwardCompatModel } from "./model-forward-compat.js"; +import type { ModelRegistry } from "./pi-model-discovery.js"; const baseModel = (): Model => ({ @@ -17,6 +19,28 @@ const baseModel = (): Model => maxTokens: 1024, }) as Model; +function createTemplateModel(provider: string, id: string): Model { + return { + id, + name: id, + provider, + api: "anthropic-messages", + input: ["text"], + reasoning: true, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + } as Model; +} + +function createRegistry(models: Record>): ModelRegistry { + return { + find(provider: string, modelId: string) { + return models[`${provider}/${modelId}`] ?? null; + }, + } as ModelRegistry; +} + describe("normalizeModelCompat", () => { it("forces supportsDeveloperRole off for z.ai models", () => { const model = baseModel(); @@ -59,3 +83,36 @@ describe("isModernModelRef", () => { expect(isModernModelRef({ provider: "opencode", id: "gemini-3-pro" })).toBe(true); }); }); + +describe("resolveForwardCompatModel", () => { + it("resolves anthropic opus 4.6 via 4.5 template", () => { + const registry = createRegistry({ + "anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"), + }); + const model = resolveForwardCompatModel("anthropic", "claude-opus-4-6", registry); + expect(model?.id).toBe("claude-opus-4-6"); + expect(model?.name).toBe("claude-opus-4-6"); + expect(model?.provider).toBe("anthropic"); + }); + + it("resolves anthropic sonnet 4.6 dot variant with suffix", () => { + const registry = createRegistry({ + "anthropic/claude-sonnet-4.5-20260219": createTemplateModel( + "anthropic", + "claude-sonnet-4.5-20260219", + ), + }); + const model = resolveForwardCompatModel("anthropic", "claude-sonnet-4.6-20260219", registry); + expect(model?.id).toBe("claude-sonnet-4.6-20260219"); + expect(model?.name).toBe("claude-sonnet-4.6-20260219"); + expect(model?.provider).toBe("anthropic"); + }); + + it("does not resolve anthropic 4.6 fallback for other providers", () => { + const registry = createRegistry({ + "anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"), + }); + const model = resolveForwardCompatModel("openai", "claude-opus-4-6", registry); + expect(model).toBeUndefined(); + }); +}); diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index add5560ea..75fca258e 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { AuthProfileStore } from "./auth-profiles.js"; import { saveAuthProfileStore } from "./auth-profiles.js"; import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; +import { isAnthropicBillingError } from "./live-auth-keys.js"; import { runWithModelFallback } from "./model-fallback.js"; import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js"; @@ -656,3 +657,36 @@ describe("runWithModelFallback", () => { expect(result.model).toBe("gpt-4.1-mini"); }); }); + +describe("isAnthropicBillingError", () => { + it("does not false-positive on plain 'a 402' prose", () => { + const samples = [ + "Use a 402 stainless bolt", + "Book a 402 room", + "There is a 402 near me", + "The building at 402 Main Street", + ]; + + for (const sample of samples) { + expect(isAnthropicBillingError(sample)).toBe(false); + } + }); + + it("matches real 402 billing payload contexts including JSON keys", () => { + const samples = [ + "HTTP 402 Payment Required", + "status: 402", + "error code 402", + '{"status":402,"type":"error"}', + '{"code":402,"message":"payment required"}', + '{"error":{"code":402,"message":"billing hard limit reached"}}', + "got a 402 from the API", + "returned 402", + "received a 402 response", + ]; + + for (const sample of samples) { + expect(isAnthropicBillingError(sample)).toBe(true); + } + }); +}); diff --git a/src/agents/model-forward-compat.test.ts b/src/agents/model-forward-compat.test.ts deleted file mode 100644 index b2017213e..000000000 --- a/src/agents/model-forward-compat.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Api, Model } from "@mariozechner/pi-ai"; -import { describe, expect, it } from "vitest"; -import { resolveForwardCompatModel } from "./model-forward-compat.js"; -import type { ModelRegistry } from "./pi-model-discovery.js"; - -function createTemplateModel(provider: string, id: string): Model { - return { - id, - name: id, - provider, - api: "anthropic-messages", - input: ["text"], - reasoning: true, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200_000, - maxTokens: 8_192, - } as Model; -} - -function createRegistry(models: Record>): ModelRegistry { - return { - find(provider: string, modelId: string) { - return models[`${provider}/${modelId}`] ?? null; - }, - } as ModelRegistry; -} - -describe("agents/model-forward-compat", () => { - it("resolves anthropic opus 4.6 via 4.5 template", () => { - const registry = createRegistry({ - "anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"), - }); - const model = resolveForwardCompatModel("anthropic", "claude-opus-4-6", registry); - expect(model?.id).toBe("claude-opus-4-6"); - expect(model?.name).toBe("claude-opus-4-6"); - expect(model?.provider).toBe("anthropic"); - }); - - it("resolves anthropic sonnet 4.6 dot variant with suffix", () => { - const registry = createRegistry({ - "anthropic/claude-sonnet-4.5-20260219": createTemplateModel( - "anthropic", - "claude-sonnet-4.5-20260219", - ), - }); - const model = resolveForwardCompatModel("anthropic", "claude-sonnet-4.6-20260219", registry); - expect(model?.id).toBe("claude-sonnet-4.6-20260219"); - expect(model?.name).toBe("claude-sonnet-4.6-20260219"); - expect(model?.provider).toBe("anthropic"); - }); - - it("does not resolve anthropic 4.6 fallback for other providers", () => { - const registry = createRegistry({ - "anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"), - }); - const model = resolveForwardCompatModel("openai", "claude-opus-4-6", registry); - expect(model).toBeUndefined(); - }); -}); diff --git a/src/agents/tools/web-fetch.response-limit.test.ts b/src/agents/tools/web-fetch.response-limit.test.ts deleted file mode 100644 index 9b246b8a6..000000000 --- a/src/agents/tools/web-fetch.response-limit.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; -import { - createBaseWebFetchToolConfig, - installWebFetchSsrfHarness, -} from "./web-fetch.test-harness.js"; -import "./web-fetch.test-mocks.js"; -import { createWebFetchTool } from "./web-tools.js"; - -const baseToolConfig = createBaseWebFetchToolConfig({ maxResponseBytes: 1024 }); -installWebFetchSsrfHarness(); - -describe("web_fetch response size limits", () => { - it("caps response bytes and does not hang on endless streams", async () => { - const chunk = new TextEncoder().encode("
hi
"); - const stream = new ReadableStream({ - pull(controller) { - controller.enqueue(chunk); - }, - }); - const response = new Response(stream, { - status: 200, - headers: { "content-type": "text/html; charset=utf-8" }, - }); - - const fetchSpy = vi.fn().mockResolvedValue(response); - global.fetch = withFetchPreconnect(fetchSpy); - - const tool = createWebFetchTool(baseToolConfig); - const result = await tool?.execute?.("call", { url: "https://example.com/stream" }); - const details = result?.details as { warning?: string } | undefined; - expect(details?.warning).toContain("Response body truncated"); - }); -}); diff --git a/src/agents/tools/web-tools.fetch.test.ts b/src/agents/tools/web-tools.fetch.test.ts index 3d65120b5..df82a2ae2 100644 --- a/src/agents/tools/web-tools.fetch.test.ts +++ b/src/agents/tools/web-tools.fetch.test.ts @@ -233,6 +233,29 @@ describe("web_fetch extraction fallbacks", () => { expect(details.truncated).toBe(true); }); + it("caps response bytes and does not hang on endless streams", async () => { + const chunk = new TextEncoder().encode("
hi
"); + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(chunk); + }, + }); + const response = new Response(stream, { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + const fetchSpy = vi.fn().mockResolvedValue(response); + global.fetch = withFetchPreconnect(fetchSpy); + + const tool = createFetchTool({ + maxResponseBytes: 1024, + firecrawl: { enabled: false }, + }); + const result = await tool?.execute?.("call", { url: "https://example.com/stream" }); + const details = result?.details as { warning?: string } | undefined; + expect(details?.warning).toContain("Response body truncated"); + }); + // NOTE: Test for wrapping url/finalUrl/warning fields requires DNS mocking. // The sanitization of these fields is verified by external-content.test.ts tests.