From e9217181c139b930292b5992c2602edea1e8a35f Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 9 Jan 2026 08:05:08 -0300 Subject: [PATCH 01/10] fix(agents): remove unsupported JSON Schema keywords for Cloud Code Assist API Cloud Code Assist API requires strict JSON Schema draft 2020-12 compliance and rejects keywords like patternProperties, additionalProperties, $schema, $id, $ref, $defs, and definitions. This extends cleanSchemaForGemini to: - Remove all unsupported keywords from tool schemas - Add oneOf literal flattening (matching existing anyOf behavior) - Add test to verify no unsupported keywords remain in tool schemas --- src/agents/pi-tools.test.ts | 48 +++++++++++++++++++++++++++++++++++++ src/agents/pi-tools.ts | 47 +++++++++++++++++++++++++----------- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index 3242f1e7e..a32f02637 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -331,4 +331,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..23e8693ef 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -195,12 +195,24 @@ function tryFlattenLiteralAnyOf( return null; } +// 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", +]); + function cleanSchemaForGemini(schema: unknown): unknown { if (!schema || typeof schema !== "object") return schema; if (Array.isArray(schema)) return schema.map(cleanSchemaForGemini); const obj = schema as Record; 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 +233,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 +264,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; } @@ -261,13 +287,6 @@ function cleanSchemaForGemini(schema: unknown): unknown { } 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); } else { cleaned[key] = value; } From fd535a50d337519099e783340e63f3da08846023 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 14:07:11 +0100 Subject: [PATCH 02/10] fix: scrub tool schemas for Cloud Code Assist (#567) (thanks @erikpr1994) --- CHANGELOG.md | 1 + src/agents/pi-tools.test.ts | 24 ++++++- src/agents/pi-tools.ts | 137 ++++++++++++++++++++++++++++++++++-- src/config/types.ts | 3 +- src/config/zod-schema.ts | 1 + src/msteams/monitor.ts | 7 +- 6 files changed, 161 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ec31f6d..e6428c76b 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/pi-tools.test.ts b/src/agents/pi-tools.test.ts index a32f02637..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 = [ diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 23e8693ef..2601f5b88 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -206,11 +206,109 @@ const UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ "definitions", ]); -function cleanSchemaForGemini(schema: unknown): unknown { +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); @@ -273,20 +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)); + cleaned[key] = value.map((variant) => + cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), + ); } else { cleaned[key] = value; } @@ -295,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" @@ -632,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..10099821a 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1202,13 +1202,14 @@ 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" 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/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(); From 63f5fa47deb6d2f193d7b98634dc0ecafecd77e6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 14:19:25 +0100 Subject: [PATCH 03/10] fix: avoid invalid UTF-16 in truncation (#567) --- src/agents/bash-tools.ts | 3 +- src/agents/pi-embedded-subscribe.ts | 3 +- src/cron/isolated-agent.ts | 4 +-- src/cron/service.ts | 3 +- src/discord/monitor.ts | 6 +++- src/imessage/monitor.ts | 3 +- src/utils.ts | 55 +++++++++++++++++++++++++++++ 7 files changed, 70 insertions(+), 7 deletions(-) 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/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/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; From 760e9b3df56f216bbaae83cf4d97fbf773e16baf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 14:36:02 +0100 Subject: [PATCH 04/10] fix: avoid Windows runner unicode crash (#567) --- src/config/types.ts | 1 - src/logging.ts | 12 +++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/config/types.ts b/src/config/types.ts index 10099821a..45413dfb9 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1212,7 +1212,6 @@ export type AgentDefaultsConfig = { | "msteams" | "signal" | "imessage" - | "msteams" | "none"; /** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */ to?: string; 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); } } From 090d16392bab056388f4cf84e87c94f800a59ada Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 14:42:44 +0100 Subject: [PATCH 05/10] test: sanitize Windows CI output (#567) --- test/setup.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/setup.ts b/test/setup.ts index f368aa4e1..a162ccbb3 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -2,6 +2,31 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +function sanitizeWindowsCIOutput(text: string): string { + return text + .replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "?") + .replace(/[\uD800-\uDFFF]/g, "?"); +} + +if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") { + const originalStdoutWrite = process.stdout.write.bind(process.stdout); + const originalStderrWrite = process.stderr.write.bind(process.stderr); + + process.stdout.write = ((chunk: unknown, ...args: unknown[]) => { + if (typeof chunk === "string") { + return originalStdoutWrite(sanitizeWindowsCIOutput(chunk), ...args); + } + return originalStdoutWrite(chunk as never, ...args); + }) as typeof process.stdout.write; + + process.stderr.write = ((chunk: unknown, ...args: unknown[]) => { + if (typeof chunk === "string") { + return originalStderrWrite(sanitizeWindowsCIOutput(chunk), ...args); + } + return originalStderrWrite(chunk as never, ...args); + }) as typeof process.stderr.write; +} + const originalHome = process.env.HOME; const originalUserProfile = process.env.USERPROFILE; const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; From f58b3d082f49d1ab02c094cec6d8b4bdd94d7b20 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 14:49:59 +0100 Subject: [PATCH 06/10] test: sanitize Windows CI buffer output (#567) (thanks @erikpr1994) --- test/setup.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/setup.ts b/test/setup.ts index a162ccbb3..039d78298 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -16,6 +16,12 @@ if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") { if (typeof chunk === "string") { return originalStdoutWrite(sanitizeWindowsCIOutput(chunk), ...args); } + if (Buffer.isBuffer(chunk)) { + return originalStdoutWrite( + sanitizeWindowsCIOutput(chunk.toString("utf-8")), + ...args, + ); + } return originalStdoutWrite(chunk as never, ...args); }) as typeof process.stdout.write; @@ -23,6 +29,12 @@ if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") { if (typeof chunk === "string") { return originalStderrWrite(sanitizeWindowsCIOutput(chunk), ...args); } + if (Buffer.isBuffer(chunk)) { + return originalStderrWrite( + sanitizeWindowsCIOutput(chunk.toString("utf-8")), + ...args, + ); + } return originalStderrWrite(chunk as never, ...args); }) as typeof process.stderr.write; } From cb96deb517670a90dcb9bb6f1540112d8422f113 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 15:00:31 +0100 Subject: [PATCH 07/10] test: harden Windows CI output sanitization (#567) (thanks @erikpr1994) --- test/setup.ts | 56 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/test/setup.ts b/test/setup.ts index 039d78298..c02df22be 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -9,34 +9,50 @@ function sanitizeWindowsCIOutput(text: string): string { } if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") { + const 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; + }; + const originalStdoutWrite = process.stdout.write.bind(process.stdout); const originalStderrWrite = process.stderr.write.bind(process.stderr); process.stdout.write = ((chunk: unknown, ...args: unknown[]) => { - if (typeof chunk === "string") { - return originalStdoutWrite(sanitizeWindowsCIOutput(chunk), ...args); - } - if (Buffer.isBuffer(chunk)) { - return originalStdoutWrite( - sanitizeWindowsCIOutput(chunk.toString("utf-8")), - ...args, - ); - } - return originalStdoutWrite(chunk as never, ...args); + 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[]) => { - if (typeof chunk === "string") { - return originalStderrWrite(sanitizeWindowsCIOutput(chunk), ...args); - } - if (Buffer.isBuffer(chunk)) { - return originalStderrWrite( - sanitizeWindowsCIOutput(chunk.toString("utf-8")), - ...args, - ); - } - return originalStderrWrite(chunk as never, ...args); + 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; } const originalHome = process.env.HOME; From fd3cbd96a8578053586663b351813a704df2b720 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 15:09:20 +0100 Subject: [PATCH 08/10] test: sanitize Windows CI vitest runner output (#567) (thanks @erikpr1994) --- test/setup.ts | 53 +------------------------- test/vitest-global-setup.ts | 5 +++ test/windows-ci-output-sanitizer.ts | 59 +++++++++++++++++++++++++++++ vitest.config.ts | 1 + 4 files changed, 67 insertions(+), 51 deletions(-) create mode 100644 test/vitest-global-setup.ts create mode 100644 test/windows-ci-output-sanitizer.ts diff --git a/test/setup.ts b/test/setup.ts index c02df22be..0af5a6299 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -2,58 +2,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -function sanitizeWindowsCIOutput(text: string): string { - return text - .replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "?") - .replace(/[\uD800-\uDFFF]/g, "?"); -} +import { installWindowsCIOutputSanitizer } from "./windows-ci-output-sanitizer"; -if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") { - const 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; - }; - - 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; -} +installWindowsCIOutputSanitizer(); const originalHome = process.env.HOME; const originalUserProfile = process.env.USERPROFILE; 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/**", From c228df8f901275a4856ce4a4e320d299bb119d10 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 15:23:41 +0100 Subject: [PATCH 09/10] fix: rebase onto main + restore build/lint (#567) (thanks @erikpr1994) --- src/agents/auth-profiles.ts | 4 +- src/auto-reply/reply/directive-handling.ts | 52 +++++++++++++++------- src/cli/models-cli.ts | 8 +++- src/commands/configure.ts | 1 + src/commands/models/auth-order.ts | 43 +++++++++++++----- 5 files changed, 75 insertions(+), 33 deletions(-) diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index c2ac71824..037ca0d29 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -718,9 +718,7 @@ export async function setAuthProfileOrder(params: { const providerKey = normalizeProviderId(params.provider); const sanitized = params.order && Array.isArray(params.order) - ? params.order - .map((entry) => String(entry).trim()) - .filter(Boolean) + ? params.order.map((entry) => String(entry).trim()).filter(Boolean) : []; const deduped: string[] = []; diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index ce5248966..b7e4eaaf8 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -88,7 +88,9 @@ const resolveAuthLabel = async ( mode: ModelAuthDetailMode = "compact", ): Promise<{ label: string; source: string }> => { const formatPath = (value: string) => shortenHomePath(value); - const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); + const store = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); const order = resolveAuthProfileOrder({ cfg, store, provider }); const providerKey = normalizeProviderId(provider); const lastGood = (() => { @@ -121,7 +123,8 @@ const resolveAuthLabel = async ( const configProfile = cfg.auth?.profiles?.[profileId]; const missing = !profile || - (configProfile?.provider && configProfile.provider !== profile.provider) || + (configProfile?.provider && + configProfile.provider !== profile.provider) || (configProfile?.mode && configProfile.mode !== profile.type && !(configProfile.mode === "oauth" && profile.type === "token")); @@ -170,7 +173,11 @@ const resolveAuthLabel = async ( if (lastGood && profileId === lastGood) flags.push("lastGood"); if (isProfileInCooldown(store, profileId)) { const until = store.usageStats?.[profileId]?.cooldownUntil; - if (typeof until === "number" && Number.isFinite(until) && until > now) { + if ( + typeof until === "number" && + Number.isFinite(until) && + until > now + ) { flags.push(`cooldown ${formatUntil(until)}`); } else { flags.push("cooldown"); @@ -197,7 +204,11 @@ const resolveAuthLabel = async ( Number.isFinite(profile.expires) && profile.expires > 0 ) { - flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`); + flags.push( + profile.expires <= now + ? "expired" + : `exp ${formatUntil(profile.expires)}`, + ); } const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : ""; return `${profileId}=token:${maskApiKey(profile.token)}${suffix}`; @@ -218,7 +229,11 @@ const resolveAuthLabel = async ( Number.isFinite(profile.expires) && profile.expires > 0 ) { - flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`); + flags.push( + profile.expires <= now + ? "expired" + : `exp ${formatUntil(profile.expires)}`, + ); } const suffixLabel = suffix ? ` ${suffix}` : ""; const suffixFlags = flags.length > 0 ? ` (${flags.join(", ")})` : ""; @@ -242,7 +257,8 @@ const resolveAuthLabel = async ( if (customKey) { return { label: maskApiKey(customKey), - source: mode === "verbose" ? `models.json: ${formatPath(modelsPath)}` : "", + source: + mode === "verbose" ? `models.json: ${formatPath(modelsPath)}` : "", }; } return { label: "missing", source: "missing" }; @@ -803,16 +819,16 @@ export async function handleDirectiveOnly(params: { } modelSelection = resolved.selection; if (modelSelection) { - if (directives.rawModelProfile) { - const profileResolved = resolveProfileOverride({ - rawProfile: directives.rawModelProfile, - provider: modelSelection.provider, - cfg: params.cfg, - agentDir, - }); - if (profileResolved.error) { - return { text: profileResolved.error }; - } + if (directives.rawModelProfile) { + const profileResolved = resolveProfileOverride({ + rawProfile: directives.rawModelProfile, + provider: modelSelection.provider, + cfg: params.cfg, + agentDir, + }); + if (profileResolved.error) { + return { text: profileResolved.error }; + } profileOverride = profileResolved.profileId; } const nextLabel = `${modelSelection.provider}/${modelSelection.model}`; @@ -994,6 +1010,10 @@ export async function persistInlineDirectives(params: { agentCfg, } = params; let { provider, model } = params; + const activeAgentId = sessionKey + ? resolveAgentIdFromSessionKey(sessionKey) + : resolveDefaultAgentId(cfg); + const agentDir = resolveAgentDir(cfg, activeAgentId); if (sessionEntry && sessionStore && sessionKey) { let updated = false; diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index dd2ca826b..ce897d66d 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -392,7 +392,9 @@ export function registerModelsCli(program: Command) { order .command("set") - .description("Set per-agent auth order override (locks rotation to this list)") + .description( + "Set per-agent auth order override (locks rotation to this list)", + ) .requiredOption("--provider ", "Provider id (e.g. anthropic)") .option("--agent ", "Agent id (default: configured default agent)") .argument("", "Auth profile ids (e.g. anthropic:claude-cli)") @@ -414,7 +416,9 @@ export function registerModelsCli(program: Command) { order .command("clear") - .description("Clear per-agent auth order override (fall back to config/round-robin)") + .description( + "Clear per-agent auth order override (fall back to config/round-robin)", + ) .requiredOption("--provider ", "Provider id (e.g. anthropic)") .option("--agent ", "Agent id (default: configured default agent)") .action(async (opts) => { diff --git a/src/commands/configure.ts b/src/commands/configure.ts index f915f439e..d75a1527e 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -80,6 +80,7 @@ import { DEFAULT_WORKSPACE, ensureWorkspaceAndSessions, guardCancel, + openUrl, printWizardHeader, probeGatewayReachable, randomToken, diff --git a/src/commands/models/auth-order.ts b/src/commands/models/auth-order.ts index e0429a372..4af49e63c 100644 --- a/src/commands/models/auth-order.ts +++ b/src/commands/models/auth-order.ts @@ -1,16 +1,22 @@ -import { resolveAgentDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { + resolveAgentDir, + resolveDefaultAgentId, +} from "../../agents/agent-scope.js"; +import { + type AuthProfileStore, ensureAuthProfileStore, setAuthProfileOrder, - type AuthProfileStore, } from "../../agents/auth-profiles.js"; import { normalizeProviderId } from "../../agents/model-selection.js"; import { loadConfig } from "../../config/config.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import type { RuntimeEnv } from "../../runtime.js"; import { shortenHomePath } from "../../utils.js"; -import { normalizeAgentId } from "../../routing/session-key.js"; -function resolveTargetAgent(cfg: ReturnType, raw?: string): { +function resolveTargetAgent( + cfg: ReturnType, + raw?: string, +): { agentId: string; agentDir: string; } { @@ -37,7 +43,9 @@ export async function modelsAuthOrderGetCommand( const cfg = loadConfig(); const { agentId, agentDir } = resolveTargetAgent(cfg, opts.agent); - const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); + const store = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); const order = describeOrder(store, provider); if (opts.json) { @@ -59,9 +67,13 @@ export async function modelsAuthOrderGetCommand( runtime.log(`Agent: ${agentId}`); runtime.log(`Provider: ${provider}`); - runtime.log(`Auth file: ${shortenHomePath(`${agentDir}/auth-profiles.json`)}`); runtime.log( - order.length > 0 ? `Order override: ${order.join(", ")}` : "Order override: (none)", + `Auth file: ${shortenHomePath(`${agentDir}/auth-profiles.json`)}`, + ); + runtime.log( + order.length > 0 + ? `Order override: ${order.join(", ")}` + : "Order override: (none)", ); } @@ -75,8 +87,13 @@ export async function modelsAuthOrderClearCommand( const cfg = loadConfig(); const { agentId, agentDir } = resolveTargetAgent(cfg, opts.agent); - const updated = await setAuthProfileOrder({ agentDir, provider, order: null }); - if (!updated) throw new Error("Failed to update auth-profiles.json (lock busy?)."); + const updated = await setAuthProfileOrder({ + agentDir, + provider, + order: null, + }); + if (!updated) + throw new Error("Failed to update auth-profiles.json (lock busy?)."); runtime.log(`Agent: ${agentId}`); runtime.log(`Provider: ${provider}`); @@ -94,7 +111,9 @@ export async function modelsAuthOrderSetCommand( const cfg = loadConfig(); const { agentId, agentDir } = resolveTargetAgent(cfg, opts.agent); - const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); + const store = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); const providerKey = normalizeProviderId(provider); const requested = (opts.order ?? []) .map((entry) => String(entry).trim()) @@ -120,10 +139,10 @@ export async function modelsAuthOrderSetCommand( provider, order: requested, }); - if (!updated) throw new Error("Failed to update auth-profiles.json (lock busy?)."); + if (!updated) + throw new Error("Failed to update auth-profiles.json (lock busy?)."); runtime.log(`Agent: ${agentId}`); runtime.log(`Provider: ${provider}`); runtime.log(`Order override: ${describeOrder(updated, provider).join(", ")}`); } - From 17a7d4e8ddcc0117f5b800e40f258c003224d15d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 15:40:02 +0100 Subject: [PATCH 10/10] test: stabilize Windows test env (#567) (thanks @erikpr1994) --- src/agents/bash-tools.test.ts | 59 +++++++++++++++++++---------------- test/setup.ts | 5 +++ 2 files changed, 37 insertions(+), 27 deletions(-) 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/test/setup.ts b/test/setup.ts index 0af5a6299..b52d10977 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -12,12 +12,16 @@ 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"); @@ -35,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 });