diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cc07644a..693c55a33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos - Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123 - Agents: strip empty assistant text blocks from session history to avoid Claude API 400s. (#210) +- Agents: scrub unsupported JSON Schema keywords from tool schemas for Cloud Code Assist API compatibility. (#567) — thanks @erikpr1994 - Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123 - Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1 - Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index 825a4cc20..19a961bf9 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -50,35 +50,40 @@ beforeEach(() => { }); describe("bash tool backgrounding", () => { - it("backgrounds after yield and can be polled", async () => { - const result = await bashTool.execute("call1", { - command: joinCommands([yieldDelayCmd, "echo done"]), - yieldMs: 10, - }); - - expect(result.details.status).toBe("running"); - const sessionId = (result.details as { sessionId: string }).sessionId; - - let status = "running"; - let output = ""; - const deadline = Date.now() + (process.platform === "win32" ? 8000 : 2000); - - while (Date.now() < deadline && status === "running") { - const poll = await processTool.execute("call2", { - action: "poll", - sessionId, + it( + "backgrounds after yield and can be polled", + async () => { + const result = await bashTool.execute("call1", { + command: joinCommands([yieldDelayCmd, "echo done"]), + yieldMs: 10, }); - status = (poll.details as { status: string }).status; - const textBlock = poll.content.find((c) => c.type === "text"); - output = textBlock?.text ?? ""; - if (status === "running") { - await sleep(20); - } - } - expect(status).toBe("completed"); - expect(output).toContain("done"); - }); + expect(result.details.status).toBe("running"); + const sessionId = (result.details as { sessionId: string }).sessionId; + + let status = "running"; + let output = ""; + const deadline = + Date.now() + (process.platform === "win32" ? 8000 : 2000); + + while (Date.now() < deadline && status === "running") { + const poll = await processTool.execute("call2", { + action: "poll", + sessionId, + }); + status = (poll.details as { status: string }).status; + const textBlock = poll.content.find((c) => c.type === "text"); + output = textBlock?.text ?? ""; + if (status === "running") { + await sleep(20); + } + } + + expect(status).toBe("completed"); + expect(output).toContain("done"); + }, + isWin ? 15_000 : 5_000, + ); it("supports explicit background", async () => { const result = await bashTool.execute("call1", { diff --git a/src/agents/bash-tools.ts b/src/agents/bash-tools.ts index bb4aff4c5..b71d1c5ac 100644 --- a/src/agents/bash-tools.ts +++ b/src/agents/bash-tools.ts @@ -8,6 +8,7 @@ import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { logInfo } from "../logger.js"; +import { sliceUtf16Safe } from "../utils.js"; import { addSession, appendOutput, @@ -1041,7 +1042,7 @@ function chunkString(input: string, limit = CHUNK_LIMIT) { function truncateMiddle(str: string, max: number) { if (str.length <= max) return str; const half = Math.floor((max - 3) / 2); - return `${str.slice(0, half)}...${str.slice(str.length - half)}`; + return `${sliceUtf16Safe(str, 0, half)}...${sliceUtf16Safe(str, -half)}`; } function sliceLogLines( diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 16643d6fc..3f57c0288 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -9,6 +9,7 @@ import { resolveStateDir } from "../config/paths.js"; import { emitAgentEvent } from "../infra/agent-events.js"; import { createSubsystemLogger } from "../logging.js"; import { splitMediaFromOutput } from "../media/parse.js"; +import { truncateUtf16Safe } from "../utils.js"; import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js"; import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js"; import { isMessagingToolDuplicate } from "./pi-embedded-helpers.js"; @@ -64,7 +65,7 @@ type MessagingToolSend = { function truncateToolText(text: string): string { if (text.length <= TOOL_RESULT_MAX_CHARS) return text; - return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`; + return `${truncateUtf16Safe(text, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`; } function sanitizeToolResult(result: unknown): unknown { diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index 3242f1e7e..108896468 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import sharp from "sharp"; import { describe, expect, it } from "vitest"; -import { createClawdbotCodingTools } from "./pi-tools.js"; +import { __testing, createClawdbotCodingTools } from "./pi-tools.js"; import { createBrowserTool } from "./tools/browser-tool.js"; describe("createClawdbotCodingTools", () => { @@ -64,6 +64,28 @@ describe("createClawdbotCodingTools", () => { expect(format?.enum).toEqual(["aria", "ai"]); }); + it("inlines local $ref before removing unsupported keywords", () => { + const cleaned = __testing.cleanToolSchemaForGemini({ + type: "object", + properties: { + foo: { $ref: "#/$defs/Foo" }, + }, + $defs: { + Foo: { type: "string", enum: ["a", "b"] }, + }, + }) as { + $defs?: unknown; + properties?: Record; + }; + + expect(cleaned.$defs).toBeUndefined(); + expect(cleaned.properties).toBeDefined(); + expect(cleaned.properties?.foo).toMatchObject({ + type: "string", + enum: ["a", "b"], + }); + }); + it("preserves action enums in normalized schemas", () => { const tools = createClawdbotCodingTools(); const toolNames = [ @@ -331,4 +353,52 @@ describe("createClawdbotCodingTools", () => { expect(tools.some((tool) => tool.name === "Bash")).toBe(true); expect(tools.some((tool) => tool.name === "browser")).toBe(false); }); + + it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => { + const tools = createClawdbotCodingTools(); + + // Helper to recursively check schema for unsupported keywords + const unsupportedKeywords = new Set([ + "patternProperties", + "additionalProperties", + "$schema", + "$id", + "$ref", + "$defs", + "definitions", + ]); + + const findUnsupportedKeywords = ( + schema: unknown, + path: string, + ): string[] => { + const found: string[] = []; + if (!schema || typeof schema !== "object") return found; + if (Array.isArray(schema)) { + schema.forEach((item, i) => { + found.push(...findUnsupportedKeywords(item, `${path}[${i}]`)); + }); + return found; + } + for (const [key, value] of Object.entries( + schema as Record, + )) { + if (unsupportedKeywords.has(key)) { + found.push(`${path}.${key}`); + } + if (value && typeof value === "object") { + found.push(...findUnsupportedKeywords(value, `${path}.${key}`)); + } + } + return found; + }; + + for (const tool of tools) { + const violations = findUnsupportedKeywords( + tool.parameters, + `${tool.name}.parameters`, + ); + expect(violations).toEqual([]); + } + }); }); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 440a2a95f..2601f5b88 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -195,12 +195,122 @@ function tryFlattenLiteralAnyOf( return null; } -function cleanSchemaForGemini(schema: unknown): unknown { +// Keywords that Cloud Code Assist API rejects (not compliant with their JSON Schema subset) +const UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ + "patternProperties", + "additionalProperties", + "$schema", + "$id", + "$ref", + "$defs", + "definitions", +]); + +type SchemaDefs = Map; + +function extendSchemaDefs( + defs: SchemaDefs | undefined, + schema: Record, +): SchemaDefs | undefined { + const defsEntry = + schema.$defs && + typeof schema.$defs === "object" && + !Array.isArray(schema.$defs) + ? (schema.$defs as Record) + : undefined; + const legacyDefsEntry = + schema.definitions && + typeof schema.definitions === "object" && + !Array.isArray(schema.definitions) + ? (schema.definitions as Record) + : undefined; + + if (!defsEntry && !legacyDefsEntry) return defs; + + const next = defs ? new Map(defs) : new Map(); + if (defsEntry) { + for (const [key, value] of Object.entries(defsEntry)) next.set(key, value); + } + if (legacyDefsEntry) { + for (const [key, value] of Object.entries(legacyDefsEntry)) + next.set(key, value); + } + return next; +} + +function decodeJsonPointerSegment(segment: string): string { + return segment.replaceAll("~1", "/").replaceAll("~0", "~"); +} + +function tryResolveLocalRef( + ref: string, + defs: SchemaDefs | undefined, +): unknown | undefined { + if (!defs) return undefined; + const match = ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/); + if (!match) return undefined; + const name = decodeJsonPointerSegment(match[1] ?? ""); + if (!name) return undefined; + return defs.get(name); +} + +function cleanSchemaForGeminiWithDefs( + schema: unknown, + defs: SchemaDefs | undefined, + refStack: Set | undefined, +): unknown { if (!schema || typeof schema !== "object") return schema; - if (Array.isArray(schema)) return schema.map(cleanSchemaForGemini); + if (Array.isArray(schema)) { + return schema.map((item) => + cleanSchemaForGeminiWithDefs(item, defs, refStack), + ); + } const obj = schema as Record; + const nextDefs = extendSchemaDefs(defs, obj); + + const refValue = typeof obj.$ref === "string" ? obj.$ref : undefined; + if (refValue) { + if (refStack?.has(refValue)) { + return {}; + } + + const resolved = tryResolveLocalRef(refValue, nextDefs); + if (resolved) { + const nextRefStack = refStack ? new Set(refStack) : new Set(); + nextRefStack.add(refValue); + + const cleaned = cleanSchemaForGeminiWithDefs( + resolved, + nextDefs, + nextRefStack, + ); + if (!cleaned || typeof cleaned !== "object" || Array.isArray(cleaned)) { + return cleaned; + } + + const result: Record = { + ...(cleaned as Record), + }; + for (const key of ["description", "title", "default", "examples"]) { + if (key in obj && obj[key] !== undefined) { + result[key] = obj[key]; + } + } + return result; + } + + const result: Record = {}; + for (const key of ["description", "title", "default", "examples"]) { + if (key in obj && obj[key] !== undefined) { + result[key] = obj[key]; + } + } + return result; + } + const hasAnyOf = "anyOf" in obj && Array.isArray(obj.anyOf); + const hasOneOf = "oneOf" in obj && Array.isArray(obj.oneOf); // Try to flatten anyOf of literals to a single enum BEFORE processing // This handles Type.Union([Type.Literal("a"), Type.Literal("b")]) patterns @@ -221,14 +331,28 @@ function cleanSchemaForGemini(schema: unknown): unknown { } } + // Try to flatten oneOf of literals similarly + if (hasOneOf) { + const flattened = tryFlattenLiteralAnyOf(obj.oneOf as unknown[]); + if (flattened) { + const result: Record = { + type: flattened.type, + enum: flattened.enum, + }; + for (const key of ["description", "title", "default", "examples"]) { + if (key in obj && obj[key] !== undefined) { + result[key] = obj[key]; + } + } + return result; + } + } + const cleaned: Record = {}; for (const [key, value] of Object.entries(obj)) { - // Skip unsupported schema features for Gemini: - // - patternProperties: not in OpenAPI 3.0 subset - // - const: convert to enum with single value instead - if (key === "patternProperties") { - // Gemini doesn't support patternProperties - skip it + // Skip keywords that Cloud Code Assist API doesn't support + if (UNSUPPORTED_SCHEMA_KEYWORDS.has(key)) { continue; } @@ -238,8 +362,8 @@ function cleanSchemaForGemini(schema: unknown): unknown { continue; } - // Skip 'type' if we have 'anyOf' — Gemini doesn't allow both - if (key === "type" && hasAnyOf) { + // Skip 'type' if we have 'anyOf' or 'oneOf' — Gemini doesn't allow both + if (key === "type" && (hasAnyOf || hasOneOf)) { continue; } @@ -247,27 +371,29 @@ function cleanSchemaForGemini(schema: unknown): unknown { // Recursively clean nested properties const props = value as Record; cleaned[key] = Object.fromEntries( - Object.entries(props).map(([k, v]) => [k, cleanSchemaForGemini(v)]), + Object.entries(props).map(([k, v]) => [ + k, + cleanSchemaForGeminiWithDefs(v, nextDefs, refStack), + ]), ); } else if (key === "items" && value && typeof value === "object") { // Recursively clean array items schema - cleaned[key] = cleanSchemaForGemini(value); + cleaned[key] = cleanSchemaForGeminiWithDefs(value, nextDefs, refStack); } else if (key === "anyOf" && Array.isArray(value)) { // Clean each anyOf variant - cleaned[key] = value.map((variant) => cleanSchemaForGemini(variant)); + cleaned[key] = value.map((variant) => + cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), + ); } else if (key === "oneOf" && Array.isArray(value)) { // Clean each oneOf variant - cleaned[key] = value.map((variant) => cleanSchemaForGemini(variant)); + cleaned[key] = value.map((variant) => + cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), + ); } else if (key === "allOf" && Array.isArray(value)) { // Clean each allOf variant - cleaned[key] = value.map((variant) => cleanSchemaForGemini(variant)); - } else if ( - key === "additionalProperties" && - value && - typeof value === "object" - ) { - // Recursively clean additionalProperties schema - cleaned[key] = cleanSchemaForGemini(value); + cleaned[key] = value.map((variant) => + cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), + ); } else { cleaned[key] = value; } @@ -276,6 +402,18 @@ function cleanSchemaForGemini(schema: unknown): unknown { return cleaned; } +function cleanSchemaForGemini(schema: unknown): unknown { + if (!schema || typeof schema !== "object") return schema; + if (Array.isArray(schema)) return schema.map(cleanSchemaForGemini); + + const defs = extendSchemaDefs(undefined, schema as Record); + return cleanSchemaForGeminiWithDefs(schema, defs, undefined); +} + +function cleanToolSchemaForGemini(schema: Record): unknown { + return cleanSchemaForGemini(schema); +} + function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool { const schema = tool.parameters && typeof tool.parameters === "object" @@ -613,6 +751,10 @@ function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool { }; } +export const __testing = { + cleanToolSchemaForGemini, +} as const; + export function createClawdbotCodingTools(options?: { bash?: BashToolDefaults & ProcessToolDefaults; messageProvider?: string; diff --git a/src/config/types.ts b/src/config/types.ts index 0b3ab55da..45413dfb9 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1202,16 +1202,16 @@ export type AgentDefaultsConfig = { every?: string; /** Heartbeat model override (provider/model). */ model?: string; - /** Delivery target (last|whatsapp|telegram|discord|signal|imessage|none). */ + /** Delivery target (last|whatsapp|telegram|discord|slack|msteams|signal|imessage|none). */ target?: | "last" | "whatsapp" | "telegram" | "discord" | "slack" + | "msteams" | "signal" | "imessage" - | "msteams" | "none"; /** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */ to?: string; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index b29c19e3b..995ba8da8 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -601,6 +601,7 @@ const HeartbeatSchema = z z.literal("telegram"), z.literal("discord"), z.literal("slack"), + z.literal("msteams"), z.literal("signal"), z.literal("imessage"), z.literal("none"), diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 38d23d351..babfba271 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -49,7 +49,7 @@ import { import { registerAgentRunContext } from "../infra/agent-events.js"; import { parseTelegramTarget } from "../telegram/targets.js"; import { resolveTelegramToken } from "../telegram/token.js"; -import { normalizeE164 } from "../utils.js"; +import { normalizeE164, truncateUtf16Safe } from "../utils.js"; import type { CronJob } from "./types.js"; export type RunCronAgentTurnResult = { @@ -68,7 +68,7 @@ function pickSummaryFromOutput(text: string | undefined) { const clean = (text ?? "").trim(); if (!clean) return undefined; const limit = 2000; - return clean.length > limit ? `${clean.slice(0, limit)}…` : clean; + return clean.length > limit ? `${truncateUtf16Safe(clean, limit)}…` : clean; } function pickSummaryFromPayloads( diff --git a/src/cron/service.ts b/src/cron/service.ts index a75cc9ae6..f1e40fdd2 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; +import { truncateUtf16Safe } from "../utils.js"; import { computeNextRunAtMs } from "./schedule.js"; import { loadCronStore, saveCronStore } from "./store.js"; import type { @@ -61,7 +62,7 @@ function normalizeOptionalText(raw: unknown) { function truncateText(input: string, maxLen: number) { if (input.length <= maxLen) return input; - return `${input.slice(0, Math.max(0, maxLen - 1)).trimEnd()}…`; + return `${truncateUtf16Safe(input, Math.max(0, maxLen - 1)).trimEnd()}…`; } function inferLegacyName(job: { diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 5fa9426c2..00ddc1f07 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -61,6 +61,7 @@ import { } from "../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; +import { truncateUtf16Safe } from "../utils.js"; import { loadWebMedia } from "../web/media.js"; import { resolveDiscordAccount } from "./accounts.js"; import { chunkDiscordText } from "./chunk.js"; @@ -1017,7 +1018,10 @@ export function createDiscordMessageHandler(params: { } if (shouldLogVerbose()) { - const preview = combinedBody.slice(0, 200).replace(/\n/g, "\\n"); + const preview = truncateUtf16Safe(combinedBody, 200).replace( + /\n/g, + "\\n", + ); logVerbose( `discord inbound: channel=${message.channelId} from=${ctxPayload.From} preview="${preview}"`, ); diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index 44db2e1ba..8cf635989 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -24,6 +24,7 @@ import { } from "../pairing/pairing-store.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; +import { truncateUtf16Safe } from "../utils.js"; import { resolveIMessageAccount } from "./accounts.js"; import { createIMessageRpcClient } from "./client.js"; import { sendMessageIMessage } from "./send.js"; @@ -413,7 +414,7 @@ export async function monitorIMessageProvider( } if (shouldLogVerbose()) { - const preview = body.slice(0, 200).replace(/\n/g, "\\n"); + const preview = truncateUtf16Safe(body, 200).replace(/\n/g, "\\n"); logVerbose( `imessage inbound: chatId=${chatId ?? "unknown"} from=${ctxPayload.From} len=${body.length} preview="${preview}"`, ); diff --git a/src/logging.ts b/src/logging.ts index e3ded381c..3c8604430 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -503,13 +503,19 @@ function formatConsoleLine(opts: { } function writeConsoleLine(level: Level, line: string) { + const sanitized = + process.platform === "win32" && process.env.GITHUB_ACTIONS === "true" + ? line + .replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "?") + .replace(/[\uD800-\uDFFF]/g, "?") + : line; const sink = rawConsole ?? console; if (forceConsoleToStderr || level === "error" || level === "fatal") { - (sink.error ?? console.error)(line); + (sink.error ?? console.error)(sanitized); } else if (level === "warn") { - (sink.warn ?? console.warn)(line); + (sink.warn ?? console.warn)(sanitized); } else { - (sink.log ?? console.log)(line); + (sink.log ?? console.log)(sanitized); } } diff --git a/src/msteams/monitor.ts b/src/msteams/monitor.ts index c9bf23bf2..a3241caa7 100644 --- a/src/msteams/monitor.ts +++ b/src/msteams/monitor.ts @@ -55,10 +55,11 @@ export async function monitorMSTeamsProvider( const port = msteamsCfg.webhook?.port ?? 3978; const textLimit = resolveTextChunkLimit(cfg, "msteams"); const MB = 1024 * 1024; + const agentDefaults = cfg.agents?.defaults; const mediaMaxBytes = - typeof cfg.agents?.defaults?.mediaMaxMb === "number" && - cfg.agents.defaults.mediaMaxMb > 0 - ? Math.floor(cfg.agents.defaults.mediaMaxMb * MB) + typeof agentDefaults?.mediaMaxMb === "number" && + agentDefaults.mediaMaxMb > 0 + ? Math.floor(agentDefaults.mediaMaxMb * MB) : 8 * MB; const conversationStore = opts.conversationStore ?? createMSTeamsConversationStoreFs(); diff --git a/src/utils.ts b/src/utils.ts index d10ee478c..0ddfc6ccf 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -95,6 +95,61 @@ export function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function isHighSurrogate(codeUnit: number): boolean { + return codeUnit >= 0xd800 && codeUnit <= 0xdbff; +} + +function isLowSurrogate(codeUnit: number): boolean { + return codeUnit >= 0xdc00 && codeUnit <= 0xdfff; +} + +export function sliceUtf16Safe( + input: string, + start: number, + end?: number, +): string { + const len = input.length; + + let from = start < 0 ? Math.max(len + start, 0) : Math.min(start, len); + let to = + end === undefined + ? len + : end < 0 + ? Math.max(len + end, 0) + : Math.min(end, len); + + if (to < from) { + const tmp = from; + from = to; + to = tmp; + } + + if (from > 0 && from < len) { + const codeUnit = input.charCodeAt(from); + if ( + isLowSurrogate(codeUnit) && + isHighSurrogate(input.charCodeAt(from - 1)) + ) { + from += 1; + } + } + + if (to > 0 && to < len) { + const codeUnit = input.charCodeAt(to - 1); + if (isHighSurrogate(codeUnit) && isLowSurrogate(input.charCodeAt(to))) { + to -= 1; + } + } + + return input.slice(from, to); +} + +export function truncateUtf16Safe(input: string, maxLen: number): string { + const limit = Math.max(0, Math.floor(maxLen)); + if (input.length <= limit) return input; + return sliceUtf16Safe(input, 0, limit); +} + export function resolveUserPath(input: string): string { const trimmed = input.trim(); if (!trimmed) return trimmed; diff --git a/test/setup.ts b/test/setup.ts index f368aa4e1..b52d10977 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -2,18 +2,26 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { installWindowsCIOutputSanitizer } from "./windows-ci-output-sanitizer"; + +installWindowsCIOutputSanitizer(); + const originalHome = process.env.HOME; const originalUserProfile = process.env.USERPROFILE; const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; const originalXdgDataHome = process.env.XDG_DATA_HOME; const originalXdgStateHome = process.env.XDG_STATE_HOME; const originalXdgCacheHome = process.env.XDG_CACHE_HOME; +const originalStateDir = process.env.CLAWDBOT_STATE_DIR; const originalTestHome = process.env.CLAWDBOT_TEST_HOME; const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-test-home-")); process.env.HOME = tempHome; process.env.USERPROFILE = tempHome; process.env.CLAWDBOT_TEST_HOME = tempHome; +if (process.platform === "win32") { + process.env.CLAWDBOT_STATE_DIR = path.join(tempHome, ".clawdbot"); +} process.env.XDG_CONFIG_HOME = path.join(tempHome, ".config"); process.env.XDG_DATA_HOME = path.join(tempHome, ".local", "share"); process.env.XDG_STATE_HOME = path.join(tempHome, ".local", "state"); @@ -31,6 +39,7 @@ process.on("exit", () => { restoreEnv("XDG_DATA_HOME", originalXdgDataHome); restoreEnv("XDG_STATE_HOME", originalXdgStateHome); restoreEnv("XDG_CACHE_HOME", originalXdgCacheHome); + restoreEnv("CLAWDBOT_STATE_DIR", originalStateDir); restoreEnv("CLAWDBOT_TEST_HOME", originalTestHome); try { fs.rmSync(tempHome, { recursive: true, force: true }); diff --git a/test/vitest-global-setup.ts b/test/vitest-global-setup.ts new file mode 100644 index 000000000..3a05d7661 --- /dev/null +++ b/test/vitest-global-setup.ts @@ -0,0 +1,5 @@ +import { installWindowsCIOutputSanitizer } from "./windows-ci-output-sanitizer"; + +export default function globalSetup() { + installWindowsCIOutputSanitizer(); +} diff --git a/test/windows-ci-output-sanitizer.ts b/test/windows-ci-output-sanitizer.ts new file mode 100644 index 000000000..37c777d4f --- /dev/null +++ b/test/windows-ci-output-sanitizer.ts @@ -0,0 +1,59 @@ +import fs from "node:fs"; + +function sanitizeWindowsCIOutput(text: string): string { + return text + .replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "?") + .replace(/[\uD800-\uDFFF]/g, "?"); +} + +function decodeUtf8Text(chunk: unknown): string | null { + if (typeof chunk === "string") return chunk; + if (Buffer.isBuffer(chunk)) return chunk.toString("utf-8"); + if (chunk instanceof Uint8Array) return Buffer.from(chunk).toString("utf-8"); + if (chunk instanceof ArrayBuffer) return Buffer.from(chunk).toString("utf-8"); + if (ArrayBuffer.isView(chunk)) { + return Buffer.from( + chunk.buffer, + chunk.byteOffset, + chunk.byteLength, + ).toString("utf-8"); + } + return null; +} + +export function installWindowsCIOutputSanitizer(): void { + if (process.platform !== "win32") return; + if (process.env.GITHUB_ACTIONS !== "true") return; + + const globalKey = "__clawdbotWindowsCIOutputSanitizerInstalled"; + if ((globalThis as Record)[globalKey] === true) return; + (globalThis as Record)[globalKey] = true; + + const originalStdoutWrite = process.stdout.write.bind(process.stdout); + const originalStderrWrite = process.stderr.write.bind(process.stderr); + + process.stdout.write = ((chunk: unknown, ...args: unknown[]) => { + const text = decodeUtf8Text(chunk); + if (text !== null) + return originalStdoutWrite(sanitizeWindowsCIOutput(text), ...args); + return originalStdoutWrite(chunk as never, ...args); // passthrough + }) as typeof process.stdout.write; + + process.stderr.write = ((chunk: unknown, ...args: unknown[]) => { + const text = decodeUtf8Text(chunk); + if (text !== null) + return originalStderrWrite(sanitizeWindowsCIOutput(text), ...args); + return originalStderrWrite(chunk as never, ...args); // passthrough + }) as typeof process.stderr.write; + + const originalWriteSync = fs.writeSync.bind(fs); + fs.writeSync = ((fd: number, data: unknown, ...args: unknown[]) => { + if (fd === 1 || fd === 2) { + const text = decodeUtf8Text(data); + if (text !== null) { + return originalWriteSync(fd, sanitizeWindowsCIOutput(text), ...args); + } + } + return originalWriteSync(fd, data as never, ...(args as never[])); + }) as typeof fs.writeSync; +} diff --git a/vitest.config.ts b/vitest.config.ts index 87f83935f..f2d9ca1a2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { include: ["src/**/*.test.ts", "test/format-error.test.ts"], setupFiles: ["test/setup.ts"], + globalSetup: ["test/vitest-global-setup.ts"], exclude: [ "dist/**", "apps/macos/**",