diff --git a/index.ts b/index.ts index ab67747..e5c5166 100644 --- a/index.ts +++ b/index.ts @@ -2,14 +2,8 @@ * Command Code provider for pi. * * Connects pi to Command Code's API (https://api.commandcode.ai/alpha/generate). - * - * Authentication (pick one): - * 1. Run `/login`, then select Command Code — opens browser to commandcode.ai, auto-stores API key - * 2. Set COMMANDCODE_API_KEY environment variable - * 3. Place API key in `~/.commandcode/auth.json` or `~/.pi/agent/auth.json` - * as {"apiKey": "user_..."} or {"commandcode": "user_..."} - * - * Models are fetched from Command Code's Provider API at startup. + * The provider uses pi's legacy extension registration surface because the + * current pi host exposes `registerProvider(name, config)`, including OMP. */ import { AssistantMessageEventStream } from "@earendil-works/pi-ai" @@ -21,8 +15,12 @@ import { } from "@earendil-works/pi-coding-agent" import { join } from "node:path" -import { getApiKey as getStoredApiKey } from "./src/converters.ts" -import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts" +import { + COMMAND_CODE_CLI_VERSION, + COMMAND_CODE_INPUT_TYPES, + createStreamCommandCode, + DEFAULT_API_BASE, +} from "./src/core.ts" import { calculateCommandCodeCost } from "./src/cost.ts" import { DEFAULT_MODELS_URL, @@ -34,6 +32,7 @@ import { import { getApiKey as getOAuthApiKey, login, refreshToken } from "./src/oauth.ts" import { MODEL_COSTS, ZERO_MODEL_COST } from "./src/pricing.ts" import { createCommandCodeRuntime } from "./src/runtime.ts" +import { normalizeCommandCodeMessage } from "./src/overflow.ts" function createProviderConfig( models: readonly CommandCodeModel[], @@ -43,12 +42,16 @@ function createProviderConfig( return { name: "Command Code", baseUrl: apiBase, + // Keep environment authentication dynamic. OAuth credentials are resolved + // by pi's oauth registration, while the custom stream retains its own + // request-time legacy-file fallback for older compatible hosts. apiKey: "$COMMANDCODE_API_KEY", authHeader: true, api: "commandcode-custom", streamSimple: streamCommandCode, headers: { - "x-commandcode-version": COMMAND_CODE_CLI_VERSION, + "x-command-code-version": COMMAND_CODE_CLI_VERSION, + "x-cli-environment": "production", }, oauth: { name: "Command Code", @@ -56,19 +59,29 @@ function createProviderConfig( refreshToken, getApiKey: getOAuthApiKey, }, - models: models.map((model) => ({ - id: model.id, - name: model.name, - reasoning: model.reasoning, - ...(thinkingMetadataForModel(model.id) ?? {}), - input: ["text"] as const, - cost: MODEL_COSTS[model.id] ?? ZERO_MODEL_COST, - contextWindow: model.contextWindow, - maxTokens: model.maxTokens, - })), + models: models.map(createProviderModel), } } +function createProviderModel(model: { + id: string + name: string + reasoning: boolean + contextWindow: number + maxTokens: number +}) { + return { + id: model.id, + name: model.name, + reasoning: model.reasoning, + ...(thinkingMetadataForModel(model.id) ?? {}), + input: COMMAND_CODE_INPUT_TYPES, + cost: MODEL_COSTS[model.id] ?? ZERO_MODEL_COST, + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + } as const +} + export default async function (pi: ExtensionAPI) { const apiBase = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE const modelsUrl = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL @@ -81,6 +94,12 @@ export default async function (pi: ExtensionAPI) { apiBase, }) + pi.on("message_end", async (event, ctx) => { + if (event.message.role !== "assistant") return + const normalized = normalizeCommandCodeMessage(event.message, ctx.model?.provider) + return normalized ? { message: normalized.message } : undefined + }) + const runtime = createCommandCodeRuntime(pi, { endpoint: modelsUrl, cachePath: modelsCachePath, diff --git a/package.json b/package.json index dac7cc8..a128486 100644 --- a/package.json +++ b/package.json @@ -29,13 +29,14 @@ "LICENSE" ], "scripts": { - "test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs", + "test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs", "typecheck": "tsc --noEmit", "format:check": "prettier --check '**/*.{ts,mjs,json,md}'", "format": "prettier --write '**/*.{ts,mjs,json,md}'", "pi:isolated": "node scripts/pi-isolated.mjs", "pi:authenticated": "node scripts/pi-authenticated.mjs", "test:unit": "tsx tests/test-pure-functions.ts", + "test:overflow": "tsx tests/test-overflow.ts", "test:models": "tsx tests/test-models.ts", "test:pricing": "tsx tests/test-pricing.ts", "test:oauth": "tsx tests/test-oauth.ts", diff --git a/src/converters.ts b/src/converters.ts index 38ca0d3..eaba961 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -3,6 +3,9 @@ import { homedir } from "node:os" import { join } from "node:path" import type { MessageLike, StopReason, ToolLike } from "./types.ts" +import { toJsonSchema } from "./json-schema.ts" + +export { toJsonSchema } from "./json-schema.ts" export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) @@ -12,10 +15,6 @@ export function stringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined } -function booleanValue(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined -} - export function recordArray(value: unknown): readonly Record[] { if (!Array.isArray(value)) return [] return value.filter(isRecord) @@ -56,6 +55,26 @@ function apiKeyFromCredentialRecord(value: unknown): string | undefined { return stringValue(value.key) ?? stringValue(value.access) } +function hasImageContent(value: unknown): boolean { + if (isRecord(value)) return value.type === "image" + return recordArray(value).some((part) => part.type === "image") +} + +function imageContentError(role: string): Error { + return new Error( + `Command Code does not support image content in ${role}; refusing to send it to avoid lossy handling`, + ) +} + +export function assertTextOnlyMessages(messages?: readonly MessageLike[]): void { + for (const message of messages ?? []) { + if (hasImageContent(message.content)) { + const role = message.role === "toolResult" ? "tool results" : `${message.role} messages` + throw imageContentError(role) + } + } +} + export function getApiKey( options: { env?: NodeJS.ProcessEnv @@ -96,6 +115,8 @@ export function getApiKey( } export function textContent(message: { content?: unknown }): string { + if (hasImageContent(message.content)) throw imageContentError("tool results") + return recordArray(message.content) .filter((part) => part.type === "text") .map((part) => stringValue(part.text) ?? "") @@ -106,80 +127,6 @@ export function getEnvironmentInfo(): string { return `${process.platform}-${process.arch}, Node.js ${process.version}` } -export function toJsonSchema(schema: unknown): unknown { - if (!isRecord(schema)) return {} - - const kind = stringValue(schema.kind) ?? stringValue(schema.type) - const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined - if (enumValues) { - return { type: typeof enumValues[0], enum: enumValues } - } - - switch (kind) { - case "string": - case "String": - return { type: "string" } - case "number": - case "Number": - return { type: "number" } - case "boolean": - case "Boolean": - return { type: "boolean" } - case "object": - case "Object": { - const properties: Record = {} - const inferredRequired: string[] = [] - const sourceProperties = isRecord(schema.properties) ? schema.properties : undefined - const optional = Array.isArray(schema.optional) - ? schema.optional.filter((item): item is string => typeof item === "string") - : [] - - if (sourceProperties) { - for (const [key, value] of Object.entries(sourceProperties)) { - properties[key] = toJsonSchema(value) - const valueRecord = isRecord(value) ? value : undefined - if (booleanValue(valueRecord?.optional) !== true && !optional.includes(key)) { - inferredRequired.push(key) - } - } - } - - const explicitRequired = Array.isArray(schema.required) - ? schema.required.filter((item): item is string => typeof item === "string") - : undefined - const required = explicitRequired ?? inferredRequired - const out: Record = { type: "object" } - if (Object.keys(properties).length > 0) out.properties = properties - if (required.length > 0) out.required = required - return out - } - case "array": - case "Array": - return { - type: "array", - items: toJsonSchema(schema.items ?? schema.element), - } - case "union": - case "Union": { - const variants = Array.isArray(schema.variants) - ? schema.variants - : Array.isArray(schema.anyOf) - ? schema.anyOf - : [] - for (const variant of variants) { - const converted = toJsonSchema(variant) - if (isRecord(converted) && Object.keys(converted).length > 0) return converted - } - return {} - } - case "optional": - case "Optional": - return toJsonSchema(schema.wrapped ?? schema.inner) - default: - return {} - } -} - export function toolsToJson(tools?: readonly ToolLike[]): unknown[] { if (!tools) return [] return tools.map((tool) => ({ @@ -211,6 +158,8 @@ function completeToolCallIds(messages?: readonly MessageLike[]): Set { } export function messagesToCC(messages?: readonly MessageLike[]): unknown[] { + assertTextOnlyMessages(messages) + const out: unknown[] = [] const pairedToolCallIds = completeToolCallIds(messages) diff --git a/src/core.ts b/src/core.ts index 43c0eaf..ef69049 100644 --- a/src/core.ts +++ b/src/core.ts @@ -7,10 +7,12 @@ import { randomUUID } from "node:crypto" +import { commandCodeErrorMessage, redactCommandCodeErrorText } from "./overflow.ts" import { getApiKey, getEnvironmentInfo, isRecord, + assertTextOnlyMessages, mapFinishReason, messagesToCC, numberValue, @@ -36,10 +38,17 @@ import type { } from "./types.ts" export * from "./converters.ts" +export * from "./overflow.ts" export * from "./types.ts" export const DEFAULT_API_BASE = "https://api.commandcode.ai" export const COMMAND_CODE_CLI_VERSION = "0.29.0" +/** + * The legacy /alpha/generate request path used by this provider has no + * documented image-part contract. Keep the advertised capability text-only + * until Command Code documents and tests image handling for this endpoint. + */ +export const COMMAND_CODE_INPUT_TYPES = ["text"] as const const DEFAULT_GENERATE_MAX_TOKENS = 64_000 const DEFAULT_MAX_RETRIES = 0 @@ -194,6 +203,31 @@ export function createStreamCommandCode(deps: CoreDependencies) { }) } + function raceAbortWithTimeout( + promise: Promise, + controller: AbortController, + timeoutMs: number | undefined, + ): Promise { + if (timeoutMs === undefined) return raceAbort(promise, controller.signal) + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + controller.abort() + reject(timeoutError(timeoutMs)) + }, timeoutMs) + raceAbort(promise, controller.signal).then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (error: unknown) => { + clearTimeout(timer) + reject(error) + }, + ) + }) + } + return function streamCommandCode( model: ModelLike, context: ContextLike, @@ -418,9 +452,10 @@ export function createStreamCommandCode(deps: CoreDependencies) { } case "error": { - const errorRecord = isRecord(event.error) ? event.error : undefined const message = - stringValue(errorRecord?.message) ?? stringValue(event.error) ?? "Stream error" + commandCodeErrorMessage(event.error) ?? + commandCodeErrorMessage(event.message) ?? + "Stream error" output.stopReason = "error" output.errorMessage = message throw new Error(message) @@ -430,10 +465,14 @@ export function createStreamCommandCode(deps: CoreDependencies) { try { stream.push({ type: "start", partial: output }) + if (controller.signal.aborted) throw abortError("Aborted") const workingDir = cwd() const threadId = uuid() const reasoningEffort = mappedReasoningEffort(model, options) + const timeoutMs = options?.timeoutMs + + assertTextOnlyMessages(context.messages) let body: unknown = { config: { @@ -463,15 +502,23 @@ export function createStreamCommandCode(deps: CoreDependencies) { threadId, } - const nextBody = await raceAbort( - Promise.resolve(options?.onPayload?.(body, model)), - controller.signal, - ) + const payloadController = new AbortController() + const onPayloadAbort = () => payloadController.abort() + controller.signal.addEventListener("abort", onPayloadAbort, { once: true }) + let nextBody: unknown + try { + nextBody = await raceAbortWithTimeout( + Promise.resolve(options?.onPayload?.(body, model)), + payloadController, + timeoutMs, + ) + } finally { + controller.signal.removeEventListener("abort", onPayloadAbort) + } if (nextBody !== undefined) body = nextBody const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES const maxRetryDelayMs = effectiveMaxRetryDelayMs(options?.maxRetryDelayMs) - const timeoutMs = options?.timeoutMs const requestHeaders = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, @@ -505,6 +552,11 @@ export function createStreamCommandCode(deps: CoreDependencies) { } const onOuterAbort = () => attemptController.abort() controller.signal.addEventListener("abort", onOuterAbort, { once: true }) + const raceAttempt = (promise: Promise): Promise => + raceAbort(promise, attemptController.signal).catch((error: unknown) => { + if (attemptTimedOut) throw timeoutError(timeoutMs) + throw error + }) try { try { @@ -540,25 +592,38 @@ export function createStreamCommandCode(deps: CoreDependencies) { } } - await raceAbort( - Promise.resolve( - options?.onResponse?.( - { - status: response.status, - headers: headersToRecord(response.headers), - }, - model, + try { + await raceAttempt( + Promise.resolve( + options?.onResponse?.( + { + status: response.status, + headers: headersToRecord(response.headers), + }, + model, + ), ), - ), - controller.signal, - ) + ) + } catch (error: unknown) { + if (attemptTimedOut && attempt < maxRetries) continue retryLoop + throw error + } if (!response.ok) { - const errBody = await raceAbort( - response.text().catch(() => ""), - controller.signal, + const errBody = await raceAttempt(response.text().catch(() => "")) + let errorDetail: string | undefined + try { + const parsedBody: unknown = JSON.parse(errBody) + errorDetail = commandCodeErrorMessage(parsedBody) + } catch { + // Preserve useful plain-text provider errors only after secret + // redaction; upstream/proxy bodies may echo credentials. + } + const safeBody = redactCommandCodeErrorText(errBody).slice(0, 500) + const detail = redactCommandCodeErrorText( + errorDetail ?? (safeBody || "Provider returned an error"), ) - throw new Error(`Command Code API error ${response.status}: ${errBody.slice(0, 500)}`) + throw new Error(`Command Code API error ${response.status}: ${detail}`) } // --- Read response stream --- @@ -639,9 +704,7 @@ export function createStreamCommandCode(deps: CoreDependencies) { output.errorMessage = reason === "aborted" ? "Request aborted" - : error instanceof Error - ? error.message - : String(error) + : redactCommandCodeErrorText(error instanceof Error ? error.message : String(error)) stream.push({ type: "error", reason, error: output }) stream.end() } finally { @@ -668,7 +731,9 @@ export function createStreamCommandCode(deps: CoreDependencies) { model: model.id, usage: defaultUsage(), stopReason: "error", - errorMessage: error instanceof Error ? error.message : String(error), + errorMessage: redactCommandCodeErrorText( + error instanceof Error ? error.message : String(error), + ), timestamp: now(), } stream.push({ type: "error", reason: "error", error: msg }) diff --git a/src/json-schema.ts b/src/json-schema.ts new file mode 100644 index 0000000..a2ffc02 --- /dev/null +++ b/src/json-schema.ts @@ -0,0 +1,382 @@ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined +} + +function booleanValue(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined +} + +type JsonSchemaValue = boolean | Record + +const JSON_SCHEMA_TYPES = new Set([ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string", +]) + +const LEGACY_KINDS = new Set([ + "any", + "array", + "boolean", + "enum", + "integer", + "intersect", + "intersection", + "literal", + "never", + "null", + "nullable", + "number", + "object", + "optional", + "string", + "undefined", + "union", + "unknown", +]) + +const LEGACY_FIELDS = new Set([ + "element", + "kind", + "inner", + "optional", + "value", + "values", + "variants", + "wrapped", +]) + +const SCHEMA_MAP_FIELDS = new Set([ + "$defs", + "definitions", + "dependentSchemas", + "patternProperties", + "properties", +]) + +const SCHEMA_ARRAY_FIELDS = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]) + +const SCHEMA_VALUE_FIELDS = new Set([ + "additionalItems", + "additionalProperties", + "contains", + "contentSchema", + "else", + "if", + "items", + "not", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", +]) + +const SCHEMA_KEYWORDS = new Set([ + "$anchor", + "$comment", + "$defs", + "$dynamicAnchor", + "$dynamicRef", + "$id", + "$ref", + "$schema", + "$vocabulary", + "additionalItems", + "additionalProperties", + "allOf", + "anyOf", + "const", + "contains", + "contentEncoding", + "contentMediaType", + "contentSchema", + "default", + "definitions", + "dependentRequired", + "dependentSchemas", + "description", + "else", + "enum", + "examples", + "exclusiveMaximum", + "exclusiveMinimum", + "format", + "if", + "items", + "maxContains", + "maxItems", + "maxLength", + "maxProperties", + "maximum", + "minContains", + "minItems", + "minLength", + "minProperties", + "minimum", + "multipleOf", + "not", + "oneOf", + "pattern", + "patternProperties", + "prefixItems", + "properties", + "propertyNames", + "readOnly", + "required", + "title", + "type", + "unevaluatedItems", + "unevaluatedProperties", + "uniqueItems", + "writeOnly", +]) + +function stringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined + const values = value.filter((item): item is string => typeof item === "string") + return values.length === value.length ? values : undefined +} + +function validSchemaType(value: unknown): boolean { + if (typeof value === "string") return JSON_SCHEMA_TYPES.has(value) + if (!Array.isArray(value) || value.length === 0) return false + return value.every((item) => typeof item === "string" && JSON_SCHEMA_TYPES.has(item)) +} + +function legacyKind(schema: Record): string | undefined { + const explicitKind = stringValue(schema.kind)?.toLowerCase() + if (explicitKind && LEGACY_KINDS.has(explicitKind)) return explicitKind + + const type = stringValue(schema.type) + const normalized = type?.toLowerCase() + if (!normalized || !LEGACY_KINDS.has(normalized)) return undefined + if (!validSchemaType(type) || Object.keys(schema).some((key) => LEGACY_FIELDS.has(key))) { + return normalized + } + return undefined +} + +function looksLikeJsonSchema(schema: Record): boolean { + if (Object.keys(schema).length === 0) return true + if (schema.type !== undefined && !validSchemaType(schema.type)) return false + return Object.keys(schema).some((key) => SCHEMA_KEYWORDS.has(key)) +} + +function isOptionalSchema(schema: unknown): boolean { + if (!isRecord(schema)) return false + if (booleanValue(schema.optional) === true) return true + + const kind = legacyKind(schema) + if (kind === "optional") return true + if (kind !== "union") return false + + const variants = Array.isArray(schema.variants) + ? schema.variants + : Array.isArray(schema.anyOf) + ? schema.anyOf + : [] + return variants.some((variant) => legacyKind(isRecord(variant) ? variant : {}) === "undefined") +} + +function schemaValue(value: unknown, seen: WeakSet): JsonSchemaValue { + if (typeof value === "boolean") return value + if (!isRecord(value)) return {} + return convertSchema(value, seen) +} + +function setSchemaProperty(target: Record, key: string, value: unknown): void { + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }) +} + +function schemaMap(value: unknown, seen: WeakSet): Record { + if (!isRecord(value)) return {} + const out: Record = {} + for (const [key, item] of Object.entries(value)) { + setSchemaProperty(out, key, schemaValue(item, seen)) + } + return out +} + +function schemaArray(value: unknown, seen: WeakSet): unknown[] { + if (!Array.isArray(value)) return [] + return value.map((item) => schemaValue(item, seen)) +} + +function isSchemaValue(value: unknown): value is JsonSchemaValue { + return typeof value === "boolean" || isRecord(value) +} + +function copySchemaObject( + source: Record, + seen: WeakSet, + legacy: boolean, + forcedType?: string, +): JsonSchemaValue { + const out: Record = {} + + for (const [key, value] of Object.entries(source)) { + if (legacy && LEGACY_FIELDS.has(key)) continue + if (key === "nullable" || (forcedType !== undefined && key === "type")) continue + + if (key === "required") { + const required = stringArray(value) + if (required) out.required = required + } else if (SCHEMA_MAP_FIELDS.has(key)) { + out[key] = schemaMap(value, seen) + } else if (SCHEMA_ARRAY_FIELDS.has(key)) { + out[key] = schemaArray(value, seen) + } else if (SCHEMA_VALUE_FIELDS.has(key)) { + out[key] = + Array.isArray(value) && key === "items" + ? schemaArray(value, seen) + : schemaValue(value, seen) + } else { + out[key] = value + } + } + + if (forcedType !== undefined) out.type = forcedType + if (booleanValue(source.nullable) === true) return makeNullable(out) + return out +} + +function makeNullable(schema: Record): Record { + const type = schema.type + if (typeof type === "string") { + if (type === "null") return schema + return { ...schema, type: [type, "null"] } + } + if (Array.isArray(type) && !type.includes("null")) { + return { ...schema, type: [...type, "null"] } + } + if (Array.isArray(schema.anyOf)) { + return { ...schema, anyOf: [...schema.anyOf, { type: "null" }] } + } + return { anyOf: [schema, { type: "null" }] } +} + +function legacyVariants(schema: Record): unknown[] { + if (Array.isArray(schema.variants)) return schema.variants + if (Array.isArray(schema.anyOf)) return schema.anyOf + return [] +} + +function convertLegacySchema( + source: Record, + kind: string, + seen: WeakSet, +): JsonSchemaValue { + if (kind === "optional") return schemaValue(source.wrapped ?? source.inner, seen) + if (kind === "nullable") { + const wrapped = schemaValue(source.wrapped ?? source.inner, seen) + return typeof wrapped === "boolean" ? wrapped : makeNullable(wrapped) + } + if (kind === "undefined" || kind === "never" || kind === "any" || kind === "unknown") return {} + + if (kind === "union" || kind === "intersect" || kind === "intersection") { + const variants = legacyVariants(source) + .map((variant) => schemaValue(variant, seen)) + .filter( + (variant) => + isSchemaValue(variant) && + (typeof variant === "boolean" || Object.keys(variant).length > 0), + ) + if (variants.length === 0) return copySchemaObject(source, seen, true) + if (variants.length === 1) return variants[0] ?? {} + + const out = copySchemaObject(source, seen, true) + if (typeof out !== "boolean") out[kind === "union" ? "anyOf" : "allOf"] = variants + return out + } + + if (kind === "object") { + const converted = copySchemaObject(source, seen, true, "object") + if (typeof converted === "boolean") return converted + const out = converted + const sourceProperties = isRecord(source.properties) ? source.properties : undefined + if (!sourceProperties) return out + + const properties: Record = {} + const optional = stringArray(source.optional) ?? [] + for (const [key, value] of Object.entries(sourceProperties)) { + setSchemaProperty(properties, key, schemaValue(value, seen)) + } + out.properties = properties + + const explicitRequired = stringArray(source.required) + const required = + explicitRequired ?? + Object.entries(sourceProperties) + .filter(([key, value]) => !optional.includes(key) && !isOptionalSchema(value)) + .map(([key]) => key) + if (required.length > 0) out.required = required + else delete out.required + return out + } + + if (kind === "array") { + const converted = copySchemaObject(source, seen, true, "array") + if (typeof converted === "boolean") return converted + const out = converted + if (!("items" in source) && "element" in source) out.items = schemaValue(source.element, seen) + return out + } + + if (kind === "enum") { + const converted = copySchemaObject(source, seen, true) + if (typeof converted === "boolean") return converted + const out = converted + if (!("enum" in out) && Array.isArray(source.values)) out.enum = source.values + return out + } + + if (kind === "literal") { + const converted = copySchemaObject(source, seen, true) + if (typeof converted === "boolean") return converted + const out = converted + if (!("const" in out) && "value" in source) out.const = source.value + return out + } + + const scalarType = + kind === "string" || + kind === "number" || + kind === "boolean" || + kind === "integer" || + kind === "null" + ? kind + : undefined + return scalarType ? copySchemaObject(source, seen, true, scalarType) : {} +} + +function convertSchema(source: Record, seen: WeakSet): JsonSchemaValue { + if (seen.has(source)) return {} + seen.add(source) + try { + const kind = legacyKind(source) + if (kind) return convertLegacySchema(source, kind, seen) + if (!looksLikeJsonSchema(source)) return {} + return copySchemaObject(source, seen, false) + } finally { + seen.delete(source) + } +} + +export function toJsonSchema(schema: unknown): unknown { + if (typeof schema === "boolean") return schema + if (!isRecord(schema)) return {} + return convertSchema(schema, new WeakSet()) +} diff --git a/src/overflow.ts b/src/overflow.ts new file mode 100644 index 0000000..3e3e106 --- /dev/null +++ b/src/overflow.ts @@ -0,0 +1,120 @@ +const COMMAND_CODE_PROVIDER = "commandcode" +const CONTEXT_OVERFLOW_PREFIX = "context_length_exceeded:" + +const COMMAND_CODE_OVERFLOW_PATTERNS = [ + /\b(?:context[_\s-]*(?:length|window)|model[_\s-]*context[_\s-]*window)[_\s-]*(?:exceeded|overflow(?:ed)?|too[_\s-]*(?:large|long))\b/i, + /\b(?:context|prompt|input)[_\s-]*(?:length|window|size|tokens?|limit|maximum)\b[\s\S]{0,120}\b(?:exceed(?:ed|s)?|overflow(?:ed|s)?|too\s+(?:large|long)|(?:maximum|limit)\s+(?:reached|exceeded|hit))\b/i, + /\b(?:exceed(?:ed|s)?|overflow(?:ed|s)?|too\s+(?:large|long))\b[\s\S]{0,120}\b(?:context|prompt|input)[_\s-]*(?:length|window|size|tokens?|limit|maximum)\b/i, + /\b(?:prompt|input|context)\b[\s\S]{0,32}\btoo\s+(?:large|long)\b/i, + /\b(?:prompt|input)[_\s-]*too[_\s-]*(?:large|long)\b/i, + /\b(?:prompt|input)[_\s-]*tokens?[_\s-]*(?:limit|maximum|max)[_\s-]*(?:exceeded|reached)\b/i, + /\b(?:prompt|input)[_\s-]*(?:tokens?|length|size)\b[\s\S]{0,120}\b(?:limit|maximum)\b[\s\S]{0,40}\b(?:exceed(?:ed|s)?|reached|hit)\b/i, + /\b(?:maximum|limit)[_\s-]+(?:allowed[_\s-]+)?(?:context|prompt|input)[_\s-]*(?:length|window|size|tokens?)\b/i, +] + +const NON_OVERFLOW_PATTERNS = [ + /\brate[_\s-]*limit\b/i, + /\btoo\s+many\s+requests\b/i, + /\b(?:capacity|quota|throttl(?:e|ed|ing)?|concurren(?:cy|t)|overloaded)\b/i, + /\b(?:service|temporarily)\s+unavailable\b/i, + /\bstatus(?:[_\s-]*code)?\s*[:=]\s*429\b/i, +] + +const CONTEXT_OVERFLOW_PREFIX_PATTERN = /context_length_exceeded/i + +const HTTP_RATE_LIMIT_STATUS_PATTERNS = [ + /\b(?:api\s+error|http|status(?:[_\s-]*code)?|status[_\s-]*code)\s*[:(]?\s*429\b/i, + /["']?(?:status|status[_\s-]*code)["']?\s*:\s*429\b/i, +] + +const BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi +const CREDENTIAL_PATTERN = + /\b(?:api[-_ ]?key|apikey|access[-_ ]?token|refresh[-_ ]?token|token|secret|password|authorization)\s*[=:]\s*[^\s,;)]+/gi +const USER_TOKEN_PATTERN = /\b(?:user|cc)_[A-Za-z0-9_-]{8,}\b/gi +const QUERY_SECRET_PATTERN = + /([?&](?:api[-_ ]?key|apikey|access_token|refresh_token|token|secret|password)=)[^&#\s]+/gi +const STANDALONE_SECRET_PATTERN = + /\b(?:sk|rk|ghp|github_pat|xox[baprs])[-_A-Za-z0-9]{16,}\b|\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g + +export function redactCommandCodeErrorText(value: string): string { + return value + .replace(BEARER_PATTERN, "Bearer [redacted]") + .replace(CREDENTIAL_PATTERN, (match) => { + const separatorIndex = match.search(/[=:]/) + return separatorIndex < 0 ? "[redacted]" : `${match.slice(0, separatorIndex + 1)}[redacted]` + }) + .replace(USER_TOKEN_PATTERN, "[redacted]") + .replace(QUERY_SECRET_PATTERN, "$1[redacted]") + .replace(STANDALONE_SECRET_PATTERN, "[redacted]") +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +export interface CommandCodeMessageLike { + role: string + provider: string + stopReason: string + errorMessage?: string +} + +export function commandCodeErrorMessage(value: unknown): string | undefined { + if (typeof value === "string") return value + if (!isRecord(value)) return undefined + + const record = value + const parts: string[] = [] + for (const key of [ + "message", + "errorMessage", + "error", + "detail", + "details", + "code", + "type", + "reason", + ]) { + const part = commandCodeErrorMessage(record[key]) + if (part && !parts.includes(part)) parts.push(part) + } + + for (const key of ["status", "statusCode", "httpStatus"]) { + const status = record[key] + if (typeof status === "string" || typeof status === "number") { + const statusPart = `status: ${status}` + if (!parts.includes(statusPart)) parts.push(statusPart) + } + } + + return parts.length > 0 ? redactCommandCodeErrorText(parts.join(": ")) : undefined +} + +export function normalizeCommandCodeErrorMessage( + errorMessage: string | undefined, +): string | undefined { + if (!errorMessage) return undefined + if (CONTEXT_OVERFLOW_PREFIX_PATTERN.test(errorMessage)) return undefined + if (NON_OVERFLOW_PATTERNS.some((pattern) => pattern.test(errorMessage))) return undefined + if (HTTP_RATE_LIMIT_STATUS_PATTERNS.some((pattern) => pattern.test(errorMessage))) + return undefined + if (!COMMAND_CODE_OVERFLOW_PATTERNS.some((pattern) => pattern.test(errorMessage))) + return undefined + + return `${CONTEXT_OVERFLOW_PREFIX} ${errorMessage}` +} + +export function normalizeCommandCodeMessage( + message: T, + modelProvider?: string, +): { message: T & { errorMessage: string } } | undefined { + if (message.role !== "assistant" || message.stopReason !== "error") return undefined + if (message.provider !== COMMAND_CODE_PROVIDER && modelProvider !== COMMAND_CODE_PROVIDER) { + return undefined + } + + const errorMessage = normalizeCommandCodeErrorMessage(message.errorMessage) + if (!errorMessage) return undefined + + return { message: { ...message, errorMessage } } +} diff --git a/tests/test-overflow.ts b/tests/test-overflow.ts new file mode 100644 index 0000000..dc28888 --- /dev/null +++ b/tests/test-overflow.ts @@ -0,0 +1,196 @@ +import assert from "node:assert/strict" +import { after, before, beforeEach, describe, it } from "node:test" + +import { + commandCodeErrorMessage, + normalizeCommandCodeErrorMessage, + normalizeCommandCodeMessage, +} from "../src/overflow.ts" +import { + collectEvents, + createTestDeps, + makeContext, + makeModel, + startMockCommandCodeServer, + type MockCommandCodeServer, +} from "./helpers.ts" + +let server: MockCommandCodeServer + +before(async () => { + server = await startMockCommandCodeServer() +}) + +after(async () => { + await server.close() +}) + +beforeEach(() => { + server.reset() +}) + +describe("Command Code overflow normalization", () => { + it("normalizes Command Code context errors to pi's generic overflow marker", () => { + const normalized = normalizeCommandCodeErrorMessage("Prompt token limit exceeded") + + assert.equal(normalized, "context_length_exceeded: Prompt token limit exceeded") + }) + + it("is idempotent and leaves unrelated, rate-limit, and capacity errors unchanged", () => { + assert.equal( + normalizeCommandCodeErrorMessage("context_length_exceeded: Prompt token limit exceeded"), + undefined, + ) + assert.equal(normalizeCommandCodeErrorMessage("OpenAI request failed"), undefined) + assert.equal( + normalizeCommandCodeErrorMessage("Prompt token limit exceeded due to rate limit"), + undefined, + ) + assert.equal( + normalizeCommandCodeErrorMessage("Command Code API error 429: context window exceeded"), + undefined, + ) + assert.equal( + normalizeCommandCodeErrorMessage("context window exceeded: status: 429"), + undefined, + ) + assert.equal( + normalizeCommandCodeErrorMessage("The input is too long"), + "context_length_exceeded: The input is too long", + ) + assert.equal( + normalizeCommandCodeErrorMessage("Input exceeds context limit"), + "context_length_exceeded: Input exceeds context limit", + ) + assert.equal( + normalizeCommandCodeErrorMessage("Context window exceeded: provider capacity reached"), + undefined, + ) + }) + + it("scopes finalized message normalization to Command Code", () => { + const message = { + role: "assistant" as const, + provider: "commandcode", + stopReason: "error" as const, + errorMessage: "model context window exceeded", + } + + const normalized = normalizeCommandCodeMessage(message) + assert.equal( + normalized?.message.errorMessage, + "context_length_exceeded: model context window exceeded", + ) + assert.equal(normalizeCommandCodeMessage({ ...message, provider: "openai" }), undefined) + assert.equal(normalizeCommandCodeMessage({ ...message, stopReason: "stop" }), undefined) + }) + + it("extracts nested stream error messages without exposing credentials", () => { + assert.equal( + commandCodeErrorMessage({ + error: { details: { errorMessage: "context window exceeded" } }, + }), + "context window exceeded", + ) + }) + + it("redacts secrets from finalized provider errors", async () => { + server.mockResponse({ + type: "error", + status: 400, + body: "api_key=user_secret_value", + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + const error = events.at(-1) + assert.equal(error?.type, "error") + if (error?.type !== "error") throw new Error("expected error") + assert.doesNotMatch(error.error.errorMessage ?? "", /user_secret_value/) + assert.match(error.error.errorMessage ?? "", /api_key=\[redacted\]/) + }) + + it("normalizes HTTP error bodies containing nested context errors", async () => { + server.mockResponse({ + type: "error", + status: 400, + body: JSON.stringify({ error: { message: "Prompt token limit exceeded" } }), + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + const error = events.at(-1) + + assert.equal(error?.type, "error") + if (error?.type !== "error") throw new Error("expected error") + const normalized = normalizeCommandCodeMessage(error.error) + assert.match(normalized?.message.errorMessage ?? "", /^context_length_exceeded:/) + }) + + it("does not normalize an HTTP rate-limit response that mentions context", async () => { + server.mockResponse({ + type: "error", + status: 429, + body: JSON.stringify({ error: { message: "context window exceeded" } }), + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + const error = events.at(-1) + + assert.equal(error?.type, "error") + if (error?.type !== "error") throw new Error("expected error") + assert.equal(normalizeCommandCodeMessage(error.error), undefined) + }) + + it("normalizes nested stream error events", async () => { + server.mockResponse({ + type: "success", + events: [ + JSON.stringify({ + type: "error", + error: { details: { message: "model context window exceeded" } }, + }), + ], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + const error = events.at(-1) + + assert.equal(error?.type, "error") + if (error?.type !== "error") throw new Error("expected error") + const normalized = normalizeCommandCodeMessage(error.error) + assert.equal( + normalized?.message.errorMessage, + "context_length_exceeded: model context window exceeded", + ) + + server.mockResponse({ + type: "success", + events: [ + JSON.stringify({ + type: "error", + error: { message: "context window exceeded", status: 429 }, + }), + ], + }) + const retryEvents = await collectEvents( + createTestDeps({ apiBase: server.baseUrl() }).streamCommandCode(makeModel(), makeContext(), { + apiKey: "mock-key", + }), + ) + const retryError = retryEvents.at(-1) + assert.equal(retryError?.type, "error") + if (retryError?.type !== "error") throw new Error("expected error") + assert.equal(normalizeCommandCodeMessage(retryError.error), undefined) + }) +}) diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts index 96ac37b..a6cf530 100644 --- a/tests/test-pure-functions.ts +++ b/tests/test-pure-functions.ts @@ -10,6 +10,8 @@ import { join } from "node:path" import { describe, it } from "node:test" import { + assertTextOnlyMessages, + COMMAND_CODE_INPUT_TYPES, getApiKey, getEnvironmentInfo, mapFinishReason, @@ -20,6 +22,7 @@ import { toJsonSchema, toolsToJson, } from "../src/core.ts" +import { redactCommandCodeErrorText } from "../src/overflow.ts" import { objectAt } from "./helpers.ts" @@ -90,6 +93,20 @@ describe("getApiKey()", () => { }) }) +describe("error redaction", () => { + it("redacts bearer, credential, and query-string secrets", () => { + const redacted = redactCommandCodeErrorText( + "Bearer user_secret_value api_key=user_secret_value https://example.test/x?token=user_secret_value", + ) + assert.doesNotMatch(redacted, /user_secret_value/) + assert.match(redacted, /Bearer \[redacted\]/) + assert.doesNotMatch( + redactCommandCodeErrorText("provider returned sk-test-secret-value-1234567890"), + /sk-test-secret-value/, + ) + }) +}) + describe("projectSlugFromPath()", () => { it("matches the official CLI-style slug from an absolute working directory", () => { assert.equal( @@ -100,13 +117,42 @@ describe("projectSlugFromPath()", () => { }) }) +describe("text-only image handling", () => { + it("does not advertise image input capability", () => { + assert.deepEqual(COMMAND_CODE_INPUT_TYPES, ["text"]) + }) + + it("rejects image content instead of dropping it", () => { + assert.throws( + () => + assertTextOnlyMessages([ + { + role: "user", + content: [{ type: "image", data: "base64-data", mimeType: "image/png" }], + }, + ]), + /does not support image content.*refusing to send it/i, + ) + assert.throws( + () => + assertTextOnlyMessages([ + { + role: "toolResult", + toolCallId: "c1", + content: [{ type: "image", data: "base64-data", mimeType: "image/png" }], + }, + ]), + /does not support image content.*refusing to send it/i, + ) + }) +}) + describe("textContent()", () => { it("extracts and joins text blocks", () => { assert.equal( textContent({ content: [ { type: "text", text: "hello" }, - { type: "image", data: "x" }, { type: "text", text: "world" }, ], }), @@ -114,6 +160,20 @@ describe("textContent()", () => { ) }) + it("rejects mixed text and image content instead of dropping the image", () => { + assert.throws( + () => + textContent({ + content: [ + { type: "text", text: "hello" }, + { type: "image", data: "x", mimeType: "image/png" }, + { type: "text", text: "world" }, + ], + }), + /does not support image content.*refusing to send it/i, + ) + }) + it("handles empty or missing content", () => { assert.equal(textContent({ content: [] }), "") assert.equal(textContent({}), "") @@ -177,6 +237,201 @@ describe("toJsonSchema()", () => { ) assert.deepEqual(toJsonSchema(undefined), {}) assert.deepEqual(toJsonSchema({ kind: "wat" }), {}) + assert.deepEqual(toJsonSchema({ type: "wat", description: "not a schema" }), {}) + assert.deepEqual(toJsonSchema({}), {}) + assert.equal(toJsonSchema(true), true) + }) + + it("preserves complete JSON Schema metadata and nested schemas", () => { + assert.deepEqual( + toJsonSchema({ + type: "object", + description: "Search options", + properties: { + query: { + type: "string", + description: "Text to search for", + minLength: 2, + maxLength: 50, + pattern: "^[a-z]+$", + default: "pi", + }, + limit: { + type: "integer", + minimum: 1, + maximum: 100, + exclusiveMinimum: 0, + multipleOf: 1, + default: 10, + }, + tags: { + type: "array", + minItems: 1, + maxItems: 3, + uniqueItems: true, + items: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + additionalProperties: false, + }, + }, + }, + required: ["query", "limit"], + additionalProperties: false, + }), + { + type: "object", + description: "Search options", + properties: { + query: { + type: "string", + description: "Text to search for", + minLength: 2, + maxLength: 50, + pattern: "^[a-z]+$", + default: "pi", + }, + limit: { + type: "integer", + minimum: 1, + maximum: 100, + exclusiveMinimum: 0, + multipleOf: 1, + default: 10, + }, + tags: { + type: "array", + minItems: 1, + maxItems: 3, + uniqueItems: true, + items: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + additionalProperties: false, + }, + }, + }, + required: ["query", "limit"], + additionalProperties: false, + }, + ) + }) + + it("preserves JSON Schema composition and nullable forms", () => { + assert.deepEqual( + toJsonSchema({ + anyOf: [{ type: "string" }, { type: "number" }], + oneOf: [{ const: "a" }, { const: "b" }], + allOf: [{ minLength: 1 }, { maxLength: 10 }], + nullable: true, + }), + { + anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }], + oneOf: [{ const: "a" }, { const: "b" }], + allOf: [{ minLength: 1 }, { maxLength: 10 }], + }, + ) + assert.deepEqual(toJsonSchema({ type: ["string", "null"] }), { + type: ["string", "null"], + }) + assert.deepEqual(toJsonSchema({ type: "string", nullable: true }), { + type: ["string", "null"], + }) + }) + + it("preserves dangerous schema property names", () => { + const inputProperties: Record = { + constructor: { type: "number" }, + } + Object.defineProperty(inputProperties, "__proto__", { + configurable: true, + enumerable: true, + value: { type: "string" }, + writable: true, + }) + const schema = toJsonSchema({ + type: "object", + properties: inputProperties, + required: ["__proto__", "constructor"], + }) + assert.ok(schema && typeof schema === "object" && !Array.isArray(schema)) + if (!schema || typeof schema !== "object" || Array.isArray(schema)) { + throw new Error("expected object schema") + } + const outputProperties: unknown = Object.getOwnPropertyDescriptor(schema, "properties")?.value + assert.ok(outputProperties && typeof outputProperties === "object") + if (!outputProperties || typeof outputProperties !== "object") { + throw new Error("expected object properties") + } + assert.ok(Object.prototype.hasOwnProperty.call(outputProperties, "__proto__")) + assert.deepEqual(Object.getOwnPropertyDescriptor(outputProperties, "__proto__")?.value, { + type: "string", + }) + assert.deepEqual(Object.getOwnPropertyDescriptor(outputProperties, "constructor")?.value, { + type: "number", + }) + }) + + it("converts legacy shapes without collapsing unions", () => { + assert.deepEqual( + toJsonSchema({ + kind: "Object", + description: "Legacy options", + properties: { + mode: { + kind: "union", + variants: [ + { kind: "string", enum: ["fast", "safe"] }, + { kind: "string", enum: ["debug"] }, + ], + }, + count: { kind: "Number", minimum: 1, optional: true }, + nested: { + kind: "Array", + element: { kind: "object", properties: { value: { kind: "boolean" } } }, + }, + }, + optional: ["count"], + additionalProperties: false, + }), + { + type: "object", + description: "Legacy options", + properties: { + mode: { + anyOf: [ + { type: "string", enum: ["fast", "safe"] }, + { type: "string", enum: ["debug"] }, + ], + }, + count: { type: "number", minimum: 1 }, + nested: { + type: "array", + items: { + type: "object", + properties: { value: { type: "boolean" } }, + required: ["value"], + }, + }, + }, + required: ["mode", "nested"], + additionalProperties: false, + }, + ) + assert.deepEqual( + toJsonSchema({ + kind: "intersect", + variants: [{ kind: "object", properties: { a: { kind: "string" } } }, { kind: "number" }], + }), + { + allOf: [ + { type: "object", properties: { a: { type: "string" } }, required: ["a"] }, + { type: "number" }, + ], + }, + ) }) }) diff --git a/tests/test-stream.ts b/tests/test-stream.ts index c185588..75271dd 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -354,6 +354,34 @@ describe("streamCommandCode — successful streams", () => { }) describe("streamCommandCode — request serialization", () => { + it("rejects image input before sending a lossy request", async () => { + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + const events = await collectEvents( + streamCommandCode( + makeModel(), + makeContext({ + messages: [ + { + role: "user", + content: [{ type: "image", data: "base64-data", mimeType: "image/png" }], + }, + ], + }), + { apiKey: "mock-key" }, + ), + ) + + assert.deepEqual(eventTypes(events), ["start", "error"]) + const lastEvent = events.at(-1) + assert.equal(lastEvent?.type, "error") + if (lastEvent?.type === "error") { + assert.match( + lastEvent.error.errorMessage ?? "", + /does not support image content.*refusing to send it/i, + ) + } + assert.equal(server.requestCount(), 0) + }) it("sends the expected request body and default headers", async () => { server.mockResponse({ type: "success", @@ -394,6 +422,7 @@ describe("streamCommandCode — request serialization", () => { assert.equal(objectAt(body, ["params", "model"]), "deepseek/deepseek-v4-flash") assert.equal(objectAt(body, ["params", "stream"]), true) assert.equal(objectAt(body, ["params", "max_tokens"]), 500) + assert.equal(objectAt(body, ["params", "reasoning_effort"]), undefined) assert.equal(objectAt(body, ["params", "temperature"]), 0.3) assert.equal(objectAt(body, ["params", "system"]), "You are a test assistant.") assert.equal(objectAt(body, ["memory"]), null) @@ -552,6 +581,51 @@ describe("streamCommandCode — request serialization", () => { ) }) + it("times out a hung onResponse callback", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + const started = Date.now() + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { + apiKey: "mock-key", + timeoutMs: 25, + onResponse: async () => new Promise(() => {}), + }), + 1_000, + ) + + assert.ok(Date.now() - started < 500) + assert.deepEqual(eventTypes(events), ["start", "error"]) + const error = events.at(-1) + assert.equal(error?.type, "error") + if (error?.type !== "error") throw new Error("expected error") + assert.match(error.error.errorMessage ?? "", /timed out after 25ms/) + }) + + it("times out a hung onPayload callback", async () => { + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + const started = Date.now() + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { + apiKey: "mock-key", + timeoutMs: 25, + onPayload: async () => new Promise(() => {}), + }), + 1_000, + ) + + assert.ok(Date.now() - started < 500) + assert.deepEqual(eventTypes(events), ["start", "error"]) + const error = events.at(-1) + assert.equal(error?.type, "error") + if (error?.type !== "error") throw new Error("expected error") + assert.match(error.error.errorMessage ?? "", /timed out after 25ms/) + assert.equal(server.requestCount(), 0) + }) + it("runs onPayload and onResponse hooks", async () => { server.mockResponse({ type: "success",