diff --git a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.e2e.test.ts b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.e2e.test.ts index 9cd60cb59e0..e4e852e69ef 100644 --- a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.e2e.test.ts +++ b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.e2e.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; import { buildBootstrapContextFiles, DEFAULT_BOOTSTRAP_MAX_CHARS, DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS, + resolveBootstrapMaxChars, + resolveBootstrapTotalMaxChars, } from "./pi-embedded-helpers.js"; import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; @@ -100,3 +103,39 @@ describe("buildBootstrapContextFiles", () => { expect(result[0]?.content.startsWith("[MISSING]")).toBe(true); }); }); + +describe("resolveBootstrapMaxChars", () => { + it("returns default when unset", () => { + expect(resolveBootstrapMaxChars()).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS); + }); + it("uses configured value when valid", () => { + const cfg = { + agents: { defaults: { bootstrapMaxChars: 12345 } }, + } as OpenClawConfig; + expect(resolveBootstrapMaxChars(cfg)).toBe(12345); + }); + it("falls back when invalid", () => { + const cfg = { + agents: { defaults: { bootstrapMaxChars: -1 } }, + } as OpenClawConfig; + expect(resolveBootstrapMaxChars(cfg)).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS); + }); +}); + +describe("resolveBootstrapTotalMaxChars", () => { + it("returns default when unset", () => { + expect(resolveBootstrapTotalMaxChars()).toBe(DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS); + }); + it("uses configured value when valid", () => { + const cfg = { + agents: { defaults: { bootstrapTotalMaxChars: 12345 } }, + } as OpenClawConfig; + expect(resolveBootstrapTotalMaxChars(cfg)).toBe(12345); + }); + it("falls back when invalid", () => { + const cfg = { + agents: { defaults: { bootstrapTotalMaxChars: -1 } }, + } as OpenClawConfig; + expect(resolveBootstrapTotalMaxChars(cfg)).toBe(DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS); + }); +}); diff --git a/src/agents/pi-embedded-helpers.classifyfailoverreason.e2e.test.ts b/src/agents/pi-embedded-helpers.classifyfailoverreason.e2e.test.ts deleted file mode 100644 index daf9d9cf586..00000000000 --- a/src/agents/pi-embedded-helpers.classifyfailoverreason.e2e.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { classifyFailoverReason } from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; - -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, -}); -describe("classifyFailoverReason", () => { - it("returns a stable reason", () => { - expect(classifyFailoverReason("invalid api key")).toBe("auth"); - expect(classifyFailoverReason("no credentials found")).toBe("auth"); - expect(classifyFailoverReason("no api key found")).toBe("auth"); - expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit"); - expect(classifyFailoverReason("resource has been exhausted")).toBe("rate_limit"); - expect( - classifyFailoverReason( - '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', - ), - ).toBe("rate_limit"); - expect(classifyFailoverReason("invalid request format")).toBe("format"); - expect(classifyFailoverReason("credit balance too low")).toBe("billing"); - expect(classifyFailoverReason("deadline exceeded")).toBe("timeout"); - expect(classifyFailoverReason("request ended without sending any chunks")).toBe("timeout"); - expect( - classifyFailoverReason( - "521 Web server is downCloudflare", - ), - ).toBe("timeout"); - expect(classifyFailoverReason("string should match pattern")).toBe("format"); - expect(classifyFailoverReason("bad request")).toBeNull(); - expect( - classifyFailoverReason( - "messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels", - ), - ).toBeNull(); - expect(classifyFailoverReason("image exceeds 5 MB maximum")).toBeNull(); - }); - it("classifies OpenAI usage limit errors as rate_limit", () => { - expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe( - "rate_limit", - ); - }); -}); diff --git a/src/agents/pi-embedded-helpers.downgradeopenai-reasoning.e2e.test.ts b/src/agents/pi-embedded-helpers.downgradeopenai-reasoning.e2e.test.ts deleted file mode 100644 index ee156e5a70a..00000000000 --- a/src/agents/pi-embedded-helpers.downgradeopenai-reasoning.e2e.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { downgradeOpenAIReasoningBlocks } from "./pi-embedded-helpers.js"; - -describe("downgradeOpenAIReasoningBlocks", () => { - it("keeps reasoning signatures when followed by content", () => { - const input = [ - { - role: "assistant", - content: [ - { - type: "thinking", - thinking: "internal reasoning", - thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }), - }, - { type: "text", text: "answer" }, - ], - }, - ]; - - // oxlint-disable-next-line typescript/no-explicit-any - expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input); - }); - - it("drops orphaned reasoning blocks without following content", () => { - const input = [ - { - role: "assistant", - content: [ - { - type: "thinking", - thinkingSignature: JSON.stringify({ id: "rs_abc", type: "reasoning" }), - }, - ], - }, - { role: "user", content: "next" }, - ]; - - // oxlint-disable-next-line typescript/no-explicit-any - expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([ - { role: "user", content: "next" }, - ]); - }); - - it("drops object-form orphaned signatures", () => { - const input = [ - { - role: "assistant", - content: [ - { - type: "thinking", - thinkingSignature: { id: "rs_obj", type: "reasoning" }, - }, - ], - }, - ]; - - // oxlint-disable-next-line typescript/no-explicit-any - expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([]); - }); - - it("keeps non-reasoning thinking signatures", () => { - const input = [ - { - role: "assistant", - content: [ - { - type: "thinking", - thinking: "t", - thinkingSignature: "reasoning_content", - }, - ], - }, - ]; - - // oxlint-disable-next-line typescript/no-explicit-any - expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input); - }); -}); diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.e2e.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.e2e.test.ts index 9ba67b6a147..c563ac948f3 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.e2e.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.e2e.test.ts @@ -4,6 +4,7 @@ import { BILLING_ERROR_USER_MESSAGE, formatBillingErrorMessage, formatAssistantErrorText, + formatRawAssistantErrorForUi, } from "./pi-embedded-helpers.js"; describe("formatAssistantErrorText", () => { @@ -114,3 +115,38 @@ describe("formatAssistantErrorText", () => { expect(formatAssistantErrorText(msg)).toBe("LLM request timed out."); }); }); + +describe("formatRawAssistantErrorForUi", () => { + it("renders HTTP code + type + message from Anthropic payloads", () => { + const text = formatRawAssistantErrorForUi( + '429 {"type":"error","error":{"type":"rate_limit_error","message":"Rate limited."},"request_id":"req_123"}', + ); + + expect(text).toContain("HTTP 429"); + expect(text).toContain("rate_limit_error"); + expect(text).toContain("Rate limited."); + expect(text).toContain("req_123"); + }); + + it("renders a generic unknown error message when raw is empty", () => { + expect(formatRawAssistantErrorForUi("")).toContain("unknown error"); + }); + + it("formats plain HTTP status lines", () => { + expect(formatRawAssistantErrorForUi("500 Internal Server Error")).toBe( + "HTTP 500: Internal Server Error", + ); + }); + + it("sanitizes HTML error pages into a clean unavailable message", () => { + const htmlError = `521 + + Web server is down | example.com | Cloudflare + Ray ID: abc123 +`; + + expect(formatRawAssistantErrorForUi(htmlError)).toBe( + "The AI service is temporarily unavailable (HTTP 521). Please try again in a moment.", + ); + }); +}); diff --git a/src/agents/pi-embedded-helpers.formatrawassistanterrorforui.e2e.test.ts b/src/agents/pi-embedded-helpers.formatrawassistanterrorforui.e2e.test.ts deleted file mode 100644 index 8fd0ed1aff8..00000000000 --- a/src/agents/pi-embedded-helpers.formatrawassistanterrorforui.e2e.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { formatRawAssistantErrorForUi } from "./pi-embedded-helpers.js"; - -describe("formatRawAssistantErrorForUi", () => { - it("renders HTTP code + type + message from Anthropic payloads", () => { - const text = formatRawAssistantErrorForUi( - '429 {"type":"error","error":{"type":"rate_limit_error","message":"Rate limited."},"request_id":"req_123"}', - ); - - expect(text).toContain("HTTP 429"); - expect(text).toContain("rate_limit_error"); - expect(text).toContain("Rate limited."); - expect(text).toContain("req_123"); - }); - - it("renders a generic unknown error message when raw is empty", () => { - expect(formatRawAssistantErrorForUi("")).toContain("unknown error"); - }); - - it("formats plain HTTP status lines", () => { - expect(formatRawAssistantErrorForUi("500 Internal Server Error")).toBe( - "HTTP 500: Internal Server Error", - ); - }); - - it("sanitizes HTML error pages into a clean unavailable message", () => { - const htmlError = `521 - - Web server is down | example.com | Cloudflare - Ray ID: abc123 -`; - - expect(formatRawAssistantErrorForUi(htmlError)).toBe( - "The AI service is temporarily unavailable (HTTP 521). Please try again in a moment.", - ); - }); -}); diff --git a/src/agents/pi-embedded-helpers.image-dimension-error.e2e.test.ts b/src/agents/pi-embedded-helpers.image-dimension-error.e2e.test.ts deleted file mode 100644 index 2c92ed68125..00000000000 --- a/src/agents/pi-embedded-helpers.image-dimension-error.e2e.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isImageDimensionErrorMessage, parseImageDimensionError } from "./pi-embedded-helpers.js"; - -describe("image dimension errors", () => { - it("parses anthropic image dimension errors", () => { - const raw = - '400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}'; - const parsed = parseImageDimensionError(raw); - expect(parsed).not.toBeNull(); - expect(parsed?.maxDimensionPx).toBe(2000); - expect(parsed?.messageIndex).toBe(84); - expect(parsed?.contentIndex).toBe(1); - expect(isImageDimensionErrorMessage(raw)).toBe(true); - }); -}); diff --git a/src/agents/pi-embedded-helpers.image-size-error.e2e.test.ts b/src/agents/pi-embedded-helpers.image-size-error.e2e.test.ts deleted file mode 100644 index d69a3c381ae..00000000000 --- a/src/agents/pi-embedded-helpers.image-size-error.e2e.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseImageSizeError } from "./pi-embedded-helpers.js"; - -describe("parseImageSizeError", () => { - it("parses max MB values from error text", () => { - expect(parseImageSizeError("image exceeds 5 MB maximum")?.maxMb).toBe(5); - expect(parseImageSizeError("Image exceeds 5.5 MB limit")?.maxMb).toBe(5.5); - }); - - it("returns null for unrelated errors", () => { - expect(parseImageSizeError("context overflow")).toBeNull(); - }); -}); diff --git a/src/agents/pi-embedded-helpers.isautherrormessage.e2e.test.ts b/src/agents/pi-embedded-helpers.isautherrormessage.e2e.test.ts deleted file mode 100644 index 2c8fd65d099..00000000000 --- a/src/agents/pi-embedded-helpers.isautherrormessage.e2e.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isAuthErrorMessage } from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; - -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, -}); -describe("isAuthErrorMessage", () => { - it("matches credential validation errors", () => { - const samples = [ - 'No credentials found for profile "anthropic:default".', - "No API key found for profile openai.", - ]; - for (const sample of samples) { - expect(isAuthErrorMessage(sample)).toBe(true); - } - }); - it("matches OAuth refresh failures", () => { - const samples = [ - "OAuth token refresh failed for anthropic: Failed to refresh OAuth token for anthropic. Please try again or re-authenticate.", - "Please re-authenticate to continue.", - ]; - for (const sample of samples) { - expect(isAuthErrorMessage(sample)).toBe(true); - } - }); - it("ignores unrelated errors", () => { - expect(isAuthErrorMessage("rate limit exceeded")).toBe(false); - expect(isAuthErrorMessage("billing issue detected")).toBe(false); - }); -}); diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.e2e.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.e2e.test.ts index 69b04e8bb37..4f72364de1f 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.e2e.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.e2e.test.ts @@ -1,14 +1,45 @@ import { describe, expect, it } from "vitest"; -import { isBillingErrorMessage } from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; +import { + classifyFailoverReason, + isAuthErrorMessage, + isBillingErrorMessage, + isCloudCodeAssistFormatError, + isCloudflareOrHtmlErrorPage, + isCompactionFailureError, + isContextOverflowError, + isFailoverErrorMessage, + isImageDimensionErrorMessage, + isLikelyContextOverflowError, + isTransientHttpError, + parseImageDimensionError, + parseImageSizeError, +} from "./pi-embedded-helpers.js"; -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, +describe("isAuthErrorMessage", () => { + it("matches credential validation errors", () => { + const samples = [ + 'No credentials found for profile "anthropic:default".', + "No API key found for profile openai.", + ]; + for (const sample of samples) { + expect(isAuthErrorMessage(sample)).toBe(true); + } + }); + it("matches OAuth refresh failures", () => { + const samples = [ + "OAuth token refresh failed for anthropic: Failed to refresh OAuth token for anthropic. Please try again or re-authenticate.", + "Please re-authenticate to continue.", + ]; + for (const sample of samples) { + expect(isAuthErrorMessage(sample)).toBe(true); + } + }); + it("ignores unrelated errors", () => { + expect(isAuthErrorMessage("rate limit exceeded")).toBe(false); + expect(isAuthErrorMessage("billing issue detected")).toBe(false); + }); }); + describe("isBillingErrorMessage", () => { it("matches credit / payment failures", () => { const samples = [ @@ -65,3 +96,255 @@ describe("isBillingErrorMessage", () => { } }); }); + +describe("isCloudCodeAssistFormatError", () => { + it("matches format errors", () => { + const samples = [ + "INVALID_REQUEST_ERROR: string should match pattern", + "messages.1.content.1.tool_use.id", + "tool_use.id should match pattern", + "invalid request format", + ]; + for (const sample of samples) { + expect(isCloudCodeAssistFormatError(sample)).toBe(true); + } + }); + it("ignores unrelated errors", () => { + expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false); + expect( + isCloudCodeAssistFormatError( + '400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}', + ), + ).toBe(false); + }); +}); + +describe("isCloudflareOrHtmlErrorPage", () => { + it("detects Cloudflare 521 HTML pages", () => { + const htmlError = `521 + + Web server is down | example.com | Cloudflare +

Web server is down

+`; + + expect(isCloudflareOrHtmlErrorPage(htmlError)).toBe(true); + }); + + it("detects generic 5xx HTML pages", () => { + const htmlError = `503 Service Unavailabledown`; + expect(isCloudflareOrHtmlErrorPage(htmlError)).toBe(true); + }); + + it("does not flag non-HTML status lines", () => { + expect(isCloudflareOrHtmlErrorPage("500 Internal Server Error")).toBe(false); + expect(isCloudflareOrHtmlErrorPage("429 Too Many Requests")).toBe(false); + }); + + it("does not flag quoted HTML without a closing html tag", () => { + const plainTextWithHtmlPrefix = "500 upstream responded with partial HTML text"; + expect(isCloudflareOrHtmlErrorPage(plainTextWithHtmlPrefix)).toBe(false); + }); +}); + +describe("isCompactionFailureError", () => { + it("matches compaction overflow failures", () => { + const samples = [ + 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + "auto-compaction failed due to context overflow", + "Compaction failed: prompt is too long", + "Summarization failed: context window exceeded for this request", + ]; + for (const sample of samples) { + expect(isCompactionFailureError(sample)).toBe(true); + } + }); + it("ignores non-compaction overflow errors", () => { + expect(isCompactionFailureError("Context overflow: prompt too large")).toBe(false); + expect(isCompactionFailureError("rate limit exceeded")).toBe(false); + }); +}); + +describe("isContextOverflowError", () => { + it("matches known overflow hints", () => { + const samples = [ + "request_too_large", + "Request exceeds the maximum size", + "context length exceeded", + "Maximum context length", + "prompt is too long: 208423 tokens > 200000 maximum", + "Context overflow: Summarization failed", + "413 Request Entity Too Large", + ]; + for (const sample of samples) { + expect(isContextOverflowError(sample)).toBe(true); + } + }); + + it("matches Anthropic 'Request size exceeds model context window' error", () => { + // Anthropic returns this error format when the prompt exceeds the context window. + // Without this fix, auto-compaction is NOT triggered because neither + // isContextOverflowError nor pi-ai's isContextOverflow recognizes this pattern. + // The user sees: "LLM request rejected: Request size exceeds model context window" + // instead of automatic compaction + retry. + const anthropicRawError = + '{"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}'; + expect(isContextOverflowError(anthropicRawError)).toBe(true); + }); + + it("matches 'exceeds model context window' in various formats", () => { + const samples = [ + "Request size exceeds model context window", + "request size exceeds model context window", + '400 {"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}', + "The request size exceeds model context window limit", + ]; + for (const sample of samples) { + expect(isContextOverflowError(sample)).toBe(true); + } + }); + + it("ignores unrelated errors", () => { + expect(isContextOverflowError("rate limit exceeded")).toBe(false); + expect(isContextOverflowError("request size exceeds upload limit")).toBe(false); + expect(isContextOverflowError("model not found")).toBe(false); + expect(isContextOverflowError("authentication failed")).toBe(false); + }); + + it("ignores normal conversation text mentioning context overflow", () => { + // These are legitimate conversation snippets, not error messages + expect(isContextOverflowError("Let's investigate the context overflow bug")).toBe(false); + expect(isContextOverflowError("The mystery context overflow errors are strange")).toBe(false); + expect(isContextOverflowError("We're debugging context overflow issues")).toBe(false); + expect(isContextOverflowError("Something is causing context overflow messages")).toBe(false); + }); +}); + +describe("isLikelyContextOverflowError", () => { + it("matches context overflow hints", () => { + const samples = [ + "Model context window is 128k tokens, you requested 256k tokens", + "Context window exceeded: requested 12000 tokens", + "Prompt too large for this model", + ]; + for (const sample of samples) { + expect(isLikelyContextOverflowError(sample)).toBe(true); + } + }); + + it("excludes context window too small errors", () => { + const samples = [ + "Model context window too small (minimum is 128k tokens)", + "Context window too small: minimum is 1000 tokens", + ]; + for (const sample of samples) { + expect(isLikelyContextOverflowError(sample)).toBe(false); + } + }); + + it("excludes rate limit errors that match the broad hint regex", () => { + const samples = [ + "request reached organization TPD rate limit, current: 1506556, limit: 1500000", + "rate limit exceeded", + "too many requests", + "429 Too Many Requests", + "exceeded your current quota", + "This request would exceed your account's rate limit", + "429 Too Many Requests: request exceeds rate limit", + ]; + for (const sample of samples) { + expect(isLikelyContextOverflowError(sample)).toBe(false); + } + }); +}); + +describe("isTransientHttpError", () => { + it("returns true for retryable 5xx status codes", () => { + expect(isTransientHttpError("500 Internal Server Error")).toBe(true); + expect(isTransientHttpError("502 Bad Gateway")).toBe(true); + expect(isTransientHttpError("503 Service Unavailable")).toBe(true); + expect(isTransientHttpError("521 ")).toBe(true); + expect(isTransientHttpError("529 Overloaded")).toBe(true); + }); + + it("returns false for non-retryable or non-http text", () => { + expect(isTransientHttpError("504 Gateway Timeout")).toBe(false); + expect(isTransientHttpError("429 Too Many Requests")).toBe(false); + expect(isTransientHttpError("network timeout")).toBe(false); + }); +}); + +describe("isFailoverErrorMessage", () => { + it("matches auth/rate/billing/timeout", () => { + const samples = [ + "invalid api key", + "429 rate limit exceeded", + "Your credit balance is too low", + "request timed out", + "invalid request format", + ]; + for (const sample of samples) { + expect(isFailoverErrorMessage(sample)).toBe(true); + } + }); +}); + +describe("parseImageSizeError", () => { + it("parses max MB values from error text", () => { + expect(parseImageSizeError("image exceeds 5 MB maximum")?.maxMb).toBe(5); + expect(parseImageSizeError("Image exceeds 5.5 MB limit")?.maxMb).toBe(5.5); + }); + + it("returns null for unrelated errors", () => { + expect(parseImageSizeError("context overflow")).toBeNull(); + }); +}); + +describe("image dimension errors", () => { + it("parses anthropic image dimension errors", () => { + const raw = + '400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}'; + const parsed = parseImageDimensionError(raw); + expect(parsed).not.toBeNull(); + expect(parsed?.maxDimensionPx).toBe(2000); + expect(parsed?.messageIndex).toBe(84); + expect(parsed?.contentIndex).toBe(1); + expect(isImageDimensionErrorMessage(raw)).toBe(true); + }); +}); + +describe("classifyFailoverReason", () => { + it("returns a stable reason", () => { + expect(classifyFailoverReason("invalid api key")).toBe("auth"); + expect(classifyFailoverReason("no credentials found")).toBe("auth"); + expect(classifyFailoverReason("no api key found")).toBe("auth"); + expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit"); + expect(classifyFailoverReason("resource has been exhausted")).toBe("rate_limit"); + expect( + classifyFailoverReason( + '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', + ), + ).toBe("rate_limit"); + expect(classifyFailoverReason("invalid request format")).toBe("format"); + expect(classifyFailoverReason("credit balance too low")).toBe("billing"); + expect(classifyFailoverReason("deadline exceeded")).toBe("timeout"); + expect(classifyFailoverReason("request ended without sending any chunks")).toBe("timeout"); + expect( + classifyFailoverReason( + "521 Web server is downCloudflare", + ), + ).toBe("timeout"); + expect(classifyFailoverReason("string should match pattern")).toBe("format"); + expect(classifyFailoverReason("bad request")).toBeNull(); + expect( + classifyFailoverReason( + "messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels", + ), + ).toBeNull(); + expect(classifyFailoverReason("image exceeds 5 MB maximum")).toBeNull(); + }); + it("classifies OpenAI usage limit errors as rate_limit", () => { + expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe( + "rate_limit", + ); + }); +}); diff --git a/src/agents/pi-embedded-helpers.iscloudcodeassistformaterror.e2e.test.ts b/src/agents/pi-embedded-helpers.iscloudcodeassistformaterror.e2e.test.ts deleted file mode 100644 index 2433642e46d..00000000000 --- a/src/agents/pi-embedded-helpers.iscloudcodeassistformaterror.e2e.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isCloudCodeAssistFormatError } from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; - -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, -}); -describe("isCloudCodeAssistFormatError", () => { - it("matches format errors", () => { - const samples = [ - "INVALID_REQUEST_ERROR: string should match pattern", - "messages.1.content.1.tool_use.id", - "tool_use.id should match pattern", - "invalid request format", - ]; - for (const sample of samples) { - expect(isCloudCodeAssistFormatError(sample)).toBe(true); - } - }); - it("ignores unrelated errors", () => { - expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false); - expect( - isCloudCodeAssistFormatError( - '400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}', - ), - ).toBe(false); - }); -}); diff --git a/src/agents/pi-embedded-helpers.iscloudflareorhtmlerrorpage.e2e.test.ts b/src/agents/pi-embedded-helpers.iscloudflareorhtmlerrorpage.e2e.test.ts deleted file mode 100644 index ebdb22c6c5d..00000000000 --- a/src/agents/pi-embedded-helpers.iscloudflareorhtmlerrorpage.e2e.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isCloudflareOrHtmlErrorPage } from "./pi-embedded-helpers.js"; - -describe("isCloudflareOrHtmlErrorPage", () => { - it("detects Cloudflare 521 HTML pages", () => { - const htmlError = `521 - - Web server is down | example.com | Cloudflare -

Web server is down

-`; - - expect(isCloudflareOrHtmlErrorPage(htmlError)).toBe(true); - }); - - it("detects generic 5xx HTML pages", () => { - const htmlError = `503 Service Unavailabledown`; - expect(isCloudflareOrHtmlErrorPage(htmlError)).toBe(true); - }); - - it("does not flag non-HTML status lines", () => { - expect(isCloudflareOrHtmlErrorPage("500 Internal Server Error")).toBe(false); - expect(isCloudflareOrHtmlErrorPage("429 Too Many Requests")).toBe(false); - }); - - it("does not flag quoted HTML without a closing html tag", () => { - const plainTextWithHtmlPrefix = "500 upstream responded with partial HTML text"; - expect(isCloudflareOrHtmlErrorPage(plainTextWithHtmlPrefix)).toBe(false); - }); -}); diff --git a/src/agents/pi-embedded-helpers.iscompactionfailureerror.e2e.test.ts b/src/agents/pi-embedded-helpers.iscompactionfailureerror.e2e.test.ts deleted file mode 100644 index 6abcabba5bd..00000000000 --- a/src/agents/pi-embedded-helpers.iscompactionfailureerror.e2e.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isCompactionFailureError } from "./pi-embedded-helpers/errors.js"; -describe("isCompactionFailureError", () => { - it("matches compaction overflow failures", () => { - const samples = [ - 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', - "auto-compaction failed due to context overflow", - "Compaction failed: prompt is too long", - "Summarization failed: context window exceeded for this request", - ]; - for (const sample of samples) { - expect(isCompactionFailureError(sample)).toBe(true); - } - }); - it("ignores non-compaction overflow errors", () => { - expect(isCompactionFailureError("Context overflow: prompt too large")).toBe(false); - expect(isCompactionFailureError("rate limit exceeded")).toBe(false); - }); -}); diff --git a/src/agents/pi-embedded-helpers.iscontextoverflowerror.e2e.test.ts b/src/agents/pi-embedded-helpers.iscontextoverflowerror.e2e.test.ts deleted file mode 100644 index 79a19732640..00000000000 --- a/src/agents/pi-embedded-helpers.iscontextoverflowerror.e2e.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isContextOverflowError } from "./pi-embedded-helpers.js"; - -describe("isContextOverflowError", () => { - it("matches known overflow hints", () => { - const samples = [ - "request_too_large", - "Request exceeds the maximum size", - "context length exceeded", - "Maximum context length", - "prompt is too long: 208423 tokens > 200000 maximum", - "Context overflow: Summarization failed", - "413 Request Entity Too Large", - ]; - for (const sample of samples) { - expect(isContextOverflowError(sample)).toBe(true); - } - }); - - it("matches Anthropic 'Request size exceeds model context window' error", () => { - // Anthropic returns this error format when the prompt exceeds the context window. - // Without this fix, auto-compaction is NOT triggered because neither - // isContextOverflowError nor pi-ai's isContextOverflow recognizes this pattern. - // The user sees: "LLM request rejected: Request size exceeds model context window" - // instead of automatic compaction + retry. - const anthropicRawError = - '{"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}'; - expect(isContextOverflowError(anthropicRawError)).toBe(true); - }); - - it("matches 'exceeds model context window' in various formats", () => { - const samples = [ - "Request size exceeds model context window", - "request size exceeds model context window", - '400 {"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}', - "The request size exceeds model context window limit", - ]; - for (const sample of samples) { - expect(isContextOverflowError(sample)).toBe(true); - } - }); - - it("ignores unrelated errors", () => { - expect(isContextOverflowError("rate limit exceeded")).toBe(false); - expect(isContextOverflowError("request size exceeds upload limit")).toBe(false); - expect(isContextOverflowError("model not found")).toBe(false); - expect(isContextOverflowError("authentication failed")).toBe(false); - }); - - it("ignores normal conversation text mentioning context overflow", () => { - // These are legitimate conversation snippets, not error messages - expect(isContextOverflowError("Let's investigate the context overflow bug")).toBe(false); - expect(isContextOverflowError("The mystery context overflow errors are strange")).toBe(false); - expect(isContextOverflowError("We're debugging context overflow issues")).toBe(false); - expect(isContextOverflowError("Something is causing context overflow messages")).toBe(false); - }); -}); diff --git a/src/agents/pi-embedded-helpers.isfailovererrormessage.e2e.test.ts b/src/agents/pi-embedded-helpers.isfailovererrormessage.e2e.test.ts deleted file mode 100644 index 2afb8557b2e..00000000000 --- a/src/agents/pi-embedded-helpers.isfailovererrormessage.e2e.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isFailoverErrorMessage } from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; - -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, -}); -describe("isFailoverErrorMessage", () => { - it("matches auth/rate/billing/timeout", () => { - const samples = [ - "invalid api key", - "429 rate limit exceeded", - "Your credit balance is too low", - "request timed out", - "invalid request format", - ]; - for (const sample of samples) { - expect(isFailoverErrorMessage(sample)).toBe(true); - } - }); -}); diff --git a/src/agents/pi-embedded-helpers.islikelycontextoverflowerror.e2e.test.ts b/src/agents/pi-embedded-helpers.islikelycontextoverflowerror.e2e.test.ts deleted file mode 100644 index e9ff9e457c3..00000000000 --- a/src/agents/pi-embedded-helpers.islikelycontextoverflowerror.e2e.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isLikelyContextOverflowError } from "./pi-embedded-helpers.js"; - -describe("isLikelyContextOverflowError", () => { - it("matches context overflow hints", () => { - const samples = [ - "Model context window is 128k tokens, you requested 256k tokens", - "Context window exceeded: requested 12000 tokens", - "Prompt too large for this model", - ]; - for (const sample of samples) { - expect(isLikelyContextOverflowError(sample)).toBe(true); - } - }); - - it("excludes context window too small errors", () => { - const samples = [ - "Model context window too small (minimum is 128k tokens)", - "Context window too small: minimum is 1000 tokens", - ]; - for (const sample of samples) { - expect(isLikelyContextOverflowError(sample)).toBe(false); - } - }); - - it("excludes rate limit errors that match the broad hint regex", () => { - const samples = [ - "request reached organization TPD rate limit, current: 1506556, limit: 1500000", - "rate limit exceeded", - "too many requests", - "429 Too Many Requests", - "exceeded your current quota", - "This request would exceed your account's rate limit", - "429 Too Many Requests: request exceeds rate limit", - ]; - for (const sample of samples) { - expect(isLikelyContextOverflowError(sample)).toBe(false); - } - }); -}); diff --git a/src/agents/pi-embedded-helpers.istransienthttperror.e2e.test.ts b/src/agents/pi-embedded-helpers.istransienthttperror.e2e.test.ts deleted file mode 100644 index faaf4a20139..00000000000 --- a/src/agents/pi-embedded-helpers.istransienthttperror.e2e.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isTransientHttpError } from "./pi-embedded-helpers.js"; - -describe("isTransientHttpError", () => { - it("returns true for retryable 5xx status codes", () => { - expect(isTransientHttpError("500 Internal Server Error")).toBe(true); - expect(isTransientHttpError("502 Bad Gateway")).toBe(true); - expect(isTransientHttpError("503 Service Unavailable")).toBe(true); - expect(isTransientHttpError("521 ")).toBe(true); - expect(isTransientHttpError("529 Overloaded")).toBe(true); - }); - - it("returns false for non-retryable or non-http text", () => { - expect(isTransientHttpError("504 Gateway Timeout")).toBe(false); - expect(isTransientHttpError("429 Too Many Requests")).toBe(false); - expect(isTransientHttpError("network timeout")).toBe(false); - }); -}); diff --git a/src/agents/pi-embedded-helpers.messaging-duplicate.e2e.test.ts b/src/agents/pi-embedded-helpers.messaging-duplicate.e2e.test.ts deleted file mode 100644 index 04f88d023f2..00000000000 --- a/src/agents/pi-embedded-helpers.messaging-duplicate.e2e.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isMessagingToolDuplicate, normalizeTextForComparison } from "./pi-embedded-helpers.js"; - -describe("normalizeTextForComparison", () => { - it("lowercases text", () => { - expect(normalizeTextForComparison("Hello World")).toBe("hello world"); - }); - - it("trims whitespace", () => { - expect(normalizeTextForComparison(" hello ")).toBe("hello"); - }); - - it("collapses multiple spaces", () => { - expect(normalizeTextForComparison("hello world")).toBe("hello world"); - }); - - it("strips emoji", () => { - expect(normalizeTextForComparison("Hello 👋 World 🌍")).toBe("hello world"); - }); - - it("handles mixed normalization", () => { - expect(normalizeTextForComparison(" Hello 👋 WORLD 🌍 ")).toBe("hello world"); - }); -}); - -describe("isMessagingToolDuplicate", () => { - it("returns false for empty sentTexts", () => { - expect(isMessagingToolDuplicate("hello world", [])).toBe(false); - }); - - it("returns false for short texts", () => { - expect(isMessagingToolDuplicate("short", ["short"])).toBe(false); - }); - - it("detects exact duplicates", () => { - expect( - isMessagingToolDuplicate("Hello, this is a test message!", [ - "Hello, this is a test message!", - ]), - ).toBe(true); - }); - - it("detects duplicates with different casing", () => { - expect( - isMessagingToolDuplicate("HELLO, THIS IS A TEST MESSAGE!", [ - "hello, this is a test message!", - ]), - ).toBe(true); - }); - - it("detects duplicates with emoji variations", () => { - expect( - isMessagingToolDuplicate("Hello! 👋 This is a test message!", [ - "Hello! This is a test message!", - ]), - ).toBe(true); - }); - - it("detects substring duplicates (LLM elaboration)", () => { - expect( - isMessagingToolDuplicate('I sent the message: "Hello, this is a test message!"', [ - "Hello, this is a test message!", - ]), - ).toBe(true); - }); - - it("detects when sent text contains block reply (reverse substring)", () => { - expect( - isMessagingToolDuplicate("Hello, this is a test message!", [ - 'I sent the message: "Hello, this is a test message!"', - ]), - ).toBe(true); - }); - - it("returns false for non-matching texts", () => { - expect( - isMessagingToolDuplicate("This is completely different content.", [ - "Hello, this is a test message!", - ]), - ).toBe(false); - }); -}); diff --git a/src/agents/pi-embedded-helpers.resolvebootstrapmaxchars.e2e.test.ts b/src/agents/pi-embedded-helpers.resolvebootstrapmaxchars.e2e.test.ts deleted file mode 100644 index c4a0e7471c2..00000000000 --- a/src/agents/pi-embedded-helpers.resolvebootstrapmaxchars.e2e.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { - DEFAULT_BOOTSTRAP_MAX_CHARS, - DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS, - resolveBootstrapMaxChars, - resolveBootstrapTotalMaxChars, -} from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; - -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, -}); -describe("resolveBootstrapMaxChars", () => { - it("returns default when unset", () => { - expect(resolveBootstrapMaxChars()).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS); - }); - it("uses configured value when valid", () => { - const cfg = { - agents: { defaults: { bootstrapMaxChars: 12345 } }, - } as OpenClawConfig; - expect(resolveBootstrapMaxChars(cfg)).toBe(12345); - }); - it("falls back when invalid", () => { - const cfg = { - agents: { defaults: { bootstrapMaxChars: -1 } }, - } as OpenClawConfig; - expect(resolveBootstrapMaxChars(cfg)).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS); - }); -}); - -describe("resolveBootstrapTotalMaxChars", () => { - it("returns default when unset", () => { - expect(resolveBootstrapTotalMaxChars()).toBe(DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS); - }); - it("uses configured value when valid", () => { - const cfg = { - agents: { defaults: { bootstrapTotalMaxChars: 12345 } }, - } as OpenClawConfig; - expect(resolveBootstrapTotalMaxChars(cfg)).toBe(12345); - }); - it("falls back when invalid", () => { - const cfg = { - agents: { defaults: { bootstrapTotalMaxChars: -1 } }, - } as OpenClawConfig; - expect(resolveBootstrapTotalMaxChars(cfg)).toBe(DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS); - }); -}); diff --git a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.keeps-tool-call-tool-result-ids-unchanged.e2e.test.ts b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.keeps-tool-call-tool-result-ids-unchanged.e2e.test.ts deleted file mode 100644 index 1b3210790cc..00000000000 --- a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.keeps-tool-call-tool-result-ids-unchanged.e2e.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import { describe, expect, it } from "vitest"; -import { sanitizeSessionMessagesImages } from "./pi-embedded-helpers.js"; - -describe("sanitizeSessionMessagesImages", () => { - it("keeps tool call + tool result IDs unchanged by default", async () => { - const input = [ - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "call_123|fc_456", - name: "read", - arguments: { path: "package.json" }, - }, - ], - }, - { - role: "toolResult", - toolCallId: "call_123|fc_456", - toolName: "read", - content: [{ type: "text", text: "ok" }], - isError: false, - }, - ] satisfies AgentMessage[]; - - const out = await sanitizeSessionMessagesImages(input, "test"); - - const assistant = out[0] as unknown as { role?: string; content?: unknown }; - expect(assistant.role).toBe("assistant"); - expect(Array.isArray(assistant.content)).toBe(true); - const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find( - (b) => b.type === "toolCall", - ); - expect(toolCall?.id).toBe("call_123|fc_456"); - - const toolResult = out[1] as unknown as { - role?: string; - toolCallId?: string; - }; - expect(toolResult.role).toBe("toolResult"); - expect(toolResult.toolCallId).toBe("call_123|fc_456"); - }); - - it("sanitizes tool call + tool result IDs in strict mode (alphanumeric only)", async () => { - const input = [ - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "call_123|fc_456", - name: "read", - arguments: { path: "package.json" }, - }, - ], - }, - { - role: "toolResult", - toolCallId: "call_123|fc_456", - toolName: "read", - content: [{ type: "text", text: "ok" }], - isError: false, - }, - ] satisfies AgentMessage[]; - - const out = await sanitizeSessionMessagesImages(input, "test", { - sanitizeToolCallIds: true, - toolCallIdMode: "strict", - }); - - const assistant = out[0] as unknown as { role?: string; content?: unknown }; - expect(assistant.role).toBe("assistant"); - expect(Array.isArray(assistant.content)).toBe(true); - const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find( - (b) => b.type === "toolCall", - ); - // Strict mode strips all non-alphanumeric characters - expect(toolCall?.id).toBe("call123fc456"); - - const toolResult = out[1] as unknown as { - role?: string; - toolCallId?: string; - }; - expect(toolResult.role).toBe("toolResult"); - expect(toolResult.toolCallId).toBe("call123fc456"); - }); - it("does not synthesize tool call input when missing", async () => { - const input = [ - { - role: "assistant", - content: [{ type: "toolCall", id: "call_1", name: "read" }], - }, - ] satisfies AgentMessage[]; - - const out = await sanitizeSessionMessagesImages(input, "test"); - const assistant = out[0] as { content?: Array> }; - const toolCall = assistant.content?.find((b) => b.type === "toolCall"); - expect(toolCall).toBeTruthy(); - expect("input" in (toolCall ?? {})).toBe(false); - expect("arguments" in (toolCall ?? {})).toBe(false); - }); -}); diff --git a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.e2e.test.ts b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.e2e.test.ts index 4d03c3ffe7f..27accfaad17 100644 --- a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.e2e.test.ts +++ b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.e2e.test.ts @@ -1,8 +1,111 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import { describe, expect, it } from "vitest"; -import { sanitizeSessionMessagesImages } from "./pi-embedded-helpers.js"; +import { + sanitizeGoogleTurnOrdering, + sanitizeSessionMessagesImages, +} from "./pi-embedded-helpers.js"; describe("sanitizeSessionMessagesImages", () => { + it("keeps tool call + tool result IDs unchanged by default", async () => { + const input = [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_123|fc_456", + name: "read", + arguments: { path: "package.json" }, + }, + ], + }, + { + role: "toolResult", + toolCallId: "call_123|fc_456", + toolName: "read", + content: [{ type: "text", text: "ok" }], + isError: false, + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionMessagesImages(input, "test"); + + const assistant = out[0] as unknown as { role?: string; content?: unknown }; + expect(assistant.role).toBe("assistant"); + expect(Array.isArray(assistant.content)).toBe(true); + const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find( + (b) => b.type === "toolCall", + ); + expect(toolCall?.id).toBe("call_123|fc_456"); + + const toolResult = out[1] as unknown as { + role?: string; + toolCallId?: string; + }; + expect(toolResult.role).toBe("toolResult"); + expect(toolResult.toolCallId).toBe("call_123|fc_456"); + }); + + it("sanitizes tool call + tool result IDs in strict mode (alphanumeric only)", async () => { + const input = [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_123|fc_456", + name: "read", + arguments: { path: "package.json" }, + }, + ], + }, + { + role: "toolResult", + toolCallId: "call_123|fc_456", + toolName: "read", + content: [{ type: "text", text: "ok" }], + isError: false, + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionMessagesImages(input, "test", { + sanitizeToolCallIds: true, + toolCallIdMode: "strict", + }); + + const assistant = out[0] as unknown as { role?: string; content?: unknown }; + expect(assistant.role).toBe("assistant"); + expect(Array.isArray(assistant.content)).toBe(true); + const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find( + (b) => b.type === "toolCall", + ); + // Strict mode strips all non-alphanumeric characters + expect(toolCall?.id).toBe("call123fc456"); + + const toolResult = out[1] as unknown as { + role?: string; + toolCallId?: string; + }; + expect(toolResult.role).toBe("toolResult"); + expect(toolResult.toolCallId).toBe("call123fc456"); + }); + + it("does not synthesize tool call input when missing", async () => { + const input = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "read" }], + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionMessagesImages(input, "test"); + const assistant = out[0] as { content?: Array> }; + const toolCall = assistant.content?.find((b) => b.type === "toolCall"); + expect(toolCall).toBeTruthy(); + expect("input" in (toolCall ?? {})).toBe(false); + expect("arguments" in (toolCall ?? {})).toBe(false); + }); + it("removes empty assistant text blocks but preserves tool calls", async () => { const input = [ { @@ -117,4 +220,50 @@ describe("sanitizeSessionMessagesImages", () => { expect(out[0]?.role).toBe("user"); expect(out[1]?.role).toBe("toolResult"); }); + + describe("thought_signature stripping", () => { + it("strips msg_-prefixed thought_signature from assistant message content blocks", async () => { + const input = [ + { + role: "assistant", + content: [ + { type: "text", text: "hello", thought_signature: "msg_abc123" }, + { + type: "thinking", + thinking: "reasoning", + thought_signature: "AQID", + }, + ], + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionMessagesImages(input, "test"); + + expect(out).toHaveLength(1); + const content = (out[0] as { content?: unknown[] }).content; + expect(content).toHaveLength(2); + expect("thought_signature" in ((content?.[0] ?? {}) as object)).toBe(false); + expect((content?.[1] as { thought_signature?: unknown })?.thought_signature).toBe("AQID"); + }); + }); +}); + +describe("sanitizeGoogleTurnOrdering", () => { + it("prepends a synthetic user turn when history starts with assistant", () => { + const input = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "exec", arguments: {} }], + }, + ] satisfies AgentMessage[]; + + const out = sanitizeGoogleTurnOrdering(input); + expect(out[0]?.role).toBe("user"); + expect(out[1]?.role).toBe("assistant"); + }); + it("is a no-op when history starts with user", () => { + const input = [{ role: "user", content: "hi" }] satisfies AgentMessage[]; + const out = sanitizeGoogleTurnOrdering(input); + expect(out).toBe(input); + }); }); diff --git a/src/agents/pi-embedded-helpers.sanitizegoogleturnordering.e2e.test.ts b/src/agents/pi-embedded-helpers.sanitizegoogleturnordering.e2e.test.ts deleted file mode 100644 index a12f82367c9..00000000000 --- a/src/agents/pi-embedded-helpers.sanitizegoogleturnordering.e2e.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import { describe, expect, it } from "vitest"; -import { sanitizeGoogleTurnOrdering } from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; - -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, -}); -describe("sanitizeGoogleTurnOrdering", () => { - it("prepends a synthetic user turn when history starts with assistant", () => { - const input = [ - { - role: "assistant", - content: [{ type: "toolCall", id: "call_1", name: "exec", arguments: {} }], - }, - ] satisfies AgentMessage[]; - - const out = sanitizeGoogleTurnOrdering(input); - expect(out[0]?.role).toBe("user"); - expect(out[1]?.role).toBe("assistant"); - }); - it("is a no-op when history starts with user", () => { - const input = [{ role: "user", content: "hi" }] satisfies AgentMessage[]; - const out = sanitizeGoogleTurnOrdering(input); - expect(out).toBe(input); - }); -}); diff --git a/src/agents/pi-embedded-helpers.sanitizesessionmessagesimages-thought-signature-stripping.e2e.test.ts b/src/agents/pi-embedded-helpers.sanitizesessionmessagesimages-thought-signature-stripping.e2e.test.ts deleted file mode 100644 index 977002ce9a6..00000000000 --- a/src/agents/pi-embedded-helpers.sanitizesessionmessagesimages-thought-signature-stripping.e2e.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import { describe, expect, it } from "vitest"; -import { sanitizeSessionMessagesImages } from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; - -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, -}); -describe("sanitizeSessionMessagesImages - thought_signature stripping", () => { - it("strips msg_-prefixed thought_signature from assistant message content blocks", async () => { - const input = [ - { - role: "assistant", - content: [ - { type: "text", text: "hello", thought_signature: "msg_abc123" }, - { - type: "thinking", - thinking: "reasoning", - thought_signature: "AQID", - }, - ], - }, - ] satisfies AgentMessage[]; - - const out = await sanitizeSessionMessagesImages(input, "test"); - - expect(out).toHaveLength(1); - const content = (out[0] as { content?: unknown[] }).content; - expect(content).toHaveLength(2); - expect("thought_signature" in ((content?.[0] ?? {}) as object)).toBe(false); - expect((content?.[1] as { thought_signature?: unknown })?.thought_signature).toBe("AQID"); - }); -}); diff --git a/src/agents/pi-embedded-helpers.sanitizetoolcallid.e2e.test.ts b/src/agents/pi-embedded-helpers.sanitizetoolcallid.e2e.test.ts deleted file mode 100644 index 71256a71dc6..00000000000 --- a/src/agents/pi-embedded-helpers.sanitizetoolcallid.e2e.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { sanitizeToolCallId } from "./pi-embedded-helpers.js"; - -describe("sanitizeToolCallId", () => { - describe("strict mode (default)", () => { - it("keeps valid alphanumeric tool call IDs", () => { - expect(sanitizeToolCallId("callabc123")).toBe("callabc123"); - }); - it("strips underscores and hyphens", () => { - expect(sanitizeToolCallId("call_abc-123")).toBe("callabc123"); - expect(sanitizeToolCallId("call_abc_def")).toBe("callabcdef"); - }); - it("strips invalid characters", () => { - expect(sanitizeToolCallId("call_abc|item:456")).toBe("callabcitem456"); - }); - it("returns default for empty IDs", () => { - expect(sanitizeToolCallId("")).toBe("defaulttoolid"); - }); - }); - - describe("strict mode (alphanumeric only)", () => { - it("strips all non-alphanumeric characters", () => { - expect(sanitizeToolCallId("call_abc-123", "strict")).toBe("callabc123"); - expect(sanitizeToolCallId("call_abc|item:456", "strict")).toBe("callabcitem456"); - expect(sanitizeToolCallId("whatsapp_login_1768799841527_1", "strict")).toBe( - "whatsapplogin17687998415271", - ); - }); - it("returns default for empty IDs", () => { - expect(sanitizeToolCallId("", "strict")).toBe("defaulttoolid"); - }); - }); - - describe("strict9 mode (Mistral tool call IDs)", () => { - it("returns alphanumeric IDs with length 9", () => { - const out = sanitizeToolCallId("call_abc|item:456", "strict9"); - expect(out).toMatch(/^[a-zA-Z0-9]{9}$/); - }); - it("returns default for empty IDs", () => { - expect(sanitizeToolCallId("", "strict9")).toMatch(/^[a-zA-Z0-9]{9}$/); - }); - }); -}); diff --git a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.e2e.test.ts b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.e2e.test.ts index 318bb3ce6d2..5d87a339def 100644 --- a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.e2e.test.ts +++ b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.e2e.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { sanitizeUserFacingText } from "./pi-embedded-helpers.js"; +import { + downgradeOpenAIReasoningBlocks, + isMessagingToolDuplicate, + normalizeTextForComparison, + sanitizeToolCallId, + sanitizeUserFacingText, + stripThoughtSignatures, +} from "./pi-embedded-helpers.js"; describe("sanitizeUserFacingText", () => { it("strips final tags", () => { @@ -114,3 +121,257 @@ describe("sanitizeUserFacingText", () => { expect(sanitizeUserFacingText(" \n ")).toBe(""); }); }); + +describe("stripThoughtSignatures", () => { + it("returns non-array content unchanged", () => { + expect(stripThoughtSignatures("hello")).toBe("hello"); + expect(stripThoughtSignatures(null)).toBe(null); + expect(stripThoughtSignatures(undefined)).toBe(undefined); + expect(stripThoughtSignatures(123)).toBe(123); + }); + it("removes msg_-prefixed thought_signature from content blocks", () => { + const input = [ + { type: "text", text: "hello", thought_signature: "msg_abc123" }, + { type: "thinking", thinking: "test", thought_signature: "AQID" }, + ]; + const result = stripThoughtSignatures(input); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ type: "text", text: "hello" }); + expect(result[1]).toEqual({ + type: "thinking", + thinking: "test", + thought_signature: "AQID", + }); + expect("thought_signature" in result[0]).toBe(false); + expect("thought_signature" in result[1]).toBe(true); + }); + it("preserves blocks without thought_signature", () => { + const input = [ + { type: "text", text: "hello" }, + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + ]; + const result = stripThoughtSignatures(input); + + expect(result).toEqual(input); + }); + it("handles mixed blocks with and without thought_signature", () => { + const input = [ + { type: "text", text: "hello", thought_signature: "msg_abc" }, + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + { type: "thinking", thinking: "hmm", thought_signature: "msg_xyz" }, + ]; + const result = stripThoughtSignatures(input); + + expect(result).toEqual([ + { type: "text", text: "hello" }, + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + { type: "thinking", thinking: "hmm" }, + ]); + }); + it("handles empty array", () => { + expect(stripThoughtSignatures([])).toEqual([]); + }); + it("handles null/undefined blocks in array", () => { + const input = [null, undefined, { type: "text", text: "hello" }]; + const result = stripThoughtSignatures(input); + expect(result).toEqual([null, undefined, { type: "text", text: "hello" }]); + }); +}); + +describe("sanitizeToolCallId", () => { + describe("strict mode (default)", () => { + it("keeps valid alphanumeric tool call IDs", () => { + expect(sanitizeToolCallId("callabc123")).toBe("callabc123"); + }); + it("strips underscores and hyphens", () => { + expect(sanitizeToolCallId("call_abc-123")).toBe("callabc123"); + expect(sanitizeToolCallId("call_abc_def")).toBe("callabcdef"); + }); + it("strips invalid characters", () => { + expect(sanitizeToolCallId("call_abc|item:456")).toBe("callabcitem456"); + }); + it("returns default for empty IDs", () => { + expect(sanitizeToolCallId("")).toBe("defaulttoolid"); + }); + }); + + describe("strict mode (alphanumeric only)", () => { + it("strips all non-alphanumeric characters", () => { + expect(sanitizeToolCallId("call_abc-123", "strict")).toBe("callabc123"); + expect(sanitizeToolCallId("call_abc|item:456", "strict")).toBe("callabcitem456"); + expect(sanitizeToolCallId("whatsapp_login_1768799841527_1", "strict")).toBe( + "whatsapplogin17687998415271", + ); + }); + it("returns default for empty IDs", () => { + expect(sanitizeToolCallId("", "strict")).toBe("defaulttoolid"); + }); + }); + + describe("strict9 mode (Mistral tool call IDs)", () => { + it("returns alphanumeric IDs with length 9", () => { + const out = sanitizeToolCallId("call_abc|item:456", "strict9"); + expect(out).toMatch(/^[a-zA-Z0-9]{9}$/); + }); + it("returns default for empty IDs", () => { + expect(sanitizeToolCallId("", "strict9")).toMatch(/^[a-zA-Z0-9]{9}$/); + }); + }); +}); + +describe("downgradeOpenAIReasoningBlocks", () => { + it("keeps reasoning signatures when followed by content", () => { + const input = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "internal reasoning", + thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }), + }, + { type: "text", text: "answer" }, + ], + }, + ]; + + // oxlint-disable-next-line typescript/no-explicit-any + expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input); + }); + + it("drops orphaned reasoning blocks without following content", () => { + const input = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinkingSignature: JSON.stringify({ id: "rs_abc", type: "reasoning" }), + }, + ], + }, + { role: "user", content: "next" }, + ]; + + // oxlint-disable-next-line typescript/no-explicit-any + expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([ + { role: "user", content: "next" }, + ]); + }); + + it("drops object-form orphaned signatures", () => { + const input = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinkingSignature: { id: "rs_obj", type: "reasoning" }, + }, + ], + }, + ]; + + // oxlint-disable-next-line typescript/no-explicit-any + expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([]); + }); + + it("keeps non-reasoning thinking signatures", () => { + const input = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "t", + thinkingSignature: "reasoning_content", + }, + ], + }, + ]; + + // oxlint-disable-next-line typescript/no-explicit-any + expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input); + }); +}); + +describe("normalizeTextForComparison", () => { + it("lowercases text", () => { + expect(normalizeTextForComparison("Hello World")).toBe("hello world"); + }); + + it("trims whitespace", () => { + expect(normalizeTextForComparison(" hello ")).toBe("hello"); + }); + + it("collapses multiple spaces", () => { + expect(normalizeTextForComparison("hello world")).toBe("hello world"); + }); + + it("strips emoji", () => { + expect(normalizeTextForComparison("Hello 👋 World 🌍")).toBe("hello world"); + }); + + it("handles mixed normalization", () => { + expect(normalizeTextForComparison(" Hello 👋 WORLD 🌍 ")).toBe("hello world"); + }); +}); + +describe("isMessagingToolDuplicate", () => { + it("returns false for empty sentTexts", () => { + expect(isMessagingToolDuplicate("hello world", [])).toBe(false); + }); + + it("returns false for short texts", () => { + expect(isMessagingToolDuplicate("short", ["short"])).toBe(false); + }); + + it("detects exact duplicates", () => { + expect( + isMessagingToolDuplicate("Hello, this is a test message!", [ + "Hello, this is a test message!", + ]), + ).toBe(true); + }); + + it("detects duplicates with different casing", () => { + expect( + isMessagingToolDuplicate("HELLO, THIS IS A TEST MESSAGE!", [ + "hello, this is a test message!", + ]), + ).toBe(true); + }); + + it("detects duplicates with emoji variations", () => { + expect( + isMessagingToolDuplicate("Hello! 👋 This is a test message!", [ + "Hello! This is a test message!", + ]), + ).toBe(true); + }); + + it("detects substring duplicates (LLM elaboration)", () => { + expect( + isMessagingToolDuplicate('I sent the message: "Hello, this is a test message!"', [ + "Hello, this is a test message!", + ]), + ).toBe(true); + }); + + it("detects when sent text contains block reply (reverse substring)", () => { + expect( + isMessagingToolDuplicate("Hello, this is a test message!", [ + 'I sent the message: "Hello, this is a test message!"', + ]), + ).toBe(true); + }); + + it("returns false for non-matching texts", () => { + expect( + isMessagingToolDuplicate("This is completely different content.", [ + "Hello, this is a test message!", + ]), + ).toBe(false); + }); +}); diff --git a/src/agents/pi-embedded-helpers.stripthoughtsignatures.e2e.test.ts b/src/agents/pi-embedded-helpers.stripthoughtsignatures.e2e.test.ts deleted file mode 100644 index 84ac4274fe4..00000000000 --- a/src/agents/pi-embedded-helpers.stripthoughtsignatures.e2e.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { stripThoughtSignatures } from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; - -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, -}); -describe("stripThoughtSignatures", () => { - it("returns non-array content unchanged", () => { - expect(stripThoughtSignatures("hello")).toBe("hello"); - expect(stripThoughtSignatures(null)).toBe(null); - expect(stripThoughtSignatures(undefined)).toBe(undefined); - expect(stripThoughtSignatures(123)).toBe(123); - }); - it("removes msg_-prefixed thought_signature from content blocks", () => { - const input = [ - { type: "text", text: "hello", thought_signature: "msg_abc123" }, - { type: "thinking", thinking: "test", thought_signature: "AQID" }, - ]; - const result = stripThoughtSignatures(input); - - expect(result).toHaveLength(2); - expect(result[0]).toEqual({ type: "text", text: "hello" }); - expect(result[1]).toEqual({ - type: "thinking", - thinking: "test", - thought_signature: "AQID", - }); - expect("thought_signature" in result[0]).toBe(false); - expect("thought_signature" in result[1]).toBe(true); - }); - it("preserves blocks without thought_signature", () => { - const input = [ - { type: "text", text: "hello" }, - { type: "toolCall", id: "call_1", name: "read", arguments: {} }, - ]; - const result = stripThoughtSignatures(input); - - expect(result).toEqual(input); - }); - it("handles mixed blocks with and without thought_signature", () => { - const input = [ - { type: "text", text: "hello", thought_signature: "msg_abc" }, - { type: "toolCall", id: "call_1", name: "read", arguments: {} }, - { type: "thinking", thinking: "hmm", thought_signature: "msg_xyz" }, - ]; - const result = stripThoughtSignatures(input); - - expect(result).toEqual([ - { type: "text", text: "hello" }, - { type: "toolCall", id: "call_1", name: "read", arguments: {} }, - { type: "thinking", thinking: "hmm" }, - ]); - }); - it("handles empty array", () => { - expect(stripThoughtSignatures([])).toEqual([]); - }); - it("handles null/undefined blocks in array", () => { - const input = [null, undefined, { type: "text", text: "hello" }]; - const result = stripThoughtSignatures(input); - expect(result).toEqual([null, undefined, { type: "text", text: "hello" }]); - }); -});