diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index a58ff056485a..4d3456f1f453 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -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 }) { }) }) + 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 diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts index a7f50ddf9dc6..f9e5f2d5fc2a 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts @@ -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) { if ( parsed.scroll_speed === undefined && parsed.diff_style === undefined && - parsed.scroll_acceleration === undefined + parsed.scroll_acceleration === undefined && + parsed.notification_method === undefined ) { return } diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index ed79e8e52418..44700a225ebb 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -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 diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 4a7b711a032b..6d92752efe36 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -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 }) { {session()!.title} + + {props.sessionID} + {" "} diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 8c535833c6ea..462d132c65eb 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -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 { diff --git a/packages/opencode/src/cli/cmd/tui/util/notify.ts b/packages/opencode/src/cli/cmd/tui/util/notify.ts new file mode 100644 index 000000000000..7306d2ae837e --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/notify.ts @@ -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 { + 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 + 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 +} diff --git a/packages/opencode/src/cli/cmd/tui/util/osc.ts b/packages/opencode/src/cli/cmd/tui/util/osc.ts new file mode 100644 index 000000000000..1b992ef69ae7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/osc.ts @@ -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\\` +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a2d62eaa5e6b..bfb0c2f1f40b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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 diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 877677519f03..b435f43759ae 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -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), + }), + ), + }), + ), + limit: Schema.optional( + Schema.Struct({ + context: Schema.Number, + input: Schema.optional(Schema.Number), + output: 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() + ), + 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("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 + }), + }), + [Schema.Record(Schema.String, Schema.Any)], + ), + ), + models: Schema.optional(Schema.Record(Schema.String, Model)), +}) { + static readonly zod = zod(this) +} export * as ConfigProvider from "./provider" diff --git a/packages/opencode/src/server/routes/instance/trace.ts b/packages/opencode/src/server/routes/instance/trace.ts index 3e1f72d8b242..4c7119ef3ace 100644 --- a/packages/opencode/src/server/routes/instance/trace.ts +++ b/packages/opencode/src/server/routes/instance/trace.ts @@ -4,18 +4,44 @@ import { AppRuntime } from "@/effect/app-runtime" type AppEnv = Parameters[0] extends Effect.Effect ? 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.` 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 + } +} + +// 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 { + const attributes: Record = { + "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(name: string, c: Context, effect: Effect.Effect) { - 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( diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 771795ba6826..82c661e402a9 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -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() + +// 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(schema: S): z.ZodType> { return walk(schema.ast) as z.ZodType> } 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, 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( + (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 + >, + ) + 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[] = [] + const collect = (c: SchemaAST.Check) => { + 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 { diff --git a/packages/opencode/test/cli/tui/notify.test.ts b/packages/opencode/test/cli/tui/notify.test.ts new file mode 100644 index 000000000000..1c517a629cba --- /dev/null +++ b/packages/opencode/test/cli/tui/notify.test.ts @@ -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") +}) diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index c7b6d4a50494..f585414a8cd0 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -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", + }) +}) diff --git a/packages/opencode/test/server/trace-attributes.test.ts b/packages/opencode/test/server/trace-attributes.test.ts new file mode 100644 index 000000000000..c6e8005a2066 --- /dev/null +++ b/packages/opencode/test/server/trace-attributes.test.ts @@ -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) { + 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 .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() + }) +}) diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index ba67a60e6dc0..3d72984bfcbe 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -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("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("MemoTestInner")({ + value: Schema.String, + }) {} + + class OuterA extends Schema.Class("MemoTestOuterA")({ + inner: Inner, + }) {} + + class OuterB extends Schema.Class("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"])) + }) + }) }) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index c3fd00356c2a..5a93c4db2a83 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -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",