mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-31 07:26:47 -04:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0e207eb01 | |||
| 6b7f34df20 | |||
| f3d1fd9ce8 | |||
| 280b9d4c80 | |||
| 0c1ffc6fa9 | |||
| 8d2d871a58 | |||
| 1eafb2160a | |||
| 2b73a08916 | |||
| 11c0ad24aa |
@@ -1,5 +1,6 @@
|
||||
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import * as Clipboard from "@tui/util/clipboard"
|
||||
import * as Notify from "@tui/util/notify"
|
||||
import * as Selection from "@tui/util/selection"
|
||||
import * as Terminal from "@tui/util/terminal"
|
||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||
@@ -58,8 +59,11 @@ import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
||||
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import { Permission } from "@/permission"
|
||||
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
||||
import { Question } from "@/question"
|
||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { DialogVariant } from "./component/dialog-variant"
|
||||
@@ -781,6 +785,31 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
})
|
||||
})
|
||||
|
||||
const notificationMethod = tuiConfig.notification_method ?? "off"
|
||||
const notifySession = (sessionID: string, prefix: string) => {
|
||||
const session = sync.session.get(sessionID)
|
||||
if (session?.parentID) return
|
||||
Notify.notifyTerminal({
|
||||
method: notificationMethod,
|
||||
title: "OpenCode",
|
||||
body: `${prefix}: ${session?.title ?? sessionID}`,
|
||||
})
|
||||
}
|
||||
|
||||
event.subscribe((evt) => {
|
||||
if (notificationMethod === "off") return
|
||||
if (evt.type === SessionStatus.Event.Idle.type) {
|
||||
notifySession(evt.properties.sessionID, "Response ready")
|
||||
return
|
||||
}
|
||||
if (evt.type === Permission.Event.Asked.type) {
|
||||
notifySession(evt.properties.sessionID, "Permission required")
|
||||
return
|
||||
}
|
||||
if (evt.type !== Question.Event.Asked.type) return
|
||||
notifySession(evt.properties.sessionID, "Question asked")
|
||||
})
|
||||
|
||||
event.on("installation.update-available", async (evt) => {
|
||||
const version = evt.properties.version
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ const TuiLegacy = z
|
||||
scroll_speed: TuiOptions.shape.scroll_speed.catch(undefined),
|
||||
scroll_acceleration: TuiOptions.shape.scroll_acceleration.catch(undefined),
|
||||
diff_style: TuiOptions.shape.diff_style.catch(undefined),
|
||||
notification_method: TuiOptions.shape.notification_method.catch(undefined),
|
||||
})
|
||||
.strip()
|
||||
|
||||
@@ -89,7 +90,8 @@ function normalizeTui(data: Record<string, unknown>) {
|
||||
if (
|
||||
parsed.scroll_speed === undefined &&
|
||||
parsed.diff_style === undefined &&
|
||||
parsed.scroll_acceleration === undefined
|
||||
parsed.scroll_acceleration === undefined &&
|
||||
parsed.notification_method === undefined
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import z from "zod"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { ConfigKeybinds } from "@/config/keybinds"
|
||||
import { NOTIFICATION_METHODS } from "../util/notify"
|
||||
|
||||
const KeybindOverride = z
|
||||
.object(
|
||||
@@ -24,6 +25,10 @@ export const TuiOptions = z.object({
|
||||
.optional()
|
||||
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
|
||||
mouse: z.boolean().optional().describe("Enable or disable mouse capture (default: true)"),
|
||||
notification_method: z
|
||||
.enum(NOTIFICATION_METHODS)
|
||||
.optional()
|
||||
.describe("Select how terminal notifications are emitted for response-ready and attention-needed events"),
|
||||
})
|
||||
|
||||
export const TuiInfo = z
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
import { InstallationVersion } from "@/installation/version"
|
||||
import { InstallationChannel, InstallationVersion } from "@/installation/version"
|
||||
import { TuiPluginRuntime } from "../../plugin"
|
||||
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
@@ -62,6 +62,9 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
<text fg={theme.text}>
|
||||
<b>{session()!.title}</b>
|
||||
</text>
|
||||
<Show when={InstallationChannel !== "latest"}>
|
||||
<text fg={theme.textMuted}>{props.sessionID}</text>
|
||||
</Show>
|
||||
<Show when={session()!.workspaceID}>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import * as Filesystem from "../../../../util/filesystem"
|
||||
import * as Process from "../../../../util/process"
|
||||
import { wrapOscSequence } from "./osc"
|
||||
|
||||
// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
|
||||
const getWhich = lazy(async () => {
|
||||
@@ -25,10 +26,7 @@ const getClipboardy = lazy(async () => {
|
||||
function writeOsc52(text: string): void {
|
||||
if (!process.stdout.isTTY) return
|
||||
const base64 = Buffer.from(text).toString("base64")
|
||||
const osc52 = `\x1b]52;c;${base64}\x07`
|
||||
const passthrough = process.env["TMUX"] || process.env["STY"]
|
||||
const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
|
||||
process.stdout.write(sequence)
|
||||
process.stdout.write(wrapOscSequence(`\x1b]52;c;${base64}\x07`))
|
||||
}
|
||||
|
||||
export interface Content {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { wrapOscSequence } from "./osc"
|
||||
|
||||
const MAX_LENGTH = 180
|
||||
|
||||
export const NOTIFICATION_METHODS = ["auto", "osc9", "osc777", "bell", "off"] as const
|
||||
|
||||
export type NotificationMethod = (typeof NOTIFICATION_METHODS)[number]
|
||||
|
||||
export function resolveNotificationMethod(
|
||||
method: NotificationMethod | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Exclude<NotificationMethod, "auto"> {
|
||||
if (method && method !== "auto") return method
|
||||
if (env.TERM_PROGRAM === "vscode") return "bell"
|
||||
if (env.KITTY_WINDOW_ID || env.TERM === "xterm-kitty") return "osc777"
|
||||
if (env.TERM_PROGRAM === "WezTerm") return "osc777"
|
||||
if (env.VTE_VERSION || env.TERM?.startsWith("foot")) return "osc777"
|
||||
if (env.TERM_PROGRAM === "iTerm.app") return "osc9"
|
||||
if (env.TERM_PROGRAM === "ghostty") return "osc9"
|
||||
if (env.TERM_PROGRAM === "Apple_Terminal") return "osc9"
|
||||
if (env.TERM_PROGRAM === "WarpTerminal") return "osc9"
|
||||
if (env.WT_SESSION) return "bell"
|
||||
return "bell"
|
||||
}
|
||||
|
||||
export function sanitizeNotificationText(value: string) {
|
||||
return value
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
||||
.replace(/;/g, ":")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, MAX_LENGTH)
|
||||
}
|
||||
|
||||
export function formatNotificationSequence(input: {
|
||||
method: Exclude<NotificationMethod, "auto">
|
||||
title: string
|
||||
body?: string
|
||||
}) {
|
||||
if (input.method === "off") return ""
|
||||
if (input.method === "bell") return "\x07"
|
||||
if (input.method === "osc9") {
|
||||
return `\x1b]9;${sanitizeNotificationText([input.title, input.body].filter(Boolean).join(": "))}\x07`
|
||||
}
|
||||
return `\x1b]777;notify;${sanitizeNotificationText(input.title)};${sanitizeNotificationText(input.body ?? "")}\x07`
|
||||
}
|
||||
|
||||
export function notifyTerminal(input: {
|
||||
title: string
|
||||
body?: string
|
||||
method?: NotificationMethod
|
||||
env?: NodeJS.ProcessEnv
|
||||
write?: (chunk: string) => void
|
||||
}) {
|
||||
const env = input.env ?? process.env
|
||||
const method = resolveNotificationMethod(input.method, env)
|
||||
const sequence = wrapOscSequence(
|
||||
formatNotificationSequence({
|
||||
method,
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
}),
|
||||
env,
|
||||
)
|
||||
if (!sequence) return false
|
||||
const write =
|
||||
input.write ??
|
||||
((chunk: string) => (process.stderr.isTTY ? process.stderr.write(chunk) : process.stdout.write(chunk)))
|
||||
write(sequence)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function wrapOscSequence(sequence: string, env: NodeJS.ProcessEnv = process.env) {
|
||||
if (!sequence) return sequence
|
||||
if (!env.TMUX && !env.STY) return sequence
|
||||
return `\x1bPtmux;${sequence.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`
|
||||
}
|
||||
@@ -171,7 +171,7 @@ export const Info = z
|
||||
.optional()
|
||||
.describe("Agent configuration, see https://opencode.ai/docs/agents"),
|
||||
provider: z
|
||||
.record(z.string(), ConfigProvider.Info)
|
||||
.record(z.string(), ConfigProvider.Info.zod)
|
||||
.optional()
|
||||
.describe("Custom provider configurations and model overrides"),
|
||||
mcp: z
|
||||
|
||||
@@ -1,120 +1,118 @@
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
export const Model = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
family: z.string().optional(),
|
||||
release_date: z.string(),
|
||||
attachment: z.boolean(),
|
||||
reasoning: z.boolean(),
|
||||
temperature: z.boolean(),
|
||||
tool_call: z.boolean(),
|
||||
interleaved: z
|
||||
.union([
|
||||
z.literal(true),
|
||||
z
|
||||
.object({
|
||||
field: z.enum(["reasoning_content", "reasoning_details"]),
|
||||
})
|
||||
.strict(),
|
||||
])
|
||||
.optional(),
|
||||
cost: z
|
||||
.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cache_read: z.number().optional(),
|
||||
cache_write: z.number().optional(),
|
||||
context_over_200k: z
|
||||
.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cache_read: z.number().optional(),
|
||||
cache_write: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
limit: z.object({
|
||||
context: z.number(),
|
||||
input: z.number().optional(),
|
||||
output: z.number(),
|
||||
// Positive integer preserving exact Zod JSON Schema (type: integer, exclusiveMinimum: 0).
|
||||
const PositiveInt = Schema.Number.annotate({
|
||||
[ZodOverride]: z.number().int().positive(),
|
||||
})
|
||||
|
||||
export const Model = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
family: Schema.optional(Schema.String),
|
||||
release_date: Schema.optional(Schema.String),
|
||||
attachment: Schema.optional(Schema.Boolean),
|
||||
reasoning: Schema.optional(Schema.Boolean),
|
||||
temperature: Schema.optional(Schema.Boolean),
|
||||
tool_call: Schema.optional(Schema.Boolean),
|
||||
interleaved: Schema.optional(
|
||||
Schema.Union([
|
||||
Schema.Literal(true),
|
||||
Schema.Struct({
|
||||
field: Schema.Literals(["reasoning_content", "reasoning_details"]),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
cost: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
cache_read: Schema.optional(Schema.Number),
|
||||
cache_write: Schema.optional(Schema.Number),
|
||||
context_over_200k: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
cache_read: Schema.optional(Schema.Number),
|
||||
cache_write: Schema.optional(Schema.Number),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
modalities: z
|
||||
.object({
|
||||
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
|
||||
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
|
||||
})
|
||||
.optional(),
|
||||
experimental: z.boolean().optional(),
|
||||
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
|
||||
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
|
||||
options: z.record(z.string(), z.any()),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
variants: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
disabled: z.boolean().optional().describe("Disable this variant for the model"),
|
||||
})
|
||||
.catchall(z.any()),
|
||||
)
|
||||
.optional()
|
||||
.describe("Variant-specific configuration"),
|
||||
})
|
||||
.partial()
|
||||
),
|
||||
limit: Schema.optional(
|
||||
Schema.Struct({
|
||||
context: Schema.Number,
|
||||
input: Schema.optional(Schema.Number),
|
||||
output: Schema.Number,
|
||||
}),
|
||||
),
|
||||
modalities: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
|
||||
output: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
|
||||
}),
|
||||
),
|
||||
experimental: Schema.optional(Schema.Boolean),
|
||||
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
|
||||
provider: Schema.optional(
|
||||
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
|
||||
),
|
||||
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
variants: Schema.optional(
|
||||
Schema.Record(
|
||||
Schema.String,
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
disabled: Schema.optional(Schema.Boolean).annotate({ description: "Disable this variant for the model" }),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Any)],
|
||||
),
|
||||
).annotate({ description: "Variant-specific configuration" }),
|
||||
),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
api: z.string().optional(),
|
||||
name: z.string(),
|
||||
env: z.array(z.string()),
|
||||
id: z.string(),
|
||||
npm: z.string().optional(),
|
||||
whitelist: z.array(z.string()).optional(),
|
||||
blacklist: z.array(z.string()).optional(),
|
||||
options: z
|
||||
.object({
|
||||
apiKey: z.string().optional(),
|
||||
baseURL: z.string().optional(),
|
||||
enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"),
|
||||
setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"),
|
||||
timeout: z
|
||||
.union([
|
||||
z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.describe(
|
||||
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
|
||||
),
|
||||
z.literal(false).describe("Disable timeout for this provider entirely."),
|
||||
])
|
||||
.optional()
|
||||
.describe(
|
||||
export class Info extends Schema.Class<Info>("ProviderConfig")({
|
||||
api: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||
id: Schema.optional(Schema.String),
|
||||
npm: Schema.optional(Schema.String),
|
||||
whitelist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||
blacklist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||
options: Schema.optional(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
apiKey: Schema.optional(Schema.String),
|
||||
baseURL: Schema.optional(Schema.String),
|
||||
enterpriseUrl: Schema.optional(Schema.String).annotate({
|
||||
description: "GitHub Enterprise URL for copilot authentication",
|
||||
}),
|
||||
setCacheKey: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Enable promptCacheKey for this provider (default false)",
|
||||
}),
|
||||
timeout: Schema.optional(
|
||||
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
|
||||
description:
|
||||
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
|
||||
}),
|
||||
).annotate({
|
||||
description:
|
||||
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
|
||||
),
|
||||
chunkTimeout: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe(
|
||||
}),
|
||||
chunkTimeout: Schema.optional(PositiveInt).annotate({
|
||||
description:
|
||||
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
|
||||
),
|
||||
})
|
||||
.catchall(z.any())
|
||||
.optional(),
|
||||
models: z.record(z.string(), Model).optional(),
|
||||
})
|
||||
.partial()
|
||||
.strict()
|
||||
.meta({
|
||||
ref: "ProviderConfig",
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>
|
||||
}),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Any)],
|
||||
),
|
||||
),
|
||||
models: Schema.optional(Schema.Record(Schema.String, Model)),
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
export * as ConfigProvider from "./provider"
|
||||
|
||||
@@ -4,18 +4,44 @@ import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
type AppEnv = Parameters<typeof AppRuntime.runPromise>[0] extends Effect.Effect<any, any, infer R> ? R : never
|
||||
|
||||
// Build the base span attributes for an HTTP handler: method, path, and every
|
||||
// matched route param. Names follow OTel attribute-naming guidance:
|
||||
// domain-first (`session.id`, `message.id`, …) so they match the existing
|
||||
// OTel `session.id` semantic convention and the bare `message.id` we
|
||||
// already emit from Tool.execute. Non-standard route params fall back to
|
||||
// `opencode.<name>` since those are internal implementation details
|
||||
// (per https://opentelemetry.io/blog/2025/how-to-name-your-span-attributes/).
|
||||
export interface RequestLike {
|
||||
readonly req: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
param(): Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize a Hono route param key (e.g. `sessionID`, `messageID`, `name`)
|
||||
// to an OTel attribute key. `fooID` → `foo.id` for ID-shaped params; any
|
||||
// other param is namespaced under `opencode.` to avoid colliding with
|
||||
// standard conventions.
|
||||
export function paramToAttributeKey(key: string): string {
|
||||
const m = key.match(/^(.+)ID$/)
|
||||
if (m) return `${m[1].toLowerCase()}.id`
|
||||
return `opencode.${key}`
|
||||
}
|
||||
|
||||
export function requestAttributes(c: RequestLike): Record<string, string> {
|
||||
const attributes: Record<string, string> = {
|
||||
"http.method": c.req.method,
|
||||
"http.path": new URL(c.req.url).pathname,
|
||||
}
|
||||
for (const [key, value] of Object.entries(c.req.param())) {
|
||||
attributes[paramToAttributeKey(key)] = value
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
export function runRequest<A, E>(name: string, c: Context, effect: Effect.Effect<A, E, AppEnv>) {
|
||||
const url = new URL(c.req.url)
|
||||
return AppRuntime.runPromise(
|
||||
effect.pipe(
|
||||
Effect.withSpan(name, {
|
||||
attributes: {
|
||||
"http.method": c.req.method,
|
||||
"http.path": url.pathname,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
return AppRuntime.runPromise(effect.pipe(Effect.withSpan(name, { attributes: requestAttributes(c) })))
|
||||
}
|
||||
|
||||
export async function jsonRequest<C extends Context, A, E>(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Schema, SchemaAST } from "effect"
|
||||
import { Effect, Option, Schema, SchemaAST } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
/**
|
||||
@@ -8,33 +8,90 @@ import z from "zod"
|
||||
*/
|
||||
export const ZodOverride: unique symbol = Symbol.for("effect-zod/override")
|
||||
|
||||
// AST nodes are immutable and frequently shared across schemas (e.g. a single
|
||||
// Schema.Class embedded in multiple parents). Memoizing by node identity
|
||||
// avoids rebuilding equivalent Zod subtrees and keeps derived children stable
|
||||
// by reference across callers.
|
||||
const walkCache = new WeakMap<SchemaAST.AST, z.ZodTypeAny>()
|
||||
|
||||
// Shared empty ParseOptions for the rare callers that need one — avoids
|
||||
// allocating a fresh object per parse inside refinements and transforms.
|
||||
const EMPTY_PARSE_OPTIONS = {} as SchemaAST.ParseOptions
|
||||
|
||||
export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
|
||||
return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
|
||||
}
|
||||
|
||||
function walk(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
const cached = walkCache.get(ast)
|
||||
if (cached) return cached
|
||||
const result = walkUncached(ast)
|
||||
walkCache.set(ast, result)
|
||||
return result
|
||||
}
|
||||
|
||||
function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined
|
||||
if (override) return override
|
||||
|
||||
let out = body(ast)
|
||||
for (const check of ast.checks ?? []) {
|
||||
out = applyCheck(out, check, ast)
|
||||
}
|
||||
// Schema.Class wraps its fields in a Declaration AST plus an encoding that
|
||||
// constructs the class instance. For the Zod derivation we want the plain
|
||||
// field shape (the decoded/consumer view), not the class instance — so
|
||||
// Declarations fall through to body(), not encoded(). User-level
|
||||
// Schema.decodeTo / Schema.transform attach encoding to non-Declaration
|
||||
// nodes, where we do apply the transform.
|
||||
const hasTransform = ast.encoding?.length && ast._tag !== "Declaration"
|
||||
const base = hasTransform ? encoded(ast) : body(ast)
|
||||
const out = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
|
||||
const desc = SchemaAST.resolveDescription(ast)
|
||||
const ref = SchemaAST.resolveIdentifier(ast)
|
||||
const next = desc ? out.describe(desc) : out
|
||||
return ref ? next.meta({ ref }) : next
|
||||
const described = desc ? out.describe(desc) : out
|
||||
return ref ? described.meta({ ref }) : described
|
||||
}
|
||||
|
||||
function applyCheck(out: z.ZodTypeAny, check: SchemaAST.Check<any>, ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
if (check._tag === "FilterGroup") {
|
||||
return check.checks.reduce((acc, sub) => applyCheck(acc, sub, ast), out)
|
||||
// Walk the encoded side and apply each link's decode to produce the decoded
|
||||
// shape. A node `Target` produced by `from.decodeTo(Target)` carries
|
||||
// `Target.encoding = [Link(from, transformation)]`. Chained decodeTo calls
|
||||
// nest the encoding via `Link.to` so walking it recursively threads all
|
||||
// prior transforms — typical encoding.length is 1.
|
||||
function encoded(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
const encoding = ast.encoding!
|
||||
return encoding.reduce<z.ZodTypeAny>(
|
||||
(acc, link) => acc.transform((v) => decode(link.transformation, v)),
|
||||
walk(encoding[0].to),
|
||||
)
|
||||
}
|
||||
|
||||
// Transformations built via pure `SchemaGetter.transform(fn)` (the common
|
||||
// decodeTo case) resolve synchronously, so running with no services is safe.
|
||||
// Effectful / middleware-based transforms will surface as Effect defects.
|
||||
function decode(transformation: SchemaAST.Link["transformation"], value: unknown): unknown {
|
||||
const exit = Effect.runSyncExit(
|
||||
(transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect<
|
||||
Option.Option<unknown>
|
||||
>,
|
||||
)
|
||||
if (exit._tag === "Failure") throw new Error(`effect-zod: transform failed: ${String(exit.cause)}`)
|
||||
return Option.getOrElse(exit.value, () => value)
|
||||
}
|
||||
|
||||
// Flatten FilterGroups and any nested variants into a linear list of Filters
|
||||
// so we can run all of them inside a single Zod .superRefine wrapper instead
|
||||
// of stacking N wrapper layers (one per check).
|
||||
function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
const filters: SchemaAST.Filter<unknown>[] = []
|
||||
const collect = (c: SchemaAST.Check<unknown>) => {
|
||||
if (c._tag === "FilterGroup") c.checks.forEach(collect)
|
||||
else filters.push(c)
|
||||
}
|
||||
checks.forEach(collect)
|
||||
return out.superRefine((value, ctx) => {
|
||||
const issue = check.run(value, ast, {} as any)
|
||||
if (!issue) return
|
||||
const message = issueMessage(issue) ?? (check.annotations as any)?.message ?? "Validation failed"
|
||||
ctx.addIssue({ code: "custom", message })
|
||||
for (const filter of filters) {
|
||||
const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS)
|
||||
if (!issue) continue
|
||||
const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed"
|
||||
ctx.addIssue({ code: "custom", message })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,15 +164,27 @@ function union(ast: SchemaAST.Union): z.ZodTypeAny {
|
||||
}
|
||||
|
||||
function object(ast: SchemaAST.Objects): z.ZodTypeAny {
|
||||
// Pure record: { [k: string]: V }
|
||||
if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) {
|
||||
const sig = ast.indexSignatures[0]
|
||||
if (sig.parameter._tag !== "String") return fail(ast)
|
||||
return z.record(z.string(), walk(sig.type))
|
||||
}
|
||||
|
||||
if (ast.indexSignatures.length > 0) return fail(ast)
|
||||
// Pure object with known fields and no index signatures.
|
||||
if (ast.indexSignatures.length === 0) {
|
||||
return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
|
||||
}
|
||||
|
||||
return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
|
||||
// Struct with a catchall (StructWithRest): known fields + index signature.
|
||||
// Only supports a single string-keyed index signature; multi-signature or
|
||||
// symbol/number keys fall through to fail.
|
||||
if (ast.indexSignatures.length !== 1) return fail(ast)
|
||||
const sig = ast.indexSignatures[0]
|
||||
if (sig.parameter._tag !== "String") return fail(ast)
|
||||
return z
|
||||
.object(Object.fromEntries(ast.propertySignatures.map((p) => [String(p.name), walk(p.type)])))
|
||||
.catchall(walk(sig.type))
|
||||
}
|
||||
|
||||
function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from "bun:test"
|
||||
|
||||
const { formatNotificationSequence, notifyTerminal, resolveNotificationMethod, sanitizeNotificationText } =
|
||||
await import("../../../src/cli/cmd/tui/util/notify")
|
||||
const { wrapOscSequence } = await import("../../../src/cli/cmd/tui/util/osc")
|
||||
|
||||
test("resolveNotificationMethod picks osc9 for iTerm", () => {
|
||||
expect(resolveNotificationMethod("auto", { TERM_PROGRAM: "iTerm.app" })).toBe("osc9")
|
||||
})
|
||||
|
||||
test("resolveNotificationMethod picks osc777 for kitty and bell for vscode", () => {
|
||||
expect(resolveNotificationMethod("auto", { KITTY_WINDOW_ID: "1" })).toBe("osc777")
|
||||
expect(resolveNotificationMethod("auto", { TERM_PROGRAM: "vscode" })).toBe("bell")
|
||||
})
|
||||
|
||||
test("sanitizeNotificationText removes controls and semicolons", () => {
|
||||
expect(sanitizeNotificationText("hello;\nworld\x07")).toBe("hello: world")
|
||||
})
|
||||
|
||||
test("formatNotificationSequence emits osc9 and osc777 payloads", () => {
|
||||
expect(formatNotificationSequence({ method: "osc9", title: "OpenCode", body: "Response ready" })).toBe(
|
||||
"\x1b]9;OpenCode: Response ready\x07",
|
||||
)
|
||||
expect(formatNotificationSequence({ method: "osc777", title: "OpenCode", body: "Permission required" })).toBe(
|
||||
"\x1b]777;notify;OpenCode;Permission required\x07",
|
||||
)
|
||||
})
|
||||
|
||||
test("wrapOscSequence escapes OSC sequences for passthrough", () => {
|
||||
expect(wrapOscSequence("\x1b]9;done\x07", { TMUX: "/tmp/tmux" })).toBe("\x1bPtmux;\x1b\x1b]9;done\x07\x1b\\")
|
||||
})
|
||||
|
||||
test("notifyTerminal writes the resolved sequence", () => {
|
||||
let output = ""
|
||||
expect(
|
||||
notifyTerminal({
|
||||
title: "OpenCode",
|
||||
body: "Question asked",
|
||||
method: "auto",
|
||||
env: { TERM_PROGRAM: "ghostty" },
|
||||
write: (chunk) => {
|
||||
output += chunk
|
||||
},
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(output).toBe("\x1b]9;OpenCode: Question asked\x07")
|
||||
})
|
||||
@@ -624,3 +624,39 @@ test("merges plugin_enabled flags across config layers", async () => {
|
||||
"local.plugin": true,
|
||||
})
|
||||
})
|
||||
|
||||
test("loads notification config from tui.json", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ notification_method: "osc777" }, null, 2))
|
||||
},
|
||||
})
|
||||
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config.notification_method).toBe("osc777")
|
||||
})
|
||||
|
||||
test("migrates legacy notification settings from opencode.json", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
tui: { notification_method: "bell" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config.notification_method).toBe("bell")
|
||||
|
||||
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
|
||||
expect(JSON.parse(text)).toMatchObject({
|
||||
notification_method: "bell",
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { paramToAttributeKey, requestAttributes } from "../../src/server/routes/instance/trace"
|
||||
|
||||
function fakeContext(method: string, url: string, params: Record<string, string>) {
|
||||
return {
|
||||
req: {
|
||||
method,
|
||||
url,
|
||||
param: () => params,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("paramToAttributeKey", () => {
|
||||
test("converts fooID to foo.id", () => {
|
||||
expect(paramToAttributeKey("sessionID")).toBe("session.id")
|
||||
expect(paramToAttributeKey("messageID")).toBe("message.id")
|
||||
expect(paramToAttributeKey("partID")).toBe("part.id")
|
||||
expect(paramToAttributeKey("projectID")).toBe("project.id")
|
||||
expect(paramToAttributeKey("providerID")).toBe("provider.id")
|
||||
expect(paramToAttributeKey("ptyID")).toBe("pty.id")
|
||||
expect(paramToAttributeKey("permissionID")).toBe("permission.id")
|
||||
expect(paramToAttributeKey("requestID")).toBe("request.id")
|
||||
expect(paramToAttributeKey("workspaceID")).toBe("workspace.id")
|
||||
})
|
||||
|
||||
test("namespaces non-ID params under opencode.", () => {
|
||||
expect(paramToAttributeKey("name")).toBe("opencode.name")
|
||||
expect(paramToAttributeKey("slug")).toBe("opencode.slug")
|
||||
})
|
||||
})
|
||||
|
||||
describe("requestAttributes", () => {
|
||||
test("includes http method and path", () => {
|
||||
const attrs = requestAttributes(fakeContext("GET", "http://localhost/session", {}))
|
||||
expect(attrs["http.method"]).toBe("GET")
|
||||
expect(attrs["http.path"]).toBe("/session")
|
||||
})
|
||||
|
||||
test("strips query string from path", () => {
|
||||
const attrs = requestAttributes(fakeContext("GET", "http://localhost/file/search?query=foo&limit=10", {}))
|
||||
expect(attrs["http.path"]).toBe("/file/search")
|
||||
})
|
||||
|
||||
test("emits OTel-style <domain>.id for ID-shaped route params", () => {
|
||||
const attrs = requestAttributes(
|
||||
fakeContext("GET", "http://localhost/session/ses_abc/message/msg_def/part/prt_ghi", {
|
||||
sessionID: "ses_abc",
|
||||
messageID: "msg_def",
|
||||
partID: "prt_ghi",
|
||||
}),
|
||||
)
|
||||
expect(attrs["session.id"]).toBe("ses_abc")
|
||||
expect(attrs["message.id"]).toBe("msg_def")
|
||||
expect(attrs["part.id"]).toBe("prt_ghi")
|
||||
// No camelCase leftovers:
|
||||
expect(attrs["opencode.sessionID"]).toBeUndefined()
|
||||
expect(attrs["opencode.messageID"]).toBeUndefined()
|
||||
expect(attrs["opencode.partID"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("produces no param attributes when no params are matched", () => {
|
||||
const attrs = requestAttributes(fakeContext("POST", "http://localhost/config", {}))
|
||||
expect(Object.keys(attrs).filter((k) => k !== "http.method" && k !== "http.path")).toEqual([])
|
||||
})
|
||||
|
||||
test("namespaces non-ID params under opencode. (e.g. mcp :name)", () => {
|
||||
const attrs = requestAttributes(
|
||||
fakeContext("POST", "http://localhost/mcp/exa/connect", {
|
||||
name: "exa",
|
||||
}),
|
||||
)
|
||||
expect(attrs["opencode.name"]).toBe("exa")
|
||||
expect(attrs["name"]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
import { zod, ZodOverride } from "../../src/util/effect-zod"
|
||||
@@ -263,4 +263,219 @@ describe("util.effect-zod", () => {
|
||||
expect(result.error!.issues[0].message).toBe("missing 'required' key")
|
||||
})
|
||||
})
|
||||
|
||||
describe("StructWithRest / catchall", () => {
|
||||
test("struct with a string-keyed record rest parses known AND extra keys", () => {
|
||||
const schema = zod(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
apiKey: Schema.optional(Schema.String),
|
||||
baseURL: Schema.optional(Schema.String),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
)
|
||||
|
||||
// Known fields come through as declared
|
||||
expect(schema.parse({ apiKey: "sk-x" })).toEqual({ apiKey: "sk-x" })
|
||||
|
||||
// Extra keys are preserved (catchall)
|
||||
expect(
|
||||
schema.parse({
|
||||
apiKey: "sk-x",
|
||||
baseURL: "https://api.example.com",
|
||||
customField: "anything",
|
||||
nested: { foo: 1 },
|
||||
}),
|
||||
).toEqual({
|
||||
apiKey: "sk-x",
|
||||
baseURL: "https://api.example.com",
|
||||
customField: "anything",
|
||||
nested: { foo: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
test("catchall value type constrains the extras", () => {
|
||||
const schema = zod(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
count: Schema.Number,
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Number)],
|
||||
),
|
||||
)
|
||||
|
||||
// Known field + numeric extras
|
||||
expect(schema.parse({ count: 10, a: 1, b: 2 })).toEqual({ count: 10, a: 1, b: 2 })
|
||||
|
||||
// Non-numeric extra is rejected
|
||||
expect(schema.safeParse({ count: 10, bad: "not a number" }).success).toBe(false)
|
||||
})
|
||||
|
||||
test("JSON schema output marks additionalProperties appropriately", () => {
|
||||
const schema = zod(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
)
|
||||
const shape = json(schema) as { additionalProperties?: unknown }
|
||||
// Presence of `additionalProperties` (truthy or a schema) signals catchall.
|
||||
expect(shape.additionalProperties).not.toBe(false)
|
||||
expect(shape.additionalProperties).toBeDefined()
|
||||
})
|
||||
|
||||
test("plain struct without rest still emits additionalProperties unchanged (regression)", () => {
|
||||
const schema = zod(Schema.Struct({ id: Schema.String }))
|
||||
expect(schema.parse({ id: "x" })).toEqual({ id: "x" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("transforms (Schema.decodeTo)", () => {
|
||||
test("Number -> pseudo-Duration (seconds) applies the decode function", () => {
|
||||
// Models the account/account.ts DurationFromSeconds pattern.
|
||||
const SecondsToMs = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Number, {
|
||||
decode: SchemaGetter.transform((n: number) => n * 1000),
|
||||
encode: SchemaGetter.transform((ms: number) => ms / 1000),
|
||||
}),
|
||||
)
|
||||
|
||||
const schema = zod(SecondsToMs)
|
||||
expect(schema.parse(3)).toBe(3000)
|
||||
expect(schema.parse(0)).toBe(0)
|
||||
})
|
||||
|
||||
test("String -> Number via parseInt decode", () => {
|
||||
const ParsedInt = Schema.String.pipe(
|
||||
Schema.decodeTo(Schema.Number, {
|
||||
decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)),
|
||||
encode: SchemaGetter.transform((n: number) => String(n)),
|
||||
}),
|
||||
)
|
||||
|
||||
const schema = zod(ParsedInt)
|
||||
expect(schema.parse("42")).toBe(42)
|
||||
expect(schema.parse("0")).toBe(0)
|
||||
})
|
||||
|
||||
test("transform inside a struct field applies per-field", () => {
|
||||
const Field = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Number, {
|
||||
decode: SchemaGetter.transform((n: number) => n + 1),
|
||||
encode: SchemaGetter.transform((n: number) => n - 1),
|
||||
}),
|
||||
)
|
||||
|
||||
const schema = zod(
|
||||
Schema.Struct({
|
||||
plain: Schema.Number,
|
||||
bumped: Field,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(schema.parse({ plain: 5, bumped: 10 })).toEqual({ plain: 5, bumped: 11 })
|
||||
})
|
||||
|
||||
test("chained decodeTo composes transforms in order", () => {
|
||||
// String -> Number (parseInt) -> Number (doubled).
|
||||
// Exercises the encoded() reduce, not just a single link.
|
||||
const Chained = Schema.String.pipe(
|
||||
Schema.decodeTo(Schema.Number, {
|
||||
decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)),
|
||||
encode: SchemaGetter.transform((n: number) => String(n)),
|
||||
}),
|
||||
Schema.decodeTo(Schema.Number, {
|
||||
decode: SchemaGetter.transform((n: number) => n * 2),
|
||||
encode: SchemaGetter.transform((n: number) => n / 2),
|
||||
}),
|
||||
)
|
||||
|
||||
const schema = zod(Chained)
|
||||
expect(schema.parse("21")).toBe(42)
|
||||
expect(schema.parse("0")).toBe(0)
|
||||
})
|
||||
|
||||
test("Schema.Class is unaffected by transform walker (returns plain object, not instance)", () => {
|
||||
// Schema.Class uses Declaration + encoding under the hood to construct
|
||||
// class instances. The walker must NOT apply that transform, or zod
|
||||
// parsing would return class instances instead of plain objects.
|
||||
class Method extends Schema.Class<Method>("TxTestMethod")({
|
||||
type: Schema.String,
|
||||
value: Schema.Number,
|
||||
}) {}
|
||||
|
||||
const schema = zod(Method)
|
||||
const parsed = schema.parse({ type: "oauth", value: 1 })
|
||||
expect(parsed).toEqual({ type: "oauth", value: 1 })
|
||||
// Guardrail: ensure we didn't get back a Method instance.
|
||||
expect(parsed).not.toBeInstanceOf(Method)
|
||||
})
|
||||
})
|
||||
|
||||
describe("optimizations", () => {
|
||||
test("walk() memoizes by AST identity — same AST node returns same Zod", () => {
|
||||
const shared = Schema.Struct({ id: Schema.String, name: Schema.String })
|
||||
const left = zod(shared)
|
||||
const right = zod(shared)
|
||||
expect(left).toBe(right)
|
||||
})
|
||||
|
||||
test("nested reuse of the same AST reuses the cached Zod child", () => {
|
||||
// Two different parents embed the same inner schema. The inner zod
|
||||
// child should be identical by reference inside both parents.
|
||||
class Inner extends Schema.Class<Inner>("MemoTestInner")({
|
||||
value: Schema.String,
|
||||
}) {}
|
||||
|
||||
class OuterA extends Schema.Class<OuterA>("MemoTestOuterA")({
|
||||
inner: Inner,
|
||||
}) {}
|
||||
|
||||
class OuterB extends Schema.Class<OuterB>("MemoTestOuterB")({
|
||||
inner: Inner,
|
||||
}) {}
|
||||
|
||||
const shapeA = (zod(OuterA) as any).shape ?? (zod(OuterA) as any)._def?.shape?.()
|
||||
const shapeB = (zod(OuterB) as any).shape ?? (zod(OuterB) as any)._def?.shape?.()
|
||||
expect(shapeA.inner).toBe(shapeB.inner)
|
||||
})
|
||||
|
||||
test("multiple checks run in a single refinement layer (all fire on one value)", () => {
|
||||
// Three checks attached to the same schema. All three must run and
|
||||
// report — asserting that no check silently got dropped when we
|
||||
// flattened into one superRefine.
|
||||
const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive"))
|
||||
const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even"))
|
||||
const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big"))
|
||||
|
||||
const schema = zod(Schema.Number.check(positive).check(even).check(under100))
|
||||
|
||||
const neg = schema.safeParse(-3)
|
||||
expect(neg.success).toBe(false)
|
||||
expect(neg.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
|
||||
|
||||
const big = schema.safeParse(101)
|
||||
expect(big.success).toBe(false)
|
||||
expect(big.error!.issues.map((i) => i.message)).toContain("too big")
|
||||
|
||||
// Passing value satisfies all three
|
||||
expect(schema.parse(42)).toBe(42)
|
||||
})
|
||||
|
||||
test("FilterGroup flattens into the single refinement layer alongside its siblings", () => {
|
||||
const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive"))
|
||||
const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even"))
|
||||
const group = Schema.makeFilterGroup([positive, even])
|
||||
const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big"))
|
||||
|
||||
const schema = zod(Schema.Number.check(group).check(under100))
|
||||
|
||||
const bad = schema.safeParse(-3)
|
||||
expect(bad.success).toBe(false)
|
||||
expect(bad.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11180,13 +11180,11 @@
|
||||
"description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
{
|
||||
"description": "Disable timeout for this provider entirely.",
|
||||
"type": "boolean",
|
||||
"const": false
|
||||
}
|
||||
@@ -11247,8 +11245,7 @@
|
||||
"enum": ["reasoning_content", "reasoning_details"]
|
||||
}
|
||||
},
|
||||
"required": ["field"],
|
||||
"additionalProperties": false
|
||||
"required": ["field"]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -11377,8 +11374,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"McpLocalConfig": {
|
||||
"type": "object",
|
||||
|
||||
Reference in New Issue
Block a user