mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 09:39:46 -04:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3650cc425 | |||
| 3f8c659056 | |||
| 3910a6e527 | |||
| 24892559ae | |||
| cd93533b1f | |||
| 0590452456 | |||
| 93940a1859 | |||
| 1e439b8226 | |||
| 8b2f8355b2 | |||
| aed03078f8 | |||
| c50d65b4d6 |
@@ -187,6 +187,8 @@ export async function handler(
|
||||
// Try another provider => stop retrying if using fallback provider
|
||||
if (
|
||||
res.status !== 200 &&
|
||||
// ie. 400 error is usually provider error like malformed request
|
||||
res.status !== 400 &&
|
||||
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
|
||||
res.status !== 404 &&
|
||||
// ie. cannot change codex model providers mid-session
|
||||
@@ -226,7 +228,7 @@ export async function handler(
|
||||
logger.debug("STATUS: " + res.status + " " + res.statusText)
|
||||
|
||||
// Handle non-streaming response
|
||||
if (!isStream || res.status === 429) {
|
||||
if (!isStream || [400, 404, 429].includes(res.status)) {
|
||||
const json = await res.json()
|
||||
await rateLimiter?.track()
|
||||
if (json.usage) {
|
||||
@@ -238,6 +240,9 @@ export async function handler(
|
||||
await reload(billingSource, authInfo, costInfo)
|
||||
json.cost = calculateOccurredCost(billingSource, costInfo)
|
||||
}
|
||||
if (res.status === 400) {
|
||||
logger.metric({ "error.response": JSON.stringify(json) })
|
||||
}
|
||||
if (json.error?.message) {
|
||||
json.error.message = `Error from provider${providerInfo.displayName ? ` (${providerInfo.displayName})` : ""}: ${json.error.message}`
|
||||
}
|
||||
@@ -393,7 +398,7 @@ export async function handler(
|
||||
type: "error",
|
||||
error: {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
message: "Internal server error",
|
||||
},
|
||||
}),
|
||||
{ status: 500 },
|
||||
|
||||
@@ -147,6 +147,17 @@ import `z` do so only for local `ZodOverride` bridges or for `z.ZodType`
|
||||
type annotations — the `export const <Info|Spec>` values are all Effect
|
||||
Schema at source.
|
||||
|
||||
A file is considered "done" when:
|
||||
|
||||
- its exported schema values (`Info`, `Input`, `Event`, `Definition`, etc.)
|
||||
are authored as Effect Schema
|
||||
- any remaining zod is either a derived compat bridge (via `zod()` /
|
||||
`zodObject()`), a `z.ZodType` type annotation, or a documented
|
||||
`ZodOverride` escape hatch — never a hand-written parallel source of truth
|
||||
|
||||
Files that meet this bar but still carry a compat bridge are checked off
|
||||
with an inline note describing the bridge and what unblocks its removal.
|
||||
|
||||
- [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider
|
||||
- [x] server, layout
|
||||
- [x] keybinds
|
||||
@@ -263,9 +274,9 @@ Possible later tightening after the Schema-first migration is stable:
|
||||
|
||||
### Provider domain
|
||||
|
||||
- [ ] `src/provider/auth.ts`
|
||||
- [ ] `src/provider/models.ts`
|
||||
- [ ] `src/provider/provider.ts`
|
||||
- [x] `src/provider/auth.ts`
|
||||
- [x] `src/provider/models.ts`
|
||||
- [x] `src/provider/provider.ts`
|
||||
|
||||
### Tool schemas
|
||||
|
||||
@@ -365,7 +376,7 @@ piecewise.
|
||||
- [ ] `src/snapshot/index.ts`
|
||||
- [ ] `src/storage/db.ts`
|
||||
- [ ] `src/storage/storage.ts`
|
||||
- [ ] `src/sync/index.ts`
|
||||
- [x] `src/sync/index.ts` — public API (`SyncEvent.define`) is Schema-first; still stores derived zod `schema`/`properties` on each `Definition` as a compat bridge for `BusEvent` until `bus/bus-event.ts` migrates
|
||||
- [ ] `src/util/fn.ts`
|
||||
- [ ] `src/util/log.ts`
|
||||
- [ ] `src/util/update-schema.ts`
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import z from "zod"
|
||||
import type { ZodType } from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { zodObject } from "@/util/effect-zod"
|
||||
|
||||
export type Definition = ReturnType<typeof define>
|
||||
export type Definition<Type extends string = string, Properties extends Schema.Top = Schema.Top> = {
|
||||
type: Type
|
||||
properties: Properties
|
||||
}
|
||||
|
||||
const registry = new Map<string, Definition>()
|
||||
|
||||
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
|
||||
const result = {
|
||||
type,
|
||||
properties,
|
||||
}
|
||||
export function define<Type extends string, Properties extends Schema.Top>(
|
||||
type: Type,
|
||||
properties: Properties,
|
||||
): Definition<Type, Properties> {
|
||||
const result = { type, properties }
|
||||
registry.set(type, result)
|
||||
return result
|
||||
}
|
||||
@@ -21,7 +25,7 @@ export function payloads() {
|
||||
return z
|
||||
.object({
|
||||
type: z.literal(type),
|
||||
properties: def.properties,
|
||||
properties: zodObject(def.properties),
|
||||
})
|
||||
.meta({
|
||||
ref: `Event.${def.type}`,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import z from "zod"
|
||||
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
|
||||
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream, Schema } from "effect"
|
||||
import { EffectBridge } from "@/effect"
|
||||
import { Log } from "../util"
|
||||
import { BusEvent } from "./bus-event"
|
||||
@@ -9,16 +8,18 @@ import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
const log = Log.create({ service: "bus" })
|
||||
|
||||
type BusProperties<D extends BusEvent.Definition<string, Schema.Top>> = Schema.Schema.Type<D["properties"]>
|
||||
|
||||
export const InstanceDisposed = BusEvent.define(
|
||||
"server.instance.disposed",
|
||||
z.object({
|
||||
directory: z.string(),
|
||||
Schema.Struct({
|
||||
directory: Schema.String,
|
||||
}),
|
||||
)
|
||||
|
||||
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
|
||||
type: D["type"]
|
||||
properties: z.infer<D["properties"]>
|
||||
properties: BusProperties<D>
|
||||
}
|
||||
|
||||
type State = {
|
||||
@@ -27,10 +28,7 @@ type State = {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly publish: <D extends BusEvent.Definition>(
|
||||
def: D,
|
||||
properties: z.output<D["properties"]>,
|
||||
) => Effect.Effect<void>
|
||||
readonly publish: <D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) => Effect.Effect<void>
|
||||
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
|
||||
readonly subscribeAll: () => Stream.Stream<Payload>
|
||||
readonly subscribeCallback: <D extends BusEvent.Definition>(
|
||||
@@ -79,7 +77,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
|
||||
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||
function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) {
|
||||
return Effect.gen(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const payload: Payload = { type: def.type, properties }
|
||||
@@ -175,14 +173,11 @@ const { runPromise, runSync } = makeRuntime(Service, layer)
|
||||
|
||||
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
|
||||
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
|
||||
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||
export async function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) {
|
||||
return runPromise((svc) => svc.publish(def, properties))
|
||||
}
|
||||
|
||||
export function subscribe<D extends BusEvent.Definition>(
|
||||
def: D,
|
||||
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
|
||||
) {
|
||||
export function subscribe<D extends BusEvent.Definition>(def: D, callback: (event: Payload<D>) => unknown) {
|
||||
return runSync((svc) => svc.subscribeCallback(def, callback))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const TuiEvent = {
|
||||
PromptAppend: BusEvent.define("tui.prompt.append", z.object({ text: z.string() })),
|
||||
PromptAppend: BusEvent.define("tui.prompt.append", Schema.Struct({ text: Schema.String })),
|
||||
CommandExecute: BusEvent.define(
|
||||
"tui.command.execute",
|
||||
z.object({
|
||||
command: z.union([
|
||||
z.enum([
|
||||
Schema.Struct({
|
||||
command: Schema.Union([
|
||||
Schema.Literals([
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.share",
|
||||
@@ -26,23 +26,23 @@ export const TuiEvent = {
|
||||
"prompt.submit",
|
||||
"agent.cycle",
|
||||
]),
|
||||
z.string(),
|
||||
Schema.String,
|
||||
]),
|
||||
}),
|
||||
),
|
||||
ToastShow: BusEvent.define(
|
||||
"tui.toast.show",
|
||||
z.object({
|
||||
title: z.string().optional(),
|
||||
message: z.string(),
|
||||
variant: z.enum(["info", "success", "warning", "error"]),
|
||||
duration: z.number().default(5000).optional().describe("Duration in milliseconds"),
|
||||
Schema.Struct({
|
||||
title: Schema.optional(Schema.String),
|
||||
message: Schema.String,
|
||||
variant: Schema.Literals(["info", "success", "warning", "error"]),
|
||||
duration: Schema.optional(Schema.Number).annotate({ description: "Duration in milliseconds" }),
|
||||
}),
|
||||
),
|
||||
SessionSelect: BusEvent.define(
|
||||
"tui.session.select",
|
||||
z.object({
|
||||
sessionID: SessionID.zod.describe("Session ID to navigate to"),
|
||||
Schema.Struct({
|
||||
sessionID: SessionID.annotate({ description: "Session ID to navigate to" }),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { useTheme } from "@tui/context/theme"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { SplitBorder } from "../component/border"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { type TuiEvent } from "../event"
|
||||
|
||||
export type ToastOptions = z.infer<typeof TuiEvent.ToastShow.properties>
|
||||
export type ToastOptions = Schema.Schema.Type<typeof TuiEvent.ToastShow.properties>
|
||||
|
||||
export function Toast() {
|
||||
const toast = useToast()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { InstanceState } from "@/effect"
|
||||
import { EffectBridge } from "@/effect"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { Config } from "../config"
|
||||
import { MCP } from "../mcp"
|
||||
@@ -18,11 +18,11 @@ type State = {
|
||||
export const Event = {
|
||||
Executed: BusEvent.define(
|
||||
"command.executed",
|
||||
z.object({
|
||||
name: z.string(),
|
||||
sessionID: SessionID.zod,
|
||||
arguments: z.string(),
|
||||
messageID: MessageID.zod,
|
||||
Schema.Struct({
|
||||
name: Schema.String,
|
||||
sessionID: SessionID,
|
||||
arguments: Schema.String,
|
||||
messageID: MessageID,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { Bus } from "@/bus"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { PositiveInt } from "@/util/schema"
|
||||
import { Log } from "../util"
|
||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||
@@ -15,8 +16,6 @@ import { ConfigPermission } from "./permission"
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
|
||||
|
||||
const Color = Schema.Union([
|
||||
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
|
||||
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
|
||||
|
||||
@@ -25,7 +25,7 @@ import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "e
|
||||
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema"
|
||||
import { ConfigAgent } from "./agent"
|
||||
import { ConfigCommand } from "./command"
|
||||
import { ConfigFormatter } from "./formatter"
|
||||
@@ -88,9 +88,6 @@ export type Layout = ConfigLayout.Layout
|
||||
const AgentRef = Schema.Any.annotate({ [ZodOverride]: ConfigAgent.Info })
|
||||
const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
|
||||
|
||||
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
|
||||
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
|
||||
|
||||
// The Effect Schema is the canonical source of truth. The `.zod` compatibility
|
||||
// surface is derived so existing Hono validators keep working without a parallel
|
||||
// Zod definition.
|
||||
@@ -252,26 +249,9 @@ export const Info = Schema.Struct({
|
||||
})),
|
||||
)
|
||||
|
||||
// Schema.Struct produces readonly types by default, but the service code
|
||||
// below mutates Info objects directly (e.g. `config.mode = ...`). Strip the
|
||||
// readonly recursively so callers get the same mutable shape zod inferred.
|
||||
//
|
||||
// `Types.DeepMutable` from effect-smol would be a drop-in, but its fallback
|
||||
// branch `{ -readonly [K in keyof T]: ... }` collapses `unknown` to `{}`
|
||||
// (since `keyof unknown = never`), which widens `Record<string, unknown>`
|
||||
// fields like `ConfigPlugin.Options`. The local version gates on
|
||||
// `extends object` so `unknown` passes through.
|
||||
//
|
||||
// Tuple branch preserves `ConfigPlugin.Spec`'s `readonly [string, Options]`
|
||||
// shape (otherwise the general array branch widens it to an array).
|
||||
type DeepMutable<T> = T extends readonly [unknown, ...unknown[]]
|
||||
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
|
||||
: T extends readonly (infer U)[]
|
||||
? DeepMutable<U>[]
|
||||
: T extends object
|
||||
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
|
||||
: T
|
||||
|
||||
// Uses the shared `DeepMutable` from `@/util/schema`. See the definition
|
||||
// there for why the local variant is needed over `Types.DeepMutable` from
|
||||
// effect-smol (the upstream version collapses `unknown` to `{}`).
|
||||
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
|
||||
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
|
||||
// with the file and scope it came from so later runtime code can make location-sensitive decisions.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
|
||||
import { PositiveInt, withStatics } from "@/util/schema"
|
||||
|
||||
export const Model = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { PositiveInt, withStatics } from "@/util/schema"
|
||||
|
||||
export const Server = Schema.Struct({
|
||||
port: Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))).annotate({
|
||||
port: Schema.optional(PositiveInt).annotate({
|
||||
description: "Port to listen on",
|
||||
}),
|
||||
hostname: Schema.optional(Schema.String).annotate({ description: "Hostname to listen on" }),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Database, asc, eq, inArray } from "@/storage"
|
||||
@@ -25,36 +26,37 @@ import { errorData } from "@/util/error"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { waitEvent } from "./util"
|
||||
import { WorkspaceContext } from "./workspace-context"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
|
||||
export const Info = WorkspaceInfo.meta({
|
||||
ref: "Workspace",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const ConnectionStatus = z.object({
|
||||
workspaceID: WorkspaceID.zod,
|
||||
status: z.enum(["connected", "connecting", "disconnected", "error"]),
|
||||
export const ConnectionStatus = Schema.Struct({
|
||||
workspaceID: WorkspaceID,
|
||||
status: Schema.Literals(["connected", "connecting", "disconnected", "error"]),
|
||||
})
|
||||
export type ConnectionStatus = z.infer<typeof ConnectionStatus>
|
||||
export type ConnectionStatus = Schema.Schema.Type<typeof ConnectionStatus>
|
||||
|
||||
const Restore = z.object({
|
||||
workspaceID: WorkspaceID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
total: z.number().int().min(0),
|
||||
step: z.number().int().min(0),
|
||||
const Restore = Schema.Struct({
|
||||
workspaceID: WorkspaceID,
|
||||
sessionID: SessionID,
|
||||
total: NonNegativeInt,
|
||||
step: NonNegativeInt,
|
||||
})
|
||||
|
||||
export const Event = {
|
||||
Ready: BusEvent.define(
|
||||
"workspace.ready",
|
||||
z.object({
|
||||
name: z.string(),
|
||||
Schema.Struct({
|
||||
name: Schema.String,
|
||||
}),
|
||||
),
|
||||
Failed: BusEvent.define(
|
||||
"workspace.failed",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
Schema.Struct({
|
||||
message: Schema.String,
|
||||
}),
|
||||
),
|
||||
Restore: BusEvent.define("workspace.restore", Restore),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { InstanceState } from "@/effect"
|
||||
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { Git } from "@/git"
|
||||
import { Effect, Layer, Context, Scope } from "effect"
|
||||
import { Effect, Layer, Context, Schema, Scope } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import fuzzysort from "fuzzysort"
|
||||
@@ -76,8 +76,8 @@ export type Content = z.infer<typeof Content>
|
||||
export const Event = {
|
||||
Edited: BusEvent.define(
|
||||
"file.edited",
|
||||
z.object({
|
||||
file: z.string(),
|
||||
Schema.Struct({
|
||||
file: Schema.String,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Effect, Layer, Context } from "effect"
|
||||
import { Cause, Effect, Layer, Context, Schema } from "effect"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
@@ -25,9 +25,9 @@ const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||
export const Event = {
|
||||
Updated: BusEvent.define(
|
||||
"file.watcher.updated",
|
||||
z.object({
|
||||
file: z.string(),
|
||||
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
|
||||
Schema.Struct({
|
||||
file: Schema.String,
|
||||
event: Schema.Literals(["add", "change", "unlink"]),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import { Log } from "../util"
|
||||
import { Process } from "@/util"
|
||||
@@ -17,8 +18,8 @@ const log = Log.create({ service: "ide" })
|
||||
export const Event = {
|
||||
Installed: BusEvent.define(
|
||||
"ide.installed",
|
||||
z.object({
|
||||
ide: z.string(),
|
||||
Schema.Struct({
|
||||
ide: Schema.String,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -21,14 +21,14 @@ export type ReleaseType = "patch" | "minor" | "major"
|
||||
export const Event = {
|
||||
Updated: BusEvent.define(
|
||||
"installation.updated",
|
||||
z.object({
|
||||
version: z.string(),
|
||||
Schema.Struct({
|
||||
version: Schema.String,
|
||||
}),
|
||||
),
|
||||
UpdateAvailable: BusEvent.define(
|
||||
"installation.update-available",
|
||||
z.object({
|
||||
version: z.string(),
|
||||
Schema.Struct({
|
||||
version: Schema.String,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Log } from "../util"
|
||||
import { Process } from "../util"
|
||||
import { LANGUAGE_EXTENSIONS } from "./language"
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import type * as LSPServer from "./server"
|
||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import { withTimeout } from "../util/timeout"
|
||||
@@ -41,9 +42,9 @@ export const InitializeError = NamedError.create(
|
||||
export const Event = {
|
||||
Diagnostics: BusEvent.define(
|
||||
"lsp.client.diagnostics",
|
||||
z.object({
|
||||
serverID: z.string(),
|
||||
path: z.string(),
|
||||
Schema.Struct({
|
||||
serverID: Schema.String,
|
||||
path: Schema.String,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
const log = Log.create({ service: "lsp" })
|
||||
|
||||
export const Event = {
|
||||
Updated: BusEvent.define("lsp.updated", z.object({})),
|
||||
Updated: BusEvent.define("lsp.updated", Schema.Struct({})),
|
||||
}
|
||||
|
||||
const Position = Schema.Struct({
|
||||
|
||||
@@ -25,7 +25,7 @@ import { BusEvent } from "../bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import open from "open"
|
||||
import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
|
||||
import { Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect"
|
||||
import { EffectBridge } from "@/effect"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
@@ -47,16 +47,16 @@ export type Resource = z.infer<typeof Resource>
|
||||
|
||||
export const ToolsChanged = BusEvent.define(
|
||||
"mcp.tools.changed",
|
||||
z.object({
|
||||
server: z.string(),
|
||||
Schema.Struct({
|
||||
server: Schema.String,
|
||||
}),
|
||||
)
|
||||
|
||||
export const BrowserOpenFailed = BusEvent.define(
|
||||
"mcp.browser.open.failed",
|
||||
z.object({
|
||||
mcpName: z.string(),
|
||||
url: z.string(),
|
||||
Schema.Struct({
|
||||
mcpName: Schema.String,
|
||||
url: Schema.String,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -73,16 +73,14 @@ export class Approval extends Schema.Class<Approval>("PermissionApproval")({
|
||||
}
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("permission.asked", Request.zod),
|
||||
Asked: BusEvent.define("permission.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"permission.replied",
|
||||
zod(
|
||||
Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
requestID: PermissionID,
|
||||
reply: Reply,
|
||||
}),
|
||||
),
|
||||
Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
requestID: PermissionID,
|
||||
reply: Reply,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export const Info = Schema.Struct({
|
||||
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||
|
||||
export const Event = {
|
||||
Updated: BusEvent.define("project.updated", Info.zod),
|
||||
Updated: BusEvent.define("project.updated", Info),
|
||||
}
|
||||
|
||||
type Row = typeof ProjectTable.$inferSelect
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Layer, Context, Stream, Scope } from "effect"
|
||||
import { Effect, Layer, Context, Schema, Stream, Scope } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import path from "path"
|
||||
import { Bus } from "@/bus"
|
||||
@@ -107,8 +107,8 @@ export type Mode = z.infer<typeof Mode>
|
||||
export const Event = {
|
||||
BranchUpdated: BusEvent.define(
|
||||
"vcs.branch.updated",
|
||||
z.object({
|
||||
branch: z.string().optional(),
|
||||
Schema.Struct({
|
||||
branch: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
|
||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import { Auth } from "@/auth"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { namedSchemaError } from "@/util/named-schema-error"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { Plugin } from "../plugin"
|
||||
import { ProviderID } from "./schema"
|
||||
import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
const When = Schema.Struct({
|
||||
key: Schema.String,
|
||||
@@ -70,22 +69,16 @@ export const CallbackInput = Schema.Struct({
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type CallbackInput = Schema.Schema.Type<typeof CallbackInput>
|
||||
|
||||
export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
|
||||
export const OauthMissing = namedSchemaError("ProviderAuthOauthMissing", { providerID: ProviderID })
|
||||
|
||||
export const OauthCodeMissing = NamedError.create(
|
||||
"ProviderAuthOauthCodeMissing",
|
||||
z.object({ providerID: ProviderID.zod }),
|
||||
)
|
||||
export const OauthCodeMissing = namedSchemaError("ProviderAuthOauthCodeMissing", { providerID: ProviderID })
|
||||
|
||||
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
||||
export const OauthCallbackFailed = namedSchemaError("ProviderAuthOauthCallbackFailed", {})
|
||||
|
||||
export const ValidationFailed = NamedError.create(
|
||||
"ProviderAuthValidationFailed",
|
||||
z.object({
|
||||
field: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
export const ValidationFailed = namedSchemaError("ProviderAuthValidationFailed", {
|
||||
field: Schema.String,
|
||||
message: Schema.String,
|
||||
})
|
||||
|
||||
export type Error =
|
||||
| Auth.AuthError
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { Installation } from "../installation"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { lazy } from "@/util/lazy"
|
||||
@@ -21,91 +21,85 @@ const filepath = path.join(
|
||||
)
|
||||
const ttl = 5 * 60 * 1000
|
||||
|
||||
type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[]
|
||||
|
||||
const JsonValue: z.ZodType<JsonValue> = z.lazy(() =>
|
||||
z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValue), z.record(z.string(), JsonValue)]),
|
||||
)
|
||||
|
||||
const 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(),
|
||||
const Cost = 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),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
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: Cost.optional(),
|
||||
limit: z.object({
|
||||
context: z.number(),
|
||||
input: z.number().optional(),
|
||||
output: z.number(),
|
||||
export const Model = Schema.Struct({
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
family: Schema.optional(Schema.String),
|
||||
release_date: Schema.String,
|
||||
attachment: Schema.Boolean,
|
||||
reasoning: Schema.Boolean,
|
||||
temperature: Schema.Boolean,
|
||||
tool_call: Schema.Boolean,
|
||||
interleaved: Schema.optional(
|
||||
Schema.Union([
|
||||
Schema.Literal(true),
|
||||
Schema.Struct({
|
||||
field: Schema.Literals(["reasoning_content", "reasoning_details"]),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
cost: Schema.optional(Cost),
|
||||
limit: 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
|
||||
.object({
|
||||
modes: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
cost: Cost.optional(),
|
||||
provider: z
|
||||
.object({
|
||||
body: z.record(z.string(), JsonValue).optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
modalities: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])),
|
||||
output: Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])),
|
||||
}),
|
||||
),
|
||||
experimental: Schema.optional(
|
||||
Schema.Struct({
|
||||
modes: Schema.optional(
|
||||
Schema.Record(
|
||||
Schema.String,
|
||||
Schema.Struct({
|
||||
cost: Schema.optional(Cost),
|
||||
provider: Schema.optional(
|
||||
Schema.Struct({
|
||||
body: Schema.optional(Schema.Record(Schema.String, Schema.MutableJson)),
|
||||
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
|
||||
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
|
||||
provider: Schema.optional(
|
||||
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
|
||||
),
|
||||
})
|
||||
export type Model = z.infer<typeof Model>
|
||||
export type Model = Schema.Schema.Type<typeof Model>
|
||||
|
||||
export const Provider = z.object({
|
||||
api: z.string().optional(),
|
||||
name: z.string(),
|
||||
env: z.array(z.string()),
|
||||
id: z.string(),
|
||||
npm: z.string().optional(),
|
||||
models: z.record(z.string(), Model),
|
||||
export const Provider = Schema.Struct({
|
||||
api: Schema.optional(Schema.String),
|
||||
name: Schema.String,
|
||||
env: Schema.Array(Schema.String),
|
||||
id: Schema.String,
|
||||
npm: Schema.optional(Schema.String),
|
||||
models: Schema.Record(Schema.String, Model),
|
||||
})
|
||||
|
||||
export type Provider = z.infer<typeof Provider>
|
||||
export type Provider = Schema.Schema.Type<typeof Provider>
|
||||
|
||||
function url() {
|
||||
return Flag.OPENCODE_MODELS_URL || "https://models.dev"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import z from "zod"
|
||||
import os from "os"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { Config } from "../config"
|
||||
@@ -8,7 +7,6 @@ import { Log } from "../util"
|
||||
import { Npm } from "../npm"
|
||||
import { Hash } from "@opencode-ai/shared/util/hash"
|
||||
import { Plugin } from "../plugin"
|
||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import { type LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import * as ModelsDev from "./models"
|
||||
import { Auth } from "../auth"
|
||||
@@ -16,6 +14,7 @@ import { Env } from "../env"
|
||||
import { InstallationVersion } from "../installation/version"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { namedSchemaError } from "@/util/named-schema-error"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Global } from "../global"
|
||||
import path from "path"
|
||||
@@ -1047,7 +1046,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
|
||||
id: ProviderID.make(provider.id),
|
||||
source: "custom",
|
||||
name: provider.name,
|
||||
env: provider.env ?? [],
|
||||
env: [...(provider.env ?? [])],
|
||||
options: {},
|
||||
models,
|
||||
}
|
||||
@@ -1713,18 +1712,12 @@ export function parseModel(model: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export const ModelNotFoundError = NamedError.create(
|
||||
"ProviderModelNotFoundError",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
modelID: ModelID.zod,
|
||||
suggestions: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
export const ModelNotFoundError = namedSchemaError("ProviderModelNotFoundError", {
|
||||
providerID: ProviderID,
|
||||
modelID: ModelID,
|
||||
suggestions: Schema.optional(Schema.Array(Schema.String)),
|
||||
})
|
||||
|
||||
export const InitError = NamedError.create(
|
||||
"ProviderInitError",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
)
|
||||
export const InitError = namedSchemaError("ProviderInitError", {
|
||||
providerID: ProviderID,
|
||||
})
|
||||
|
||||
@@ -3,13 +3,14 @@ import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { Instance } from "@/project/instance"
|
||||
import type { Proc } from "#pty"
|
||||
import z from "zod"
|
||||
import { Log } from "../util"
|
||||
import { lazy } from "@opencode-ai/shared/util/lazy"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { PtyID } from "./schema"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { Effect, Layer, Context, Schema, Types } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { EffectBridge } from "@/effect"
|
||||
|
||||
const log = Log.create({ service: "pty" })
|
||||
@@ -53,47 +54,47 @@ const meta = (cursor: number) => {
|
||||
|
||||
const pty = lazy(() => import("#pty"))
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: PtyID.zod,
|
||||
title: z.string(),
|
||||
command: z.string(),
|
||||
args: z.array(z.string()),
|
||||
cwd: z.string(),
|
||||
status: z.enum(["running", "exited"]),
|
||||
pid: z.number(),
|
||||
})
|
||||
.meta({ ref: "Pty" })
|
||||
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const CreateInput = z.object({
|
||||
command: z.string().optional(),
|
||||
args: z.array(z.string()).optional(),
|
||||
cwd: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
export const Info = Schema.Struct({
|
||||
id: PtyID,
|
||||
title: Schema.String,
|
||||
command: Schema.String,
|
||||
args: Schema.Array(Schema.String),
|
||||
cwd: Schema.String,
|
||||
status: Schema.Literals(["running", "exited"]),
|
||||
pid: Schema.Number,
|
||||
})
|
||||
.annotate({ identifier: "Pty" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
export type CreateInput = z.infer<typeof CreateInput>
|
||||
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||
|
||||
export const UpdateInput = z.object({
|
||||
title: z.string().optional(),
|
||||
size: z
|
||||
.object({
|
||||
rows: z.number(),
|
||||
cols: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
export const CreateInput = Schema.Struct({
|
||||
command: Schema.optional(Schema.String),
|
||||
args: Schema.optional(Schema.Array(Schema.String)),
|
||||
cwd: Schema.optional(Schema.String),
|
||||
title: Schema.optional(Schema.String),
|
||||
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
export type UpdateInput = z.infer<typeof UpdateInput>
|
||||
export type CreateInput = Types.DeepMutable<Schema.Schema.Type<typeof CreateInput>>
|
||||
|
||||
export const UpdateInput = Schema.Struct({
|
||||
title: Schema.optional(Schema.String),
|
||||
size: Schema.optional(
|
||||
Schema.Struct({
|
||||
rows: Schema.Number,
|
||||
cols: Schema.Number,
|
||||
}),
|
||||
),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
export type UpdateInput = Types.DeepMutable<Schema.Schema.Type<typeof UpdateInput>>
|
||||
|
||||
export const Event = {
|
||||
Created: BusEvent.define("pty.created", z.object({ info: Info })),
|
||||
Updated: BusEvent.define("pty.updated", z.object({ info: Info })),
|
||||
Exited: BusEvent.define("pty.exited", z.object({ id: PtyID.zod, exitCode: z.number() })),
|
||||
Deleted: BusEvent.define("pty.deleted", z.object({ id: PtyID.zod })),
|
||||
Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })),
|
||||
Updated: BusEvent.define("pty.updated", Schema.Struct({ info: Info })),
|
||||
Exited: BusEvent.define("pty.exited", Schema.Struct({ id: PtyID, exitCode: Schema.Number })),
|
||||
Deleted: BusEvent.define("pty.deleted", Schema.Struct({ id: PtyID })),
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
|
||||
@@ -94,9 +94,9 @@ class Rejected extends Schema.Class<Rejected>("QuestionRejected")({
|
||||
}) {}
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("question.asked", Request.zod),
|
||||
Replied: BusEvent.define("question.replied", zod(Replied)),
|
||||
Rejected: BusEvent.define("question.rejected", zod(Rejected)),
|
||||
Asked: BusEvent.define("question.asked", Request),
|
||||
Replied: BusEvent.define("question.replied", Replied),
|
||||
Rejected: BusEvent.define("question.rejected", Rejected),
|
||||
}
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
|
||||
@@ -194,7 +194,7 @@ export const layer = Layer.effect(
|
||||
yield* bus.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
answers: input.answers,
|
||||
answers: input.answers.map((a) => [...a]),
|
||||
})
|
||||
yield* Deferred.succeed(existing.deferred, input.answers)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Event = {
|
||||
Connected: BusEvent.define("server.connected", z.object({})),
|
||||
Disposed: BusEvent.define("global.disposed", z.object({})),
|
||||
Connected: BusEvent.define("server.connected", Schema.Struct({})),
|
||||
Disposed: BusEvent.define("global.disposed", Schema.Struct({})),
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import z from "zod"
|
||||
import sessionProjectors from "../session/projectors"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Session } from "@/session"
|
||||
@@ -10,7 +9,7 @@ export function initProjectors() {
|
||||
projectors: sessionProjectors,
|
||||
convertEvent: (type, data) => {
|
||||
if (type === "session.updated") {
|
||||
const id = (data as z.infer<typeof Session.Event.Updated.schema>).sessionID
|
||||
const id = (data as SyncEvent.Event<typeof Session.Event.Updated>["data"]).sessionID
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
|
||||
if (!row) return data
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { listAdaptors } from "@/control-plane/adaptors"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { zodObject } from "@/util/effect-zod"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
@@ -107,7 +108,7 @@ export const WorkspaceRoutes = lazy(() =>
|
||||
description: "Workspace status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(Workspace.ConnectionStatus)),
|
||||
schema: resolver(z.array(zodObject(Workspace.ConnectionStatus))),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono, type Context } from "hono"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SyncEvent } from "@/sync"
|
||||
@@ -18,7 +18,7 @@ import { errors } from "../error"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({}))
|
||||
export const GlobalDisposedEvent = BusEvent.define("global.disposed", Schema.Struct({}))
|
||||
|
||||
async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>) => () => void) {
|
||||
return streamSSE(c, async (stream) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import * as EffectZod from "@/util/effect-zod"
|
||||
import { ProviderID, ModelID } from "@/provider/schema"
|
||||
import { ToolRegistry } from "@/tool"
|
||||
import { Worktree } from "@/worktree"
|
||||
@@ -213,7 +214,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
tools.map((t) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
parameters: z.toJSONSchema(t.parameters),
|
||||
parameters: EffectZod.toJsonSchema(t.parameters),
|
||||
})),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
description: "List of sessions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Pty.Info.array()),
|
||||
schema: resolver(Pty.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -46,18 +46,18 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
description: "Created session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Pty.Info),
|
||||
schema: resolver(Pty.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Pty.CreateInput),
|
||||
validator("json", Pty.CreateInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("PtyRoutes.create", c, function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.create(c.req.valid("json"))
|
||||
return yield* pty.create(c.req.valid("json") as Pty.CreateInput)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
@@ -71,7 +71,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
description: "Session info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Pty.Info),
|
||||
schema: resolver(Pty.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -105,7 +105,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
description: "Updated session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Pty.Info),
|
||||
schema: resolver(Pty.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -113,11 +113,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
validator("json", Pty.UpdateInput),
|
||||
validator("json", Pty.UpdateInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("PtyRoutes.update", c, function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
|
||||
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json") as Pty.UpdateInput)
|
||||
}),
|
||||
)
|
||||
.delete(
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Hono, type Context } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { Bus } from "@/bus"
|
||||
import { Session } from "@/session"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import { zodObject } from "@/util/effect-zod"
|
||||
import { AsyncQueue } from "@/util/queue"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
@@ -96,9 +99,9 @@ export const TuiRoutes = lazy(() =>
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", TuiEvent.PromptAppend.properties),
|
||||
validator("json", zodObject(TuiEvent.PromptAppend.properties)),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json"))
|
||||
await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json") as { text: string })
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -305,9 +308,12 @@ export const TuiRoutes = lazy(() =>
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", TuiEvent.ToastShow.properties),
|
||||
validator("json", zodObject(TuiEvent.ToastShow.properties)),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.ToastShow, c.req.valid("json"))
|
||||
await Bus.publish(
|
||||
TuiEvent.ToastShow,
|
||||
c.req.valid("json") as Schema.Schema.Type<typeof TuiEvent.ToastShow.properties>,
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -336,7 +342,7 @@ export const TuiRoutes = lazy(() =>
|
||||
return z
|
||||
.object({
|
||||
type: z.literal(def.type),
|
||||
properties: def.properties,
|
||||
properties: zodObject(def.properties),
|
||||
})
|
||||
.meta({
|
||||
ref: `Event.${def.type}`,
|
||||
@@ -345,8 +351,9 @@ export const TuiRoutes = lazy(() =>
|
||||
),
|
||||
),
|
||||
async (c) => {
|
||||
const evt = c.req.valid("json")
|
||||
await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)!, evt.properties)
|
||||
const evt = c.req.valid("json") as { type: string; properties: Record<string, unknown> }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)! as any, evt.properties as any)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -368,9 +375,9 @@ export const TuiRoutes = lazy(() =>
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator("json", TuiEvent.SessionSelect.properties),
|
||||
validator("json", zodObject(TuiEvent.SessionSelect.properties)),
|
||||
async (c) => {
|
||||
const { sessionID } = c.req.valid("json")
|
||||
const { sessionID } = c.req.valid("json") as { sessionID: SessionID }
|
||||
await runRequest(
|
||||
"TuiRoutes.sessionSelect",
|
||||
c,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Plugin } from "@/plugin"
|
||||
import { Config } from "@/config"
|
||||
import { NotFoundError } from "@/storage"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { isOverflow as overflow, usable } from "./overflow"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
@@ -24,8 +24,8 @@ const log = Log.create({ service: "session.compaction" })
|
||||
export const Event = {
|
||||
Compacted: BusEvent.define(
|
||||
"session.compacted",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { Provider } from "@/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Effect, Schema, Types } from "effect"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { namedSchemaError } from "@/util/named-schema-error"
|
||||
import { EffectLogger } from "@/effect"
|
||||
|
||||
@@ -64,9 +64,7 @@ export class OutputFormatText extends Schema.Class<OutputFormatText>("OutputForm
|
||||
export class OutputFormatJsonSchema extends Schema.Class<OutputFormatJsonSchema>("OutputFormatJsonSchema")({
|
||||
type: Schema.Literal("json_schema"),
|
||||
schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }),
|
||||
retryCount: Schema.Number.check(Schema.isInt())
|
||||
.check(Schema.isGreaterThanOrEqualTo(0))
|
||||
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))),
|
||||
retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))),
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
@@ -138,8 +136,8 @@ export type ReasoningPart = Types.DeepMutable<Schema.Schema.Type<typeof Reasonin
|
||||
const filePartSourceBase = {
|
||||
text: Schema.Struct({
|
||||
value: Schema.String,
|
||||
start: Schema.Number.check(Schema.isInt()),
|
||||
end: Schema.Number.check(Schema.isInt()),
|
||||
start: Schema.Int,
|
||||
end: Schema.Int,
|
||||
}).annotate({ identifier: "FilePartSourceText" }),
|
||||
}
|
||||
|
||||
@@ -157,7 +155,7 @@ export const SymbolSource = Schema.Struct({
|
||||
path: Schema.String,
|
||||
range: LSP.Range,
|
||||
name: Schema.String,
|
||||
kind: Schema.Number.check(Schema.isInt()),
|
||||
kind: Schema.Int,
|
||||
})
|
||||
.annotate({ identifier: "SymbolSource" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
@@ -196,8 +194,8 @@ export const AgentPart = Schema.Struct({
|
||||
source: Schema.optional(
|
||||
Schema.Struct({
|
||||
value: Schema.String,
|
||||
start: Schema.Number.check(Schema.isInt()),
|
||||
end: Schema.Number.check(Schema.isInt()),
|
||||
start: Schema.Int,
|
||||
end: Schema.Int,
|
||||
}),
|
||||
),
|
||||
})
|
||||
@@ -501,8 +499,8 @@ export const AgentPartInput = Schema.Struct({
|
||||
source: Schema.optional(
|
||||
Schema.Struct({
|
||||
value: Schema.String,
|
||||
start: Schema.Number.check(Schema.isInt()),
|
||||
end: Schema.Number.check(Schema.isInt()),
|
||||
start: Schema.Int,
|
||||
end: Schema.Int,
|
||||
}),
|
||||
),
|
||||
})
|
||||
@@ -576,54 +574,62 @@ export const Info = Object.assign(_Info, {
|
||||
})
|
||||
export type Info = User | Assistant
|
||||
|
||||
const UpdatedEventSchema = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
info: _Info,
|
||||
})
|
||||
|
||||
const RemovedEventSchema = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
messageID: MessageID,
|
||||
})
|
||||
|
||||
const PartUpdatedEventSchema = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
part: _Part,
|
||||
time: Schema.Number,
|
||||
})
|
||||
|
||||
const PartRemovedEventSchema = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
messageID: MessageID,
|
||||
partID: PartID,
|
||||
})
|
||||
|
||||
export const Event = {
|
||||
Updated: SyncEvent.define({
|
||||
type: "message.updated",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
info: Info.zod,
|
||||
}),
|
||||
schema: UpdatedEventSchema,
|
||||
}),
|
||||
Removed: SyncEvent.define({
|
||||
type: "message.removed",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod,
|
||||
}),
|
||||
schema: RemovedEventSchema,
|
||||
}),
|
||||
PartUpdated: SyncEvent.define({
|
||||
type: "message.part.updated",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
part: Part.zod,
|
||||
time: z.number(),
|
||||
}),
|
||||
schema: PartUpdatedEventSchema,
|
||||
}),
|
||||
PartDelta: BusEvent.define(
|
||||
"message.part.delta",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod,
|
||||
partID: PartID.zod,
|
||||
field: z.string(),
|
||||
delta: z.string(),
|
||||
Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
messageID: MessageID,
|
||||
partID: PartID,
|
||||
field: Schema.String,
|
||||
delta: Schema.String,
|
||||
}),
|
||||
),
|
||||
PartRemoved: SyncEvent.define({
|
||||
type: "message.part.removed",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod,
|
||||
partID: PartID.zod,
|
||||
}),
|
||||
schema: PartRemovedEventSchema,
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export default [
|
||||
const info = data.info
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set(toPartialRow(info))
|
||||
.set(toPartialRow(info as Session.Patch))
|
||||
.where(eq(SessionTable.id, data.sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
import * as EffectZod from "@/util/effect-zod"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Log } from "../util"
|
||||
@@ -405,7 +406,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
providerID: input.model.providerID,
|
||||
agent: input.agent,
|
||||
})) {
|
||||
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
|
||||
const schema = ProviderTransform.schema(input.model, EffectZod.toJsonSchema(item.parameters))
|
||||
tools[item.id] = tool({
|
||||
description: item.description,
|
||||
inputSchema: jsonSchema(schema),
|
||||
|
||||
@@ -15,7 +15,6 @@ import { PartTable, SessionTable } from "./session.sql"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import { Storage } from "@/storage"
|
||||
import { Log } from "../util"
|
||||
import { updateSchema } from "../util/update-schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Instance } from "../project/instance"
|
||||
import { InstanceState } from "@/effect"
|
||||
@@ -28,7 +27,7 @@ import type { Provider } from "@/provider"
|
||||
import { Permission } from "@/permission"
|
||||
import { Global } from "@/global"
|
||||
import { Effect, Layer, Option, Context, Schema, Types } from "effect"
|
||||
import { zod, zodObject } from "@/util/effect-zod"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
const log = Log.create({ service: "session" })
|
||||
@@ -215,54 +214,77 @@ export const MessagesInput = Schema.Struct({
|
||||
limit: Schema.optional(Schema.Number),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
const CreatedEventSchema = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
info: Info,
|
||||
})
|
||||
|
||||
const UpdatedShare = Schema.Struct({
|
||||
url: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
})
|
||||
|
||||
const UpdatedTime = Schema.Struct({
|
||||
created: Schema.optional(Schema.NullOr(Schema.Number)),
|
||||
updated: Schema.optional(Schema.NullOr(Schema.Number)),
|
||||
compacting: Schema.optional(Schema.NullOr(Schema.Number)),
|
||||
archived: Schema.optional(Schema.NullOr(Schema.Number)),
|
||||
})
|
||||
|
||||
const UpdatedInfo = Schema.Struct({
|
||||
id: Schema.optional(Schema.NullOr(SessionID)),
|
||||
slug: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
projectID: Schema.optional(Schema.NullOr(ProjectID)),
|
||||
workspaceID: Schema.optional(Schema.NullOr(WorkspaceID)),
|
||||
directory: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
parentID: Schema.optional(Schema.NullOr(SessionID)),
|
||||
summary: Schema.optional(Schema.NullOr(Summary)),
|
||||
share: Schema.optional(UpdatedShare),
|
||||
title: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
version: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
time: Schema.optional(UpdatedTime),
|
||||
permission: Schema.optional(Schema.NullOr(Permission.Ruleset)),
|
||||
revert: Schema.optional(Schema.NullOr(Revert)),
|
||||
})
|
||||
|
||||
const UpdatedEventSchema = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
info: UpdatedInfo,
|
||||
})
|
||||
|
||||
export const Event = {
|
||||
Created: SyncEvent.define({
|
||||
type: "session.created",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
info: Info.zod,
|
||||
}),
|
||||
schema: CreatedEventSchema,
|
||||
}),
|
||||
Updated: SyncEvent.define({
|
||||
type: "session.updated",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
info: updateSchema(zodObject(Info)).extend({
|
||||
share: updateSchema(zodObject(Share)).optional(),
|
||||
time: updateSchema(zodObject(Time)).optional(),
|
||||
}),
|
||||
}),
|
||||
busSchema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
info: Info.zod,
|
||||
}),
|
||||
schema: UpdatedEventSchema,
|
||||
busSchema: CreatedEventSchema,
|
||||
}),
|
||||
Deleted: SyncEvent.define({
|
||||
type: "session.deleted",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
info: Info.zod,
|
||||
}),
|
||||
schema: CreatedEventSchema,
|
||||
}),
|
||||
Diff: BusEvent.define(
|
||||
"session.diff",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
diff: Snapshot.FileDiff.zod.array(),
|
||||
Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
diff: Schema.Array(Snapshot.FileDiff),
|
||||
}),
|
||||
),
|
||||
Error: BusEvent.define(
|
||||
"session.error",
|
||||
z.object({
|
||||
sessionID: SessionID.zod.optional(),
|
||||
// z.lazy defers access to break circular dep: session → message-v2 → provider → plugin → session
|
||||
error: z.lazy(() => (MessageV2.Assistant.zod as unknown as z.ZodObject<any>).shape.error),
|
||||
Schema.Struct({
|
||||
sessionID: Schema.optional(SessionID),
|
||||
// Reuses MessageV2.Assistant.fields.error (already Schema.optional) so
|
||||
// the derived zod keeps the same discriminated-union shape on the bus.
|
||||
error: MessageV2.Assistant.fields.error,
|
||||
}),
|
||||
),
|
||||
}
|
||||
@@ -394,7 +416,7 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Session") {}
|
||||
|
||||
type Patch = z.infer<typeof Event.Updated.schema>["info"]
|
||||
export type Patch = Types.DeepMutable<SyncEvent.Event<typeof Event.Updated>["data"]["info"]>
|
||||
|
||||
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
|
||||
Effect.sync(() => Database.use(fn))
|
||||
|
||||
@@ -28,16 +28,16 @@ export type Info = Schema.Schema.Type<typeof Info>
|
||||
export const Event = {
|
||||
Status: BusEvent.define(
|
||||
"session.status",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
status: Info.zod,
|
||||
Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
status: Info,
|
||||
}),
|
||||
),
|
||||
// deprecated
|
||||
Idle: BusEvent.define(
|
||||
"session.idle",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@ export type Info = Schema.Schema.Type<typeof Info>
|
||||
export const Event = {
|
||||
Updated: BusEvent.define(
|
||||
"todo.updated",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
todos: z.array(Info.zod),
|
||||
Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
todos: Schema.Array(Info),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ export const layer = Layer.effect(
|
||||
|
||||
yield* watch(Session.Event.Updated, (evt) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* session.get(evt.properties.sessionID)
|
||||
const info = evt.properties.info
|
||||
yield* sync(info.id, [{ type: "session", data: info }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import z from "zod"
|
||||
import type { ZodObject } from "zod"
|
||||
import { Database, eq } from "@/storage"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Bus as ProjectBus } from "@/bus"
|
||||
@@ -9,34 +8,48 @@ import { EventSequenceTable, EventTable } from "./event.sql"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { EventID } from "./schema"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Schema as EffectSchema } from "effect"
|
||||
import { zodObject } from "@/util/effect-zod"
|
||||
import type { DeepMutable } from "@/util/schema"
|
||||
|
||||
export type Definition = {
|
||||
type: string
|
||||
// Keep `Event["data"]` mutable because projectors mutate the persisted shape
|
||||
// when writing to the database. Bus payloads (`Properties`) stay readonly —
|
||||
// subscribers only read.
|
||||
|
||||
export type Definition<
|
||||
Type extends string = string,
|
||||
Schema extends EffectSchema.Top = EffectSchema.Top,
|
||||
BusSchema extends EffectSchema.Top = Schema,
|
||||
> = {
|
||||
type: Type
|
||||
version: number
|
||||
aggregate: string
|
||||
schema: z.ZodObject
|
||||
|
||||
// This is temporary and only exists for compatibility with bus
|
||||
// event definitions
|
||||
properties: z.ZodObject
|
||||
schema: Schema
|
||||
// Bus event payload schema. Defaults to `schema` unless `busSchema` was
|
||||
// passed at definition time (see `session.updated`, whose projector
|
||||
// expands the persisted data to a `{ sessionID, info }` bus payload).
|
||||
properties: BusSchema
|
||||
}
|
||||
|
||||
export type Event<Def extends Definition = Definition> = {
|
||||
id: string
|
||||
seq: number
|
||||
aggregateID: string
|
||||
data: z.infer<Def["schema"]>
|
||||
data: DeepMutable<EffectSchema.Schema.Type<Def["schema"]>>
|
||||
}
|
||||
|
||||
export type Properties<Def extends Definition = Definition> = EffectSchema.Schema.Type<Def["properties"]>
|
||||
|
||||
export type SerializedEvent<Def extends Definition = Definition> = Event<Def> & { type: string }
|
||||
|
||||
type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void
|
||||
type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise<unknown>
|
||||
|
||||
export const registry = new Map<string, Definition>()
|
||||
let projectors: Map<Definition, ProjectorFunc> | undefined
|
||||
const versions = new Map<string, number>()
|
||||
let frozen = false
|
||||
let convertEvent: (type: string, event: Event["data"]) => Promise<Record<string, unknown>> | Record<string, unknown>
|
||||
let convertEvent: ConvertEvent
|
||||
|
||||
export function reset() {
|
||||
frozen = false
|
||||
@@ -44,7 +57,7 @@ export function reset() {
|
||||
convertEvent = (_, data) => data
|
||||
}
|
||||
|
||||
export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: typeof convertEvent }) {
|
||||
export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: ConvertEvent }) {
|
||||
projectors = new Map(input.projectors)
|
||||
|
||||
// Install all the latest event defs to the bus. We only ever emit
|
||||
@@ -54,13 +67,13 @@ export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; co
|
||||
for (let [type, version] of versions.entries()) {
|
||||
let def = registry.get(versionedType(type, version))!
|
||||
|
||||
BusEvent.define(def.type, def.properties || def.schema)
|
||||
BusEvent.define(def.type, def.properties)
|
||||
}
|
||||
|
||||
// Freeze the system so it clearly errors if events are defined
|
||||
// after `init` which would cause bugs
|
||||
frozen = true
|
||||
convertEvent = input.convertEvent || ((_, data) => data)
|
||||
convertEvent = input.convertEvent ?? ((_, data) => data)
|
||||
}
|
||||
|
||||
export function versionedType<A extends string>(type: A): A
|
||||
@@ -72,9 +85,15 @@ export function versionedType(type: string, version?: number) {
|
||||
export function define<
|
||||
Type extends string,
|
||||
Agg extends string,
|
||||
Schema extends ZodObject<Record<Agg, z.ZodType<string>>>,
|
||||
BusSchema extends ZodObject = Schema,
|
||||
>(input: { type: Type; version: number; aggregate: Agg; schema: Schema; busSchema?: BusSchema }) {
|
||||
Schema extends EffectSchema.Top,
|
||||
BusSchema extends EffectSchema.Top = Schema,
|
||||
>(input: {
|
||||
type: Type
|
||||
version: number
|
||||
aggregate: Agg
|
||||
schema: Schema
|
||||
busSchema?: BusSchema
|
||||
}): Definition<Type, Schema, BusSchema> {
|
||||
if (frozen) {
|
||||
throw new Error("Error defining sync event: sync system has been frozen")
|
||||
}
|
||||
@@ -84,7 +103,7 @@ export function define<
|
||||
version: input.version,
|
||||
aggregate: input.aggregate,
|
||||
schema: input.schema,
|
||||
properties: input.busSchema ? input.busSchema : input.schema,
|
||||
properties: (input.busSchema ?? input.schema) as BusSchema,
|
||||
}
|
||||
|
||||
versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0))
|
||||
@@ -141,12 +160,11 @@ function process<Def extends Definition>(def: Def, event: Event<Def>, options: {
|
||||
Database.effect(() => {
|
||||
if (options?.publish) {
|
||||
const result = convertEvent(def.type, event.data)
|
||||
const publish = (data: unknown) => ProjectBus.publish(def, data as Properties<Def>)
|
||||
if (result instanceof Promise) {
|
||||
void result.then((data) => {
|
||||
void ProjectBus.publish({ type: def.type, properties: def.schema }, data)
|
||||
})
|
||||
void result.then(publish)
|
||||
} else {
|
||||
void ProjectBus.publish({ type: def.type, properties: def.schema }, result)
|
||||
void publish(result)
|
||||
}
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
@@ -266,7 +284,7 @@ export function payloads() {
|
||||
id: z.string(),
|
||||
seq: z.number(),
|
||||
aggregateID: z.literal(def.aggregate),
|
||||
data: def.schema,
|
||||
data: zodObject(def.schema),
|
||||
})
|
||||
.meta({
|
||||
ref: `SyncEvent.${def.type}`,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import z from "zod"
|
||||
import * as path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
import { Bus } from "../bus"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
@@ -16,8 +15,8 @@ import { File } from "../file"
|
||||
import { Format } from "../format"
|
||||
import * as Bom from "@/util/bom"
|
||||
|
||||
const PatchParams = z.object({
|
||||
patchText: z.string().describe("The full patch text that describes all changes to be made"),
|
||||
export const Parameters = Schema.Struct({
|
||||
patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }),
|
||||
})
|
||||
|
||||
export const ApplyPatchTool = Tool.define(
|
||||
@@ -28,7 +27,10 @@ export const ApplyPatchTool = Tool.define(
|
||||
const format = yield* Format.Service
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof PatchParams>, ctx: Tool.Context) {
|
||||
const run = Effect.fn("ApplyPatchTool.execute")(function* (
|
||||
params: Schema.Schema.Type<typeof Parameters>,
|
||||
ctx: Tool.Context,
|
||||
) {
|
||||
if (!params.patchText) {
|
||||
return yield* Effect.fail(new Error("patchText is required"))
|
||||
}
|
||||
@@ -297,8 +299,9 @@ export const ApplyPatchTool = Tool.define(
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: PatchParams,
|
||||
execute: (params: z.infer<typeof PatchParams>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
|
||||
parameters: Parameters,
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
run(params, ctx).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import os from "os"
|
||||
import { createWriteStream } from "node:fs"
|
||||
import * as Tool from "./tool"
|
||||
@@ -50,20 +50,16 @@ const FILES = new Set([
|
||||
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
|
||||
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
|
||||
|
||||
const Parameters = z.object({
|
||||
command: z.string().describe("The command to execute"),
|
||||
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
|
||||
workdir: z
|
||||
.string()
|
||||
.describe(
|
||||
`The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
|
||||
)
|
||||
.optional(),
|
||||
description: z
|
||||
.string()
|
||||
.describe(
|
||||
export const Parameters = Schema.Struct({
|
||||
command: Schema.String.annotate({ description: "The command to execute" }),
|
||||
timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in milliseconds" }),
|
||||
workdir: Schema.optional(Schema.String).annotate({
|
||||
description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
|
||||
}),
|
||||
description: Schema.String.annotate({
|
||||
description:
|
||||
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
type Part = {
|
||||
@@ -587,7 +583,7 @@ export const BashTool = Tool.define(
|
||||
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
|
||||
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const cwd = params.workdir
|
||||
? yield* resolvePath(params.workdir, Instance.directory, shell)
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import * as Tool from "./tool"
|
||||
import * as McpExa from "./mcp-exa"
|
||||
import DESCRIPTION from "./codesearch.txt"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
query: Schema.String.annotate({
|
||||
description:
|
||||
"Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
|
||||
}),
|
||||
tokensNum: Schema.Finite.pipe(
|
||||
Schema.check(Schema.isGreaterThanOrEqualTo(1000), Schema.isLessThanOrEqualTo(50000)),
|
||||
Schema.withDecodingDefaultTypeKey(Effect.succeed(5000)),
|
||||
)
|
||||
.annotate({
|
||||
description:
|
||||
"Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
|
||||
}),
|
||||
})
|
||||
|
||||
export const CodeSearchTool = Tool.define(
|
||||
"codesearch",
|
||||
Effect.gen(function* () {
|
||||
@@ -12,22 +26,8 @@ export const CodeSearchTool = Tool.define(
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
query: z
|
||||
.string()
|
||||
.describe(
|
||||
"Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
|
||||
),
|
||||
tokensNum: z
|
||||
.number()
|
||||
.min(1000)
|
||||
.max(50000)
|
||||
.default(5000)
|
||||
.describe(
|
||||
"Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
|
||||
),
|
||||
}),
|
||||
execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
|
||||
parameters: Parameters,
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.ask({
|
||||
permission: "codesearch",
|
||||
@@ -45,7 +45,7 @@ export const CodeSearchTool = Tool.define(
|
||||
McpExa.CodeArgs,
|
||||
{
|
||||
query: params.query,
|
||||
tokensNum: params.tokensNum || 5000,
|
||||
tokensNum: params.tokensNum,
|
||||
},
|
||||
"30 seconds",
|
||||
)
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
|
||||
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts
|
||||
|
||||
import z from "zod"
|
||||
import * as path from "path"
|
||||
import { Effect, Semaphore } from "effect"
|
||||
import { Effect, Schema, Semaphore } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
import { LSP } from "../lsp"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
@@ -45,11 +44,15 @@ function lock(filePath: string) {
|
||||
return next
|
||||
}
|
||||
|
||||
const Parameters = z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().describe("The text to replace"),
|
||||
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
||||
export const Parameters = Schema.Struct({
|
||||
filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }),
|
||||
oldString: Schema.String.annotate({ description: "The text to replace" }),
|
||||
newString: Schema.String.annotate({
|
||||
description: "The text to replace it with (must be different from oldString)",
|
||||
}),
|
||||
replaceAll: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Replace all occurrences of oldString (default false)",
|
||||
}),
|
||||
})
|
||||
|
||||
export const EditTool = Tool.define(
|
||||
@@ -63,7 +66,7 @@ export const EditTool = Tool.define(
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
if (!params.filePath) {
|
||||
throw new Error("filePath is required")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
@@ -9,6 +8,13 @@ import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
import * as Tool from "./tool"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }),
|
||||
path: Schema.optional(Schema.String).annotate({
|
||||
description: `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
|
||||
}),
|
||||
})
|
||||
|
||||
export const GlobTool = Tool.define(
|
||||
"glob",
|
||||
Effect.gen(function* () {
|
||||
@@ -17,15 +23,7 @@ export const GlobTool = Tool.define(
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
pattern: z.string().describe("The glob pattern to match files against"),
|
||||
path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
|
||||
),
|
||||
}),
|
||||
parameters: Parameters,
|
||||
execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const ins = yield* InstanceState.context
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
@@ -10,6 +10,16 @@ import * as Tool from "./tool"
|
||||
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }),
|
||||
path: Schema.optional(Schema.String).annotate({
|
||||
description: "The directory to search in. Defaults to the current working directory.",
|
||||
}),
|
||||
include: Schema.optional(Schema.String).annotate({
|
||||
description: 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
|
||||
}),
|
||||
})
|
||||
|
||||
export const GrepTool = Tool.define(
|
||||
"grep",
|
||||
Effect.gen(function* () {
|
||||
@@ -18,11 +28,7 @@ export const GrepTool = Tool.define(
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
pattern: z.string().describe("The regex pattern to search for in file contents"),
|
||||
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
|
||||
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
|
||||
}),
|
||||
parameters: Parameters,
|
||||
execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const empty = {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
tool: Schema.String,
|
||||
error: Schema.String,
|
||||
})
|
||||
|
||||
export const InvalidTool = Tool.define(
|
||||
"invalid",
|
||||
Effect.succeed({
|
||||
description: "Do not use",
|
||||
parameters: z.object({
|
||||
tool: z.string(),
|
||||
error: z.string(),
|
||||
}),
|
||||
parameters: Parameters,
|
||||
execute: (params: { tool: string; error: string }) =>
|
||||
Effect.succeed({
|
||||
title: "Invalid Tool",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
import path from "path"
|
||||
import { LSP } from "../lsp"
|
||||
@@ -21,6 +20,17 @@ const operations = [
|
||||
"outgoingCalls",
|
||||
] as const
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }),
|
||||
filePath: Schema.String.annotate({ description: "The absolute or relative path to the file" }),
|
||||
line: Schema.Number.check(Schema.isInt())
|
||||
.check(Schema.isGreaterThanOrEqualTo(1))
|
||||
.annotate({ description: "The line number (1-based, as shown in editors)" }),
|
||||
character: Schema.Number.check(Schema.isInt())
|
||||
.check(Schema.isGreaterThanOrEqualTo(1))
|
||||
.annotate({ description: "The character offset (1-based, as shown in editors)" }),
|
||||
})
|
||||
|
||||
export const LspTool = Tool.define(
|
||||
"lsp",
|
||||
Effect.gen(function* () {
|
||||
@@ -29,12 +39,7 @@ export const LspTool = Tool.define(
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
operation: z.enum(operations).describe("The LSP operation to perform"),
|
||||
filePath: z.string().describe("The absolute or relative path to the file"),
|
||||
line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
|
||||
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
|
||||
}),
|
||||
parameters: Parameters,
|
||||
execute: (
|
||||
args: { operation: (typeof operations)[number]; filePath: string; line: number; character: number },
|
||||
ctx: Tool.Context,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
import { Question } from "../question"
|
||||
import { Session } from "../session"
|
||||
@@ -17,6 +16,8 @@ function getLastModel(sessionID: SessionID) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const Parameters = Schema.Struct({})
|
||||
|
||||
export const PlanExitTool = Tool.define(
|
||||
"plan_exit",
|
||||
Effect.gen(function* () {
|
||||
@@ -26,7 +27,7 @@ export const PlanExitTool = Tool.define(
|
||||
|
||||
return {
|
||||
description: EXIT_DESCRIPTION,
|
||||
parameters: z.object({}),
|
||||
parameters: Parameters,
|
||||
execute: (_params: {}, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* session.get(ctx.sessionID)
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
import { Question } from "../question"
|
||||
import DESCRIPTION from "./question.txt"
|
||||
|
||||
const parameters = z.object({
|
||||
questions: z.array(Question.Prompt.zod).describe("Questions to ask"),
|
||||
export const Parameters = Schema.Struct({
|
||||
questions: Schema.mutable(Schema.Array(Question.Prompt)).annotate({ description: "Questions to ask" }),
|
||||
})
|
||||
|
||||
type Metadata = {
|
||||
answers: ReadonlyArray<Question.Answer>
|
||||
}
|
||||
|
||||
export const QuestionTool = Tool.define<typeof parameters, Metadata, Question.Service>(
|
||||
export const QuestionTool = Tool.define<typeof Parameters, Metadata, Question.Service>(
|
||||
"question",
|
||||
Effect.gen(function* () {
|
||||
const question = yield* Question.Service
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters,
|
||||
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
|
||||
parameters: Parameters,
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
|
||||
Effect.gen(function* () {
|
||||
const answers = yield* question.ask({
|
||||
sessionID: ctx.sessionID,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import z from "zod"
|
||||
import { Effect, Option, Scope } from "effect"
|
||||
import { Effect, Option, Schema, Scope } from "effect"
|
||||
import { createReadStream } from "fs"
|
||||
import * as path from "path"
|
||||
import { createInterface } from "readline"
|
||||
@@ -19,10 +18,19 @@ const MAX_BYTES = 50 * 1024
|
||||
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
|
||||
const SAMPLE_BYTES = 4096
|
||||
|
||||
const parameters = z.object({
|
||||
filePath: z.string().describe("The absolute path to the file or directory to read"),
|
||||
offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(),
|
||||
limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
|
||||
// `offset` and `limit` were originally `z.coerce.number()` — the runtime
|
||||
// coercion was useful when the tool was called from a shell but serves no
|
||||
// purpose in the LLM tool-call path (the model emits typed JSON). The JSON
|
||||
// Schema output is identical (`type: "number"`), so the LLM view is
|
||||
// unchanged; purely CLI-facing uses must now send numbers rather than strings.
|
||||
export const Parameters = Schema.Struct({
|
||||
filePath: Schema.String.annotate({ description: "The absolute path to the file or directory to read" }),
|
||||
offset: Schema.optional(Schema.Number).annotate({
|
||||
description: "The line number to start reading from (1-indexed)",
|
||||
}),
|
||||
limit: Schema.optional(Schema.Number).annotate({
|
||||
description: "The maximum number of lines to read (defaults to 2000)",
|
||||
}),
|
||||
})
|
||||
|
||||
export const ReadTool = Tool.define(
|
||||
@@ -140,7 +148,10 @@ export const ReadTool = Tool.define(
|
||||
return nonPrintableCount / bytes.length > 0.3
|
||||
}
|
||||
|
||||
const run = Effect.fn("ReadTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
|
||||
const run = Effect.fn("ReadTool.execute")(function* (
|
||||
params: Schema.Schema.Type<typeof Parameters>,
|
||||
ctx: Tool.Context,
|
||||
) {
|
||||
if (params.offset !== undefined && params.offset < 1) {
|
||||
return yield* Effect.fail(new Error("offset must be greater than or equal to 1"))
|
||||
}
|
||||
@@ -275,8 +286,9 @@ export const ReadTool = Tool.define(
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters,
|
||||
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
|
||||
parameters: Parameters,
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
run(params, ctx).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -15,7 +15,9 @@ import { SkillTool } from "./skill"
|
||||
import * as Tool from "./tool"
|
||||
import { Config } from "../config"
|
||||
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { ZodOverride } from "@/util/effect-zod"
|
||||
import { Plugin } from "../plugin"
|
||||
import { Provider } from "../provider"
|
||||
import { ProviderID, type ModelID } from "../provider/schema"
|
||||
@@ -120,9 +122,17 @@ export const layer: Layer.Layer<
|
||||
const custom: Tool.Def[] = []
|
||||
|
||||
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
|
||||
// Plugin tools define their args as a raw Zod shape. Wrap the
|
||||
// derived Zod object in a `Schema.declare` so it slots into the
|
||||
// Schema-typed framework, and annotate with `ZodOverride` so the
|
||||
// walker emits the original Zod object for LLM JSON Schema.
|
||||
const zodParams = z.object(def.args)
|
||||
const parameters = Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success).annotate({
|
||||
[ZodOverride]: zodParams,
|
||||
})
|
||||
return {
|
||||
id,
|
||||
parameters: z.object(def.args),
|
||||
parameters,
|
||||
description: def.description,
|
||||
execute: (args, toolCtx) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Skill } from "../skill"
|
||||
import * as Tool from "./tool"
|
||||
import DESCRIPTION from "./skill.txt"
|
||||
|
||||
const Parameters = z.object({
|
||||
name: z.string().describe("The name of the skill from available_skills"),
|
||||
export const Parameters = Schema.Struct({
|
||||
name: Schema.String.annotate({ description: "The name of the skill from available_skills" }),
|
||||
})
|
||||
|
||||
export const SkillTool = Tool.define(
|
||||
@@ -21,7 +20,7 @@ export const SkillTool = Tool.define(
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* skill.get(params.name)
|
||||
if (!info) {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import * as Tool from "./tool"
|
||||
import DESCRIPTION from "./task.txt"
|
||||
import z from "zod"
|
||||
import { Session } from "../session"
|
||||
import { SessionID, MessageID } from "../session/schema"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
import { Agent } from "../agent/agent"
|
||||
import type { SessionPrompt } from "../session/prompt"
|
||||
import { Config } from "../config"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
export interface TaskPromptOps {
|
||||
cancel(sessionID: SessionID): void
|
||||
@@ -17,17 +16,15 @@ export interface TaskPromptOps {
|
||||
|
||||
const id = "task"
|
||||
|
||||
const parameters = z.object({
|
||||
description: z.string().describe("A short (3-5 words) description of the task"),
|
||||
prompt: z.string().describe("The task for the agent to perform"),
|
||||
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
|
||||
task_id: z
|
||||
.string()
|
||||
.describe(
|
||||
export const Parameters = Schema.Struct({
|
||||
description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }),
|
||||
prompt: Schema.String.annotate({ description: "The task for the agent to perform" }),
|
||||
subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
|
||||
task_id: Schema.optional(Schema.String).annotate({
|
||||
description:
|
||||
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
|
||||
)
|
||||
.optional(),
|
||||
command: z.string().describe("The command that triggered this task").optional(),
|
||||
}),
|
||||
command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }),
|
||||
})
|
||||
|
||||
export const TaskTool = Tool.define(
|
||||
@@ -37,7 +34,10 @@ export const TaskTool = Tool.define(
|
||||
const config = yield* Config.Service
|
||||
const sessions = yield* Session.Service
|
||||
|
||||
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
|
||||
const run = Effect.fn("TaskTool.execute")(function* (
|
||||
params: Schema.Schema.Type<typeof Parameters>,
|
||||
ctx: Tool.Context,
|
||||
) {
|
||||
const cfg = yield* config.get()
|
||||
|
||||
if (!ctx.extra?.bypassAgentCheck) {
|
||||
@@ -168,8 +168,9 @@ export const TaskTool = Tool.define(
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters,
|
||||
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
|
||||
parameters: Parameters,
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
run(params, ctx).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,39 +1,36 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
import DESCRIPTION_WRITE from "./todowrite.txt"
|
||||
import { Todo } from "../session/todo"
|
||||
|
||||
// Parameters are kept inline rather than derived from Todo.Info because
|
||||
// Tool.define requires z.ZodObject-typed parameters for execute() inference,
|
||||
// and zodObject(Todo.Info) returns ZodObject<any> — reaching into .shape would
|
||||
// erase field types. Tool schemas migrate to Effect Schema as a separate slice
|
||||
// per specs/effect/schema.md.
|
||||
const parameters = z.object({
|
||||
todos: z
|
||||
.array(
|
||||
z.object({
|
||||
content: z.string().describe("Brief description of the task"),
|
||||
status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
|
||||
priority: z.string().describe("Priority level of the task: high, medium, low"),
|
||||
}),
|
||||
)
|
||||
.describe("The updated todo list"),
|
||||
// Todo.Info is still a zod schema (session/todo.ts). Inline the field shape
|
||||
// here rather than referencing its `.shape` — the LLM-visible JSON Schema is
|
||||
// identical, and it removes the last zod dependency from this tool.
|
||||
const TodoItem = Schema.Struct({
|
||||
content: Schema.String.annotate({ description: "Brief description of the task" }),
|
||||
status: Schema.String.annotate({
|
||||
description: "Current status of the task: pending, in_progress, completed, cancelled",
|
||||
}),
|
||||
priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }),
|
||||
})
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
todos: Schema.mutable(Schema.Array(TodoItem)).annotate({ description: "The updated todo list" }),
|
||||
})
|
||||
|
||||
type Metadata = {
|
||||
todos: Todo.Info[]
|
||||
}
|
||||
|
||||
export const TodoWriteTool = Tool.define<typeof parameters, Metadata, Todo.Service>(
|
||||
export const TodoWriteTool = Tool.define<typeof Parameters, Metadata, Todo.Service>(
|
||||
"todowrite",
|
||||
Effect.gen(function* () {
|
||||
const todo = yield* Todo.Service
|
||||
|
||||
return {
|
||||
description: DESCRIPTION_WRITE,
|
||||
parameters,
|
||||
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
|
||||
parameters: Parameters,
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.ask({
|
||||
permission: "todowrite",
|
||||
@@ -55,6 +52,6 @@ export const TodoWriteTool = Tool.define<typeof parameters, Metadata, Todo.Servi
|
||||
},
|
||||
}
|
||||
}),
|
||||
} satisfies Tool.DefWithoutID<typeof parameters, Metadata>
|
||||
} satisfies Tool.DefWithoutID<typeof Parameters, Metadata>
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { MessageV2 } from "../session/message-v2"
|
||||
import type { Permission } from "../permission"
|
||||
import type { SessionID, MessageID } from "../session/schema"
|
||||
@@ -32,29 +31,39 @@ export interface ExecuteResult<M extends Metadata = Metadata> {
|
||||
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
|
||||
}
|
||||
|
||||
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
|
||||
export interface Def<
|
||||
Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,
|
||||
M extends Metadata = Metadata,
|
||||
> {
|
||||
id: string
|
||||
description: string
|
||||
parameters: Parameters
|
||||
execute(args: z.infer<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
|
||||
formatValidationError?(error: z.ZodError): string
|
||||
execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
|
||||
formatValidationError?(error: unknown): string
|
||||
}
|
||||
export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
|
||||
Def<Parameters, M>,
|
||||
"id"
|
||||
>
|
||||
export type DefWithoutID<
|
||||
Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,
|
||||
M extends Metadata = Metadata,
|
||||
> = Omit<Def<Parameters, M>, "id">
|
||||
|
||||
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
|
||||
export interface Info<
|
||||
Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,
|
||||
M extends Metadata = Metadata,
|
||||
> {
|
||||
id: string
|
||||
init: () => Effect.Effect<DefWithoutID<Parameters, M>>
|
||||
}
|
||||
|
||||
type Init<Parameters extends z.ZodType, M extends Metadata> =
|
||||
type Init<Parameters extends Schema.Decoder<unknown>, M extends Metadata> =
|
||||
| DefWithoutID<Parameters, M>
|
||||
| (() => Effect.Effect<DefWithoutID<Parameters, M>>)
|
||||
|
||||
export type InferParameters<T> =
|
||||
T extends Info<infer P, any> ? z.infer<P> : T extends Effect.Effect<Info<infer P, any>, any, any> ? z.infer<P> : never
|
||||
T extends Info<infer P, any>
|
||||
? Schema.Schema.Type<P>
|
||||
: T extends Effect.Effect<Info<infer P, any>, any, any>
|
||||
? Schema.Schema.Type<P>
|
||||
: never
|
||||
export type InferMetadata<T> =
|
||||
T extends Info<any, infer M> ? M : T extends Effect.Effect<Info<any, infer M>, any, any> ? M : never
|
||||
|
||||
@@ -65,7 +74,7 @@ export type InferDef<T> =
|
||||
? Def<P, M>
|
||||
: never
|
||||
|
||||
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
||||
function wrap<Parameters extends Schema.Decoder<unknown>, Result extends Metadata>(
|
||||
id: string,
|
||||
init: Init<Parameters, Result>,
|
||||
truncate: Truncate.Interface,
|
||||
@@ -74,6 +83,10 @@ function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
||||
return () =>
|
||||
Effect.gen(function* () {
|
||||
const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }
|
||||
// Compile the parser closure once per tool init; `decodeUnknownEffect`
|
||||
// allocates a new closure per call, so hoisting avoids re-closing it for
|
||||
// every LLM tool invocation.
|
||||
const decode = Schema.decodeUnknownEffect(toolInfo.parameters)
|
||||
const execute = toolInfo.execute
|
||||
toolInfo.execute = (args, ctx) => {
|
||||
const attrs = {
|
||||
@@ -83,19 +96,17 @@ function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
||||
...(ctx.callID ? { "tool.call_id": ctx.callID } : {}),
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.try({
|
||||
try: () => toolInfo.parameters.parse(args),
|
||||
catch: (error) => {
|
||||
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
|
||||
return new Error(toolInfo.formatValidationError(error), { cause: error })
|
||||
}
|
||||
return new Error(
|
||||
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
|
||||
{ cause: error },
|
||||
)
|
||||
},
|
||||
})
|
||||
const result = yield* execute(args, ctx)
|
||||
const decoded = yield* decode(args).pipe(
|
||||
Effect.mapError((error) =>
|
||||
toolInfo.formatValidationError
|
||||
? new Error(toolInfo.formatValidationError(error), { cause: error })
|
||||
: new Error(
|
||||
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
|
||||
{ cause: error },
|
||||
),
|
||||
),
|
||||
)
|
||||
const result = yield* execute(decoded as Schema.Schema.Type<Parameters>, ctx)
|
||||
if (result.metadata.truncated !== undefined) {
|
||||
return result
|
||||
}
|
||||
@@ -116,7 +127,12 @@ function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
||||
})
|
||||
}
|
||||
|
||||
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
|
||||
export function define<
|
||||
Parameters extends Schema.Decoder<unknown>,
|
||||
Result extends Metadata,
|
||||
R,
|
||||
ID extends string = string,
|
||||
>(
|
||||
id: ID,
|
||||
init: Effect.Effect<Init<Parameters, Result>, never, R>,
|
||||
): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {
|
||||
@@ -131,7 +147,9 @@ export function define<Parameters extends z.ZodType, Result extends Metadata, R,
|
||||
)
|
||||
}
|
||||
|
||||
export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
|
||||
export function init<P extends Schema.Decoder<unknown>, M extends Metadata>(
|
||||
info: Info<P, M>,
|
||||
): Effect.Effect<Def<P, M>> {
|
||||
return Effect.gen(function* () {
|
||||
const init = yield* info.init()
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import * as Tool from "./tool"
|
||||
import TurndownService from "turndown"
|
||||
@@ -10,13 +9,14 @@ const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
|
||||
const MAX_TIMEOUT = 120 * 1000 // 2 minutes
|
||||
|
||||
const parameters = z.object({
|
||||
url: z.string().describe("The URL to fetch content from"),
|
||||
format: z
|
||||
.enum(["text", "markdown", "html"])
|
||||
.default("markdown")
|
||||
.describe("The format to return the content in (text, markdown, or html). Defaults to markdown."),
|
||||
timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
|
||||
export const Parameters = Schema.Struct({
|
||||
url: Schema.String.annotate({ description: "The URL to fetch content from" }),
|
||||
format: Schema.Literals(["text", "markdown", "html"])
|
||||
.pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed("markdown" as const)))
|
||||
.annotate({
|
||||
description: "The format to return the content in (text, markdown, or html). Defaults to markdown.",
|
||||
}),
|
||||
timeout: Schema.optional(Schema.Finite).annotate({ description: "Optional timeout in seconds (max 120)" }),
|
||||
})
|
||||
|
||||
export const WebFetchTool = Tool.define(
|
||||
@@ -27,8 +27,8 @@ export const WebFetchTool = Tool.define(
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters,
|
||||
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) =>
|
||||
parameters: Parameters,
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
|
||||
throw new Error("URL must start with http:// or https://")
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import * as Tool from "./tool"
|
||||
import * as McpExa from "./mcp-exa"
|
||||
import DESCRIPTION from "./websearch.txt"
|
||||
|
||||
const Parameters = z.object({
|
||||
query: z.string().describe("Websearch query"),
|
||||
numResults: z.number().optional().describe("Number of search results to return (default: 8)"),
|
||||
livecrawl: z
|
||||
.enum(["fallback", "preferred"])
|
||||
.optional()
|
||||
.describe(
|
||||
export const Parameters = Schema.Struct({
|
||||
query: Schema.String.annotate({ description: "Websearch query" }),
|
||||
numResults: Schema.Finite.pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed(8))).annotate({
|
||||
description: "Number of search results to return (default: 8)",
|
||||
}),
|
||||
livecrawl: Schema.Literals(["fallback", "preferred"]).pipe(
|
||||
Schema.withDecodingDefaultTypeKey(Effect.succeed("fallback" as const)),
|
||||
).annotate({
|
||||
description:
|
||||
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
|
||||
),
|
||||
type: z
|
||||
.enum(["auto", "fast", "deep"])
|
||||
.optional()
|
||||
.describe("Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search"),
|
||||
contextMaxCharacters: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
|
||||
}),
|
||||
type: Schema.Literals(["auto", "fast", "deep"]).pipe(
|
||||
Schema.withDecodingDefaultTypeKey(Effect.succeed("auto" as const)),
|
||||
).annotate({
|
||||
description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
|
||||
}),
|
||||
contextMaxCharacters: Schema.optional(Schema.Finite).annotate({
|
||||
description: "Maximum characters for context string optimized for LLMs (default: 10000)",
|
||||
}),
|
||||
})
|
||||
|
||||
export const WebSearchTool = Tool.define(
|
||||
@@ -34,7 +35,7 @@ export const WebSearchTool = Tool.define(
|
||||
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
|
||||
},
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.ask({
|
||||
permission: "websearch",
|
||||
@@ -55,9 +56,9 @@ export const WebSearchTool = Tool.define(
|
||||
McpExa.SearchArgs,
|
||||
{
|
||||
query: params.query,
|
||||
type: params.type || "auto",
|
||||
numResults: params.numResults || 8,
|
||||
livecrawl: params.livecrawl || "fallback",
|
||||
type: params.type,
|
||||
numResults: params.numResults,
|
||||
livecrawl: params.livecrawl,
|
||||
contextMaxCharacters: params.contextMaxCharacters,
|
||||
},
|
||||
"25 seconds",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import * as path from "path"
|
||||
import { Effect } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
@@ -17,6 +17,13 @@ import * as Bom from "@/util/bom"
|
||||
|
||||
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
content: Schema.String.annotate({ description: "The content to write to the file" }),
|
||||
filePath: Schema.String.annotate({
|
||||
description: "The absolute path to the file to write (must be absolute, not relative)",
|
||||
}),
|
||||
})
|
||||
|
||||
export const WriteTool = Tool.define(
|
||||
"write",
|
||||
Effect.gen(function* () {
|
||||
@@ -27,10 +34,7 @@ export const WriteTool = Tool.define(
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
content: z.string().describe("The content to write to the file"),
|
||||
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
|
||||
}),
|
||||
parameters: Parameters,
|
||||
execute: (params: { content: string; filePath: string }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.isAbsolute(params.filePath)
|
||||
|
||||
@@ -8,6 +8,14 @@ import z from "zod"
|
||||
*/
|
||||
export const ZodOverride: unique symbol = Symbol.for("effect-zod/override")
|
||||
|
||||
/**
|
||||
* Annotation key for attaching a `z.preprocess(...)` coercion to the derived
|
||||
* zod schema without changing the Effect Schema's runtime behaviour. Useful
|
||||
* for LLM-facing JSON Schemas that need to accept looser inputs (e.g. string
|
||||
* → number) than the underlying Schema admits.
|
||||
*/
|
||||
export const ZodPreprocess: unique symbol = Symbol.for("effect-zod/preprocess")
|
||||
|
||||
// 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
|
||||
@@ -49,6 +57,16 @@ function isZodType(value: unknown): value is z.ZodTypeAny {
|
||||
return typeof value === "object" && value !== null && "_zod" in value
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a JSON Schema for a tool/route parameter schema — derives the zod form
|
||||
* via the walker so Effect Schema inputs flow through the same zod-openapi
|
||||
* pipeline the LLM/SDK layer already depends on. `io: "input"` mirrors what
|
||||
* `session/prompt.ts` has always passed to `ai`'s `jsonSchema()` helper.
|
||||
*/
|
||||
export function toJsonSchema<S extends Schema.Top>(schema: S) {
|
||||
return z.toJSONSchema(zod(schema), { io: "input" })
|
||||
}
|
||||
|
||||
function walk(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
const cached = walkCache.get(ast)
|
||||
if (cached) return cached
|
||||
@@ -59,8 +77,17 @@ function walk(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
|
||||
function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined
|
||||
if (override) return override
|
||||
// `description` annotations layer on top of an override so callers can
|
||||
// reuse a shared override schema (e.g. `SessionID`) and still add a
|
||||
// per-field description on the outer wrapper.
|
||||
const base = override ?? bodyWithChecks(ast)
|
||||
const desc = SchemaAST.resolveDescription(ast)
|
||||
const ref = SchemaAST.resolveIdentifier(ast)
|
||||
const described = desc ? base.describe(desc) : base
|
||||
return ref ? described.meta({ ref }) : described
|
||||
}
|
||||
|
||||
function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
// 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
|
||||
@@ -68,17 +95,18 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
// Schema.decodeTo / Schema.transform attach encoding to non-Declaration
|
||||
// nodes, where we do apply the transform.
|
||||
//
|
||||
// Schema.withDecodingDefault also attaches encoding, but we want `.default(v)`
|
||||
// on the inner Zod rather than a transform wrapper — so optional ASTs whose
|
||||
// encoding resolves a default from Option.none() route through body()/opt().
|
||||
// Schema.withDecodingDefault and Schema.withDecodingDefaultTypeKey both
|
||||
// attach encodings. For JSON Schema we want those as plain `.default(v)`
|
||||
// annotations rather than transform wrappers, so ASTs whose encoding
|
||||
// resolves a default from Option.none() route through body()/opt().
|
||||
const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration"
|
||||
const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined)
|
||||
const base = hasTransform ? encoded(ast) : body(ast)
|
||||
const fallback = hasEncoding ? extractDefault(ast) : undefined
|
||||
const hasTransform = hasEncoding && fallback === undefined
|
||||
const base = hasTransform ? encoded(ast) : body(ast, fallback)
|
||||
const checked = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
|
||||
const desc = SchemaAST.resolveDescription(ast)
|
||||
const ref = SchemaAST.resolveIdentifier(ast)
|
||||
const described = desc ? checked.describe(desc) : checked
|
||||
return ref ? described.meta({ ref }) : described
|
||||
const defaulted = fallback !== undefined && !SchemaAST.isOptional(ast) ? checked.default(fallback.value) : checked
|
||||
const preprocess = (ast.annotations as { [ZodPreprocess]?: (val: unknown) => unknown } | undefined)?.[ZodPreprocess]
|
||||
return preprocess ? z.preprocess(preprocess, defaulted) : defaulted
|
||||
}
|
||||
|
||||
// Walk the encoded side and apply each link's decode to produce the decoded
|
||||
@@ -212,8 +240,8 @@ function issueMessage(issue: any): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function body(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
if (SchemaAST.isOptional(ast)) return opt(ast)
|
||||
function body(ast: SchemaAST.AST, fallback?: { value: unknown }): z.ZodTypeAny {
|
||||
if (SchemaAST.isOptional(ast)) return opt(ast, fallback)
|
||||
|
||||
switch (ast._tag) {
|
||||
case "String":
|
||||
@@ -246,7 +274,7 @@ function body(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
}
|
||||
}
|
||||
|
||||
function opt(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
function opt(ast: SchemaAST.AST, fallback = extractDefault(ast)): z.ZodTypeAny {
|
||||
if (ast._tag !== "Union") return fail(ast)
|
||||
const items = ast.types.filter((item) => item._tag !== "Undefined")
|
||||
const inner =
|
||||
@@ -258,7 +286,6 @@ function opt(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
// Schema.withDecodingDefault attaches an encoding `Link` whose transformation
|
||||
// decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke
|
||||
// it to extract the default and emit `.default(...)` instead of `.optional()`.
|
||||
const fallback = extractDefault(ast)
|
||||
if (fallback !== undefined) return inner.default(fallback.value)
|
||||
return inner.optional()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
/**
|
||||
* Integer greater than zero.
|
||||
*/
|
||||
export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
|
||||
|
||||
/**
|
||||
* Integer greater than or equal to zero.
|
||||
*/
|
||||
export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
|
||||
|
||||
/**
|
||||
* Strip `readonly` from a nested type. Stand-in for `effect`'s `Types.DeepMutable`
|
||||
* until `effect:core/x228my` ("Types.DeepMutable widens unknown to `{}`") lands.
|
||||
*
|
||||
* The upstream version falls through `unknown` into `{ -readonly [K in keyof T]: ... }`
|
||||
* where `keyof unknown = never`, so `unknown` collapses to `{}`. This local
|
||||
* version gates the object branch on `extends object` (which `unknown` does
|
||||
* not) so `unknown` passes through untouched.
|
||||
*
|
||||
* Primitive bailout matches upstream — without it, branded strings like
|
||||
* `string & Brand<"SessionID">` fall into the object branch and get their
|
||||
* prototype methods walked.
|
||||
*
|
||||
* Tuple branch preserves readonly tuples (e.g. `ConfigPlugin.Spec`'s
|
||||
* `readonly [string, Options]`); the general array branch would otherwise
|
||||
* widen them to unbounded arrays.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export type DeepMutable<T> = T extends string | number | boolean | bigint | symbol | Function
|
||||
? T
|
||||
: T extends readonly [unknown, ...unknown[]]
|
||||
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
|
||||
: T extends readonly (infer U)[]
|
||||
? DeepMutable<U>[]
|
||||
: T extends object
|
||||
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
|
||||
: T
|
||||
|
||||
/**
|
||||
* Attach static methods to a schema object. Designed to be used with `.pipe()`:
|
||||
*
|
||||
@@ -16,13 +54,16 @@ export const withStatics =
|
||||
(schema: S): S & M =>
|
||||
Object.assign(schema, methods(schema))
|
||||
|
||||
declare const NewtypeBrand: unique symbol
|
||||
type NewtypeBrand<Tag extends string> = { readonly [NewtypeBrand]: Tag }
|
||||
|
||||
/**
|
||||
* Nominal wrapper for scalar types. The class itself is a valid schema —
|
||||
* pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc.
|
||||
*
|
||||
* Overrides `~type.make` on the derived `Schema.Opaque` so `Schema.Schema.Type`
|
||||
* of a field using this newtype resolves to `Self` rather than the underlying
|
||||
* branded phantom. Without that override, passing a class instance to code
|
||||
* typed against `Schema.Schema.Type<FieldSchema>` would require a cast even
|
||||
* though the values are structurally equivalent at runtime.
|
||||
*
|
||||
* @example
|
||||
* class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {
|
||||
* static make(id: string): QuestionID {
|
||||
@@ -34,10 +75,8 @@ type NewtypeBrand<Tag extends string> = { readonly [NewtypeBrand]: Tag }
|
||||
*/
|
||||
export function Newtype<Self>() {
|
||||
return <const Tag extends string, S extends Schema.Top>(tag: Tag, schema: S) => {
|
||||
type Branded = NewtypeBrand<Tag>
|
||||
|
||||
abstract class Base {
|
||||
declare readonly [NewtypeBrand]: Tag
|
||||
declare readonly _newtype: Tag
|
||||
|
||||
static make(value: Schema.Schema.Type<S>): Self {
|
||||
return value as unknown as Self
|
||||
@@ -46,8 +85,10 @@ export function Newtype<Self>() {
|
||||
|
||||
Object.setPrototypeOf(Base, schema)
|
||||
|
||||
return Base as unknown as (abstract new (_: never) => Branded) & {
|
||||
return Base as unknown as (abstract new (_: never) => { readonly _newtype: Tag }) & {
|
||||
readonly make: (value: Schema.Schema.Type<S>) => Self
|
||||
} & Omit<Schema.Opaque<Self, S, {}>, "make">
|
||||
} & Omit<Schema.Opaque<Self, S, {}>, "make" | "~type.make"> & {
|
||||
readonly "~type.make": Self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { errorMessage } from "../util/error"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Git } from "@/git"
|
||||
import { Effect, Layer, Path, Scope, Context, Stream } from "effect"
|
||||
import { Effect, Layer, Path, Schema, Scope, Context, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
@@ -26,15 +26,15 @@ const log = Log.create({ service: "worktree" })
|
||||
export const Event = {
|
||||
Ready: BusEvent.define(
|
||||
"worktree.ready",
|
||||
z.object({
|
||||
name: z.string(),
|
||||
branch: z.string(),
|
||||
Schema.Struct({
|
||||
name: Schema.String,
|
||||
branch: Schema.String,
|
||||
}),
|
||||
),
|
||||
Failed: BusEvent.define(
|
||||
"worktree.failed",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
Schema.Struct({
|
||||
message: Schema.String,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Stream } from "effect"
|
||||
import z from "zod"
|
||||
import { Deferred, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
@@ -9,8 +8,8 @@ import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const TestEvent = {
|
||||
Ping: BusEvent.define("test.effect.ping", z.object({ value: z.number() })),
|
||||
Pong: BusEvent.define("test.effect.pong", z.object({ message: z.string() })),
|
||||
Ping: BusEvent.define("test.effect.ping", Schema.Struct({ value: Schema.Number })),
|
||||
Pong: BusEvent.define("test.effect.pong", Schema.Struct({ message: Schema.String })),
|
||||
}
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const TestEvent = BusEvent.define("test.integration", z.object({ value: z.number() }))
|
||||
const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number }))
|
||||
|
||||
function withInstance(directory: string, fn: () => Promise<void>) {
|
||||
return Instance.provide({ directory, fn })
|
||||
@@ -42,7 +42,7 @@ describe("Bus integration: acquireRelease subscriber pattern", () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: Array<{ type: string; value?: number }> = []
|
||||
|
||||
const OtherEvent = BusEvent.define("test.other", z.object({ value: z.number() }))
|
||||
const OtherEvent = BusEvent.define("test.other", Schema.Struct({ value: Schema.Number }))
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribeAll((evt) => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const TestEvent = {
|
||||
Ping: BusEvent.define("test.ping", z.object({ value: z.number() })),
|
||||
Pong: BusEvent.define("test.pong", z.object({ message: z.string() })),
|
||||
Ping: BusEvent.define("test.ping", Schema.Struct({ value: Schema.Number })),
|
||||
Pong: BusEvent.define("test.pong", Schema.Struct({ message: Schema.String })),
|
||||
}
|
||||
|
||||
function withInstance(directory: string, fn: () => Promise<void>) {
|
||||
|
||||
@@ -111,9 +111,12 @@ describe("step-finish token propagation via Bus event", () => {
|
||||
mode: "",
|
||||
} as unknown as MessageV2.Info)
|
||||
|
||||
// Bus subscribers receive readonly Schema.Type payloads; `MessageV2.Part`
|
||||
// is the mutable domain type. Cast bridges the two — safe because the
|
||||
// test only reads the value afterwards.
|
||||
let received: MessageV2.Part | undefined
|
||||
const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, (event) => {
|
||||
received = event.properties.part
|
||||
received = event.properties.part as MessageV2.Part
|
||||
})
|
||||
|
||||
const tokens = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { SyncEvent } from "../../src/sync"
|
||||
@@ -43,13 +43,13 @@ describe("SyncEvent", () => {
|
||||
type: "item.created",
|
||||
version: 1,
|
||||
aggregate: "id",
|
||||
schema: z.object({ id: z.string(), name: z.string() }),
|
||||
schema: Schema.Struct({ id: Schema.String, name: Schema.String }),
|
||||
})
|
||||
const Sent = SyncEvent.define({
|
||||
type: "item.sent",
|
||||
version: 1,
|
||||
aggregate: "item_id",
|
||||
schema: z.object({ item_id: z.string(), to: z.string() }),
|
||||
schema: Schema.Struct({ item_id: Schema.String, to: Schema.String }),
|
||||
})
|
||||
|
||||
SyncEvent.init({
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
|
||||
exports[`tool parameters JSON Schema (wire shape) apply_patch 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"patchText": {
|
||||
"description": "The full patch text that describes all changes to be made",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"patchText",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`tool parameters JSON Schema (wire shape) bash 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command to execute",
|
||||
"type": "string",
|
||||
},
|
||||
"description": {
|
||||
"description":
|
||||
"Clear, concise description of what this command does in 5-10 words. Examples:
|
||||
Input: ls
|
||||
Output: Lists files in current directory
|
||||
|
||||
Input: git status
|
||||
Output: Shows working tree status
|
||||
|
||||
Input: npm install
|
||||
Output: Installs package dependencies
|
||||
|
||||
Input: mkdir foo
|
||||
Output: Creates directory 'foo'"
|
||||
,
|
||||
"type": "string",
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Optional timeout in milliseconds",
|
||||
"type": "number",
|
||||
},
|
||||
"workdir": {
|
||||
"description": "The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"command",
|
||||
"description",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`tool parameters JSON Schema (wire shape) codesearch 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"query": {
|
||||
"description": "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
|
||||
"type": "string",
|
||||
},
|
||||
"tokensNum": {
|
||||
"default": 5000,
|
||||
"description": "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
|
||||
"maximum": 50000,
|
||||
"minimum": 1000,
|
||||
"type": "number",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"query",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`tool parameters JSON Schema (wire shape) edit 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"filePath": {
|
||||
"description": "The absolute path to the file to modify",
|
||||
"type": "string",
|
||||
},
|
||||
"newString": {
|
||||
"description": "The text to replace it with (must be different from oldString)",
|
||||
"type": "string",
|
||||
},
|
||||
"oldString": {
|
||||
"description": "The text to replace",
|
||||
"type": "string",
|
||||
},
|
||||
"replaceAll": {
|
||||
"description": "Replace all occurrences of oldString (default false)",
|
||||
"type": "boolean",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"filePath",
|
||||
"oldString",
|
||||
"newString",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`tool parameters JSON Schema (wire shape) glob 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"path": {
|
||||
"description": "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.",
|
||||
"type": "string",
|
||||
},
|
||||
"pattern": {
|
||||
"description": "The glob pattern to match files against",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"pattern",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`tool parameters JSON Schema (wire shape) grep 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"include": {
|
||||
"description": "File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")",
|
||||
"type": "string",
|
||||
},
|
||||
"path": {
|
||||
"description": "The directory to search in. Defaults to the current working directory.",
|
||||
"type": "string",
|
||||
},
|
||||
"pattern": {
|
||||
"description": "The regex pattern to search for in file contents",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"pattern",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`tool parameters JSON Schema (wire shape) invalid 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string",
|
||||
},
|
||||
"tool": {
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"tool",
|
||||
"error",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`tool parameters JSON Schema (wire shape) lsp 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"character": {
|
||||
"description": "The character offset (1-based, as shown in editors)",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "integer",
|
||||
},
|
||||
"filePath": {
|
||||
"description": "The absolute or relative path to the file",
|
||||
"type": "string",
|
||||
},
|
||||
"line": {
|
||||
"description": "The line number (1-based, as shown in editors)",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "integer",
|
||||
},
|
||||
"operation": {
|
||||
"description": "The LSP operation to perform",
|
||||
"enum": [
|
||||
"goToDefinition",
|
||||
"findReferences",
|
||||
"hover",
|
||||
"documentSymbol",
|
||||
"workspaceSymbol",
|
||||
"goToImplementation",
|
||||
"prepareCallHierarchy",
|
||||
"incomingCalls",
|
||||
"outgoingCalls",
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"operation",
|
||||
"filePath",
|
||||
"line",
|
||||
"character",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
exports[`tool parameters JSON Schema (wire shape) plan 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {},
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`tool parameters JSON Schema (wire shape) question 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"questions": {
|
||||
"description": "Questions to ask",
|
||||
"items": {
|
||||
"properties": {
|
||||
"header": {
|
||||
"description": "Very short label (max 30 chars)",
|
||||
"type": "string",
|
||||
},
|
||||
"multiple": {
|
||||
"description": "Allow selecting multiple choices",
|
||||
"type": "boolean",
|
||||
},
|
||||
"options": {
|
||||
"description": "Available choices",
|
||||
"items": {
|
||||
"properties": {
|
||||
"description": {
|
||||
"description": "Explanation of choice",
|
||||
"type": "string",
|
||||
},
|
||||
"label": {
|
||||
"description": "Display text (1-5 words, concise)",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"ref": "QuestionOption",
|
||||
"required": [
|
||||
"label",
|
||||
"description",
|
||||
],
|
||||
"type": "object",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"question": {
|
||||
"description": "Complete question",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"ref": "QuestionPrompt",
|
||||
"required": [
|
||||
"question",
|
||||
"header",
|
||||
"options",
|
||||
],
|
||||
"type": "object",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"questions",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`tool parameters JSON Schema (wire shape) read 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"filePath": {
|
||||
"description": "The absolute path to the file or directory to read",
|
||||
"type": "string",
|
||||
},
|
||||
"limit": {
|
||||
"description": "The maximum number of lines to read (defaults to 2000)",
|
||||
"type": "number",
|
||||
},
|
||||
"offset": {
|
||||
"description": "The line number to start reading from (1-indexed)",
|
||||
"type": "number",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"filePath",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`tool parameters JSON Schema (wire shape) skill 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "The name of the skill from available_skills",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`tool parameters JSON Schema (wire shape) task 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command that triggered this task",
|
||||
"type": "string",
|
||||
},
|
||||
"description": {
|
||||
"description": "A short (3-5 words) description of the task",
|
||||
"type": "string",
|
||||
},
|
||||
"prompt": {
|
||||
"description": "The task for the agent to perform",
|
||||
"type": "string",
|
||||
},
|
||||
"subagent_type": {
|
||||
"description": "The type of specialized agent to use for this task",
|
||||
"type": "string",
|
||||
},
|
||||
"task_id": {
|
||||
"description": "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"description",
|
||||
"prompt",
|
||||
"subagent_type",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`tool parameters JSON Schema (wire shape) todo 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"todos": {
|
||||
"description": "The updated todo list",
|
||||
"items": {
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Brief description of the task",
|
||||
"type": "string",
|
||||
},
|
||||
"priority": {
|
||||
"description": "Priority level of the task: high, medium, low",
|
||||
"type": "string",
|
||||
},
|
||||
"status": {
|
||||
"description": "Current status of the task: pending, in_progress, completed, cancelled",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"content",
|
||||
"status",
|
||||
"priority",
|
||||
],
|
||||
"type": "object",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"todos",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"format": {
|
||||
"default": "markdown",
|
||||
"description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
|
||||
"enum": [
|
||||
"text",
|
||||
"markdown",
|
||||
"html",
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Optional timeout in seconds (max 120)",
|
||||
"type": "number",
|
||||
},
|
||||
"url": {
|
||||
"description": "The URL to fetch content from",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"url",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`tool parameters JSON Schema (wire shape) websearch 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"contextMaxCharacters": {
|
||||
"description": "Maximum characters for context string optimized for LLMs (default: 10000)",
|
||||
"type": "number",
|
||||
},
|
||||
"livecrawl": {
|
||||
"default": "fallback",
|
||||
"description": "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
|
||||
"enum": [
|
||||
"fallback",
|
||||
"preferred",
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
"numResults": {
|
||||
"default": 8,
|
||||
"description": "Number of search results to return (default: 8)",
|
||||
"type": "number",
|
||||
},
|
||||
"query": {
|
||||
"description": "Websearch query",
|
||||
"type": "string",
|
||||
},
|
||||
"type": {
|
||||
"default": "auto",
|
||||
"description": "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
|
||||
"enum": [
|
||||
"auto",
|
||||
"fast",
|
||||
"deep",
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"query",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`tool parameters JSON Schema (wire shape) write 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "The content to write to the file",
|
||||
"type": "string",
|
||||
},
|
||||
"filePath": {
|
||||
"description": "The absolute path to the file to write (must be absolute, not relative)",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"content",
|
||||
"filePath",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,304 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Result, Schema } from "effect"
|
||||
import { toJsonSchema } from "../../src/util/effect-zod"
|
||||
|
||||
// Each tool exports its parameters schema at module scope so this test can
|
||||
// import them without running the tool's Effect-based init. The JSON Schema
|
||||
// snapshot captures what the LLM sees; the parse assertions pin down the
|
||||
// accepts/rejects contract. `toJsonSchema` is the same helper `session/
|
||||
// prompt.ts` uses to emit tool schemas to the LLM, so the snapshots stay
|
||||
// byte-identical regardless of whether a tool has migrated from zod to Schema.
|
||||
|
||||
import { Parameters as ApplyPatch } from "../../src/tool/apply_patch"
|
||||
import { Parameters as Bash } from "../../src/tool/bash"
|
||||
import { Parameters as CodeSearch } from "../../src/tool/codesearch"
|
||||
import { Parameters as Edit } from "../../src/tool/edit"
|
||||
import { Parameters as Glob } from "../../src/tool/glob"
|
||||
import { Parameters as Grep } from "../../src/tool/grep"
|
||||
import { Parameters as Invalid } from "../../src/tool/invalid"
|
||||
import { Parameters as Lsp } from "../../src/tool/lsp"
|
||||
import { Parameters as Plan } from "../../src/tool/plan"
|
||||
import { Parameters as Question } from "../../src/tool/question"
|
||||
import { Parameters as Read } from "../../src/tool/read"
|
||||
import { Parameters as Skill } from "../../src/tool/skill"
|
||||
import { Parameters as Task } from "../../src/tool/task"
|
||||
import { Parameters as Todo } from "../../src/tool/todo"
|
||||
import { Parameters as WebFetch } from "../../src/tool/webfetch"
|
||||
import { Parameters as WebSearch } from "../../src/tool/websearch"
|
||||
import { Parameters as Write } from "../../src/tool/write"
|
||||
|
||||
const parse = <S extends Schema.Decoder<unknown>>(schema: S, input: unknown): S["Type"] =>
|
||||
Schema.decodeUnknownSync(schema)(input)
|
||||
|
||||
const accepts = (schema: Schema.Decoder<unknown>, input: unknown): boolean =>
|
||||
Result.isSuccess(Schema.decodeUnknownResult(schema)(input))
|
||||
|
||||
const toNativeJsonSchema = <S extends Schema.Decoder<unknown>>(schema: S) =>
|
||||
Schema.toStandardJSONSchemaV1(schema)["~standard"].jsonSchema.input({ target: "draft-2020-12" })
|
||||
|
||||
describe("tool parameters", () => {
|
||||
describe("JSON Schema (wire shape)", () => {
|
||||
test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot())
|
||||
test("bash", () => expect(toJsonSchema(Bash)).toMatchSnapshot())
|
||||
test("codesearch", () => expect(toJsonSchema(CodeSearch)).toMatchSnapshot())
|
||||
test("edit", () => expect(toJsonSchema(Edit)).toMatchSnapshot())
|
||||
test("glob", () => expect(toJsonSchema(Glob)).toMatchSnapshot())
|
||||
test("grep", () => expect(toJsonSchema(Grep)).toMatchSnapshot())
|
||||
test("invalid", () => expect(toJsonSchema(Invalid)).toMatchSnapshot())
|
||||
test("lsp", () => expect(toJsonSchema(Lsp)).toMatchSnapshot())
|
||||
test("plan", () => expect(toJsonSchema(Plan)).toMatchSnapshot())
|
||||
test("question", () => expect(toJsonSchema(Question)).toMatchSnapshot())
|
||||
test("read", () => expect(toJsonSchema(Read)).toMatchSnapshot())
|
||||
test("skill", () => expect(toJsonSchema(Skill)).toMatchSnapshot())
|
||||
test("task", () => expect(toJsonSchema(Task)).toMatchSnapshot())
|
||||
test("todo", () => expect(toJsonSchema(Todo)).toMatchSnapshot())
|
||||
test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot())
|
||||
test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot())
|
||||
test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot())
|
||||
})
|
||||
|
||||
describe("native JSON Schema (experimental tool route)", () => {
|
||||
test("codesearch uses a plain finite number", () => {
|
||||
const native = toNativeJsonSchema(CodeSearch) as any
|
||||
expect(native.properties.tokensNum).toEqual({
|
||||
type: "number",
|
||||
allOf: [{ minimum: 1000 }, { maximum: 50000 }],
|
||||
})
|
||||
})
|
||||
|
||||
test("webfetch format stays a string enum", () => {
|
||||
const native = toNativeJsonSchema(WebFetch) as any
|
||||
expect(native.properties.format).toEqual({
|
||||
type: "string",
|
||||
enum: ["text", "markdown", "html"],
|
||||
})
|
||||
})
|
||||
|
||||
test("websearch defaulted fields stay non-nullable", () => {
|
||||
const native = toNativeJsonSchema(WebSearch) as any
|
||||
expect(native.properties.numResults).toEqual({
|
||||
type: "number",
|
||||
})
|
||||
expect(native.properties.livecrawl).toEqual({
|
||||
type: "string",
|
||||
enum: ["fallback", "preferred"],
|
||||
})
|
||||
expect(native.properties.type).toEqual({
|
||||
type: "string",
|
||||
enum: ["auto", "fast", "deep"],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("apply_patch", () => {
|
||||
test("accepts patchText", () => {
|
||||
expect(parse(ApplyPatch, { patchText: "*** Begin Patch\n*** End Patch" })).toEqual({
|
||||
patchText: "*** Begin Patch\n*** End Patch",
|
||||
})
|
||||
})
|
||||
test("rejects missing patchText", () => {
|
||||
expect(accepts(ApplyPatch, {})).toBe(false)
|
||||
})
|
||||
test("rejects non-string patchText", () => {
|
||||
expect(accepts(ApplyPatch, { patchText: 123 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("bash", () => {
|
||||
test("accepts minimum: command + description", () => {
|
||||
expect(parse(Bash, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" })
|
||||
})
|
||||
test("accepts optional timeout + workdir", () => {
|
||||
const parsed = parse(Bash, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" })
|
||||
expect(parsed.timeout).toBe(5000)
|
||||
expect(parsed.workdir).toBe("/tmp")
|
||||
})
|
||||
test("rejects missing description (required by zod)", () => {
|
||||
expect(accepts(Bash, { command: "ls" })).toBe(false)
|
||||
})
|
||||
test("rejects missing command", () => {
|
||||
expect(accepts(Bash, { description: "list" })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("codesearch", () => {
|
||||
test("accepts query; tokensNum defaults to 5000", () => {
|
||||
expect(parse(CodeSearch, { query: "hooks" })).toEqual({ query: "hooks", tokensNum: 5000 })
|
||||
})
|
||||
test("accepts override tokensNum", () => {
|
||||
expect(parse(CodeSearch, { query: "hooks", tokensNum: 10000 }).tokensNum).toBe(10000)
|
||||
})
|
||||
test("rejects tokensNum under 1000", () => {
|
||||
expect(accepts(CodeSearch, { query: "x", tokensNum: 500 })).toBe(false)
|
||||
})
|
||||
test("rejects tokensNum over 50000", () => {
|
||||
expect(accepts(CodeSearch, { query: "x", tokensNum: 60000 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edit", () => {
|
||||
test("accepts all four fields", () => {
|
||||
expect(parse(Edit, { filePath: "/a", oldString: "x", newString: "y", replaceAll: true })).toEqual({
|
||||
filePath: "/a",
|
||||
oldString: "x",
|
||||
newString: "y",
|
||||
replaceAll: true,
|
||||
})
|
||||
})
|
||||
test("replaceAll is optional", () => {
|
||||
const parsed = parse(Edit, { filePath: "/a", oldString: "x", newString: "y" })
|
||||
expect(parsed.replaceAll).toBeUndefined()
|
||||
})
|
||||
test("rejects missing filePath", () => {
|
||||
expect(accepts(Edit, { oldString: "x", newString: "y" })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("glob", () => {
|
||||
test("accepts pattern-only", () => {
|
||||
expect(parse(Glob, { pattern: "**/*.ts" })).toEqual({ pattern: "**/*.ts" })
|
||||
})
|
||||
test("accepts optional path", () => {
|
||||
expect(parse(Glob, { pattern: "**/*.ts", path: "/tmp" }).path).toBe("/tmp")
|
||||
})
|
||||
test("rejects missing pattern", () => {
|
||||
expect(accepts(Glob, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("grep", () => {
|
||||
test("accepts pattern-only", () => {
|
||||
expect(parse(Grep, { pattern: "TODO" })).toEqual({ pattern: "TODO" })
|
||||
})
|
||||
test("accepts optional path + include", () => {
|
||||
const parsed = parse(Grep, { pattern: "TODO", path: "/tmp", include: "*.ts" })
|
||||
expect(parsed.path).toBe("/tmp")
|
||||
expect(parsed.include).toBe("*.ts")
|
||||
})
|
||||
test("rejects missing pattern", () => {
|
||||
expect(accepts(Grep, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("invalid", () => {
|
||||
test("accepts tool + error", () => {
|
||||
expect(parse(Invalid, { tool: "foo", error: "bar" })).toEqual({ tool: "foo", error: "bar" })
|
||||
})
|
||||
test("rejects missing fields", () => {
|
||||
expect(accepts(Invalid, { tool: "foo" })).toBe(false)
|
||||
expect(accepts(Invalid, { error: "bar" })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("lsp", () => {
|
||||
test("accepts all fields", () => {
|
||||
const parsed = parse(Lsp, { operation: "hover", filePath: "/a.ts", line: 1, character: 1 })
|
||||
expect(parsed.operation).toBe("hover")
|
||||
})
|
||||
test("rejects line < 1", () => {
|
||||
expect(accepts(Lsp, { operation: "hover", filePath: "/a.ts", line: 0, character: 1 })).toBe(false)
|
||||
})
|
||||
test("rejects character < 1", () => {
|
||||
expect(accepts(Lsp, { operation: "hover", filePath: "/a.ts", line: 1, character: 0 })).toBe(false)
|
||||
})
|
||||
test("rejects unknown operation", () => {
|
||||
expect(accepts(Lsp, { operation: "bogus", filePath: "/a.ts", line: 1, character: 1 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("plan", () => {
|
||||
test("accepts empty object", () => {
|
||||
expect(parse(Plan, {})).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("question", () => {
|
||||
test("accepts questions array", () => {
|
||||
const parsed = parse(Question, {
|
||||
questions: [
|
||||
{
|
||||
question: "pick one",
|
||||
header: "Header",
|
||||
custom: false,
|
||||
options: [{ label: "a", description: "desc" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(parsed.questions.length).toBe(1)
|
||||
})
|
||||
test("rejects missing questions", () => {
|
||||
expect(accepts(Question, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("read", () => {
|
||||
test("accepts filePath-only", () => {
|
||||
expect(parse(Read, { filePath: "/a" }).filePath).toBe("/a")
|
||||
})
|
||||
test("accepts optional offset + limit", () => {
|
||||
const parsed = parse(Read, { filePath: "/a", offset: 10, limit: 100 })
|
||||
expect(parsed.offset).toBe(10)
|
||||
expect(parsed.limit).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe("skill", () => {
|
||||
test("accepts name", () => {
|
||||
expect(parse(Skill, { name: "foo" }).name).toBe("foo")
|
||||
})
|
||||
test("rejects missing name", () => {
|
||||
expect(accepts(Skill, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("task", () => {
|
||||
test("accepts description + prompt + subagent_type", () => {
|
||||
const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general" })
|
||||
expect(parsed.subagent_type).toBe("general")
|
||||
})
|
||||
test("rejects missing prompt", () => {
|
||||
expect(accepts(Task, { description: "d", subagent_type: "general" })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("todo", () => {
|
||||
test("accepts todos array", () => {
|
||||
const parsed = parse(Todo, {
|
||||
todos: [{ id: "t1", content: "do x", status: "pending", priority: "medium" }],
|
||||
})
|
||||
expect(parsed.todos.length).toBe(1)
|
||||
})
|
||||
test("rejects missing todos", () => {
|
||||
expect(accepts(Todo, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("webfetch", () => {
|
||||
test("accepts url-only", () => {
|
||||
expect(parse(WebFetch, { url: "https://example.com" })).toEqual({
|
||||
url: "https://example.com",
|
||||
format: "markdown",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("websearch", () => {
|
||||
test("accepts query", () => {
|
||||
expect(parse(WebSearch, { query: "opencode" })).toEqual({
|
||||
query: "opencode",
|
||||
numResults: 8,
|
||||
livecrawl: "fallback",
|
||||
type: "auto",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("write", () => {
|
||||
test("accepts content + filePath", () => {
|
||||
expect(parse(Write, { content: "hi", filePath: "/a" })).toEqual({ content: "hi", filePath: "/a" })
|
||||
})
|
||||
test("rejects missing filePath", () => {
|
||||
expect(accepts(Write, { content: "hi" })).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import z from "zod"
|
||||
import { Effect, Layer, ManagedRuntime, Schema } from "effect"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { Tool } from "../../src/tool"
|
||||
import { Truncate } from "../../src/tool"
|
||||
|
||||
const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
|
||||
|
||||
const params = z.object({ input: z.string() })
|
||||
const params = Schema.Struct({ input: Schema.String })
|
||||
|
||||
function makeTool(id: string, executeFn?: () => void) {
|
||||
return {
|
||||
@@ -56,4 +56,44 @@ describe("Tool.define", () => {
|
||||
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
|
||||
test("execute receives decoded parameters", async () => {
|
||||
const parameters = Schema.Struct({
|
||||
count: Schema.NumberFromString.pipe(Schema.optional, Schema.withDecodingDefaultType(Effect.succeed(5))),
|
||||
})
|
||||
const calls: Array<Schema.Schema.Type<typeof parameters>> = []
|
||||
const info = await runtime.runPromise(
|
||||
Tool.define(
|
||||
"test-decoded",
|
||||
Effect.succeed({
|
||||
description: "test tool",
|
||||
parameters,
|
||||
execute(args: Schema.Schema.Type<typeof parameters>) {
|
||||
calls.push(args)
|
||||
return Effect.succeed({ title: "test", output: "ok", metadata: { truncated: false } })
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
const ctx: Tool.Context = {
|
||||
sessionID: SessionID.descending(),
|
||||
messageID: MessageID.ascending(),
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata() {
|
||||
return Effect.void
|
||||
},
|
||||
ask() {
|
||||
return Effect.void
|
||||
},
|
||||
}
|
||||
const tool = await Effect.runPromise(info.init())
|
||||
const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType<typeof tool.execute>
|
||||
|
||||
await Effect.runPromise(execute({}, ctx))
|
||||
await Effect.runPromise(execute({ count: "7" }, ctx))
|
||||
|
||||
expect(calls).toEqual([{ count: 5 }, { count: 7 }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema, SchemaGetter } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
import { zod, ZodOverride } from "../../src/util/effect-zod"
|
||||
import { toJsonSchema, zod, ZodOverride, ZodPreprocess } from "../../src/util/effect-zod"
|
||||
|
||||
function json(schema: z.ZodTypeAny) {
|
||||
const { $schema: _, ...rest } = z.toJSONSchema(schema)
|
||||
@@ -750,5 +750,35 @@ describe("util.effect-zod", () => {
|
||||
expect(schema.parse({})).toEqual({})
|
||||
expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" })
|
||||
})
|
||||
|
||||
test("key defaults fill in missing struct keys", () => {
|
||||
const schema = zod(
|
||||
Schema.Struct({
|
||||
mode: Schema.String.pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed("ctrl-x"))),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(schema.parse({})).toEqual({ mode: "ctrl-x" })
|
||||
})
|
||||
|
||||
test("key defaults still accept explicit values", () => {
|
||||
const schema = zod(
|
||||
Schema.Struct({
|
||||
mode: Schema.String.pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed("ctrl-x"))),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(schema.parse({ mode: "ctrl-c" })).toEqual({ mode: "ctrl-c" })
|
||||
})
|
||||
|
||||
test("JSON Schema output includes the default key for key defaults", () => {
|
||||
const shape = toJsonSchema(
|
||||
Schema.Struct({
|
||||
mode: Schema.String.pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed("ctrl-x"))),
|
||||
}),
|
||||
) as any
|
||||
expect(shape.properties.mode.default).toBe("ctrl-x")
|
||||
expect(shape.required).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1058,31 +1058,31 @@ export type SyncEventSessionUpdated = {
|
||||
data: {
|
||||
sessionID: string
|
||||
info: {
|
||||
id: string | null
|
||||
slug: string | null
|
||||
projectID: string | null
|
||||
workspaceID: string | null
|
||||
directory: string | null
|
||||
parentID: string | null
|
||||
summary: {
|
||||
id?: string | null
|
||||
slug?: string | null
|
||||
projectID?: string | null
|
||||
workspaceID?: string | null
|
||||
directory?: string | null
|
||||
parentID?: string | null
|
||||
summary?: {
|
||||
additions: number
|
||||
deletions: number
|
||||
files: number
|
||||
diffs?: Array<SnapshotFileDiff>
|
||||
} | null
|
||||
share?: {
|
||||
url: string | null
|
||||
url?: string | null
|
||||
}
|
||||
title: string | null
|
||||
version: string | null
|
||||
title?: string | null
|
||||
version?: string | null
|
||||
time?: {
|
||||
created: number | null
|
||||
updated: number | null
|
||||
compacting: number | null
|
||||
archived: number | null
|
||||
created?: number | null
|
||||
updated?: number | null
|
||||
compacting?: number | null
|
||||
archived?: number | null
|
||||
}
|
||||
permission: PermissionRuleset | null
|
||||
revert: {
|
||||
permission?: PermissionRuleset | null
|
||||
revert?: {
|
||||
messageID: string
|
||||
partID?: string
|
||||
snapshot?: string
|
||||
|
||||
@@ -6804,7 +6804,6 @@
|
||||
},
|
||||
"duration": {
|
||||
"description": "Duration in milliseconds",
|
||||
"default": 5000,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -7638,20 +7637,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"event": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"const": "add"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "change"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "unlink"
|
||||
}
|
||||
]
|
||||
"type": "string",
|
||||
"enum": ["add", "change", "unlink"]
|
||||
}
|
||||
},
|
||||
"required": ["file", "event"]
|
||||
@@ -8507,7 +8494,6 @@
|
||||
},
|
||||
"duration": {
|
||||
"description": "Duration in milliseconds",
|
||||
"default": 5000,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -10570,8 +10556,7 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
@@ -10636,8 +10621,7 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["created", "updated", "compacting", "archived"]
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"anyOf": [
|
||||
@@ -10676,20 +10660,7 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"slug",
|
||||
"projectID",
|
||||
"workspaceID",
|
||||
"directory",
|
||||
"parentID",
|
||||
"summary",
|
||||
"title",
|
||||
"version",
|
||||
"permission",
|
||||
"revert"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "info"]
|
||||
|
||||
Reference in New Issue
Block a user