mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1803aaf857 |
@@ -1003,6 +1003,7 @@
|
|||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/simulation": "workspace:*",
|
"@opencode-ai/simulation": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { run } from "@opencode-ai/tui"
|
import { run } from "@opencode-ai/tui"
|
||||||
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
||||||
|
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
import { TuiConfig } from "../../tui-config"
|
|
||||||
import { Effect, Option } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
import { Server } from "../../services/server"
|
import { Server } from "../../services/server"
|
||||||
import { Updater } from "../../services/updater"
|
import { Updater } from "../../services/updater"
|
||||||
import { UpdatePreflight } from "../../services/update-preflight"
|
|
||||||
|
|
||||||
export default Runtime.handler(Commands, (input) =>
|
export default Runtime.handler(Commands, (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -16,33 +15,23 @@ export default Runtime.handler(Commands, (input) =>
|
|||||||
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
||||||
const updater = yield* Updater.Service
|
const updater = yield* Updater.Service
|
||||||
yield* updater.check().pipe(Effect.forkScoped)
|
yield* updater.check().pipe(Effect.forkScoped)
|
||||||
const preflight = UpdatePreflight.make()
|
|
||||||
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
|
||||||
const server = yield* Server.resolve({
|
const server = yield* Server.resolve({
|
||||||
server: Option.getOrUndefined(input.server),
|
server: Option.getOrUndefined(input.server),
|
||||||
standalone: input.standalone,
|
standalone: input.standalone,
|
||||||
onStart: (reason, existing) => {
|
onStart: (reason) =>
|
||||||
if (reason === "version-mismatch" && preflight.begin(existing?.version)) return
|
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
reason === "version-mismatch"
|
reason === "version-mismatch"
|
||||||
? "Restarting background server (version mismatch)...\n"
|
? "Restarting background server (version mismatch)...\n"
|
||||||
: "Starting background server...\n",
|
: "Starting background server...\n",
|
||||||
)
|
),
|
||||||
},
|
})
|
||||||
}).pipe(
|
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||||
Effect.tapError(() =>
|
|
||||||
Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
preflight.loading()
|
|
||||||
const config = yield* TuiConfig.load()
|
|
||||||
let disposeSlots: (() => void) | undefined
|
let disposeSlots: (() => void) | undefined
|
||||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||||
yield* run({
|
yield* run({
|
||||||
server,
|
server,
|
||||||
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||||
config,
|
config,
|
||||||
terminalHandoff: () => preflight.finish(),
|
|
||||||
log: (level, message, tags) => {
|
log: (level, message, tags) => {
|
||||||
const effect =
|
const effect =
|
||||||
level === "debug"
|
level === "debug"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// history ring. All are async because they read config or hit the SDK, but
|
// history ring. All are async because they read config or hit the SDK, but
|
||||||
// none block each other.
|
// none block each other.
|
||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { resolve } from "@opencode-ai/tui/config/v1"
|
import { resolve } from "@opencode-ai/tui/config"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
// → OpenTUI split-footer renderer writes to terminal
|
// → OpenTUI split-footer renderer writes to terminal
|
||||||
import type { OpenCodeClient, ReferenceListOutput } from "@opencode-ai/client/promise"
|
import type { OpenCodeClient, ReferenceListOutput } from "@opencode-ai/client/promise"
|
||||||
import type { FilePart, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
import type { FilePart, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||||
import type { TuiConfig } from "@opencode-ai/tui/config/v1"
|
import type { TuiConfig } from "@opencode-ai/tui/config"
|
||||||
|
|
||||||
export type RunFilePart = {
|
export type RunFilePart = {
|
||||||
type: "file"
|
type: "file"
|
||||||
|
|||||||
@@ -1,494 +0,0 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
|
||||||
// Split-footer status shown while a freshly launched CLI replaces a
|
|
||||||
// version-mismatched background service before the TUI attaches.
|
|
||||||
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
|
|
||||||
import { render, useTerminalDimensions } from "@opentui/solid"
|
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
|
||||||
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
|
|
||||||
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
|
|
||||||
import { go } from "@opencode-ai/tui/logo"
|
|
||||||
import {
|
|
||||||
batch,
|
|
||||||
createEffect,
|
|
||||||
createMemo,
|
|
||||||
createSignal,
|
|
||||||
For,
|
|
||||||
Index,
|
|
||||||
on,
|
|
||||||
onCleanup,
|
|
||||||
onMount,
|
|
||||||
Show,
|
|
||||||
untrack,
|
|
||||||
} from "solid-js"
|
|
||||||
|
|
||||||
const stages = ["Keeping your session safe", "Starting the new background service", "Loading OpenCode"] as const
|
|
||||||
const stageFloor = 480
|
|
||||||
const transitionDuration = 420
|
|
||||||
const completionHold = 650
|
|
||||||
|
|
||||||
export type Handle = {
|
|
||||||
readonly begin: (from?: string) => boolean
|
|
||||||
readonly loading: () => void
|
|
||||||
readonly finish: () => Promise<Handoff | undefined>
|
|
||||||
readonly fail: (message: string) => Promise<void>
|
|
||||||
readonly close: () => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Handoff = {
|
|
||||||
readonly renderer: CliRenderer
|
|
||||||
readonly mode: ThemeMode | null
|
|
||||||
readonly complete: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const make = (): Handle => {
|
|
||||||
let session: Promise<Session | undefined> | undefined
|
|
||||||
return {
|
|
||||||
begin: (from) => {
|
|
||||||
if (!process.stdout.isTTY || !process.stdin.isTTY) return false
|
|
||||||
session ??= open(from).catch(() => {
|
|
||||||
process.stderr.write("Restarting background server (version mismatch)...\n")
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
loading: () => {
|
|
||||||
void session?.then((active) => active?.loading())
|
|
||||||
},
|
|
||||||
finish: async () => {
|
|
||||||
const active = await session
|
|
||||||
return active?.finish()
|
|
||||||
},
|
|
||||||
fail: async (message) => {
|
|
||||||
const active = await session
|
|
||||||
await active?.fail(message)
|
|
||||||
},
|
|
||||||
close: async () => {
|
|
||||||
const active = await session
|
|
||||||
await active?.close()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Session = {
|
|
||||||
readonly loading: () => Promise<void>
|
|
||||||
readonly finish: () => Promise<Handoff>
|
|
||||||
readonly fail: (message: string) => Promise<void>
|
|
||||||
readonly close: () => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
async function open(from?: string): Promise<Session> {
|
|
||||||
registerOpencodeSpinner()
|
|
||||||
const [active, setActive] = createSignal(0)
|
|
||||||
const [outcome, setOutcome] = createSignal<"running" | "success" | "failure">("running")
|
|
||||||
const [failure, setFailure] = createSignal("")
|
|
||||||
const [animating, setAnimating] = createSignal(true)
|
|
||||||
const [visible, setVisible] = createSignal(true)
|
|
||||||
let resolveOutcome: (() => void) | undefined
|
|
||||||
const renderer = await createCliRenderer({
|
|
||||||
stdin: process.stdin,
|
|
||||||
useMouse: false,
|
|
||||||
autoFocus: false,
|
|
||||||
openConsoleOnError: false,
|
|
||||||
exitOnCtrlC: false,
|
|
||||||
screenMode: "split-footer",
|
|
||||||
footerHeight: 4,
|
|
||||||
targetFps: 60,
|
|
||||||
useKittyKeyboard: {},
|
|
||||||
consoleOptions: {
|
|
||||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
|
||||||
},
|
|
||||||
externalOutputMode: "capture-stdout",
|
|
||||||
consoleMode: "disabled",
|
|
||||||
})
|
|
||||||
const terminalMode = renderer.waitForThemeMode(1000).catch(() => null)
|
|
||||||
await render(
|
|
||||||
() => (
|
|
||||||
<Show when={visible()}>
|
|
||||||
<UpdateFooter
|
|
||||||
from={from}
|
|
||||||
active={active}
|
|
||||||
outcome={outcome}
|
|
||||||
failure={failure}
|
|
||||||
animating={animating}
|
|
||||||
renderer={renderer}
|
|
||||||
onOutcomeSettled={() => resolveOutcome?.()}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
),
|
|
||||||
renderer,
|
|
||||||
).catch((error) => {
|
|
||||||
if (!renderer.isDestroyed) renderer.destroy()
|
|
||||||
throw error
|
|
||||||
})
|
|
||||||
let shownAt = performance.now()
|
|
||||||
const waitForStage = async () => {
|
|
||||||
const remaining = stageFloor - (performance.now() - shownAt)
|
|
||||||
if (remaining > 0) await Bun.sleep(remaining)
|
|
||||||
}
|
|
||||||
const advance = async (stage: number) => {
|
|
||||||
await waitForStage()
|
|
||||||
if (outcome() !== "running") return
|
|
||||||
setActive(stage)
|
|
||||||
shownAt = performance.now()
|
|
||||||
}
|
|
||||||
// Service.start currently exposes only its start boundary, so this first
|
|
||||||
// transition is time-based. Finer lifecycle callbacks remain follow-up work.
|
|
||||||
const auto = advance(1)
|
|
||||||
const transitionTo = async (next: "success" | "failure", hold: number) => {
|
|
||||||
const settled = Promise.withResolvers<void>()
|
|
||||||
resolveOutcome = settled.resolve
|
|
||||||
setOutcome(next)
|
|
||||||
const completed = await Promise.race([
|
|
||||||
settled.promise.then(() => true),
|
|
||||||
Bun.sleep(transitionDuration + 500).then(() => false),
|
|
||||||
])
|
|
||||||
resolveOutcome = undefined
|
|
||||||
setAnimating(false)
|
|
||||||
if (completed) await Bun.sleep(hold)
|
|
||||||
}
|
|
||||||
let closing: Promise<void> | undefined
|
|
||||||
let transferred = false
|
|
||||||
const close = () =>
|
|
||||||
(closing ??= (async () => {
|
|
||||||
if (transferred) return
|
|
||||||
setAnimating(false)
|
|
||||||
if (renderer.isDestroyed) return
|
|
||||||
renderer.pause()
|
|
||||||
await Promise.race([renderer.idle(), Bun.sleep(500)])
|
|
||||||
renderer.destroy()
|
|
||||||
})())
|
|
||||||
let loading: Promise<void> | undefined
|
|
||||||
const load = () =>
|
|
||||||
(loading ??= (async () => {
|
|
||||||
await auto
|
|
||||||
await advance(2)
|
|
||||||
})())
|
|
||||||
let settled: Promise<void> | undefined
|
|
||||||
const settle = (task: () => Promise<void>) => (settled ??= task())
|
|
||||||
return {
|
|
||||||
loading: load,
|
|
||||||
finish: async () => {
|
|
||||||
await settle(async () => {
|
|
||||||
await load()
|
|
||||||
await waitForStage()
|
|
||||||
await transitionTo("success", completionHold)
|
|
||||||
})
|
|
||||||
const mode = await terminalMode
|
|
||||||
renderer.externalOutputMode = "passthrough"
|
|
||||||
renderer.screenMode = "alternate-screen"
|
|
||||||
renderer.consoleMode = "console-overlay"
|
|
||||||
renderer.requestRender()
|
|
||||||
await Promise.race([renderer.idle(), Bun.sleep(500)])
|
|
||||||
transferred = true
|
|
||||||
return {
|
|
||||||
renderer,
|
|
||||||
mode,
|
|
||||||
complete: () => setVisible(false),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fail: (message) =>
|
|
||||||
settle(async () => {
|
|
||||||
setFailure(message)
|
|
||||||
await transitionTo("failure", 250)
|
|
||||||
await close()
|
|
||||||
}),
|
|
||||||
close,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const colors = {
|
|
||||||
accent: RGBA.fromHex("#a6b8ff"),
|
|
||||||
accentBright: RGBA.fromHex("#eef1ff"),
|
|
||||||
accentDim: RGBA.fromHex("#596998"),
|
|
||||||
error: RGBA.fromHex("#ff8192"),
|
|
||||||
muted: RGBA.fromHex("#808080"),
|
|
||||||
success: RGBA.fromHex("#8bd5a5"),
|
|
||||||
text: RGBA.fromHex("#eeeeee"),
|
|
||||||
}
|
|
||||||
|
|
||||||
const monogram = go.right.slice(1)
|
|
||||||
const sweepBlend = 8
|
|
||||||
const textDim = RGBA.fromHex("#4c4c4c")
|
|
||||||
const rampSteps = 32
|
|
||||||
|
|
||||||
const blend = (from: RGBA, to: RGBA, amount: number) =>
|
|
||||||
RGBA.fromValues(
|
|
||||||
from.r + (to.r - from.r) * amount,
|
|
||||||
from.g + (to.g - from.g) * amount,
|
|
||||||
from.b + (to.b - from.b) * amount,
|
|
||||||
)
|
|
||||||
const ramp = (from: RGBA, to: RGBA) =>
|
|
||||||
Array.from({ length: rampSteps + 1 }, (_, step) => blend(from, to, step / rampSteps))
|
|
||||||
const railRamp = ramp(colors.accentDim, colors.accentBright)
|
|
||||||
const monogramRamp = ramp(colors.muted, colors.accent)
|
|
||||||
const rampCache = new Map<RGBA, ReadonlyArray<RGBA>>()
|
|
||||||
const rampFor = (color: RGBA) => {
|
|
||||||
const cached = rampCache.get(color)
|
|
||||||
if (cached) return cached
|
|
||||||
const result = ramp(textDim, color)
|
|
||||||
rampCache.set(color, result)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
const shade = (palette: ReadonlyArray<RGBA>, brightness: number) =>
|
|
||||||
palette[Math.round(Math.max(0, Math.min(1, brightness)) * rampSteps)]
|
|
||||||
|
|
||||||
type Cell = { readonly char: string; readonly color: RGBA; readonly bold?: boolean }
|
|
||||||
const styled = (text: string, color: RGBA, bold?: boolean): Cell[] =>
|
|
||||||
Array.from(text).map((char) => ({ char, color, bold }))
|
|
||||||
const phrase = (...segments: ReadonlyArray<readonly [string, RGBA, boolean?]>): Cell[] =>
|
|
||||||
segments.flatMap((segment, index) => [
|
|
||||||
...(index > 0 ? styled(" ", colors.muted) : []),
|
|
||||||
...styled(segment[0], segment[1], segment[2]),
|
|
||||||
])
|
|
||||||
|
|
||||||
function Monogram(props: { ink: () => RGBA }) {
|
|
||||||
const shadow = createMemo(() => {
|
|
||||||
const ink = props.ink()
|
|
||||||
return RGBA.fromValues(ink.r * 0.25, ink.g * 0.25, ink.b * 0.25)
|
|
||||||
})
|
|
||||||
return (
|
|
||||||
<box flexDirection="column">
|
|
||||||
<For each={monogram}>
|
|
||||||
{(line) => (
|
|
||||||
<box flexDirection="row">
|
|
||||||
<For each={Array.from(line)}>
|
|
||||||
{(char) =>
|
|
||||||
char === "_" ? (
|
|
||||||
<text bg={shadow()} selectable={false}>
|
|
||||||
{" "}
|
|
||||||
</text>
|
|
||||||
) : (
|
|
||||||
<text fg={props.ink()} selectable={false}>
|
|
||||||
{char}
|
|
||||||
</text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type CellTransition = { from: Cell[]; to: Cell[]; done?: () => void }
|
|
||||||
|
|
||||||
function createTransition(render: (transition: CellTransition, progress: number) => Cell[]) {
|
|
||||||
const [state, setState] = createSignal<{ from: Cell[]; to: Cell[]; done?: () => void } | undefined>()
|
|
||||||
const [progress, setProgress] = createSignal(0)
|
|
||||||
let elapsed = 0
|
|
||||||
const cells = createMemo(() => {
|
|
||||||
const transition = state()
|
|
||||||
if (!transition) return undefined
|
|
||||||
return render(transition, progress())
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
start(from: Cell[], to: Cell[], done?: () => void) {
|
|
||||||
elapsed = 0
|
|
||||||
setProgress(0)
|
|
||||||
setState({ from, to, done })
|
|
||||||
},
|
|
||||||
tick(deltaTime: number) {
|
|
||||||
const transition = state()
|
|
||||||
if (!transition) return
|
|
||||||
elapsed = Math.min(transitionDuration, elapsed + deltaTime)
|
|
||||||
setProgress(elapsed / transitionDuration)
|
|
||||||
if (elapsed < transitionDuration) return
|
|
||||||
setState(undefined)
|
|
||||||
transition.done?.()
|
|
||||||
},
|
|
||||||
cells,
|
|
||||||
progress,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const createSweep = () =>
|
|
||||||
createTransition((transition, progress) => {
|
|
||||||
const length = Math.max(transition.from.length, transition.to.length)
|
|
||||||
const front = smoothstep(progress) * (length + 2 * sweepBlend) - sweepBlend
|
|
||||||
return Array.from({ length }, (_, index) => {
|
|
||||||
const passed = Math.max(0, Math.min(1, (front - index) / sweepBlend))
|
|
||||||
const brightness = smoothstep(Math.abs(passed * 2 - 1))
|
|
||||||
const cell = (passed >= 0.5 ? transition.to[index] : transition.from[index]) ?? {
|
|
||||||
char: " ",
|
|
||||||
color: colors.text,
|
|
||||||
}
|
|
||||||
return { ...cell, color: shade(rampFor(cell.color), brightness) }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const createFade = () =>
|
|
||||||
createTransition((transition, progress) => {
|
|
||||||
const entering = progress >= 0.5
|
|
||||||
const brightness = smoothstep(entering ? progress * 2 - 1 : 1 - progress * 2)
|
|
||||||
return (entering ? transition.to : transition.from).map((cell) => ({
|
|
||||||
...cell,
|
|
||||||
color: shade(rampFor(cell.color), brightness),
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
const smoothstep = (value: number) => value * value * (3 - 2 * value)
|
|
||||||
const frameDone = Promise.resolve()
|
|
||||||
|
|
||||||
function UpdateFooter(props: {
|
|
||||||
from?: string
|
|
||||||
active: () => number
|
|
||||||
outcome: () => "running" | "success" | "failure"
|
|
||||||
failure: () => string
|
|
||||||
animating: () => boolean
|
|
||||||
renderer: CliRenderer
|
|
||||||
onOutcomeSettled: () => void
|
|
||||||
}) {
|
|
||||||
const term = useTerminalDimensions()
|
|
||||||
const [position, setPosition] = createSignal(0)
|
|
||||||
const [pulse, setPulse] = createSignal(0)
|
|
||||||
const headerFade = createFade()
|
|
||||||
const statusSweep = createSweep()
|
|
||||||
const runningHeader = () =>
|
|
||||||
phrase(
|
|
||||||
["OpenCode", colors.muted, true],
|
|
||||||
["is updating", colors.muted],
|
|
||||||
...(props.from
|
|
||||||
? ([
|
|
||||||
["from", colors.muted],
|
|
||||||
[props.from, colors.accentDim],
|
|
||||||
] as const)
|
|
||||||
: []),
|
|
||||||
["to", colors.muted],
|
|
||||||
[InstallationVersion, colors.accent],
|
|
||||||
)
|
|
||||||
const completedHeader = phrase(
|
|
||||||
["OpenCode", colors.muted, true],
|
|
||||||
["updated to", colors.muted],
|
|
||||||
[InstallationVersion, colors.accent],
|
|
||||||
)
|
|
||||||
const pausedHeader = phrase(["OpenCode", colors.muted, true], ["update paused", colors.muted])
|
|
||||||
const outcomeStatus = () =>
|
|
||||||
props.outcome() === "success"
|
|
||||||
? [...styled("✓", colors.success), ...styled(" Ready", colors.text)]
|
|
||||||
: [...styled("!", colors.error), ...styled(" " + props.failure(), colors.text)]
|
|
||||||
let previousStage: string = stages[0]
|
|
||||||
createEffect(
|
|
||||||
on(props.active, (index) => {
|
|
||||||
if (props.outcome() !== "running") return
|
|
||||||
const next = stages[index]
|
|
||||||
if (next === previousStage) return
|
|
||||||
statusSweep.start(styled(previousStage, colors.text), styled(next, colors.text))
|
|
||||||
previousStage = next
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
props.outcome,
|
|
||||||
(outcome) => {
|
|
||||||
if (outcome === "running") return
|
|
||||||
const visibleStatus = untrack(statusSweep.cells) ?? styled(previousStage, colors.text)
|
|
||||||
headerFade.start(runningHeader(), outcome === "success" ? completedHeader : pausedHeader)
|
|
||||||
statusSweep.start([...styled(" ", colors.text), ...visibleStatus], outcomeStatus(), props.onOutcomeSettled)
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const header = createMemo(
|
|
||||||
() =>
|
|
||||||
headerFade.cells() ??
|
|
||||||
(props.outcome() === "success"
|
|
||||||
? completedHeader
|
|
||||||
: props.outcome() === "failure"
|
|
||||||
? pausedHeader
|
|
||||||
: runningHeader()),
|
|
||||||
)
|
|
||||||
const monogramInk = createMemo(() =>
|
|
||||||
props.outcome() === "success" ? shade(monogramRamp, smoothstep(headerFade.progress())) : colors.muted,
|
|
||||||
)
|
|
||||||
const rail = createMemo(() => {
|
|
||||||
const width = Math.max(0, Math.min(30, term().width - 39))
|
|
||||||
if (width === 0) return []
|
|
||||||
const filled = Math.round(position() * width)
|
|
||||||
const glowRadius = 6
|
|
||||||
const span = Math.max(1, filled + glowRadius * 2)
|
|
||||||
const center = pulse() * span - glowRadius
|
|
||||||
const success = props.outcome() === "success"
|
|
||||||
const completion = smoothstep(headerFade.progress())
|
|
||||||
return Array.from({ length: width }, (_, index) => {
|
|
||||||
const color =
|
|
||||||
index >= filled
|
|
||||||
? colors.muted
|
|
||||||
: shade(railRamp, Math.max(0, 1 - Math.abs(index - center) / glowRadius) ** 2)
|
|
||||||
return {
|
|
||||||
char: success || index < filled ? "━" : "·",
|
|
||||||
color: success ? blend(color, colors.accent, completion) : color,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
let value = 0
|
|
||||||
let velocity = 0
|
|
||||||
let phase = 0
|
|
||||||
const frame = (deltaTime: number) => {
|
|
||||||
if (!props.animating()) return frameDone
|
|
||||||
const elapsed = Math.min(0.032, deltaTime / 1_000)
|
|
||||||
const stiffness = 110
|
|
||||||
const damping = 2 * Math.sqrt(stiffness)
|
|
||||||
const target = props.outcome() === "success" ? 1 : (props.active() + 1) / stages.length
|
|
||||||
velocity += (stiffness * (target - value) - damping * velocity) * elapsed
|
|
||||||
value += velocity * elapsed
|
|
||||||
phase = (phase + deltaTime / 900) % 1
|
|
||||||
batch(() => {
|
|
||||||
setPosition(Math.max(0, Math.min(1, value)))
|
|
||||||
setPulse(phase)
|
|
||||||
})
|
|
||||||
headerFade.tick(deltaTime)
|
|
||||||
statusSweep.tick(deltaTime)
|
|
||||||
return frameDone
|
|
||||||
}
|
|
||||||
props.renderer.setFrameCallback(frame)
|
|
||||||
onCleanup(() => props.renderer.removeFrameCallback(frame))
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box width="100%" height={4} flexDirection="row" gap={1} live={props.animating()}>
|
|
||||||
<Monogram ink={monogramInk} />
|
|
||||||
<box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
||||||
<CellLine cells={header()} />
|
|
||||||
<Show
|
|
||||||
when={props.outcome() === "running"}
|
|
||||||
fallback={<CellLine cells={statusSweep.cells() ?? outcomeStatus()} />}
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<spinner frames={SPINNER_FRAMES} interval={80} color={colors.accent} />
|
|
||||||
<CellLine cells={statusSweep.cells() ?? styled(stages[props.active()], colors.text)} />
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<CellLine cells={rail()} />
|
|
||||||
<text fg={colors.muted}>
|
|
||||||
{props.outcome() === "success" ? stages.length : props.active() + 1}/{stages.length}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CellLine(props: { cells: ReadonlyArray<Cell> }) {
|
|
||||||
return (
|
|
||||||
<text truncate>
|
|
||||||
<Index each={props.cells}>
|
|
||||||
{(cell) => (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fg: cell().color,
|
|
||||||
attributes: cell().bold ? TextAttributes.BOLD : TextAttributes.NONE,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cell().char}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Index>
|
|
||||||
</text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export * as UpdatePreflight from "./update-preflight"
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
export * as TuiConfig from "./tui-config"
|
|
||||||
|
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { TuiConfig } from "@opencode-ai/tui/config/v1"
|
|
||||||
import { Effect, FileSystem, Option, Schema } from "effect"
|
|
||||||
import { parse, type ParseError } from "jsonc-parser"
|
|
||||||
import path from "path"
|
|
||||||
|
|
||||||
export const load = Effect.fn("TuiConfig.load")(function* () {
|
|
||||||
const fs = yield* FileSystem.FileSystem
|
|
||||||
const global = yield* Global.Service
|
|
||||||
const filepath = path.join(global.config, "tui.json")
|
|
||||||
const text = yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
if (!text) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
|
|
||||||
|
|
||||||
const errors: ParseError[] = []
|
|
||||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
|
||||||
if (errors.length) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
|
|
||||||
|
|
||||||
return TuiConfig.resolve(
|
|
||||||
Option.getOrElse(Schema.decodeUnknownOption(TuiConfig.Info)(input), () => ({})),
|
|
||||||
{ terminalSuspend: process.platform !== "win32" },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||||
import { testRender, useRenderer } from "@opentui/solid"
|
import { testRender, useRenderer } from "@opentui/solid"
|
||||||
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
|
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
|
||||||
import { resolve } from "@opencode-ai/tui/config/v1"
|
import { resolve } from "@opencode-ai/tui/config"
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { createComponent, createSignal } from "solid-js"
|
import { createComponent, createSignal } from "solid-js"
|
||||||
import { RunFooterView } from "../src/mini/footer.view"
|
import { RunFooterView } from "../src/mini/footer.view"
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import { expect, test } from "bun:test"
|
|
||||||
import path from "path"
|
|
||||||
import { TuiConfig } from "../src/tui-config"
|
|
||||||
|
|
||||||
test("loads the global tui config", async () => {
|
|
||||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
|
||||||
await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ keybinds: { leader: "ctrl+o" } }))
|
|
||||||
|
|
||||||
try {
|
|
||||||
const config = await Effect.runPromise(
|
|
||||||
TuiConfig.load().pipe(
|
|
||||||
Effect.provide(Global.layerWith({ config: directory })),
|
|
||||||
Effect.provide(NodeFileSystem.layer),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(config.keybinds.get("leader")?.[0]?.key).toBe("ctrl+o")
|
|
||||||
expect(config.keybinds.get("session.new")?.[0]?.key).toBe("<leader>n")
|
|
||||||
} finally {
|
|
||||||
await Bun.$`rm -rf ${directory}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -476,16 +476,18 @@ export interface IntegrationApi<E = never> {
|
|||||||
type Endpoint11_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
|
type Endpoint11_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
|
||||||
export type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
|
export type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
|
||||||
export type Endpoint11_0Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.list"]>>
|
export type Endpoint11_0Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.list"]>>
|
||||||
export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
|
export type ServerMcpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
|
||||||
|
|
||||||
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
|
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
|
||||||
export type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] }
|
export type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] }
|
||||||
export type Endpoint11_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
|
export type Endpoint11_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
|
||||||
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_1Input) => Effect.Effect<Endpoint11_1Output, E>
|
export type ServerMcpResourceCatalogOperation<E = never> = (
|
||||||
|
input?: Endpoint11_1Input,
|
||||||
|
) => Effect.Effect<Endpoint11_1Output, E>
|
||||||
|
|
||||||
export interface McpApi<E = never> {
|
export interface ServerMcpApi<E = never> {
|
||||||
readonly list: McpListOperation<E>
|
readonly list: ServerMcpListOperation<E>
|
||||||
readonly resource: { readonly catalog: McpResourceCatalogOperation<E> }
|
readonly resource: { readonly catalog: ServerMcpResourceCatalogOperation<E> }
|
||||||
}
|
}
|
||||||
|
|
||||||
type Endpoint12_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
|
type Endpoint12_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
|
||||||
@@ -953,7 +955,7 @@ export interface AppApi<E = never> {
|
|||||||
readonly generate: GenerateApi<E>
|
readonly generate: GenerateApi<E>
|
||||||
readonly provider: ProviderApi<E>
|
readonly provider: ProviderApi<E>
|
||||||
readonly integration: IntegrationApi<E>
|
readonly integration: IntegrationApi<E>
|
||||||
readonly mcp: McpApi<E>
|
readonly "server.mcp": ServerMcpApi<E>
|
||||||
readonly credential: CredentialApi<E>
|
readonly credential: CredentialApi<E>
|
||||||
readonly project: ProjectApi<E>
|
readonly project: ProjectApi<E>
|
||||||
readonly form: FormApi<E>
|
readonly form: FormApi<E>
|
||||||
|
|||||||
@@ -1134,7 +1134,7 @@ const adaptClient = (raw: RawClient) => ({
|
|||||||
generate: adaptGroup8(raw["server.generate"]),
|
generate: adaptGroup8(raw["server.generate"]),
|
||||||
provider: adaptGroup9(raw["server.provider"]),
|
provider: adaptGroup9(raw["server.provider"]),
|
||||||
integration: adaptGroup10(raw["server.integration"]),
|
integration: adaptGroup10(raw["server.integration"]),
|
||||||
mcp: adaptGroup11(raw["server.mcp"]),
|
"server.mcp": adaptGroup11(raw["server.mcp"]),
|
||||||
credential: adaptGroup12(raw["server.credential"]),
|
credential: adaptGroup12(raw["server.credential"]),
|
||||||
project: adaptGroup13(raw["server.project"]),
|
project: adaptGroup13(raw["server.project"]),
|
||||||
form: adaptGroup14(raw["server.form"]),
|
form: adaptGroup14(raw["server.form"]),
|
||||||
|
|||||||
@@ -35,10 +35,7 @@ export type Options = {
|
|||||||
export type StartReason = "missing" | "version-mismatch"
|
export type StartReason = "missing" | "version-mismatch"
|
||||||
|
|
||||||
export type StartOptions = Options & {
|
export type StartOptions = Options & {
|
||||||
// Called once when start() decides it must spawn: either no service was
|
readonly onStart?: (reason: StartReason) => void
|
||||||
// found, or a healthy service with a different version is being replaced.
|
|
||||||
// `existing` carries the registration of the service being replaced.
|
|
||||||
readonly onStart?: (reason: StartReason, existing?: Info) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read-only lookup: registration file plus health check and version gate.
|
// Read-only lookup: registration file plus health check and version gate.
|
||||||
@@ -60,9 +57,7 @@ export const start = Effect.fn("service.start")(function* (options: StartOptions
|
|||||||
const compatible = yield* discover(options)
|
const compatible = yield* discover(options)
|
||||||
if (compatible !== undefined) return compatible
|
if (compatible !== undefined) return compatible
|
||||||
const mismatched = yield* find(options)
|
const mismatched = yield* find(options)
|
||||||
yield* Effect.sync(() =>
|
yield* Effect.sync(() => options.onStart?.(mismatched === undefined ? "missing" : "version-mismatch"))
|
||||||
options.onStart?.(mismatched === undefined ? "missing" : "version-mismatch", mismatched?.info),
|
|
||||||
)
|
|
||||||
if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
|
if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
|
||||||
|
|
||||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
export type ClientErrorReason =
|
export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"
|
||||||
| "Transport"
|
|
||||||
| "UnexpectedStatus"
|
|
||||||
| "UnsupportedContentType"
|
|
||||||
| "MalformedResponse"
|
|
||||||
| "SseEventTooLarge"
|
|
||||||
|
|
||||||
export class ClientError extends Error {
|
export class ClientError extends Error {
|
||||||
override readonly name = "ClientError"
|
override readonly name = "ClientError"
|
||||||
|
|||||||
@@ -90,10 +90,10 @@ import type {
|
|||||||
IntegrationAttemptCompleteOutput,
|
IntegrationAttemptCompleteOutput,
|
||||||
IntegrationAttemptCancelInput,
|
IntegrationAttemptCancelInput,
|
||||||
IntegrationAttemptCancelOutput,
|
IntegrationAttemptCancelOutput,
|
||||||
McpListInput,
|
ServerMcpListInput,
|
||||||
McpListOutput,
|
ServerMcpListOutput,
|
||||||
McpResourceCatalogInput,
|
ServerMcpResourceCatalogInput,
|
||||||
McpResourceCatalogOutput,
|
ServerMcpResourceCatalogOutput,
|
||||||
CredentialUpdateInput,
|
CredentialUpdateInput,
|
||||||
CredentialUpdateOutput,
|
CredentialUpdateOutput,
|
||||||
CredentialRemoveInput,
|
CredentialRemoveInput,
|
||||||
@@ -213,8 +213,6 @@ interface RequestDescriptor {
|
|||||||
readonly binary?: true
|
readonly binary?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxSseEventBytes = 16 * 1024 * 1024
|
|
||||||
|
|
||||||
export function make(options: ClientOptions) {
|
export function make(options: ClientOptions) {
|
||||||
const fetch = options.fetch ?? globalThis.fetch
|
const fetch = options.fetch ?? globalThis.fetch
|
||||||
|
|
||||||
@@ -291,7 +289,7 @@ export function make(options: ClientOptions) {
|
|||||||
throw new ClientError("Transport", { cause })
|
throw new ClientError("Transport", { cause })
|
||||||
}
|
}
|
||||||
buffer += decoder.decode(next.value, { stream: !next.done })
|
buffer += decoder.decode(next.value, { stream: !next.done })
|
||||||
if (buffer.length > maxSseEventBytes) throw new ClientError("SseEventTooLarge")
|
if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")
|
||||||
const trailingCarriageReturn = !next.done && buffer.endsWith("\r")
|
const trailingCarriageReturn = !next.done && buffer.endsWith("\r")
|
||||||
if (trailingCarriageReturn) buffer = buffer.slice(0, -1)
|
if (trailingCarriageReturn) buffer = buffer.slice(0, -1)
|
||||||
buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
|
buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
|
||||||
@@ -941,9 +939,9 @@ export function make(options: ClientOptions) {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mcp: {
|
"server.mcp": {
|
||||||
list: (input?: McpListInput, requestOptions?: RequestOptions) =>
|
list: (input?: ServerMcpListInput, requestOptions?: RequestOptions) =>
|
||||||
request<McpListOutput>(
|
request<ServerMcpListOutput>(
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/api/mcp`,
|
path: `/api/mcp`,
|
||||||
@@ -955,8 +953,8 @@ export function make(options: ClientOptions) {
|
|||||||
requestOptions,
|
requestOptions,
|
||||||
),
|
),
|
||||||
resource: {
|
resource: {
|
||||||
catalog: (input?: McpResourceCatalogInput, requestOptions?: RequestOptions) =>
|
catalog: (input?: ServerMcpResourceCatalogInput, requestOptions?: RequestOptions) =>
|
||||||
request<McpResourceCatalogOutput>(
|
request<ServerMcpResourceCatalogOutput>(
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/api/mcp/resource`,
|
path: `/api/mcp/resource`,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -284,43 +284,6 @@ test("event.subscribe terminates on malformed Promise SSE data", async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("event.subscribe accepts a fragmented SSE event below the size limit", async () => {
|
|
||||||
const event = { id: "evt_large", type: "test.large", data: { output: "x".repeat(12 * 1024 * 1024) } }
|
|
||||||
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)
|
|
||||||
const client = OpenCode.make({
|
|
||||||
baseUrl: "http://localhost:3000",
|
|
||||||
fetch: async () =>
|
|
||||||
new Response(
|
|
||||||
new ReadableStream({
|
|
||||||
start(controller) {
|
|
||||||
for (let offset = 0; offset < encoded.length; offset += 64 * 1024) {
|
|
||||||
controller.enqueue(encoded.slice(offset, offset + 64 * 1024))
|
|
||||||
}
|
|
||||||
controller.close()
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{ headers: { "content-type": "text/event-stream" } },
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("event.subscribe rejects an SSE event above the size limit", async () => {
|
|
||||||
const client = OpenCode.make({
|
|
||||||
baseUrl: "http://localhost:3000",
|
|
||||||
fetch: async () =>
|
|
||||||
new Response(`data: ${JSON.stringify({ output: "x".repeat(16 * 1024 * 1024) })}`, {
|
|
||||||
headers: { "content-type": "text/event-stream" },
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
|
|
||||||
name: "ClientError",
|
|
||||||
reason: "SseEventTooLarge",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("session methods use the public HTTP contract", async () => {
|
test("session methods use the public HTTP contract", async () => {
|
||||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
|
|||||||
+261
-92
@@ -1,40 +1,37 @@
|
|||||||
# @opencode-ai/codemode
|
# @opencode-ai/codemode
|
||||||
|
|
||||||
This is our take on code mode. Programs are written in a lightweight, JavaScript-like DSL and run in the package's
|
Effect-native confined code execution over explicit, schema-described tools.
|
||||||
own interpreter. They never execute as actual JavaScript, so there is no runtime to escape into. The interpreter
|
|
||||||
itself can reach nothing; every effect a program has goes through a tool you explicitly supplied. The tradeoff is a
|
|
||||||
bounded language rather than full JavaScript: the [interpreter support checklist](./interpreter-support.md) documents
|
|
||||||
exactly what is supported.
|
|
||||||
|
|
||||||
[Cloudflare's post](https://blog.cloudflare.com/code-mode/) introduced the idea. Their implementation executes
|
CodeMode lets a model write a small JavaScript program that can call only the tools supplied by the host. The program can sequence calls, transform plain data, branch, loop, and run independent calls in parallel without receiving ambient filesystem, process, network, module, or application authority.
|
||||||
generated code in isolate sandboxes. We took a lighter route: a pure interpreter that runs wherever your application
|
|
||||||
runs, no sandbox required.
|
|
||||||
|
|
||||||
## How it differs from JavaScript
|
The package is currently private to this workspace. Its API is designed around one-shot and reusable execution:
|
||||||
|
|
||||||
The deliberate differences:
|
```ts
|
||||||
|
// One execution
|
||||||
|
yield * CodeMode.execute({ tools, code })
|
||||||
|
|
||||||
- **No ambient authority.** No `fetch`, `process`, filesystem, timers, or host globals - only the allowlisted standard
|
// A reusable runtime
|
||||||
library and the `tools` tree.
|
const runtime = CodeMode.make({ tools, limits })
|
||||||
- **No dynamic code.** No `eval`, `Function`, or module loading.
|
yield * runtime.execute(code)
|
||||||
- **Plain-data boundaries.** Tool arguments and program results are JSON-like data. Dates become ISO strings, RegExp,
|
```
|
||||||
Map, and Set serialize as `{}`, and promises, functions, and runtime references cannot cross the boundary.
|
|
||||||
- **Eager, supervised promises.** Tool calls and async functions start immediately when called. Whatever is still
|
|
||||||
running when the program returns is interrupted - race losers and fire-and-forget calls alike - so a program must
|
|
||||||
await every call whose completion matters. Rejections that settle un-awaited become `warnings` on the result instead
|
|
||||||
of crashing the run.
|
|
||||||
- **REPL-style results.** An omitted `return` yields the final top-level expression; `undefined` normalizes to `null`.
|
|
||||||
|
|
||||||
Beyond these, the language is a growing subset rather than a divergent one: unsupported syntax returns an
|
## Install
|
||||||
`UnsupportedSyntax` diagnostic with a source location, and current gaps (for example thenable assimilation, classes,
|
|
||||||
generators, and full sparse-array parity) are tracked as unchecked items in the
|
Within this workspace:
|
||||||
[interpreter support checklist](./interpreter-support.md).
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@opencode-ai/codemode": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Hosts interact with CodeMode through `effect` (tool `run` implementations, `Effect`-typed results), so they should depend on `effect` themselves.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
The package is workspace-private (`"@opencode-ai/codemode": "workspace:*"`). Hosts interact with it through `effect`
|
Define tools with Effect Schema, then place them in the object tree exposed to programs as `tools`:
|
||||||
and should depend on `effect` themselves. Define tools with Effect Schema, then place them in the object tree exposed
|
|
||||||
to programs as `tools`:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { CodeMode, Tool } from "@opencode-ai/codemode"
|
import { CodeMode, Tool } from "@opencode-ai/codemode"
|
||||||
@@ -63,53 +60,69 @@ const result =
|
|||||||
`)
|
`)
|
||||||
```
|
```
|
||||||
|
|
||||||
`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics
|
`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption.
|
||||||
rather than failing the Effect; host interruption remains interruption.
|
|
||||||
|
Successful result values are JSON-safe data. An explicit `return` produces the program result; when it is omitted, the final executable top-level expression is returned as a model-friendly REPL convenience. Otherwise reaching the end produces `null`. Returned `undefined` and nested `undefined` values are normalized to `null` as well.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
### `Tool.make`
|
### `Tool.make`
|
||||||
|
|
||||||
`input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document. Effect Schema input
|
```ts
|
||||||
is decoded before `run` is invoked; an Effect Schema `output` is decoded and copied before the program sees it. JSON
|
const tool = Tool.make({
|
||||||
Schemas only shape the model-visible signature. Without `output` the signature advertises `Promise<unknown>`.
|
description,
|
||||||
Descriptions and schemas are model-visible contract; keep authorization in `run`.
|
input, // Effect Schema (validating) or JSON Schema (render-only)
|
||||||
|
output, // optional; same choice
|
||||||
|
run,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
### `CodeMode.execute` and `CodeMode.make`
|
`input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document (the natural shape for adapter-provided tools whose schemas arrive as JSON Schema, e.g. MCP definitions). Effect Schema input is decoded before `run` is invoked, and `run` returns the encoded representation of an Effect Schema `output`, which CodeMode decodes and copies before exposing it to the program. JSON Schemas only shape the model-visible signature; values pass through unvalidated (they still cross the plain-data boundary).
|
||||||
|
|
||||||
`CodeMode.execute({ ...options, code })` runs once and is equivalent to `CodeMode.make(options).execute(code)`. A
|
`output` is optional. Without it the tool's signature advertises `Promise<unknown>` and the host result is exposed as-is.
|
||||||
runtime from `make` reuses the tool set and policy:
|
|
||||||
|
The description and schemas are part of the model-visible tool contract. Keep descriptions concrete and put authorization in `run` or in the service it calls.
|
||||||
|
|
||||||
|
Public tool types are grouped under the same namespace: `Tool.Definition`, `Tool.Options`, `Tool.SchemaType`, and `Tool.JsonSchema`.
|
||||||
|
|
||||||
|
### `CodeMode.execute`
|
||||||
|
|
||||||
|
Use `CodeMode.execute` for a single execution:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const runtime = CodeMode.make({ tools, limits: { timeoutMs: 30_000 } })
|
const result =
|
||||||
|
yield *
|
||||||
|
CodeMode.execute({
|
||||||
|
tools: { orders: { lookup: lookupOrder } },
|
||||||
|
code: `return await tools.orders.lookup({ id: "order_42" })`,
|
||||||
|
limits: { maxToolCalls: 10 },
|
||||||
|
onToolCallStart: (call) => Effect.logDebug("CodeMode tool started", call),
|
||||||
|
onToolCallEnd: (call) => Effect.logDebug("CodeMode tool settled", call),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The Effect environment is inferred from the supplied tools. CodeMode does not erase service requirements introduced by tool implementations.
|
||||||
|
|
||||||
|
### `CodeMode.make`
|
||||||
|
|
||||||
|
Use `CodeMode.make` when the tool set and execution policy are reused:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const runtime = CodeMode.make({
|
||||||
|
tools: { orders: { lookup: lookupOrder } },
|
||||||
|
limits: { timeoutMs: 30_000 },
|
||||||
|
})
|
||||||
|
|
||||||
runtime.catalog() // structured tool descriptions
|
runtime.catalog() // structured tool descriptions
|
||||||
runtime.instructions() // model-facing syntax and tool guide
|
runtime.instructions() // model-facing syntax and tool guide
|
||||||
runtime.execute(source) // CodeMode.Result
|
runtime.execute(source) // CodeMode.Result
|
||||||
```
|
```
|
||||||
|
|
||||||
The Effect environment is inferred from the supplied tools; service requirements are not erased. Optional
|
`CodeMode.Input`, `CodeMode.Result`, `CodeMode.Success`, `CodeMode.Failure`, `CodeMode.Diagnostic`, and `CodeMode.DiagnosticKind` are both Effect schemas and their inferred TypeScript types. Hosts can combine `CodeMode.Input` and `CodeMode.Result` with `runtime.instructions()` and `runtime.execute()` when constructing a framework-specific agent tool.
|
||||||
`onToolCallStart` / `onToolCallEnd` hooks observe admitted calls with decoded input, outcome, and duration; both are
|
|
||||||
Effect-returning and must not fail.
|
|
||||||
|
|
||||||
### OpenAPI tools
|
All other CodeMode types use the same namespace: `CodeMode.Options`, `CodeMode.ExecuteOptions`, `CodeMode.Runtime`, `CodeMode.ExecutionLimits`, `CodeMode.DiscoveryOptions`, `CodeMode.DataValue`, `CodeMode.ToolDescription`, and the `CodeMode.ToolCall*` observation types.
|
||||||
|
|
||||||
`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation, namespaced by dotted
|
### Results
|
||||||
`operationId`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const api = OpenAPI.fromSpec({ spec, auth: { resolve } })
|
|
||||||
const runtime = CodeMode.make({ tools: { opencode: api.tools } })
|
|
||||||
```
|
|
||||||
|
|
||||||
It is synchronous and returns `{ tools, skipped }`: operations with unsupported encodings, non-JSON bodies, binary
|
|
||||||
responses, or streaming land in `skipped` instead of producing broken tools. Auth is resolved host-side and never
|
|
||||||
model-visible; generated tools require `HttpClient.HttpClient` in the environment. See the option docstrings in
|
|
||||||
`src/openapi/types.ts` for full semantics.
|
|
||||||
|
|
||||||
## Outputs
|
|
||||||
|
|
||||||
Every execution returns a `CodeMode.Result`:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type Result = Success | Failure
|
type Result = Success | Failure
|
||||||
@@ -132,11 +145,152 @@ interface Failure {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`value` is JSON-safe data. `warnings` are non-fatal diagnostics alongside a valid value (un-awaited rejections,
|
`toolCalls` contains the names of calls admitted by the runtime in call order. It is retained on failure so hosts can audit partial execution without exposing inputs or host failures. A successful execution may also contain `warnings`: runtime-authored, non-fatal diagnostics alongside a valid value - unhandled rejections from promises that failed, un-awaited, before the program returned, or background work interrupted by the timeout after the program returned. Anything still running when the program returns is interrupted - race losers and fire-and-forget calls alike - so a program must await every call whose completion matters. Failure has an `error`; success may have `warnings`; program-authored console output stays in `logs`. Keeping the value on an unhandled rejection is a deliberate divergence from Node's crash-on-unhandled-rejection default: the computed value and the background failure are independently useful to the model, so the result carries both. When warnings are cut by `maxOutputBytes`, a final `Truncated` diagnostic marks the omission in-band, and `truncated` marks any result, warning, or log truncation (see Execution Limits).
|
||||||
timeout cleanup after the return). `logs` holds program console output, `truncated` marks any output-budget cut, and
|
|
||||||
`toolCalls` lists admitted calls in order - retained on failure for auditing.
|
|
||||||
|
|
||||||
Failure `error` and success `warnings` share one diagnostic vocabulary:
|
### Tool-call hooks
|
||||||
|
|
||||||
|
`onToolCallStart` receives `{ index, name, input }` after input decoding and before tool execution. The input is decoded host-side data and may include values produced by schema transformations; applications should avoid logging sensitive tool arguments indiscriminately.
|
||||||
|
|
||||||
|
`onToolCallEnd` receives `{ index, name, input, durationMs, outcome, message? }` when an admitted call settles. `outcome` is `"success"` or `"failure"`; `message` is the model-safe failure message and is present only on failure. Interrupted calls (for example when the execution timeout fires) do not produce an end event. Both hooks are Effect-returning and must not fail.
|
||||||
|
|
||||||
|
### OpenAPI tools
|
||||||
|
|
||||||
|
`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation. Dotted `operationId` values form namespaces such as `v2.session.get`. Missing IDs receive a flat method/path fallback such as `getUsersById`; names are sanitized and deduplicated. The host places the subtree under a key in its `tools` tree; that key is the model-visible namespace.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { CodeMode, OpenAPI } from "@opencode-ai/codemode"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
|
|
||||||
|
const api = OpenAPI.fromSpec({
|
||||||
|
spec: await Bun.file("openapi.json").json(), // parsed document (no YAML)
|
||||||
|
auth: {
|
||||||
|
resolve: ({ name, scopes, operation }) =>
|
||||||
|
name === "BearerAuth" ? Effect.succeed({ type: "bearer", token }) : Effect.succeed(undefined),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const runtime = CodeMode.make({ tools: { opencode: api.tools } })
|
||||||
|
const result = await Effect.runPromise(runtime.execute(code).pipe(Effect.provide(FetchHttpClient.layer)))
|
||||||
|
```
|
||||||
|
|
||||||
|
`fromSpec` is synchronous and returns `{ tools, skipped }`. The initial adapter supports query `form`/`deepObject`, path/header `simple`, JSON request bodies, JSON responses, and text responses; unsupported parameter encodings, non-JSON request bodies, binary responses, and streaming operations land in `skipped` instead of producing broken tools. Operation and path servers take precedence over document servers unless `baseUrl` explicitly overrides all of them. Tool inputs flatten path, query, header, and closed object-body fields into one model-facing object while retaining their HTTP locations internally. Cross-location name collisions receive a location prefix such as `path_id` and `query_id`; composed, nullable, dictionary, conditionally-required, and non-object JSON bodies remain under `body`. Auth is never model-visible. Responses are limited to 50 MiB, and non-2xx responses become safe tool failures carrying the status and a size-capped body summary. Deferred capabilities are tracked in `src/openapi/TODO.md`.
|
||||||
|
|
||||||
|
Supported bearer, basic, header, and query authentication follows OpenAPI `security` semantics and is resolved host-side via `auth.resolve` - credential storage, OAuth flows, and token refresh never enter the compiler. Cookie authentication alternatives are discarded; an operation is skipped when it has no supported alternative. See the option docstrings in `src/openapi/types.ts` for the full semantics. Generated tools require `HttpClient.HttpClient` (from `effect/unstable/http`) in the Effect environment - provide `FetchHttpClient.layer` or a custom/test client layer at execution. The supplied client owns redirect policy; credentialed hosts should reject redirects or strip credentials when the origin changes.
|
||||||
|
|
||||||
|
## Discovery
|
||||||
|
|
||||||
|
The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete, JSDoc-annotated tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Schema field descriptions and tags are part of each signature's measured cost. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature against the shared budget, and a namespace whose next signature does not fit drops out while the others keep going - so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL - N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`).
|
||||||
|
|
||||||
|
The catalog-entry budget defaults to 2,000 estimated tokens (characters / 4, the same heuristic OpenCode uses). It applies only to full tool entries shown in the catalog; fixed instructions and namespace summaries are not counted. Override it when constructing a runtime:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const runtime = CodeMode.make({
|
||||||
|
tools,
|
||||||
|
discovery: { catalogBudget: 6_000 },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The budget must be a non-negative safe integer.
|
||||||
|
|
||||||
|
The runtime search tool is always registered - including when the catalog is fully inlined - so a speculative `tools.$codemode.search` call never fails as an unknown tool. It is only advertised in the instructions when the inlined list is partial:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const matches = await tools.$codemode.search({
|
||||||
|
query: "order status",
|
||||||
|
namespace: "orders", // optional: scope to one top-level namespace
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). Each term also carries naive singular variants (trailing `s`/`es` stripped), and a field check passes when the term or any variant matches - so a plural query term (`issues`) still finds a tool whose text only says `issue`, without changing the weights. The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path), then sliced from the zero-based `offset` (default 0) to the configured `limit` (default 10). `remaining` counts matches after the current page. `next` is `{ offset }` when another page exists and `null` on the final page; spread it into the original request to preserve its query, namespace, and limit.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const request = { query: "order status", namespace: "orders", limit: 10 }
|
||||||
|
const page = await tools.$codemode.search(request)
|
||||||
|
const nextPage = page.next ? await tools.$codemode.search({ ...request, ...page.next }) : undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
Each result contains the path, description, and the same generated TypeScript signature used by the inline catalog, so no second lookup is needed. Signatures use the JSDoc-annotated multiline form: each described input/output field carries its schema `description` as a `/** ... */` comment, and constraints TypeScript cannot express ride along as tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
tools.github.list_issues(input: {
|
||||||
|
/** Repository owner */
|
||||||
|
owner: string,
|
||||||
|
/** Cursor from the previous response's pageInfo */
|
||||||
|
after?: string,
|
||||||
|
/**
|
||||||
|
* Results per page
|
||||||
|
* @default 30
|
||||||
|
*/
|
||||||
|
perPage?: number,
|
||||||
|
}): Promise<unknown>
|
||||||
|
```
|
||||||
|
|
||||||
|
Result paths are rendered as JavaScript expressions rooted at `tools` (`tools.orders.lookup`, or `tools.context7["resolve-library-id"]` for non-identifier segments), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (canonical path, `tools.`-prefixed path, or rendered JavaScript expression) is treated as a lookup and returns that tool alone.
|
||||||
|
|
||||||
|
The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result Code Mode tools and internal runtime tools exist inside `tools`; filter and aggregate collections in code; narrow `Promise<unknown>` results at runtime; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace and paginate search results when search is advertised), a short `## Language` section that identifies the runtime as a restricted JavaScript orchestration language and names its major unavailable capabilities, and the budgeted `## Available tools` catalog. Example call forms use explicit `<namespace>.<tool>`/`<field>` placeholders - never a real or fabricated tool name.
|
||||||
|
|
||||||
|
A host cannot define its own `$codemode` top-level namespace.
|
||||||
|
|
||||||
|
## Supported Programs
|
||||||
|
|
||||||
|
CodeMode executes a deliberately bounded JavaScript subset. See the
|
||||||
|
[interpreter support checklist](./interpreter-support.md) for the complete, checkable language and standard-library
|
||||||
|
matrix, known semantic gaps, and intentional exclusions.
|
||||||
|
|
||||||
|
At a high level, it supports:
|
||||||
|
|
||||||
|
- Plain data, property access and assignment, destructuring, functions, conditionals, loops, spread, optional chaining,
|
||||||
|
and structured error handling.
|
||||||
|
- Allowlisted Array, String, Number, Object, Math, JSON, console, Date, RegExp, Map, Set, URL, and URLSearchParams APIs.
|
||||||
|
- Eager supervised tool promises, direct `await`, and the supported `Promise` combinators for concurrent work.
|
||||||
|
- Live standard-library values inside the sandbox and predictable JSON-like serialization at tool/result boundaries.
|
||||||
|
- Actionable diagnostics for unsupported syntax, invalid data, tool failures, limits, and execution failures.
|
||||||
|
|
||||||
|
It does not expose ambient host authority or arbitrary JavaScript execution. Unsupported syntax returns an
|
||||||
|
`UnsupportedSyntax` diagnostic with a source location when available.
|
||||||
|
|
||||||
|
CodeMode is an orchestration language, not a general JavaScript runtime.
|
||||||
|
|
||||||
|
## Execution Limits
|
||||||
|
|
||||||
|
The limits are exactly three knobs:
|
||||||
|
|
||||||
|
| Limit | Default | Bounds |
|
||||||
|
| ---------------- | -------------------: | ---------------------------------------------------- |
|
||||||
|
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
|
||||||
|
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
|
||||||
|
| `maxOutputBytes` | none - no truncation | Retained result value and logs; warnings separately. |
|
||||||
|
|
||||||
|
No limit has a default, on purpose: execution budgets are host policy, not library policy - a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context.
|
||||||
|
|
||||||
|
Pass only the overrides you need:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const runtime = CodeMode.make({
|
||||||
|
tools,
|
||||||
|
limits: {
|
||||||
|
maxToolCalls: 20,
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Limits are safe integers. `timeoutMs` must be at least `1`; the others may be `0`. Invalid configuration throws a `RangeError` when `CodeMode.make` or `CodeMode.execute` is called. An explicitly `undefined` value is the same as leaving the limit unset.
|
||||||
|
|
||||||
|
`maxOutputBytes` is a payload budget, not a strict byte cap on the final rendered tool message. It counts the serialized result value and retained log lines; warning diagnostics are bounded by a separate budget of the same size, so a large value never silences runtime diagnostics. Fixed truncation notices and framing added by a host when it renders the structured result are additional and may make the final message exceed the configured number.
|
||||||
|
|
||||||
|
Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept within the remaining budget, warnings are kept within their own budget, omitted entries receive a summary marker, and the result carries `truncated: true`.
|
||||||
|
|
||||||
|
When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) - no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded: the timeout bounds when interruption begins, not when the result is delivered, which waits for tool interruption cleanup to finish. If the timeout fires after the program has already returned a valid value - while the runtime is interrupting leftover work and waiting for its cleanup - the result stays successful: the computed value is returned with a `TimeoutExceeded` warning instead of being discarded.
|
||||||
|
|
||||||
|
Two interpreter internals are fixed constants rather than knobs: at most 8 tool calls run concurrently, and values crossing a data boundary may nest at most 32 levels deep (deeper values fail as `InvalidDataValue`, which reads better than a native stack-overflow error). Neither is part of the public contract.
|
||||||
|
|
||||||
|
## Diagnostics
|
||||||
|
|
||||||
|
Failures are data:
|
||||||
|
|
||||||
| Kind | Meaning |
|
| Kind | Meaning |
|
||||||
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||||
@@ -152,45 +306,58 @@ Failure `error` and success `warnings` share one diagnostic vocabulary:
|
|||||||
| `ExecutionFailure` | The program threw or another execution error occurred. |
|
| `ExecutionFailure` | The program threw or another execution error occurred. |
|
||||||
| `Truncated` | Warning-only marker: additional warnings were omitted by `maxOutputBytes`. |
|
| `Truncated` | Warning-only marker: additional warnings were omitted by `maxOutputBytes`. |
|
||||||
|
|
||||||
Unknown host failures, defects, and invalid outputs are sanitized. `toolError("safe message")` is the explicit channel
|
Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`:
|
||||||
for a model-visible refusal; its optional cause never crosses the boundary.
|
|
||||||
|
|
||||||
## Discovery
|
```ts
|
||||||
|
import { toolError } from "@opencode-ai/codemode"
|
||||||
|
|
||||||
The generated instructions inline a budgeted catalog (default 2,000 estimated tokens, override with
|
run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable")))
|
||||||
`discovery: { catalogBudget }`): every namespace is always listed with its tool count, signatures are selected
|
```
|
||||||
round-robin so every namespace gets representation, and the instructions state whether the list is complete or
|
|
||||||
partial. Programs also get a global `search(...)` built-in - always available, advertised when the list is partial:
|
|
||||||
synchronous, deterministic field-weighted substring matching that returns directly callable paths with full
|
|
||||||
signatures, supports namespace scoping and pagination, and treats an empty query as browsing and an exact path as
|
|
||||||
lookup. Search counts as an admitted tool call.
|
|
||||||
|
|
||||||
## Execution Limits
|
Only the supplied message is model-visible. The optional cause is never returned in `CodeMode.Result`; hosts should perform any required internal logging before crossing this boundary.
|
||||||
|
|
||||||
| Limit | Default | Bounds |
|
## Authority Boundary
|
||||||
| ---------------- | -------------------: | ---------------------------------------------------- |
|
|
||||||
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
|
|
||||||
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
|
|
||||||
| `maxOutputBytes` | none - no truncation | Retained result value and logs; warnings separately. |
|
|
||||||
|
|
||||||
No limit has a default, on purpose: execution budgets are host policy. A host without its own truncation or
|
CodeMode confines programs to the supplied tool tree, but it does not decide what those tools may do.
|
||||||
interruption should set `maxOutputBytes` and `timeoutMs`. Limits are safe integers; invalid configuration throws a
|
|
||||||
`RangeError` at construction. Exceeding `maxOutputBytes` never fails the execution - oversized output is truncated
|
|
||||||
with an in-band marker. The timeout interrupts in-flight tool fibers and pure busy loops alike; a value the program
|
|
||||||
already returned survives a cleanup timeout as a success with a `TimeoutExceeded` warning. Two internals are fixed
|
|
||||||
constants, not knobs: at most 8 concurrent tool calls, and 32 levels of data nesting at boundaries.
|
|
||||||
|
|
||||||
## Boundaries and Non-Goals
|
The host owns:
|
||||||
|
|
||||||
The host owns authentication, authorization, tool selection, credentials, persistence, approval, and logging policy.
|
- Authentication and authorization.
|
||||||
CodeMode owns interpretation, schema and plain-data boundaries, resource limits, diagnostics, and discovery. A program
|
- Tool selection and immutable scope.
|
||||||
can only exercise authority already present in the supplied tools - do not expose a broad tool and expect the prompt
|
- Credentials and network clients.
|
||||||
to restrict it.
|
- Persistence, idempotency, approval, and durable side effects.
|
||||||
|
- Logging and redaction policy.
|
||||||
|
|
||||||
Non-goals: permission prompts and approval workflows, durable pause/resume or replay, exactly-once side effects,
|
CodeMode owns:
|
||||||
application authorization policy, sandboxing arbitrary JavaScript, and compatibility with the full language or npm
|
|
||||||
ecosystem. Applications that need approval or durable consequences should model those above CodeMode and expose only
|
- Parsing and interpreting the supported subset without `eval`.
|
||||||
the currently authorized tools.
|
- Schema boundaries around tool calls.
|
||||||
|
- Plain-data copying and blocked prototype members.
|
||||||
|
- Resource limits, call accounting, and normalized diagnostics.
|
||||||
|
- Model-facing tool discovery and instructions.
|
||||||
|
|
||||||
|
A program cannot gain authority through prose or generated code. It can only exercise authority already present in the supplied tools. Do not expose a broad tool and expect the prompt to restrict it.
|
||||||
|
|
||||||
|
## Laws
|
||||||
|
|
||||||
|
The public contract is guided by these equivalences:
|
||||||
|
|
||||||
|
- `CodeMode.execute({ ...options, code })` is equivalent to `CodeMode.make(options).execute(code)`.
|
||||||
|
- A tool implementation is not invoked unless its input has decoded successfully.
|
||||||
|
- A tool result is not visible to the program unless its output has decoded and crossed the plain-data boundary successfully.
|
||||||
|
- Unknown host failures do not become model-visible diagnostics; `ToolError` is the explicit safe-message channel.
|
||||||
|
- Host interruption remains interruption rather than a `CodeMode.Failure`.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Generic permission prompts or approval workflows.
|
||||||
|
- Durable pause/resume, replay, or storage adapters.
|
||||||
|
- Exactly-once external side effects.
|
||||||
|
- Application authorization or product policy.
|
||||||
|
- A filesystem or process sandbox for arbitrary JavaScript.
|
||||||
|
- Compatibility with the full JavaScript language or npm ecosystem.
|
||||||
|
|
||||||
|
Applications that need approval or durable consequences should model those above CodeMode and expose only the currently authorized tools.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -200,3 +367,5 @@ From the package directory:
|
|||||||
bun test
|
bun test
|
||||||
bun run typecheck
|
bun run typecheck
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The direct suite covers public projections, discovery, schema boundaries, diagnostic sanitization, resource limits, tool-call observation, and interruption.
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ CodeMode is an orchestration language, not a general JavaScript runtime or an ap
|
|||||||
The generic runtime lives in `packages/codemode` and is host-neutral:
|
The generic runtime lives in `packages/codemode` and is host-neutral:
|
||||||
|
|
||||||
1. The host builds a tree of `Tool.make(...)` definitions and calls `CodeMode.make(...)` or `CodeMode.execute(...)`.
|
1. The host builds a tree of `Tool.make(...)` definitions and calls `CodeMode.make(...)` or `CodeMode.execute(...)`.
|
||||||
2. CodeMode generates model instructions, a budgeted inline catalog, and the global `search(...)` built-in.
|
2. CodeMode generates model instructions, a budgeted inline catalog, and the internal `$codemode.search` tool.
|
||||||
3. TypeScript syntax is transpiled away, Acorn parses the resulting JavaScript, and an owned tree-walking interpreter
|
3. TypeScript syntax is transpiled away, Acorn parses the resulting JavaScript, and an owned tree-walking interpreter
|
||||||
executes it without `eval`.
|
executes it without `eval`.
|
||||||
4. Tool inputs and outputs cross schema and plain-data boundaries before they become visible on either side.
|
4. Tool inputs and outputs cross schema and plain-data boundaries before they become visible on either side.
|
||||||
@@ -46,14 +46,13 @@ advertised as `Promise<unknown>`.
|
|||||||
### Discovery and model workflow
|
### Discovery and model workflow
|
||||||
|
|
||||||
The model sees a token-budgeted catalog. Every namespace remains visible, and complete signatures are selected
|
The model sees a token-budgeted catalog. Every namespace remains visible, and complete signatures are selected
|
||||||
round-robin across namespaces so one large namespace cannot starve the others. The global `search(...)` built-in is
|
round-robin across namespaces so one large namespace cannot starve the others. `$codemode.search` is always callable
|
||||||
always callable - synchronously, counted as an admitted tool call - and is advertised when the inline catalog is
|
and is advertised when the inline catalog is partial.
|
||||||
partial.
|
|
||||||
|
|
||||||
The intended workflow is:
|
The intended workflow is:
|
||||||
|
|
||||||
1. Pick an exact signature from the inline catalog, or return `search(...)` results and use a selected path in the
|
1. Pick an exact signature from the inline catalog, or return `$codemode.search(...)` results and use a selected path
|
||||||
next execution.
|
in the next execution.
|
||||||
2. Call the exact returned path without guessing or normalizing segments.
|
2. Call the exact returned path without guessing or normalizing segments.
|
||||||
3. Narrow `Promise<unknown>` results before reading fields.
|
3. Narrow `Promise<unknown>` results before reading fields.
|
||||||
4. Start independent calls together and await them with `Promise.all`.
|
4. Start independent calls together and await them with `Promise.all`.
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ ultimate source of truth.
|
|||||||
- [x] JSON-like host boundaries with `undefined` and non-finite numbers normalized to `null`.
|
- [x] JSON-like host boundaries with `undefined` and non-finite numbers normalized to `null`.
|
||||||
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside the sandbox.
|
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside the sandbox.
|
||||||
- [x] Tool calls through the host-provided `tools` tree only.
|
- [x] Tool calls through the host-provided `tools` tree only.
|
||||||
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
|
|
||||||
shadowable by program declarations like other globals.
|
|
||||||
- [x] Cooperative timeout, tool-call accounting, output bounding, and a maximum of eight concurrent tool calls.
|
- [x] Cooperative timeout, tool-call accounting, output bounding, and a maximum of eight concurrent tool calls.
|
||||||
- [ ] Full JavaScript or TypeScript compatibility. CodeMode is a bounded orchestration language.
|
- [ ] Full JavaScript or TypeScript compatibility. CodeMode is a bounded orchestration language.
|
||||||
|
|
||||||
@@ -176,9 +174,9 @@ ultimate source of truth.
|
|||||||
## Strings
|
## Strings
|
||||||
|
|
||||||
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
|
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
|
||||||
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`.
|
- [x] Trimming: `trim`, `trimStart`, `trimEnd`, `trimLeft`, and `trimRight`.
|
||||||
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
|
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
|
||||||
- [x] Slicing/access: `slice`, `substring`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
- [x] Slicing/access: `slice`, `substring`, `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
||||||
- [x] Construction/transformation: `split`, `concat`, `repeat`, `padStart`, `padEnd`, `replace`, and `replaceAll`.
|
- [x] Construction/transformation: `split`, `concat`, `repeat`, `padStart`, `padEnd`, `replace`, and `replaceAll`.
|
||||||
- [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`.
|
- [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`.
|
||||||
- [x] `localeCompare`; locale and options arguments are currently ignored.
|
- [x] `localeCompare`; locale and options arguments are currently ignored.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { executeWithLimits } from "./interpreter/execute.js"
|
import { executeWithLimits } from "./interpreter/runtime.js"
|
||||||
import { type HostTools, type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js"
|
import { type HostTools, type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js"
|
||||||
import type { Definition } from "./tool.js"
|
import type { Definition } from "./tool.js"
|
||||||
|
|
||||||
@@ -9,15 +9,15 @@ export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescr
|
|||||||
/** Resource budgets enforced independently during each CodeMode program execution. */
|
/** Resource budgets enforced independently during each CodeMode program execution. */
|
||||||
export type ExecutionLimits = {
|
export type ExecutionLimits = {
|
||||||
/**
|
/**
|
||||||
* Wall-clock milliseconds before interruption. Result delivery waits for tool cleanup.
|
* Wall-clock milliseconds before execution is interrupted; result delivery additionally
|
||||||
* No default: absent means no timeout.
|
* waits for tool interruption cleanup. No default: absent means no timeout.
|
||||||
*/
|
*/
|
||||||
readonly timeoutMs?: number
|
readonly timeoutMs?: number
|
||||||
/** Maximum number of tool calls admitted by the runtime. No default: absent means unlimited. */
|
/** Maximum number of tool calls admitted by the runtime. No default: absent means unlimited. */
|
||||||
readonly maxToolCalls?: number
|
readonly maxToolCalls?: number
|
||||||
/**
|
/**
|
||||||
* Maximum UTF-8 bytes retained from the result and logs. Warnings have a separate equal budget;
|
* Maximum UTF-8 bytes retained from the result value and logs; warnings have a separate
|
||||||
* truncation notices and host formatting are additional.
|
* budget of the same size. Fixed truncation notices and host formatting are additional.
|
||||||
*/
|
*/
|
||||||
readonly maxOutputBytes?: number
|
readonly maxOutputBytes?: number
|
||||||
}
|
}
|
||||||
@@ -94,6 +94,7 @@ const ToolCallSchema = Schema.Struct({ name: Schema.String })
|
|||||||
export const Success = Schema.Struct({
|
export const Success = Schema.Struct({
|
||||||
ok: Schema.Literal(true),
|
ok: Schema.Literal(true),
|
||||||
value: Schema.Json,
|
value: Schema.Json,
|
||||||
|
// Runtime-authored non-fatal diagnostics; program console output stays in `logs`.
|
||||||
warnings: Schema.optionalKey(Schema.Array(Diagnostic)),
|
warnings: Schema.optionalKey(Schema.Array(Diagnostic)),
|
||||||
logs: Schema.optionalKey(Schema.Array(Schema.String)),
|
logs: Schema.optionalKey(Schema.Array(Schema.String)),
|
||||||
truncated: Schema.optionalKey(Schema.Boolean),
|
truncated: Schema.optionalKey(Schema.Boolean),
|
||||||
@@ -142,6 +143,7 @@ export const execute = <const Tools extends Record<string, unknown>>(
|
|||||||
options: ExecuteOptions<Tools>,
|
options: ExecuteOptions<Tools>,
|
||||||
): Effect.Effect<Result, never, Services<Tools>> => {
|
): Effect.Effect<Result, never, Services<Tools>> => {
|
||||||
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
|
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
|
||||||
|
ToolRuntime.assertValidTools(tools)
|
||||||
return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools))
|
return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +152,7 @@ export const make = <const Tools extends Record<string, unknown> = {}>(
|
|||||||
options: Options<Tools> = {} as Options<Tools>,
|
options: Options<Tools> = {} as Options<Tools>,
|
||||||
): Runtime<Services<Tools>> => {
|
): Runtime<Services<Tools>> => {
|
||||||
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
|
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
|
||||||
|
ToolRuntime.assertValidTools(tools)
|
||||||
const limits = resolveExecutionLimits(options.limits)
|
const limits = resolveExecutionLimits(options.limits)
|
||||||
const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget)
|
const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget)
|
||||||
|
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import type { Diagnostic } from "../codemode.js"
|
|
||||||
import { ToolError } from "../tool-error.js"
|
|
||||||
import { copyOut, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
|
|
||||||
import { type AstNode, formatLocation, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js"
|
|
||||||
import { containsRuntimeReference } from "./references.js"
|
|
||||||
import { spreadItems } from "../stdlib/collections.js"
|
|
||||||
import { coerceToString, createAggregateErrorValue, createErrorValue, errorConstructors } from "../stdlib/value.js"
|
|
||||||
|
|
||||||
export const normalizeError = (error: unknown): Diagnostic => {
|
|
||||||
if (error instanceof InterpreterRuntimeError) {
|
|
||||||
return {
|
|
||||||
kind: error.kind,
|
|
||||||
message: `${error.message}${formatLocation(error.node)}`,
|
|
||||||
...(error.node?.loc ? { location: sourceLocation(error.node) } : {}),
|
|
||||||
...(error.suggestions ? { suggestions: error.suggestions } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof ToolRuntimeError) {
|
|
||||||
return {
|
|
||||||
kind: error.kind,
|
|
||||||
message: error.message,
|
|
||||||
...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof ToolError) {
|
|
||||||
return { kind: "ToolFailure", message: error.message }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof ProgramThrow) {
|
|
||||||
const value = error.value
|
|
||||||
let message: string
|
|
||||||
if (containsRuntimeReference(value)) {
|
|
||||||
// Never expose runtime reference internals through thrown values.
|
|
||||||
message = "a non-data value"
|
|
||||||
} else if (typeof value === "string") {
|
|
||||||
message = value
|
|
||||||
} else if (
|
|
||||||
value !== null &&
|
|
||||||
typeof value === "object" &&
|
|
||||||
typeof (value as { message?: unknown }).message === "string"
|
|
||||||
) {
|
|
||||||
message = (value as { message: string }).message
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
message = JSON.stringify(copyOut(value)) ?? String(value)
|
|
||||||
} catch {
|
|
||||||
message = String(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { kind: "ExecutionFailure", message: `Uncaught: ${message}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) {
|
|
||||||
return {
|
|
||||||
kind: "ExecutionFailure",
|
|
||||||
message: "Execution exceeded the maximum nesting depth.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return {
|
|
||||||
kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure",
|
|
||||||
message: error.message,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: "ExecutionFailure",
|
|
||||||
message: String(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const caughtErrorValue = (thrown: unknown): unknown => {
|
|
||||||
if (thrown instanceof ProgramThrow) return thrown.value
|
|
||||||
if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message)
|
|
||||||
const name = thrown instanceof Error && errorConstructors.has(thrown.name) ? thrown.name : "Error"
|
|
||||||
return createErrorValue(name, normalizeError(thrown).message)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const constructErrorValue = (name: string, args: Array<unknown>, node: AstNode): SafeObject => {
|
|
||||||
if (name !== "AggregateError") return createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0]))
|
|
||||||
const errors = spreadItems(args[0])
|
|
||||||
if (errors === undefined) {
|
|
||||||
throw new InterpreterRuntimeError(
|
|
||||||
"new AggregateError(...) expects an array of errors (e.g. new AggregateError(errors, message?)).",
|
|
||||||
node,
|
|
||||||
).as("TypeError")
|
|
||||||
}
|
|
||||||
// Error values must not alias caller-owned arrays.
|
|
||||||
return createAggregateErrorValue([...errors], args[1] === undefined ? "" : coerceToString(args[1]))
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
import { parse } from "acorn"
|
|
||||||
import { Cause, Effect, Scope } from "effect"
|
|
||||||
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
|
||||||
import type { DataValue, Diagnostic, ExecuteOptions, ResolvedExecutionLimits, Result } from "../codemode.js"
|
|
||||||
import { copyIn, copyOut, ToolRuntime, type HostTools, type Services } from "../tool-runtime.js"
|
|
||||||
import { normalizeError } from "./errors.js"
|
|
||||||
import { InterpreterRuntimeError, isRecord, type ProgramNode } from "./model.js"
|
|
||||||
import { PromiseRuntime } from "./promises.js"
|
|
||||||
import { Interpreter } from "./runtime.js"
|
|
||||||
|
|
||||||
export const executeWithLimits = <const Tools extends Record<string, unknown>>(
|
|
||||||
options: ExecuteOptions<Tools>,
|
|
||||||
limits: ResolvedExecutionLimits,
|
|
||||||
searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"],
|
|
||||||
): Effect.Effect<Result, never, Services<Tools>> => {
|
|
||||||
if (options.code.trim().length === 0) {
|
|
||||||
return Effect.succeed({
|
|
||||||
ok: false,
|
|
||||||
error: { kind: "ParseError", message: "Code cannot be empty." },
|
|
||||||
toolCalls: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allocate execution state inside suspension so reused Effects never share it.
|
|
||||||
return Effect.suspend(() => {
|
|
||||||
const tools = ToolRuntime.make(
|
|
||||||
(options.tools ?? {}) as HostTools<Services<Tools>>,
|
|
||||||
limits.maxToolCalls,
|
|
||||||
searchIndex,
|
|
||||||
{
|
|
||||||
onToolCallStart: options.onToolCallStart,
|
|
||||||
onToolCallEnd: options.onToolCallEnd,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const logs: Array<string> = []
|
|
||||||
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
|
|
||||||
// Set only after copy-out so timeouts cannot report invalid values as completed.
|
|
||||||
let returned: { value: DataValue; promises: PromiseRuntime<Services<Tools>> } | undefined
|
|
||||||
|
|
||||||
const base = Effect.acquireUseRelease(
|
|
||||||
Scope.make("parallel"),
|
|
||||||
(scope) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const program = parseProgram(options.code)
|
|
||||||
const promises = new PromiseRuntime<Services<Tools>>(scope)
|
|
||||||
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.search, tools.keys, promises, logs)
|
|
||||||
const value = yield* interpreter.run(program)
|
|
||||||
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
|
|
||||||
returned = { value: result, promises }
|
|
||||||
const warnings = yield* promises.interrupt()
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
value: result,
|
|
||||||
...(warnings.length > 0 ? { warnings } : {}),
|
|
||||||
...logged(),
|
|
||||||
toolCalls: tools.calls,
|
|
||||||
} satisfies Result
|
|
||||||
}),
|
|
||||||
(scope, exit) => Scope.close(scope, exit),
|
|
||||||
)
|
|
||||||
const timeoutMs = limits.timeoutMs
|
|
||||||
const operation =
|
|
||||||
timeoutMs === undefined
|
|
||||||
? base
|
|
||||||
: base.pipe(
|
|
||||||
Effect.timeoutOrElse({
|
|
||||||
duration: timeoutMs,
|
|
||||||
orElse: () =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
if (returned === undefined) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` },
|
|
||||||
...logged(),
|
|
||||||
toolCalls: tools.calls,
|
|
||||||
} satisfies Result
|
|
||||||
}
|
|
||||||
// Keep the timeout warning first so truncation preserves it.
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
value: returned.value,
|
|
||||||
warnings: [
|
|
||||||
{
|
|
||||||
kind: "TimeoutExceeded",
|
|
||||||
message: `The program returned, but background work was still running at the ${timeoutMs}ms timeout and was interrupted. Await all started promises.`,
|
|
||||||
},
|
|
||||||
...returned.promises.diagnostics(),
|
|
||||||
],
|
|
||||||
...logged(),
|
|
||||||
toolCalls: tools.calls,
|
|
||||||
} satisfies Result
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return operation.pipe(
|
|
||||||
Effect.catchCause((cause) =>
|
|
||||||
Cause.hasInterruptsOnly(cause)
|
|
||||||
? Effect.interrupt
|
|
||||||
: Effect.succeed({
|
|
||||||
ok: false,
|
|
||||||
error: normalizeError(Cause.squash(cause)),
|
|
||||||
...logged(),
|
|
||||||
toolCalls: tools.calls,
|
|
||||||
} satisfies Result),
|
|
||||||
),
|
|
||||||
Effect.map((result) =>
|
|
||||||
limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseProgram = (code: string): ProgramNode => {
|
|
||||||
const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, {
|
|
||||||
reportDiagnostics: true,
|
|
||||||
compilerOptions: {
|
|
||||||
target: ScriptTarget.ESNext,
|
|
||||||
module: ModuleKind.ESNext,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
|
|
||||||
|
|
||||||
if (diagnostic) {
|
|
||||||
throw new InterpreterRuntimeError(
|
|
||||||
`Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`,
|
|
||||||
undefined,
|
|
||||||
"ParseError",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyStart = transpiled.outputText.indexOf("{") + 1
|
|
||||||
const bodyEnd = transpiled.outputText.lastIndexOf("}")
|
|
||||||
const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
|
|
||||||
const parsed = parse(executableCode, {
|
|
||||||
ecmaVersion: "latest",
|
|
||||||
sourceType: "script",
|
|
||||||
allowReturnOutsideFunction: true,
|
|
||||||
allowAwaitOutsideFunction: true,
|
|
||||||
locations: true,
|
|
||||||
}) as unknown
|
|
||||||
|
|
||||||
if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) {
|
|
||||||
throw new InterpreterRuntimeError("Failed to parse script as a Program node.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed as ProgramNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength
|
|
||||||
|
|
||||||
// Drop a replacement character produced by truncating inside a UTF-8 sequence.
|
|
||||||
const utf8Truncate = (value: string, maxBytes: number): string => {
|
|
||||||
const bytes = new TextEncoder().encode(value)
|
|
||||||
if (bytes.byteLength <= maxBytes) return value
|
|
||||||
const text = new TextDecoder("utf-8").decode(bytes.slice(0, Math.max(0, maxBytes)))
|
|
||||||
return text.endsWith("\uFFFD") ? text.slice(0, -1) : text
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warnings have a separate budget so result data cannot starve diagnostics.
|
|
||||||
const boundOutput = (result: Result, maxOutputBytes: number): Result => {
|
|
||||||
let truncated = false
|
|
||||||
|
|
||||||
let value: DataValue = null
|
|
||||||
let valueBytes = 0
|
|
||||||
if (result.ok) {
|
|
||||||
const serialized = JSON.stringify(result.value) ?? "null"
|
|
||||||
const bytes = utf8ByteLength(serialized)
|
|
||||||
if (bytes > maxOutputBytes) {
|
|
||||||
truncated = true
|
|
||||||
value = `${utf8Truncate(serialized, maxOutputBytes)} [result truncated: ${bytes} bytes exceeds the ${maxOutputBytes}-byte output limit; return a smaller value]`
|
|
||||||
valueBytes = maxOutputBytes
|
|
||||||
} else {
|
|
||||||
value = result.value
|
|
||||||
valueBytes = bytes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const warnings = result.ok ? (result.warnings ?? []) : []
|
|
||||||
const keptWarnings: Array<Diagnostic> = []
|
|
||||||
let warningBytes = 0
|
|
||||||
for (const warning of warnings) {
|
|
||||||
const bytes = utf8ByteLength(JSON.stringify(warning)) + 1
|
|
||||||
if (warningBytes + bytes > maxOutputBytes) break
|
|
||||||
warningBytes += bytes
|
|
||||||
keptWarnings.push(warning)
|
|
||||||
}
|
|
||||||
if (keptWarnings.length < warnings.length) {
|
|
||||||
truncated = true
|
|
||||||
keptWarnings.push({
|
|
||||||
kind: "Truncated",
|
|
||||||
message: `${warnings.length - keptWarnings.length} additional warnings omitted by the output limit.`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const logs = result.logs ?? []
|
|
||||||
const kept: Array<string> = []
|
|
||||||
const logBudget = Math.max(0, maxOutputBytes - valueBytes)
|
|
||||||
let logBytes = 0
|
|
||||||
for (const line of logs) {
|
|
||||||
const lineBytes = utf8ByteLength(line) + 1
|
|
||||||
if (logBytes + lineBytes > logBudget) break
|
|
||||||
logBytes += lineBytes
|
|
||||||
kept.push(line)
|
|
||||||
}
|
|
||||||
if (kept.length < logs.length) {
|
|
||||||
truncated = true
|
|
||||||
kept.push(`[logs truncated: showing ${kept.length} of ${logs.length} lines]`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!truncated) return result
|
|
||||||
const warningsPart = keptWarnings.length > 0 ? { warnings: keptWarnings } : {}
|
|
||||||
const logsPart = kept.length > 0 ? { logs: kept } : {}
|
|
||||||
return result.ok
|
|
||||||
? {
|
|
||||||
ok: true,
|
|
||||||
value,
|
|
||||||
...warningsPart,
|
|
||||||
...logsPart,
|
|
||||||
truncated: true,
|
|
||||||
toolCalls: result.toolCalls,
|
|
||||||
}
|
|
||||||
: { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls }
|
|
||||||
}
|
|
||||||
@@ -1,819 +0,0 @@
|
|||||||
import { Effect } from "effect"
|
|
||||||
import {
|
|
||||||
type AstNode,
|
|
||||||
CodeModeFunction,
|
|
||||||
CoercionFunction,
|
|
||||||
GlobalMethodReference,
|
|
||||||
IntrinsicReference,
|
|
||||||
InterpreterRuntimeError,
|
|
||||||
PromiseCapabilityFunction,
|
|
||||||
supportedSyntaxMessage,
|
|
||||||
UriFunction,
|
|
||||||
} from "./model.js"
|
|
||||||
import { rejectCircularInsertion } from "./references.js"
|
|
||||||
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
|
||||||
import {
|
|
||||||
SandboxDate,
|
|
||||||
SandboxMap,
|
|
||||||
SandboxPromise,
|
|
||||||
SandboxRegExp,
|
|
||||||
SandboxSet,
|
|
||||||
SandboxURL,
|
|
||||||
SandboxURLSearchParams,
|
|
||||||
} from "../values.js"
|
|
||||||
import { invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
|
|
||||||
import { invokeJsonMethod } from "../stdlib/json.js"
|
|
||||||
import { invokeMathMethod } from "../stdlib/math.js"
|
|
||||||
import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js"
|
|
||||||
import { invokeObjectMethod } from "../stdlib/object.js"
|
|
||||||
import { invokeRegExpMethod, matchToValue, toHostRegex } from "../stdlib/regexp.js"
|
|
||||||
import { invokeStringStatic } from "../stdlib/string.js"
|
|
||||||
import { invokeUriFunction, invokeURLMethod, invokeURLStatic, uriArgument } from "../stdlib/url.js"
|
|
||||||
import { boundedData, coerceToNumber, coerceToString, invokeCoercion } from "../stdlib/value.js"
|
|
||||||
|
|
||||||
export type CallbackRunner<R> = {
|
|
||||||
readonly invokeFunction: (fn: CodeModeFunction, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
|
||||||
readonly settlePromise: (promise: SandboxPromise) => Effect.Effect<unknown, unknown, never>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const invokeIntrinsic = <R>(
|
|
||||||
runner: CallbackRunner<R>,
|
|
||||||
ref: IntrinsicReference,
|
|
||||||
args: Array<unknown>,
|
|
||||||
node: AstNode,
|
|
||||||
): Effect.Effect<unknown, unknown, R> => {
|
|
||||||
if (typeof ref.receiver === "string") {
|
|
||||||
if (
|
|
||||||
(ref.name === "replace" || ref.name === "replaceAll") &&
|
|
||||||
(args[1] instanceof CodeModeFunction || args[1] instanceof CoercionFunction || args[1] instanceof UriFunction)
|
|
||||||
) {
|
|
||||||
return invokeStringReplacer(runner, ref.receiver, ref.name, args, node)
|
|
||||||
}
|
|
||||||
return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node))
|
|
||||||
}
|
|
||||||
if (typeof ref.receiver === "number") {
|
|
||||||
return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node))
|
|
||||||
}
|
|
||||||
if (Array.isArray(ref.receiver)) {
|
|
||||||
return invokeArrayMethod(runner, ref.receiver, ref.name, args, node)
|
|
||||||
}
|
|
||||||
if (ref.receiver instanceof SandboxDate) {
|
|
||||||
return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node))
|
|
||||||
}
|
|
||||||
if (ref.receiver instanceof SandboxRegExp) {
|
|
||||||
return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node))
|
|
||||||
}
|
|
||||||
if (ref.receiver instanceof SandboxMap) {
|
|
||||||
return invokeMapMethod(runner, ref.receiver, ref.name, args, node)
|
|
||||||
}
|
|
||||||
if (ref.receiver instanceof SandboxSet) {
|
|
||||||
return invokeSetMethod(runner, ref.receiver, ref.name, args, node)
|
|
||||||
}
|
|
||||||
if (ref.receiver instanceof SandboxURL) {
|
|
||||||
return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node))
|
|
||||||
}
|
|
||||||
if (ref.receiver instanceof SandboxURLSearchParams) {
|
|
||||||
return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node)
|
|
||||||
}
|
|
||||||
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
|
|
||||||
if (ref.namespace === "console")
|
|
||||||
throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node)
|
|
||||||
if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node)
|
|
||||||
if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node)
|
|
||||||
if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node)
|
|
||||||
if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node)
|
|
||||||
if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node)
|
|
||||||
if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node)
|
|
||||||
if (ref.namespace === "Date") return invokeDateStatic(ref.name, args, node)
|
|
||||||
if (
|
|
||||||
ref.namespace === "RegExp" ||
|
|
||||||
ref.namespace === "Map" ||
|
|
||||||
ref.namespace === "Set" ||
|
|
||||||
ref.namespace === "URLSearchParams"
|
|
||||||
) {
|
|
||||||
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
|
|
||||||
}
|
|
||||||
return invokeJsonMethod(ref.name, args, node)
|
|
||||||
}
|
|
||||||
|
|
||||||
const invokeStringMethod = (value: string, name: string, args: Array<unknown>, node: AstNode): unknown => {
|
|
||||||
const str = (index: number): string => {
|
|
||||||
const arg = args[index]
|
|
||||||
if (typeof arg !== "string")
|
|
||||||
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node)
|
|
||||||
return arg
|
|
||||||
}
|
|
||||||
const num = (index: number): number => {
|
|
||||||
const arg = args[index]
|
|
||||||
if (typeof arg !== "number")
|
|
||||||
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node)
|
|
||||||
return arg
|
|
||||||
}
|
|
||||||
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
|
|
||||||
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
|
|
||||||
|
|
||||||
let result: unknown
|
|
||||||
switch (name) {
|
|
||||||
case "toLowerCase":
|
|
||||||
result = value.toLowerCase()
|
|
||||||
break
|
|
||||||
case "toUpperCase":
|
|
||||||
result = value.toUpperCase()
|
|
||||||
break
|
|
||||||
case "trim":
|
|
||||||
result = value.trim()
|
|
||||||
break
|
|
||||||
case "trimStart":
|
|
||||||
result = value.trimStart()
|
|
||||||
break
|
|
||||||
case "trimEnd":
|
|
||||||
result = value.trimEnd()
|
|
||||||
break
|
|
||||||
// Locale/options are deliberately unsupported; comparison uses the host default locale.
|
|
||||||
case "localeCompare":
|
|
||||||
result = value.localeCompare(str(0))
|
|
||||||
break
|
|
||||||
case "normalize": {
|
|
||||||
const form = optStr(0)
|
|
||||||
try {
|
|
||||||
result = value.normalize(form)
|
|
||||||
} catch {
|
|
||||||
throw new InterpreterRuntimeError(
|
|
||||||
`String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
|
|
||||||
node,
|
|
||||||
).as("RangeError")
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "split": {
|
|
||||||
if (args.length === 0) {
|
|
||||||
result = [value]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (args[0] instanceof SandboxRegExp) {
|
|
||||||
result = value.split(args[0].regex, optNum(1))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
const requestedLimit = optNum(1)
|
|
||||||
result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "slice":
|
|
||||||
result = value.slice(optNum(0), optNum(1))
|
|
||||||
break
|
|
||||||
case "includes":
|
|
||||||
result = value.includes(str(0), optNum(1))
|
|
||||||
break
|
|
||||||
case "startsWith":
|
|
||||||
result = value.startsWith(str(0), optNum(1))
|
|
||||||
break
|
|
||||||
case "endsWith":
|
|
||||||
result = value.endsWith(str(0), optNum(1))
|
|
||||||
break
|
|
||||||
case "indexOf":
|
|
||||||
result = value.indexOf(str(0), optNum(1))
|
|
||||||
break
|
|
||||||
case "lastIndexOf":
|
|
||||||
result = value.lastIndexOf(str(0), optNum(1))
|
|
||||||
break
|
|
||||||
case "replace":
|
|
||||||
case "replaceAll": {
|
|
||||||
if (args[0] instanceof SandboxRegExp) {
|
|
||||||
const pattern = args[0].regex
|
|
||||||
const replacement = str(1)
|
|
||||||
if (name === "replaceAll" && !pattern.global) {
|
|
||||||
throw new InterpreterRuntimeError(
|
|
||||||
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`,
|
|
||||||
node,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
result = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (name === "replace") {
|
|
||||||
result = value.replace(str(0), str(1))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
result = value.replaceAll(str(0), str(1))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "match": {
|
|
||||||
const pattern = toHostRegex(args[0], name, node)
|
|
||||||
const matched = value.match(pattern)
|
|
||||||
if (matched === null) return null
|
|
||||||
// Preserve the own `index` and `groups` properties on non-global matches.
|
|
||||||
if (pattern.global) return boundedData(matched, "String.match result")
|
|
||||||
return matchToValue(matched)
|
|
||||||
}
|
|
||||||
case "matchAll": {
|
|
||||||
const pattern = toHostRegex(args[0], name, node, "g")
|
|
||||||
if (!pattern.global) {
|
|
||||||
throw new InterpreterRuntimeError(
|
|
||||||
`String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`,
|
|
||||||
node,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return Array.from(value.matchAll(pattern), matchToValue)
|
|
||||||
}
|
|
||||||
case "search": {
|
|
||||||
result = value.search(toHostRegex(args[0], name, node))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "repeat": {
|
|
||||||
const count = num(0)
|
|
||||||
if (!Number.isFinite(count) || count < 0)
|
|
||||||
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node)
|
|
||||||
result = value.repeat(count)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "padStart":
|
|
||||||
result = value.padStart(num(0), optStr(1))
|
|
||||||
break
|
|
||||||
case "padEnd":
|
|
||||||
result = value.padEnd(num(0), optStr(1))
|
|
||||||
break
|
|
||||||
case "charAt":
|
|
||||||
result = value.charAt(optNum(0) ?? 0)
|
|
||||||
break
|
|
||||||
case "at":
|
|
||||||
result = value.at(optNum(0) ?? 0)
|
|
||||||
break
|
|
||||||
case "substring":
|
|
||||||
result = value.substring(optNum(0) ?? 0, optNum(1))
|
|
||||||
break
|
|
||||||
case "charCodeAt":
|
|
||||||
result = value.charCodeAt(optNum(0) ?? 0)
|
|
||||||
break
|
|
||||||
case "codePointAt":
|
|
||||||
result = value.codePointAt(optNum(0) ?? 0)
|
|
||||||
break
|
|
||||||
case "toString":
|
|
||||||
result = value
|
|
||||||
break
|
|
||||||
case "concat": {
|
|
||||||
result = value.concat(...args.map((_, index) => str(index)))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node)
|
|
||||||
}
|
|
||||||
return boundedData(result, `String.${name} result`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
|
||||||
switch (name) {
|
|
||||||
case "isArray":
|
|
||||||
return Array.isArray(args[0])
|
|
||||||
case "of":
|
|
||||||
return [...args]
|
|
||||||
case "from": {
|
|
||||||
if (args.length > 1) {
|
|
||||||
throw new InterpreterRuntimeError(
|
|
||||||
"Array.from(...) does not support a map function in CodeMode; call .map() on the result instead.",
|
|
||||||
node,
|
|
||||||
"UnsupportedSyntax",
|
|
||||||
[supportedSyntaxMessage],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (args[0] instanceof SandboxMap) return Array.from(args[0].map.entries(), ([key, item]) => [key, item])
|
|
||||||
if (args[0] instanceof SandboxSet) return Array.from(args[0].set.values())
|
|
||||||
if (args[0] instanceof SandboxURLSearchParams) {
|
|
||||||
return Array.from(args[0].params.entries(), ([key, value]) => [key, value])
|
|
||||||
}
|
|
||||||
const source = args[0]
|
|
||||||
if (source instanceof SandboxPromise) {
|
|
||||||
throw new InterpreterRuntimeError(
|
|
||||||
"Array.from received an un-awaited Promise; await it before creating the array.",
|
|
||||||
node,
|
|
||||||
"InvalidDataValue",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (typeof source === "string") return Array.from(source)
|
|
||||||
if (Array.isArray(source)) return [...source]
|
|
||||||
if (
|
|
||||||
source !== null &&
|
|
||||||
typeof source === "object" &&
|
|
||||||
(Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) &&
|
|
||||||
typeof (source as { length?: unknown }).length === "number"
|
|
||||||
) {
|
|
||||||
return Array.from(source as ArrayLike<unknown>)
|
|
||||||
}
|
|
||||||
throw new InterpreterRuntimeError(
|
|
||||||
"Array.from expects an array, string, Map, Set, or array-like value.",
|
|
||||||
node,
|
|
||||||
"InvalidDataValue",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const invokeStringReplacer = <R>(
|
|
||||||
runner: CallbackRunner<R>,
|
|
||||||
value: string,
|
|
||||||
name: "replace" | "replaceAll",
|
|
||||||
args: Array<unknown>,
|
|
||||||
node: AstNode,
|
|
||||||
): Effect.Effect<unknown, unknown, R> => {
|
|
||||||
const apply = applyCollectionCallback(runner, args[1], `String.${name}`, node)
|
|
||||||
const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array<unknown> }> = []
|
|
||||||
const collect = (...callbackArgs: Array<unknown>): string => {
|
|
||||||
const match = callbackArgs[0]
|
|
||||||
const groups = callbackArgs[callbackArgs.length - 1]
|
|
||||||
const hasGroups = groups !== null && typeof groups === "object"
|
|
||||||
const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)]
|
|
||||||
if (typeof match !== "string" || typeof offset !== "number") {
|
|
||||||
throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node)
|
|
||||||
}
|
|
||||||
if (hasGroups) {
|
|
||||||
const safeGroups: SafeObject = Object.create(null) as SafeObject
|
|
||||||
for (const [key, group] of Object.entries(groups)) {
|
|
||||||
if (!isBlockedMember(key)) safeGroups[key] = group
|
|
||||||
}
|
|
||||||
callbackArgs[callbackArgs.length - 1] = safeGroups
|
|
||||||
}
|
|
||||||
matches.push({ match, offset, args: callbackArgs })
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
|
|
||||||
const pattern = args[0]
|
|
||||||
if (pattern instanceof SandboxRegExp) {
|
|
||||||
if (name === "replaceAll" && !pattern.regex.global) {
|
|
||||||
throw new InterpreterRuntimeError(
|
|
||||||
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`,
|
|
||||||
node,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (name === "replace") value.replace(pattern.regex, collect)
|
|
||||||
else value.replaceAll(pattern.regex, collect)
|
|
||||||
} else {
|
|
||||||
if (typeof pattern !== "string") {
|
|
||||||
throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node)
|
|
||||||
}
|
|
||||||
if (name === "replace") value.replace(pattern, collect)
|
|
||||||
else value.replaceAll(pattern, collect)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const output: Array<string> = []
|
|
||||||
let end = 0
|
|
||||||
for (const match of matches) {
|
|
||||||
const replacement = yield* apply(match.args)
|
|
||||||
const resolved =
|
|
||||||
args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof SandboxPromise
|
|
||||||
? yield* runner.settlePromise(replacement)
|
|
||||||
: replacement
|
|
||||||
output.push(
|
|
||||||
value.slice(end, match.offset),
|
|
||||||
coerceToString(boundedData(resolved, `String.${name} replacer result`)),
|
|
||||||
)
|
|
||||||
end = match.offset + match.match.length
|
|
||||||
}
|
|
||||||
output.push(value.slice(end))
|
|
||||||
return boundedData(output.join(""), `String.${name} result`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const applyCollectionCallback = <R>(
|
|
||||||
runner: CallbackRunner<R>,
|
|
||||||
callback: unknown,
|
|
||||||
name: string,
|
|
||||||
node: AstNode,
|
|
||||||
): ((args: Array<unknown>) => Effect.Effect<unknown, unknown, R>) => {
|
|
||||||
if (
|
|
||||||
!(callback instanceof CodeModeFunction) &&
|
|
||||||
!(callback instanceof CoercionFunction) &&
|
|
||||||
!(callback instanceof UriFunction) &&
|
|
||||||
!(callback instanceof PromiseCapabilityFunction)
|
|
||||||
) {
|
|
||||||
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
|
|
||||||
}
|
|
||||||
return (callbackArgs) =>
|
|
||||||
callback instanceof CoercionFunction
|
|
||||||
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
|
|
||||||
: callback instanceof UriFunction
|
|
||||||
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
|
|
||||||
: callback instanceof PromiseCapabilityFunction
|
|
||||||
? Effect.sync(() => callback.settle(callbackArgs[0]))
|
|
||||||
: runner.invokeFunction(callback, callbackArgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
const invokeMapMethod = <R>(
|
|
||||||
runner: CallbackRunner<R>,
|
|
||||||
target: SandboxMap,
|
|
||||||
name: string,
|
|
||||||
args: Array<unknown>,
|
|
||||||
node: AstNode,
|
|
||||||
): Effect.Effect<unknown, unknown, R> => {
|
|
||||||
switch (name) {
|
|
||||||
case "get":
|
|
||||||
return Effect.succeed(target.map.get(args[0]))
|
|
||||||
case "has":
|
|
||||||
return Effect.succeed(target.map.has(args[0]))
|
|
||||||
case "set":
|
|
||||||
return Effect.sync(() => {
|
|
||||||
target.map.set(args[0], args[1])
|
|
||||||
return target
|
|
||||||
})
|
|
||||||
case "delete":
|
|
||||||
return Effect.sync(() => target.map.delete(args[0]))
|
|
||||||
case "clear":
|
|
||||||
return Effect.sync(() => {
|
|
||||||
target.map.clear()
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
case "keys":
|
|
||||||
return Effect.sync(() => Array.from(target.map.keys()))
|
|
||||||
case "values":
|
|
||||||
return Effect.sync(() => Array.from(target.map.values()))
|
|
||||||
case "entries":
|
|
||||||
return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array<unknown> => [key, item]))
|
|
||||||
case "forEach": {
|
|
||||||
const apply = applyCollectionCallback(runner, args[0], "Map.forEach", node)
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target])
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const invokeSetMethod = <R>(
|
|
||||||
runner: CallbackRunner<R>,
|
|
||||||
target: SandboxSet,
|
|
||||||
name: string,
|
|
||||||
args: Array<unknown>,
|
|
||||||
node: AstNode,
|
|
||||||
): Effect.Effect<unknown, unknown, R> => {
|
|
||||||
switch (name) {
|
|
||||||
case "has":
|
|
||||||
return Effect.succeed(target.set.has(args[0]))
|
|
||||||
case "add":
|
|
||||||
return Effect.sync(() => {
|
|
||||||
target.set.add(args[0])
|
|
||||||
return target
|
|
||||||
})
|
|
||||||
case "delete":
|
|
||||||
return Effect.sync(() => target.set.delete(args[0]))
|
|
||||||
case "clear":
|
|
||||||
return Effect.sync(() => {
|
|
||||||
target.set.clear()
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
case "keys":
|
|
||||||
case "values":
|
|
||||||
return Effect.sync(() => Array.from(target.set.values()))
|
|
||||||
case "entries":
|
|
||||||
return Effect.sync(() => Array.from(target.set.values(), (item): Array<unknown> => [item, item]))
|
|
||||||
case "forEach": {
|
|
||||||
const apply = applyCollectionCallback(runner, args[0], "Set.forEach", node)
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
for (const item of Array.from(target.set.values())) yield* apply([item, item, target])
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const invokeURLSearchParamsMethod = <R>(
|
|
||||||
runner: CallbackRunner<R>,
|
|
||||||
target: SandboxURLSearchParams,
|
|
||||||
name: string,
|
|
||||||
args: Array<unknown>,
|
|
||||||
node: AstNode,
|
|
||||||
): Effect.Effect<unknown, unknown, R> => {
|
|
||||||
const arg = (index: number): string => uriArgument(args[index], `URLSearchParams.${name} argument ${index + 1}`)
|
|
||||||
const requireArgs = (count: number): void => {
|
|
||||||
if (args.length < count) {
|
|
||||||
throw new InterpreterRuntimeError(
|
|
||||||
`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`,
|
|
||||||
node,
|
|
||||||
).as("TypeError")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch (name) {
|
|
||||||
case "append": {
|
|
||||||
requireArgs(2)
|
|
||||||
return Effect.sync(() => {
|
|
||||||
target.params.append(arg(0), arg(1))
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
case "delete": {
|
|
||||||
requireArgs(1)
|
|
||||||
return Effect.sync(() => {
|
|
||||||
if (args[1] !== undefined) target.params.delete(arg(0), arg(1))
|
|
||||||
else target.params.delete(arg(0))
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
case "get":
|
|
||||||
requireArgs(1)
|
|
||||||
return Effect.sync(() => target.params.get(arg(0)))
|
|
||||||
case "getAll":
|
|
||||||
requireArgs(1)
|
|
||||||
return Effect.sync(() => target.params.getAll(arg(0)))
|
|
||||||
case "has":
|
|
||||||
requireArgs(1)
|
|
||||||
return Effect.sync(() => (args[1] !== undefined ? target.params.has(arg(0), arg(1)) : target.params.has(arg(0))))
|
|
||||||
case "set": {
|
|
||||||
requireArgs(2)
|
|
||||||
return Effect.sync(() => {
|
|
||||||
target.params.set(arg(0), arg(1))
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
case "sort":
|
|
||||||
return Effect.sync(() => {
|
|
||||||
target.params.sort()
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
case "keys":
|
|
||||||
return Effect.sync(() => Array.from(target.params.keys()))
|
|
||||||
case "values":
|
|
||||||
return Effect.sync(() => Array.from(target.params.values()))
|
|
||||||
case "entries":
|
|
||||||
return Effect.sync(() => Array.from(target.params.entries(), ([key, value]): Array<unknown> => [key, value]))
|
|
||||||
case "toString":
|
|
||||||
return Effect.sync(() => target.params.toString())
|
|
||||||
case "forEach": {
|
|
||||||
requireArgs(1)
|
|
||||||
const apply = applyCollectionCallback(runner, args[0], "URLSearchParams.forEach", node)
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target])
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available in CodeMode.`, node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const invokeArrayMethod = <R>(
|
|
||||||
runner: CallbackRunner<R>,
|
|
||||||
target: Array<unknown>,
|
|
||||||
name: string,
|
|
||||||
args: Array<unknown>,
|
|
||||||
node: AstNode,
|
|
||||||
): Effect.Effect<unknown, unknown, R> => {
|
|
||||||
const optNumber = (value: unknown, label: string): number | undefined => {
|
|
||||||
if (value === undefined) return undefined
|
|
||||||
if (typeof value !== "number")
|
|
||||||
throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node)
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
switch (name) {
|
|
||||||
case "join": {
|
|
||||||
if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) {
|
|
||||||
throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node)
|
|
||||||
}
|
|
||||||
const input = boundedData(target, "Array.join input") as Array<unknown>
|
|
||||||
return Effect.succeed(
|
|
||||||
input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case "includes":
|
|
||||||
if (args.length === 0 || args.length > 2)
|
|
||||||
throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node)
|
|
||||||
return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index")))
|
|
||||||
case "indexOf":
|
|
||||||
return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index")))
|
|
||||||
case "lastIndexOf":
|
|
||||||
return Effect.succeed(
|
|
||||||
args[1] === undefined
|
|
||||||
? target.lastIndexOf(args[0])
|
|
||||||
: target.lastIndexOf(args[0], optNumber(args[1], "start index")),
|
|
||||||
)
|
|
||||||
case "at":
|
|
||||||
return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0))
|
|
||||||
case "slice":
|
|
||||||
return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end")))
|
|
||||||
case "concat":
|
|
||||||
return Effect.succeed(target.concat(...args))
|
|
||||||
case "flat":
|
|
||||||
return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1))
|
|
||||||
case "reverse":
|
|
||||||
return Effect.succeed(target.reverse())
|
|
||||||
case "sort":
|
|
||||||
return Effect.map(sortArray(runner, target, args[0], node), (sorted) => {
|
|
||||||
target.splice(0, target.length, ...sorted)
|
|
||||||
return target
|
|
||||||
})
|
|
||||||
case "toSorted":
|
|
||||||
return sortArray(runner, target, args[0], node)
|
|
||||||
case "toReversed":
|
|
||||||
return Effect.succeed([...target].reverse())
|
|
||||||
case "with": {
|
|
||||||
const index = optNumber(args[0], "index") ?? 0
|
|
||||||
const resolved = index < 0 ? target.length + index : index
|
|
||||||
if (resolved < 0 || resolved >= target.length) {
|
|
||||||
throw new InterpreterRuntimeError("Array.with index is out of range.", node)
|
|
||||||
}
|
|
||||||
const copied = [...target]
|
|
||||||
copied[resolved] = args[1]
|
|
||||||
return Effect.succeed(copied)
|
|
||||||
}
|
|
||||||
case "push": {
|
|
||||||
// Validate all insertions before mutating to avoid partial cyclic updates.
|
|
||||||
for (const item of args) rejectCircularInsertion(target, item, "Array.push result", node)
|
|
||||||
target.push(...args)
|
|
||||||
return Effect.succeed(target.length)
|
|
||||||
}
|
|
||||||
case "unshift": {
|
|
||||||
for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result", node)
|
|
||||||
target.unshift(...args)
|
|
||||||
return Effect.succeed(target.length)
|
|
||||||
}
|
|
||||||
case "pop":
|
|
||||||
return Effect.succeed(target.pop())
|
|
||||||
case "shift":
|
|
||||||
return Effect.succeed(target.shift())
|
|
||||||
case "splice": {
|
|
||||||
if (args.length === 0) return Effect.succeed(target.splice(0, 0))
|
|
||||||
const start = optNumber(args[0], "start") ?? 0
|
|
||||||
if (args.length === 1) return Effect.succeed(target.splice(start))
|
|
||||||
const deleteCount = optNumber(args[1], "delete count") ?? 0
|
|
||||||
const inserted = args.slice(2)
|
|
||||||
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result", node)
|
|
||||||
return Effect.succeed(target.splice(start, deleteCount, ...inserted))
|
|
||||||
}
|
|
||||||
case "fill": {
|
|
||||||
rejectCircularInsertion(target, args[0], "Array.fill result", node)
|
|
||||||
return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end")))
|
|
||||||
}
|
|
||||||
case "copyWithin":
|
|
||||||
return Effect.succeed(
|
|
||||||
target.copyWithin(
|
|
||||||
optNumber(args[0], "target index") ?? 0,
|
|
||||||
optNumber(args[1], "start") ?? 0,
|
|
||||||
optNumber(args[2], "end"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
case "keys":
|
|
||||||
return Effect.succeed(Array.from(target.keys()))
|
|
||||||
case "values":
|
|
||||||
return Effect.succeed([...target])
|
|
||||||
case "entries":
|
|
||||||
return Effect.succeed(Array.from(target.entries(), ([index, item]): Array<unknown> => [index, item]))
|
|
||||||
}
|
|
||||||
|
|
||||||
const apply = applyCollectionCallback(runner, args[0], `Array.${name}`, node)
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
// Fix iteration length while reading existing elements live.
|
|
||||||
const length = target.length
|
|
||||||
switch (name) {
|
|
||||||
case "map": {
|
|
||||||
const values: Array<unknown> = []
|
|
||||||
values.length = length
|
|
||||||
for (let index = 0; index < length; index += 1) {
|
|
||||||
if (!(index in target)) continue
|
|
||||||
values[index] = yield* apply([target[index], index, target])
|
|
||||||
}
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
case "flatMap": {
|
|
||||||
const values: Array<unknown> = []
|
|
||||||
for (let index = 0; index < length; index += 1) {
|
|
||||||
if (!(index in target)) continue
|
|
||||||
const mapped = yield* apply([target[index], index, target])
|
|
||||||
if (Array.isArray(mapped)) values.push(...mapped)
|
|
||||||
else values.push(mapped)
|
|
||||||
}
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
case "filter": {
|
|
||||||
const values: Array<unknown> = []
|
|
||||||
for (let index = 0; index < length; index += 1) {
|
|
||||||
if (!(index in target)) continue
|
|
||||||
const item = target[index]
|
|
||||||
if (yield* apply([item, index, target])) values.push(item)
|
|
||||||
}
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
case "find":
|
|
||||||
for (let index = 0; index < length; index += 1) {
|
|
||||||
const item = target[index]
|
|
||||||
if (yield* apply([item, index, target])) return item
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
case "findIndex":
|
|
||||||
for (let index = 0; index < length; index += 1) {
|
|
||||||
if (yield* apply([target[index], index, target])) return index
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
case "some":
|
|
||||||
for (let index = 0; index < length; index += 1) {
|
|
||||||
if (!(index in target)) continue
|
|
||||||
if (yield* apply([target[index], index, target])) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
case "every":
|
|
||||||
for (let index = 0; index < length; index += 1) {
|
|
||||||
if (!(index in target)) continue
|
|
||||||
if (!(yield* apply([target[index], index, target]))) return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
case "forEach":
|
|
||||||
for (let index = 0; index < length; index += 1) {
|
|
||||||
if (index in target) yield* apply([target[index], index, target])
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
case "reduce": {
|
|
||||||
let accumulator: unknown
|
|
||||||
let start: number
|
|
||||||
if (args.length >= 2) {
|
|
||||||
accumulator = args[1]
|
|
||||||
start = 0
|
|
||||||
} else {
|
|
||||||
if (length === 0)
|
|
||||||
throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node)
|
|
||||||
accumulator = target[0]
|
|
||||||
start = 1
|
|
||||||
}
|
|
||||||
for (let index = start; index < length; index += 1) {
|
|
||||||
if (!(index in target)) continue
|
|
||||||
accumulator = yield* apply([accumulator, target[index], index, target])
|
|
||||||
}
|
|
||||||
return accumulator
|
|
||||||
}
|
|
||||||
case "reduceRight": {
|
|
||||||
let accumulator: unknown
|
|
||||||
let start: number
|
|
||||||
if (args.length >= 2) {
|
|
||||||
accumulator = args[1]
|
|
||||||
start = length - 1
|
|
||||||
} else {
|
|
||||||
if (length === 0)
|
|
||||||
throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node)
|
|
||||||
accumulator = target[length - 1]
|
|
||||||
start = length - 2
|
|
||||||
}
|
|
||||||
for (let index = start; index >= 0; index -= 1) {
|
|
||||||
if (!(index in target)) continue
|
|
||||||
accumulator = yield* apply([accumulator, target[index], index, target])
|
|
||||||
}
|
|
||||||
return accumulator
|
|
||||||
}
|
|
||||||
case "findLast":
|
|
||||||
for (let index = length - 1; index >= 0; index -= 1) {
|
|
||||||
if (yield* apply([target[index], index, target])) return target[index]
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
case "findLastIndex":
|
|
||||||
for (let index = length - 1; index >= 0; index -= 1) {
|
|
||||||
if (yield* apply([target[index], index, target])) return index
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortArray = <R>(
|
|
||||||
runner: CallbackRunner<R>,
|
|
||||||
target: Array<unknown>,
|
|
||||||
comparator: unknown,
|
|
||||||
node: AstNode,
|
|
||||||
): Effect.Effect<Array<unknown>, unknown, R> => {
|
|
||||||
if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) {
|
|
||||||
throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node)
|
|
||||||
}
|
|
||||||
if (!(comparator instanceof CodeModeFunction)) {
|
|
||||||
return Effect.sync(() =>
|
|
||||||
[...target].sort((a, b) => {
|
|
||||||
const left = coerceToString(a)
|
|
||||||
const right = coerceToString(b)
|
|
||||||
return left < right ? -1 : left > right ? 1 : 0
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const mergeSort = (items: Array<unknown>): Effect.Effect<Array<unknown>, unknown, R> => {
|
|
||||||
if (items.length <= 1) return Effect.succeed(items)
|
|
||||||
const midpoint = Math.floor(items.length / 2)
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const left = yield* mergeSort(items.slice(0, midpoint))
|
|
||||||
const right = yield* mergeSort(items.slice(midpoint))
|
|
||||||
const merged: Array<unknown> = []
|
|
||||||
let leftIndex = 0
|
|
||||||
let rightIndex = 0
|
|
||||||
while (leftIndex < left.length && rightIndex < right.length) {
|
|
||||||
// Treat a NaN comparator result as equal to preserve stable ordering.
|
|
||||||
const order = coerceToNumber(yield* runner.invokeFunction(comparator, [left[leftIndex], right[rightIndex]]))
|
|
||||||
if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++])
|
|
||||||
else merged.push(right[rightIndex++])
|
|
||||||
}
|
|
||||||
return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const defined = target.filter((item) => item !== undefined)
|
|
||||||
const undefinedCount = target.length - defined.length
|
|
||||||
return Effect.map(mergeSort(defined), (items) => [...items, ...Array(undefinedCount).fill(undefined)])
|
|
||||||
}
|
|
||||||
@@ -76,6 +76,8 @@ export class PromiseInstanceMethodReference {
|
|||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The resolve/reject callables handed to a `new Promise(executor)` executor. `settle` closes
|
||||||
|
// over the promise's deferred and is first-settlement-wins; later calls are no-ops, as in JS.
|
||||||
export class PromiseCapabilityFunction {
|
export class PromiseCapabilityFunction {
|
||||||
constructor(readonly settle: (value: unknown) => void) {}
|
constructor(readonly settle: (value: unknown) => void) {}
|
||||||
}
|
}
|
||||||
@@ -112,8 +114,6 @@ export class UriFunction {
|
|||||||
constructor(readonly name: "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent") {}
|
constructor(readonly name: "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent") {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SearchFunction {}
|
|
||||||
|
|
||||||
export class ProgramThrow {
|
export class ProgramThrow {
|
||||||
constructor(readonly value: unknown) {}
|
constructor(readonly value: unknown) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,336 +0,0 @@
|
|||||||
import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
|
|
||||||
import type { Diagnostic } from "../codemode.js"
|
|
||||||
import type { SafeObject } from "../tool-runtime.js"
|
|
||||||
import {
|
|
||||||
type AstNode,
|
|
||||||
CodeModeFunction,
|
|
||||||
CoercionFunction,
|
|
||||||
InterpreterRuntimeError,
|
|
||||||
ProgramThrow,
|
|
||||||
PromiseCapabilityFunction,
|
|
||||||
PromiseInstanceMethodReference,
|
|
||||||
PromiseMethodReference,
|
|
||||||
UriFunction,
|
|
||||||
} from "./model.js"
|
|
||||||
import { caughtErrorValue, normalizeError } from "./errors.js"
|
|
||||||
import { applyCollectionCallback, type CallbackRunner } from "./methods.js"
|
|
||||||
import { typeofValue } from "./references.js"
|
|
||||||
import { spreadItems } from "../stdlib/collections.js"
|
|
||||||
import { createAggregateErrorValue } from "../stdlib/value.js"
|
|
||||||
import { SandboxPromise } from "../values.js"
|
|
||||||
|
|
||||||
// Observation only controls rejection reporting; program completion interrupts all promise work.
|
|
||||||
export class PromiseRuntime<R> {
|
|
||||||
private readonly active = new Set<SandboxPromise>()
|
|
||||||
private readonly ids = new WeakMap<SandboxPromise, number>()
|
|
||||||
private readonly observed = new WeakSet<SandboxPromise>()
|
|
||||||
private readonly failures = new Map<number, Diagnostic>()
|
|
||||||
private nextID = 0
|
|
||||||
|
|
||||||
constructor(private readonly scope: Scope.Scope) {}
|
|
||||||
|
|
||||||
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
|
|
||||||
return Effect.suspend(() => {
|
|
||||||
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
|
|
||||||
const id = this.nextID++
|
|
||||||
return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => {
|
|
||||||
const promise = new SandboxPromise(fiber)
|
|
||||||
this.active.add(promise)
|
|
||||||
this.ids.set(promise, id)
|
|
||||||
fiber.addObserver((exit) => {
|
|
||||||
this.active.delete(promise)
|
|
||||||
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) || this.observed.has(promise)) {
|
|
||||||
this.ids.delete(promise)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const failure = normalizeError(Cause.squash(exit.cause))
|
|
||||||
this.failures.set(id, {
|
|
||||||
...failure,
|
|
||||||
message: `Unhandled rejection from an un-awaited promise: ${failure.message}`,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return promise
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observation must be recorded when responsibility transfers, before the consumer fiber runs.
|
|
||||||
markObserved(promise: SandboxPromise): void {
|
|
||||||
this.observed.add(promise)
|
|
||||||
const id = this.ids.get(promise)
|
|
||||||
this.ids.delete(promise)
|
|
||||||
if (id !== undefined) this.failures.delete(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
await(promise: SandboxPromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
|
|
||||||
return Fiber.await(promise.fiber)
|
|
||||||
}
|
|
||||||
|
|
||||||
diagnostics(): Array<Diagnostic> {
|
|
||||||
return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-check because a straggler can create promises before its interruption lands.
|
|
||||||
interrupt(): Effect.Effect<Array<Diagnostic>> {
|
|
||||||
const self = this
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
while (self.active.size > 0) {
|
|
||||||
yield* Fiber.interruptAll([...self.active].map((promise) => promise.fiber))
|
|
||||||
}
|
|
||||||
return self.diagnostics()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const selfResolutionError = (node?: AstNode): InterpreterRuntimeError =>
|
|
||||||
new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError")
|
|
||||||
|
|
||||||
export const invokePromiseMethod = <R>(
|
|
||||||
runner: CallbackRunner<R>,
|
|
||||||
promises: PromiseRuntime<R>,
|
|
||||||
ref: PromiseMethodReference,
|
|
||||||
args: Array<unknown>,
|
|
||||||
node: AstNode,
|
|
||||||
): Effect.Effect<unknown, unknown, R> => {
|
|
||||||
if (ref.name === "resolve") {
|
|
||||||
const value = args[0]
|
|
||||||
return value instanceof SandboxPromise ? Effect.succeed(value) : promises.create(Effect.succeed(value))
|
|
||||||
}
|
|
||||||
if (ref.name === "reject") {
|
|
||||||
return promises.create(Effect.fail(new ProgramThrow(args[0])))
|
|
||||||
}
|
|
||||||
|
|
||||||
const spread = spreadItems(args[0])
|
|
||||||
if (spread === undefined) {
|
|
||||||
return promises.create(
|
|
||||||
Effect.fail(
|
|
||||||
new InterpreterRuntimeError(
|
|
||||||
`Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
|
|
||||||
node,
|
|
||||||
).as("TypeError"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const items = Array.from(spread)
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
if (item instanceof SandboxPromise) promises.markObserved(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (ref.name) {
|
|
||||||
case "all": {
|
|
||||||
const observations = items.map((item) =>
|
|
||||||
item instanceof SandboxPromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item),
|
|
||||||
)
|
|
||||||
return promises.create(settleAfterTurn(Effect.all(observations, { concurrency: "unbounded" })))
|
|
||||||
}
|
|
||||||
case "allSettled": {
|
|
||||||
const observations = items.map((item) =>
|
|
||||||
item instanceof SandboxPromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
|
|
||||||
)
|
|
||||||
return promises.create(
|
|
||||||
settleAfterTurn(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const outcomes: Array<unknown> = []
|
|
||||||
for (const observation of observations) {
|
|
||||||
const exit = yield* observation
|
|
||||||
if (Exit.isSuccess(exit)) {
|
|
||||||
outcomes.push(
|
|
||||||
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (Cause.hasInterruptsOnly(exit.cause)) {
|
|
||||||
// Teardown interruption is not a program-level rejection.
|
|
||||||
return yield* Effect.failCause(exit.cause)
|
|
||||||
}
|
|
||||||
outcomes.push(
|
|
||||||
Object.assign(Object.create(null) as SafeObject, {
|
|
||||||
status: "rejected",
|
|
||||||
reason: caughtErrorValue(Cause.squash(exit.cause)),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return outcomes
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case "race": {
|
|
||||||
if (items.length === 0) {
|
|
||||||
return promises.create(
|
|
||||||
Effect.fail(
|
|
||||||
new InterpreterRuntimeError(
|
|
||||||
"Promise.race([]) would never settle; provide at least one promise or value.",
|
|
||||||
node,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const observations = items.map((item) =>
|
|
||||||
item instanceof SandboxPromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
|
|
||||||
)
|
|
||||||
return promises.create(settleAfterTurn(Effect.flatten(Effect.raceAll(observations))))
|
|
||||||
}
|
|
||||||
case "any": {
|
|
||||||
const flipped = items.map((item) =>
|
|
||||||
item instanceof SandboxPromise
|
|
||||||
? Effect.flatMap(promises.await(item), (exit) => {
|
|
||||||
if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value))
|
|
||||||
if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause)
|
|
||||||
return Effect.succeed(caughtErrorValue(Cause.squash(exit.cause)))
|
|
||||||
})
|
|
||||||
: Effect.fail(new PromiseAnyFulfilled(item)),
|
|
||||||
)
|
|
||||||
const body = Effect.all(flipped, { concurrency: "unbounded" }).pipe(
|
|
||||||
Effect.flatMap((reasons) =>
|
|
||||||
Effect.fail(new ProgramThrow(createAggregateErrorValue(reasons, "All promises were rejected"))),
|
|
||||||
),
|
|
||||||
Effect.catch((error) =>
|
|
||||||
error instanceof PromiseAnyFulfilled ? Effect.succeed(error.value) : Effect.fail(error),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return promises.create(settleAfterTurn(body))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const invokePromiseInstanceMethod = <R>(
|
|
||||||
runner: CallbackRunner<R>,
|
|
||||||
promises: PromiseRuntime<R>,
|
|
||||||
ref: PromiseInstanceMethodReference,
|
|
||||||
args: Array<unknown>,
|
|
||||||
node: AstNode,
|
|
||||||
): Effect.Effect<SandboxPromise, never, R> => {
|
|
||||||
const method = `Promise.prototype.${ref.name}`
|
|
||||||
promises.markObserved(ref.promise)
|
|
||||||
if (ref.name === "finally") {
|
|
||||||
return chainFinally(runner, promises, ref.promise, reactionHandler(args[0], method, node), method, node)
|
|
||||||
}
|
|
||||||
const onFulfilled = ref.name === "then" ? reactionHandler(args[0], method, node) : undefined
|
|
||||||
const onRejected = reactionHandler(ref.name === "then" ? args[1] : args[0], method, node)
|
|
||||||
return chainReaction(runner, promises, ref.promise, onFulfilled, onRejected, method, node)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const constructPromise = <R>(
|
|
||||||
runner: CallbackRunner<R>,
|
|
||||||
promises: PromiseRuntime<R>,
|
|
||||||
executor: unknown,
|
|
||||||
node: AstNode,
|
|
||||||
): Effect.Effect<SandboxPromise, unknown, R> => {
|
|
||||||
if (!(executor instanceof CodeModeFunction)) {
|
|
||||||
throw new InterpreterRuntimeError(
|
|
||||||
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
|
|
||||||
node,
|
|
||||||
).as("TypeError")
|
|
||||||
}
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const deferred = Deferred.makeUnsafe<unknown, unknown>()
|
|
||||||
const box: { own?: SandboxPromise } = {}
|
|
||||||
const promise = yield* promises.create(
|
|
||||||
Effect.flatMap(Deferred.await(deferred), (value) => {
|
|
||||||
if (!(value instanceof SandboxPromise)) return Effect.succeed(value)
|
|
||||||
if (value === box.own) return Effect.fail(selfResolutionError(node))
|
|
||||||
return runner.settlePromise(value)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
box.own = promise
|
|
||||||
const resolve = new PromiseCapabilityFunction((value) => {
|
|
||||||
Deferred.doneUnsafe(deferred, Exit.succeed(value))
|
|
||||||
})
|
|
||||||
const reject = new PromiseCapabilityFunction((value) => {
|
|
||||||
Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(value)))
|
|
||||||
})
|
|
||||||
const executed = yield* Effect.exit(runner.invokeFunction(executor, [resolve, reject]))
|
|
||||||
if (!Exit.isSuccess(executed)) {
|
|
||||||
if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause)
|
|
||||||
Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause)))
|
|
||||||
}
|
|
||||||
return promise
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settle one reaction turn after the deciding member, after its existing reactions.
|
|
||||||
const settleAfterTurn = <A, E, R>(body: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
|
||||||
Effect.flatMap(Effect.exit(body), (exit) => Effect.andThen(Effect.yieldNow, exit))
|
|
||||||
|
|
||||||
class PromiseAnyFulfilled {
|
|
||||||
constructor(readonly value: unknown) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReactionHandler = CodeModeFunction | CoercionFunction | UriFunction | PromiseCapabilityFunction
|
|
||||||
|
|
||||||
const reactionHandler = (value: unknown, method: string, node: AstNode): ReactionHandler | undefined => {
|
|
||||||
if (
|
|
||||||
value instanceof CodeModeFunction ||
|
|
||||||
value instanceof CoercionFunction ||
|
|
||||||
value instanceof UriFunction ||
|
|
||||||
value instanceof PromiseCapabilityFunction
|
|
||||||
) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
if (typeofValue(value) === "function") {
|
|
||||||
throw new InterpreterRuntimeError(
|
|
||||||
`${method} handlers must be plain functions; wrap other callables in an arrow function, e.g. (value) => tools.ns.tool(value).`,
|
|
||||||
node,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// Teardown bypasses handlers; settled reactions yield once so handlers never run inline.
|
|
||||||
const reactionExit = <R>(
|
|
||||||
promises: PromiseRuntime<R>,
|
|
||||||
source: SandboxPromise,
|
|
||||||
): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const exit = yield* promises.await(source)
|
|
||||||
if (!Exit.isSuccess(exit) && Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause)
|
|
||||||
yield* Effect.yieldNow
|
|
||||||
return exit
|
|
||||||
})
|
|
||||||
|
|
||||||
const chainReaction = <R>(
|
|
||||||
runner: CallbackRunner<R>,
|
|
||||||
promises: PromiseRuntime<R>,
|
|
||||||
source: SandboxPromise,
|
|
||||||
onFulfilled: ReactionHandler | undefined,
|
|
||||||
onRejected: ReactionHandler | undefined,
|
|
||||||
method: string,
|
|
||||||
node: AstNode,
|
|
||||||
): Effect.Effect<SandboxPromise, never, R> => {
|
|
||||||
const box: { derived?: SandboxPromise } = {}
|
|
||||||
const body = Effect.gen(function* () {
|
|
||||||
const exit = yield* reactionExit(promises, source)
|
|
||||||
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
|
|
||||||
if (handler === undefined) return yield* exit
|
|
||||||
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
|
|
||||||
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
|
|
||||||
if (result === box.derived) return yield* Effect.fail(selfResolutionError(node))
|
|
||||||
if (result instanceof SandboxPromise) return yield* runner.settlePromise(result)
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
return Effect.map(promises.create(body), (derived) => {
|
|
||||||
box.derived = derived
|
|
||||||
return derived
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const chainFinally = <R>(
|
|
||||||
runner: CallbackRunner<R>,
|
|
||||||
promises: PromiseRuntime<R>,
|
|
||||||
source: SandboxPromise,
|
|
||||||
cleanup: ReactionHandler | undefined,
|
|
||||||
method: string,
|
|
||||||
node: AstNode,
|
|
||||||
): Effect.Effect<SandboxPromise, never, R> =>
|
|
||||||
promises.create(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const exit = yield* reactionExit(promises, source)
|
|
||||||
if (cleanup !== undefined) {
|
|
||||||
const result = yield* applyCollectionCallback(runner, cleanup, method, node)([])
|
|
||||||
if (result instanceof SandboxPromise) yield* runner.settlePromise(result)
|
|
||||||
}
|
|
||||||
return yield* exit
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import {
|
|
||||||
type AstNode,
|
|
||||||
CodeModeFunction,
|
|
||||||
CoercionFunction,
|
|
||||||
ErrorConstructorReference,
|
|
||||||
GlobalMethodReference,
|
|
||||||
GlobalNamespace,
|
|
||||||
InterpreterRuntimeError,
|
|
||||||
IntrinsicReference,
|
|
||||||
PromiseCapabilityFunction,
|
|
||||||
PromiseInstanceMethodReference,
|
|
||||||
PromiseMethodReference,
|
|
||||||
PromiseNamespace,
|
|
||||||
SearchFunction,
|
|
||||||
UriFunction,
|
|
||||||
} from "./model.js"
|
|
||||||
import { ToolReference } from "../tool-runtime.js"
|
|
||||||
import { isSandboxValue, SandboxPromise } from "../values.js"
|
|
||||||
|
|
||||||
export const isRuntimeReference = (value: unknown): boolean =>
|
|
||||||
value instanceof CodeModeFunction ||
|
|
||||||
value instanceof ToolReference ||
|
|
||||||
value instanceof IntrinsicReference ||
|
|
||||||
value instanceof GlobalNamespace ||
|
|
||||||
value instanceof GlobalMethodReference ||
|
|
||||||
value instanceof PromiseNamespace ||
|
|
||||||
value instanceof PromiseMethodReference ||
|
|
||||||
value instanceof PromiseInstanceMethodReference ||
|
|
||||||
value instanceof SandboxPromise ||
|
|
||||||
value instanceof CoercionFunction ||
|
|
||||||
value instanceof UriFunction ||
|
|
||||||
value instanceof SearchFunction ||
|
|
||||||
value instanceof PromiseCapabilityFunction ||
|
|
||||||
value instanceof ErrorConstructorReference ||
|
|
||||||
isSandboxValue(value)
|
|
||||||
|
|
||||||
export const containsRuntimeReference = (value: unknown, seen = new Set<object>()): boolean => {
|
|
||||||
if (isRuntimeReference(value)) return true
|
|
||||||
if (value === null || typeof value !== "object") return false
|
|
||||||
if (seen.has(value)) return false
|
|
||||||
seen.add(value)
|
|
||||||
const contains = Array.isArray(value)
|
|
||||||
? value.some((item) => containsRuntimeReference(item, seen))
|
|
||||||
: Object.values(value).some((item) => containsRuntimeReference(item, seen))
|
|
||||||
seen.delete(value)
|
|
||||||
return contains
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sandbox values are data here, not opaque interpreter references.
|
|
||||||
export const containsOpaqueReference = (value: unknown, seen = new Set<object>()): boolean => {
|
|
||||||
if (isSandboxValue(value)) return false
|
|
||||||
if (isRuntimeReference(value)) return true
|
|
||||||
if (value === null || typeof value !== "object") return false
|
|
||||||
if (seen.has(value)) return false
|
|
||||||
seen.add(value)
|
|
||||||
const contains = Array.isArray(value)
|
|
||||||
? value.some((item) => containsOpaqueReference(item, seen))
|
|
||||||
: Object.values(value).some((item) => containsOpaqueReference(item, seen))
|
|
||||||
seen.delete(value)
|
|
||||||
return contains
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject cycles before mutation so later boundary walks remain safe.
|
|
||||||
export const rejectCircularInsertion = (
|
|
||||||
container: object,
|
|
||||||
value: unknown,
|
|
||||||
label: string,
|
|
||||||
node: AstNode,
|
|
||||||
seen = new Set<object>(),
|
|
||||||
): void => {
|
|
||||||
if (value === container)
|
|
||||||
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
|
|
||||||
if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return
|
|
||||||
seen.add(value)
|
|
||||||
const items = Array.isArray(value) ? value : Object.values(value)
|
|
||||||
for (const item of items) rejectCircularInsertion(container, item, label, node, seen)
|
|
||||||
seen.delete(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const typeofValue = (value: unknown): string => {
|
|
||||||
if (
|
|
||||||
value instanceof CodeModeFunction ||
|
|
||||||
value instanceof CoercionFunction ||
|
|
||||||
value instanceof IntrinsicReference ||
|
|
||||||
value instanceof GlobalMethodReference ||
|
|
||||||
value instanceof PromiseMethodReference ||
|
|
||||||
value instanceof PromiseInstanceMethodReference ||
|
|
||||||
value instanceof PromiseNamespace ||
|
|
||||||
value instanceof PromiseCapabilityFunction ||
|
|
||||||
value instanceof ErrorConstructorReference
|
|
||||||
)
|
|
||||||
return "function"
|
|
||||||
if (value instanceof UriFunction || value instanceof SearchFunction) return "function"
|
|
||||||
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
|
|
||||||
if (value instanceof GlobalNamespace) {
|
|
||||||
return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function"
|
|
||||||
}
|
|
||||||
return typeof value
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,84 +0,0 @@
|
|||||||
import { type AstNode, type Binding, InterpreterRuntimeError } from "./model.js"
|
|
||||||
|
|
||||||
export class ScopeStack {
|
|
||||||
private readonly scopes: Array<Map<string, Binding>>
|
|
||||||
|
|
||||||
constructor(scopes: Array<Map<string, Binding>>) {
|
|
||||||
this.scopes = scopes
|
|
||||||
}
|
|
||||||
|
|
||||||
declare(name: string, value: unknown, mutable: boolean, node: AstNode): void {
|
|
||||||
const scope = this.current()
|
|
||||||
|
|
||||||
const existing = scope.get(name)
|
|
||||||
if (existing && existing.initialized !== false) {
|
|
||||||
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
|
|
||||||
}
|
|
||||||
|
|
||||||
scope.set(name, { mutable, value, initialized: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
get(name: string, node: AstNode): unknown {
|
|
||||||
const binding = this.resolve(name)
|
|
||||||
|
|
||||||
if (!binding) {
|
|
||||||
throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (binding.initialized === false) {
|
|
||||||
throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node).as("ReferenceError")
|
|
||||||
}
|
|
||||||
|
|
||||||
return binding.value
|
|
||||||
}
|
|
||||||
|
|
||||||
set(name: string, value: unknown, node: AstNode): unknown {
|
|
||||||
const binding = this.resolve(name)
|
|
||||||
|
|
||||||
if (!binding) {
|
|
||||||
throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!binding.mutable) {
|
|
||||||
throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError")
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.value = value
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(name: string): Binding | undefined {
|
|
||||||
for (let index = this.scopes.length - 1; index >= 0; index -= 1) {
|
|
||||||
const scope = this.scopes[index]
|
|
||||||
const binding = scope?.get(name)
|
|
||||||
|
|
||||||
if (binding) {
|
|
||||||
return binding
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
current(): Map<string, Binding> {
|
|
||||||
const scope = this.scopes[this.scopes.length - 1]
|
|
||||||
|
|
||||||
if (!scope) {
|
|
||||||
throw new InterpreterRuntimeError("Interpreter scope stack is empty.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return scope
|
|
||||||
}
|
|
||||||
|
|
||||||
push(scope: Map<string, Binding> = new Map()): void {
|
|
||||||
this.scopes.push(scope)
|
|
||||||
}
|
|
||||||
|
|
||||||
pop(): void {
|
|
||||||
this.scopes.pop()
|
|
||||||
}
|
|
||||||
|
|
||||||
capture(): Array<Map<string, Binding>> {
|
|
||||||
return this.scopes.slice()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,8 +31,10 @@ export type {
|
|||||||
} from "./types.js"
|
} from "./types.js"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds one CodeMode tool per representable OpenAPI 3.x operation. Auth remains host-side,
|
* Builds a CodeMode tool subtree from an OpenAPI 3.x document, one tool per
|
||||||
* tools require `HttpClient.HttpClient`, and unrepresentable operations land in `skipped`.
|
* operation. Auth is resolved host-side via `auth.resolve` and never
|
||||||
|
* model-visible. Tools require `HttpClient.HttpClient`; unrepresentable
|
||||||
|
* operations land in `skipped`.
|
||||||
*/
|
*/
|
||||||
export const fromSpec = (options: Options): Result => {
|
export const fromSpec = (options: Options): Result => {
|
||||||
const document = options.spec
|
const document = options.spec
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const buildRequest = (
|
|||||||
input: Readonly<Record<string, unknown>>,
|
input: Readonly<Record<string, unknown>>,
|
||||||
): Effect.Effect<HttpClientRequest.HttpClientRequest, ToolError> =>
|
): Effect.Effect<HttpClientRequest.HttpClientRequest, ToolError> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
// Validate model input before auth resolution can refresh credentials.
|
// Validate every model-controlled value before auth resolution, which may refresh tokens.
|
||||||
const url = buildUrl(plan, input)
|
const url = buildUrl(plan, input)
|
||||||
if (url instanceof ToolError) return yield* Effect.fail(url)
|
if (url instanceof ToolError) return yield* Effect.fail(url)
|
||||||
const missing = plan.fields.find(
|
const missing = plan.fields.find(
|
||||||
@@ -77,6 +77,7 @@ const buildRequest = (
|
|||||||
request = serialized
|
request = serialized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Host headers first, then declared header parameters.
|
||||||
request = HttpClientRequest.setHeaders(request, plan.headers)
|
request = HttpClientRequest.setHeaders(request, plan.headers)
|
||||||
for (const field of plan.fields) {
|
for (const field of plan.fields) {
|
||||||
if (field.location !== "header") continue
|
if (field.location !== "header") continue
|
||||||
@@ -168,7 +169,7 @@ const applyCredentials = (
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (credential.type === "basic") {
|
if (credential.type === "basic") {
|
||||||
// Basic auth credentials are UTF-8; btoa rejects non-Latin-1 input.
|
// Buffer instead of btoa: btoa throws on non-Latin-1 credentials.
|
||||||
const duplicate = add(
|
const duplicate = add(
|
||||||
"header",
|
"header",
|
||||||
"authorization",
|
"authorization",
|
||||||
@@ -182,6 +183,7 @@ const applyCredentials = (
|
|||||||
if (duplicate !== undefined) return duplicate
|
if (duplicate !== undefined) return duplicate
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// apiKey: the carrier comes from the scheme declaration.
|
||||||
if (definition.type !== "apiKey") {
|
if (definition.type !== "apiKey") {
|
||||||
return toolError(
|
return toolError(
|
||||||
`Security scheme '${name}' is not an apiKey scheme; resolve a bearer, basic, or header credential for it.`,
|
`Security scheme '${name}' is not an apiKey scheme; resolve a bearer, basic, or header credential for it.`,
|
||||||
@@ -210,7 +212,8 @@ const buildUrl = (plan: Plan, input: Readonly<Record<string, unknown>>): string
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (fieldValue instanceof ToolError) return fieldValue
|
if (fieldValue instanceof ToolError) return fieldValue
|
||||||
// URL normalization collapses encoded `.` and `..`, which could retarget the request.
|
// '.'/'..' survive encoding and URL normalization collapses them, letting a
|
||||||
|
// model-supplied value retarget the request to a different endpoint.
|
||||||
if (fieldValue === "" || fieldValue === "." || fieldValue === "..") {
|
if (fieldValue === "" || fieldValue === "." || fieldValue === "..") {
|
||||||
return toolError(`Invalid path parameter '${field.inputName}'.`)
|
return toolError(`Invalid path parameter '${field.inputName}'.`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ const asArray = (value: unknown): ReadonlyArray<unknown> => (Array.isArray(value
|
|||||||
export const nonEmptyString = (value: unknown): string | undefined =>
|
export const nonEmptyString = (value: unknown): string | undefined =>
|
||||||
typeof value === "string" && value !== "" ? value : undefined
|
typeof value === "string" && value !== "" ? value : undefined
|
||||||
|
|
||||||
// Spec- and model-controlled keys must not resolve inherited properties.
|
// Guards record lookups keyed by spec- or model-controlled names against
|
||||||
|
// prototype-inherited values (e.g. a parameter named `toString`).
|
||||||
export const own = <T>(record: Readonly<Record<string, T>>, key: string): T | undefined =>
|
export const own = <T>(record: Readonly<Record<string, T>>, key: string): T | undefined =>
|
||||||
Object.hasOwn(record, key) ? record[key] : undefined
|
Object.hasOwn(record, key) ? record[key] : undefined
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ const operationParameters = (
|
|||||||
pathItem: Record<string, unknown>,
|
pathItem: Record<string, unknown>,
|
||||||
operation: Record<string, unknown>,
|
operation: Record<string, unknown>,
|
||||||
): Parsed<ReadonlyArray<PlannedField>> => {
|
): Parsed<ReadonlyArray<PlannedField>> => {
|
||||||
// OpenAPI operation parameters override path parameters with the same location and name.
|
// Operation-level parameters override path-level ones sharing (location, name).
|
||||||
const declared = new Map<
|
const declared = new Map<
|
||||||
string,
|
string,
|
||||||
{ readonly name: string; readonly location: string; readonly parameter: Record<string, unknown> }
|
{ readonly name: string; readonly location: string; readonly parameter: Record<string, unknown> }
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ export type SecurityScheme =
|
|||||||
| { readonly type: "openIdConnect" }
|
| { readonly type: "openIdConnect" }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Credential material returned by a host auth resolver. `apiKey` uses the scheme's carrier;
|
* Credential material returned by a host auth resolver. The carrier for `apiKey`
|
||||||
* `header` supports nonstandard schemes.
|
* comes from the scheme definition, not the credential. `header` is the escape
|
||||||
|
* hatch for nonstandard schemes.
|
||||||
*/
|
*/
|
||||||
export type Credential =
|
export type Credential =
|
||||||
| { readonly type: "bearer"; readonly token: string }
|
| { readonly type: "bearer"; readonly token: string }
|
||||||
@@ -32,7 +33,9 @@ export type Credential =
|
|||||||
| { readonly type: "header"; readonly name: string; readonly value: string }
|
| { readonly type: "header"; readonly name: string; readonly value: string }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves credentials at call time. `undefined` tries the next OR alternative; failure aborts.
|
* Resolves credential material for one named security scheme at call time.
|
||||||
|
* `undefined` means unavailable, try the next OR alternative; a failure aborts
|
||||||
|
* the call rather than falling through.
|
||||||
*/
|
*/
|
||||||
export type AuthResolver = (context: {
|
export type AuthResolver = (context: {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
@@ -71,7 +74,9 @@ export type Parsed<T> = { readonly ok: true; readonly value: T } | { readonly ok
|
|||||||
export type InputLocation = "path" | "query" | "header" | "body"
|
export type InputLocation = "path" | "query" | "header" | "body"
|
||||||
|
|
||||||
export type InputField = {
|
export type InputField = {
|
||||||
|
/** Model-visible field name after cross-location collision handling. */
|
||||||
readonly inputName: string
|
readonly inputName: string
|
||||||
|
/** Original parameter or body-property name used on the wire. */
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly location: InputLocation
|
readonly location: InputLocation
|
||||||
readonly required: boolean
|
readonly required: boolean
|
||||||
@@ -87,6 +92,7 @@ export type OperationInput = {
|
|||||||
readonly body: Body | undefined
|
readonly body: Body | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One OR alternative: scheme name -> required scopes. Empty object = unauthenticated is acceptable. */
|
||||||
export type SecurityRequirement = Readonly<Record<string, ReadonlyArray<string>>>
|
export type SecurityRequirement = Readonly<Record<string, ReadonlyArray<string>>>
|
||||||
|
|
||||||
export type Plan = {
|
export type Plan = {
|
||||||
|
|||||||
@@ -1,122 +1,4 @@
|
|||||||
import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js"
|
|
||||||
import { copyIn, copyOut } from "../tool-runtime.js"
|
|
||||||
import {
|
|
||||||
isSandboxValue,
|
|
||||||
SandboxDate,
|
|
||||||
SandboxMap,
|
|
||||||
SandboxPromise,
|
|
||||||
SandboxRegExp,
|
|
||||||
SandboxSet,
|
|
||||||
SandboxURL,
|
|
||||||
SandboxURLSearchParams,
|
|
||||||
} from "../values.js"
|
|
||||||
import { boundedData, coerceToString } from "./value.js"
|
|
||||||
|
|
||||||
export const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"])
|
export const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"])
|
||||||
|
|
||||||
const MAX_CONSOLE_DEPTH = 32
|
/** Console formatting recursion ceiling; deeper values render as "...". */
|
||||||
|
export const MAX_CONSOLE_DEPTH = 32
|
||||||
export const formatConsoleMessage = (name: string, args: Array<unknown>): string => {
|
|
||||||
if (name === "dir") return args.length === 0 ? "undefined" : formatConsoleArgument(args[0])
|
|
||||||
if (name === "table") return formatConsoleTable(args[0], args[1])
|
|
||||||
const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : ""
|
|
||||||
return `${prefix}${args.map((arg) => formatConsoleArgument(arg)).join(" ")}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatConsoleArgument = (value: unknown): string => {
|
|
||||||
if (value === undefined) return "undefined"
|
|
||||||
if (typeof value === "string") return value
|
|
||||||
return formatConsoleValue(value, new Set(), 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): string => {
|
|
||||||
if (value === null || value === undefined) return "null"
|
|
||||||
if (typeof value === "string") return JSON.stringify(value)
|
|
||||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
|
||||||
if (typeof value !== "object") return String(value)
|
|
||||||
if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]"
|
|
||||||
if (value instanceof SandboxDate) return coerceToString(value)
|
|
||||||
if (value instanceof SandboxRegExp) return coerceToString(value)
|
|
||||||
if (value instanceof SandboxURL) return coerceToString(value)
|
|
||||||
if (value instanceof SandboxURLSearchParams) return coerceToString(value)
|
|
||||||
if (depth > MAX_CONSOLE_DEPTH) return "..."
|
|
||||||
if (seen.has(value)) return "[Circular]"
|
|
||||||
if (value instanceof SandboxMap) {
|
|
||||||
seen.add(value)
|
|
||||||
try {
|
|
||||||
const entries = Array.from(value.map.entries(), ([key, item]): Array<unknown> => [key, item])
|
|
||||||
return `Map(${value.map.size}) ${formatConsoleValue(entries, seen, depth + 1)}`
|
|
||||||
} finally {
|
|
||||||
seen.delete(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (value instanceof SandboxSet) {
|
|
||||||
seen.add(value)
|
|
||||||
try {
|
|
||||||
return `Set(${value.set.size}) ${formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}`
|
|
||||||
} finally {
|
|
||||||
seen.delete(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isRuntimeReference(value)) return "[CodeMode reference]"
|
|
||||||
seen.add(value)
|
|
||||||
try {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return `[${value.map((item) => formatConsoleValue(item, seen, depth + 1)).join(",")}]`
|
|
||||||
}
|
|
||||||
return `{${Object.entries(value)
|
|
||||||
.map(([key, item]) => `${JSON.stringify(key)}:${formatConsoleValue(item, seen, depth + 1)}`)
|
|
||||||
.join(",")}}`
|
|
||||||
} finally {
|
|
||||||
seen.delete(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatConsoleTable = (value: unknown, columnsArgument: unknown): string => {
|
|
||||||
if (value === undefined) return "undefined"
|
|
||||||
if (containsOpaqueReference(value)) return "[CodeMode reference]"
|
|
||||||
const data = boundedData(value, "console.table argument")
|
|
||||||
const columns = consoleTableColumns(columnsArgument)
|
|
||||||
const rows = consoleTableRows(data, columns)
|
|
||||||
const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values))))
|
|
||||||
const header = ["(index)", ...keys].join("\t")
|
|
||||||
return [
|
|
||||||
header,
|
|
||||||
...rows.map((row) => [row.index, ...keys.map((key) => formatConsoleTableCell(row.values[key]))].join("\t")),
|
|
||||||
].join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
const consoleTableColumns = (value: unknown): ReadonlyArray<string> | undefined => {
|
|
||||||
if (value === undefined) return undefined
|
|
||||||
if (containsRuntimeReference(value)) return undefined
|
|
||||||
const columns = copyOut(copyIn(value, "console.table columns"), true)
|
|
||||||
return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const consoleTableRows = (
|
|
||||||
data: unknown,
|
|
||||||
columns: ReadonlyArray<string> | undefined,
|
|
||||||
): Array<{ readonly index: string; readonly values: Record<string, unknown> }> => {
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
return data.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) }))
|
|
||||||
}
|
|
||||||
if (data !== null && typeof data === "object" && !isSandboxValue(data)) {
|
|
||||||
return Object.entries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) }))
|
|
||||||
}
|
|
||||||
return [{ index: "0", values: { Value: data } }]
|
|
||||||
}
|
|
||||||
|
|
||||||
const consoleTableValues = (value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> => {
|
|
||||||
if (value !== null && typeof value === "object" && !Array.isArray(value) && !isSandboxValue(value)) {
|
|
||||||
const source = value as Record<string, unknown>
|
|
||||||
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]]))
|
|
||||||
return Object.fromEntries(Object.entries(source))
|
|
||||||
}
|
|
||||||
return { Value: value }
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatConsoleTableCell = (value: unknown): string => {
|
|
||||||
if (value === undefined) return ""
|
|
||||||
if (typeof value === "string") return value
|
|
||||||
return formatConsoleValue(value, new Set(), 0)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ import type { PromiseMethodName } from "../interpreter/model.js"
|
|||||||
|
|
||||||
export const promiseStatics = new Set<PromiseMethodName>(["all", "allSettled", "race", "any", "resolve", "reject"])
|
export const promiseStatics = new Set<PromiseMethodName>(["all", "allSettled", "race", "any", "resolve", "reject"])
|
||||||
|
|
||||||
|
/** Maximum number of eagerly forked tool calls that may run concurrently. */
|
||||||
export const TOOL_CALL_CONCURRENCY = 8
|
export const TOOL_CALL_CONCURRENCY = 8
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ export const stringMethods = new Set([
|
|||||||
"trim",
|
"trim",
|
||||||
"trimStart",
|
"trimStart",
|
||||||
"trimEnd",
|
"trimEnd",
|
||||||
|
"trimLeft",
|
||||||
|
"trimRight",
|
||||||
"split",
|
"split",
|
||||||
"slice",
|
"slice",
|
||||||
"substring",
|
"substring",
|
||||||
|
"substr",
|
||||||
"includes",
|
"includes",
|
||||||
"startsWith",
|
"startsWith",
|
||||||
"endsWith",
|
"endsWith",
|
||||||
|
|||||||
@@ -44,30 +44,36 @@ type ServicesOf<Tools, Depth extends ReadonlyArray<unknown>> = Depth["length"] e
|
|||||||
: ServicesOf<Tools[keyof Tools], [...Depth, unknown]>
|
: ServicesOf<Tools[keyof Tools], [...Depth, unknown]>
|
||||||
: never
|
: never
|
||||||
|
|
||||||
|
/** Minimal audit record retained for each admitted tool call. */
|
||||||
export type ToolCall = {
|
export type ToolCall = {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Decoded tool call observed immediately before tool execution. */
|
||||||
export type ToolCallStarted = {
|
export type ToolCallStarted = {
|
||||||
readonly index: number
|
readonly index: number
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly input: unknown
|
readonly input: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Completed tool call observed immediately after tool execution settles. */
|
||||||
export type ToolCallEnded = {
|
export type ToolCallEnded = {
|
||||||
readonly index: number
|
readonly index: number
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly input: unknown
|
readonly input: unknown
|
||||||
readonly durationMs: number
|
readonly durationMs: number
|
||||||
readonly outcome: "success" | "failure"
|
readonly outcome: "success" | "failure"
|
||||||
|
/** Model-safe failure message; present only when `outcome` is `"failure"`. */
|
||||||
readonly message?: string
|
readonly message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Non-throwing observation hooks fired around each admitted tool call. */
|
||||||
export type ToolCallHooks<R = never> = {
|
export type ToolCallHooks<R = never> = {
|
||||||
readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect<void, never, R>) | undefined
|
readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect<void, never, R>) | undefined
|
||||||
readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect<void, never, R>) | undefined
|
readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect<void, never, R>) | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Model-visible description of one schema-backed tool. */
|
||||||
export type ToolDescription = {
|
export type ToolDescription = {
|
||||||
readonly path: string
|
readonly path: string
|
||||||
readonly description: string
|
readonly description: string
|
||||||
@@ -76,6 +82,7 @@ export type ToolDescription = {
|
|||||||
|
|
||||||
export type SafeObject = Record<string, unknown>
|
export type SafeObject = Record<string, unknown>
|
||||||
|
|
||||||
|
const reservedNamespace = "$codemode"
|
||||||
const defaultCatalogBudget = 2_000
|
const defaultCatalogBudget = 2_000
|
||||||
const defaultSearchLimit = 10
|
const defaultSearchLimit = 10
|
||||||
const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
|
const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
|
||||||
@@ -107,6 +114,11 @@ export class ToolReference {
|
|||||||
constructor(readonly path: ReadonlyArray<string>) {}
|
constructor(readonly path: ReadonlyArray<string>) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum nesting depth for values crossing a data boundary. Fixed (not a configurable
|
||||||
|
* limit) purely because it produces a clearer diagnostic than a native stack-overflow
|
||||||
|
* RangeError would.
|
||||||
|
*/
|
||||||
const MAX_VALUE_DEPTH = 32
|
const MAX_VALUE_DEPTH = 32
|
||||||
|
|
||||||
export class ToolRuntimeError extends Error {
|
export class ToolRuntimeError extends Error {
|
||||||
@@ -141,7 +153,21 @@ const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
|
|||||||
|
|
||||||
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
|
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
|
||||||
|
|
||||||
// Checkpoint mode preserves sandbox values; boundary mode JSON-normalizes them.
|
/**
|
||||||
|
* Validates and copies a value against the plain-data contract (depth, circularity, plain
|
||||||
|
* objects only, blocked properties, data-only leaves).
|
||||||
|
*
|
||||||
|
* Two modes share the walk:
|
||||||
|
* - **Boundary** (`preserveSandboxValues` false, the default): the host<->sandbox boundary -
|
||||||
|
* final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize
|
||||||
|
* exactly as JSON.stringify would: Date/URL -> strings, the remaining value types -> {}.
|
||||||
|
* - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in
|
||||||
|
* codemode.ts): standard-library value instances pass through untouched (treated as leaves,
|
||||||
|
* contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and
|
||||||
|
* other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...).
|
||||||
|
*
|
||||||
|
* Both modes reject un-awaited promises with an await-hinting diagnostic.
|
||||||
|
*/
|
||||||
export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown =>
|
export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown =>
|
||||||
copyBounded(value, label, 0, new Set(), preserveSandboxValues)
|
copyBounded(value, label, 0, new Set(), preserveSandboxValues)
|
||||||
|
|
||||||
@@ -160,6 +186,10 @@ const copyBounded = (
|
|||||||
value === undefined ||
|
value === undefined ||
|
||||||
typeof value === "string" ||
|
typeof value === "string" ||
|
||||||
typeof value === "boolean" ||
|
typeof value === "boolean" ||
|
||||||
|
// NaN/Infinity are allowed to exist as in-sandbox intermediates (matching real JS and a real
|
||||||
|
// engine) so defensive guards like `Number.isNaN(x)` / `parseInt(x) || 0` can run. They are
|
||||||
|
// normalized to `null` when the value leaves the sandbox - see copyOut - exactly as
|
||||||
|
// JSON.stringify already does at any tool boundary.
|
||||||
typeof value === "number"
|
typeof value === "number"
|
||||||
) {
|
) {
|
||||||
return value
|
return value
|
||||||
@@ -169,6 +199,8 @@ const copyBounded = (
|
|||||||
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
|
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An un-awaited promise never crosses a data checkpoint as `{}`; the diagnostic tells the
|
||||||
|
// model exactly how to fix the program instead.
|
||||||
if (value instanceof SandboxPromise) {
|
if (value instanceof SandboxPromise) {
|
||||||
throw new ToolRuntimeError(
|
throw new ToolRuntimeError(
|
||||||
"InvalidDataValue",
|
"InvalidDataValue",
|
||||||
@@ -177,6 +209,9 @@ const copyBounded = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (preserveSandboxValues) {
|
if (preserveSandboxValues) {
|
||||||
|
// Intra-sandbox checkpoints keep sandbox value instances alive as leaves; their contents
|
||||||
|
// are never walked here (Map/Set members are validated where mutation happens, and the
|
||||||
|
// real boundary still serializes them below).
|
||||||
if (
|
if (
|
||||||
value instanceof SandboxDate ||
|
value instanceof SandboxDate ||
|
||||||
value instanceof SandboxRegExp ||
|
value instanceof SandboxRegExp ||
|
||||||
@@ -187,6 +222,8 @@ const copyBounded = (
|
|||||||
) {
|
) {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
// Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross
|
||||||
|
// the boundary first), but wrap them defensively rather than degrading to JSON forms.
|
||||||
if (value instanceof Date) return new SandboxDate(value.getTime())
|
if (value instanceof Date) return new SandboxDate(value.getTime())
|
||||||
if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags)
|
if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags)
|
||||||
if (value instanceof Map) {
|
if (value instanceof Map) {
|
||||||
@@ -205,6 +242,9 @@ const copyBounded = (
|
|||||||
if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value))
|
if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sandbox value types (and their host counterparts, which a host tool may legitimately
|
||||||
|
// return) serialize exactly as JSON.stringify would at the data boundary: Date/URL use
|
||||||
|
// toJSON(), while RegExp/Map/Set/URLSearchParams have no JSON form beyond {}.
|
||||||
if (value instanceof SandboxDate) {
|
if (value instanceof SandboxDate) {
|
||||||
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
|
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
|
||||||
}
|
}
|
||||||
@@ -235,7 +275,7 @@ const copyBounded = (
|
|||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues))
|
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues))
|
||||||
if (preserveSandboxValues) {
|
if (preserveSandboxValues) {
|
||||||
// Checkpoint copies retain array metadata that boundary copies omit.
|
// Array metadata is not serialized, but intra-sandbox copies must retain it.
|
||||||
for (const [key, item] of Object.entries(value)) {
|
for (const [key, item] of Object.entries(value)) {
|
||||||
if (Object.hasOwn(copied, key)) continue
|
if (Object.hasOwn(copied, key)) continue
|
||||||
if (isBlockedMember(key)) {
|
if (isBlockedMember(key)) {
|
||||||
@@ -266,6 +306,9 @@ const copyBounded = (
|
|||||||
|
|
||||||
export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
|
export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
|
||||||
if (value === undefined && undefinedAsNull) return null
|
if (value === undefined && undefinedAsNull) return null
|
||||||
|
// Normalize non-finite numbers to null as the value crosses out of the sandbox (final return
|
||||||
|
// and tool-call arguments both funnel through here), matching JSON semantics - NaN/Infinity
|
||||||
|
// have no JSON representation, so JSON.stringify would produce null anyway.
|
||||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -311,10 +354,18 @@ export type DiscoveryPlan = {
|
|||||||
|
|
||||||
export type SearchEntry = {
|
export type SearchEntry = {
|
||||||
readonly description: ToolDescription
|
readonly description: ToolDescription
|
||||||
|
/** Top-level namespace (first path segment), matched by the search `namespace` option. */
|
||||||
readonly namespace: string
|
readonly namespace: string
|
||||||
|
/** Lowercased path + description + input property names/descriptions, for substring matching. */
|
||||||
readonly searchText: string
|
readonly searchText: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a query into lowercased search terms. camelCase boundaries are split
|
||||||
|
* (`resolveLibrary` -> `resolve library`) and every non-alphanumeric character is a
|
||||||
|
* separator, so `resolve-library-id`, `resolveLibraryId`, and `resolve library id` all
|
||||||
|
* tokenize alike. Empties and the `*` wildcard are dropped.
|
||||||
|
*/
|
||||||
const tokenize = (query: string): Array<string> =>
|
const tokenize = (query: string): Array<string> =>
|
||||||
query
|
query
|
||||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||||
@@ -322,6 +373,13 @@ const tokenize = (query: string): Array<string> =>
|
|||||||
.split(/[^a-z0-9]+/)
|
.split(/[^a-z0-9]+/)
|
||||||
.filter((term) => term.length > 0 && term !== "*")
|
.filter((term) => term.length > 0 && term !== "*")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A term plus its naive singular variants (trailing "s"/"es" stripped), so a plural
|
||||||
|
* query term ("issues") still matches indexed text that only carries the singular
|
||||||
|
* ("issue"). Matching is one-directional substring containment, so the variants are
|
||||||
|
* needed only on the query side; scoring weights are unchanged - each field check
|
||||||
|
* passes when ANY form matches.
|
||||||
|
*/
|
||||||
const termForms = (term: string): Array<string> => {
|
const termForms = (term: string): Array<string> => {
|
||||||
const forms = [term]
|
const forms = [term]
|
||||||
if (term.endsWith("es") && term.length > 3) forms.push(term.slice(0, -2))
|
if (term.endsWith("es") && term.length > 3) forms.push(term.slice(0, -2))
|
||||||
@@ -343,6 +401,8 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
|
|||||||
request.namespace === undefined
|
request.namespace === undefined
|
||||||
? searchIndex
|
? searchIndex
|
||||||
: searchIndex.filter((entry) => entry.namespace === request.namespace)
|
: searchIndex.filter((entry) => entry.namespace === request.namespace)
|
||||||
|
// A query that names one tool path exactly (canonical path or rendered JavaScript
|
||||||
|
// expression) is a lookup, not a search: return that tool alone.
|
||||||
const trimmed = query.trim()
|
const trimmed = query.trim()
|
||||||
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
|
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
|
||||||
const exact =
|
const exact =
|
||||||
@@ -352,6 +412,9 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
|
|||||||
(entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
|
(entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
|
||||||
)
|
)
|
||||||
const terms = tokenize(query).map(termForms)
|
const terms = tokenize(query).map(termForms)
|
||||||
|
// Additive field-weighted scoring, summed across terms: exact path or path segment
|
||||||
|
// (20) > path substring (8) > description substring (4) > any searchable text,
|
||||||
|
// including input parameter names and descriptions (2).
|
||||||
const ranked =
|
const ranked =
|
||||||
exact !== undefined
|
exact !== undefined
|
||||||
? [exact]
|
? [exact]
|
||||||
@@ -389,12 +452,10 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const searchSignature = (() => {
|
const searchDescription = describeDefinition(`${reservedNamespace}.search`, makeSearchTool([]))
|
||||||
const definition = makeSearchTool([])
|
|
||||||
return `search(input: ${inputTypeScript(definition, true)}): ${outputTypeScript(definition, true)}`
|
|
||||||
})()
|
|
||||||
|
|
||||||
const catalogLine = (tool: ToolDescription) => {
|
const catalogLine = (tool: ToolDescription) => {
|
||||||
|
// Keep the tool description concise; the full schema documentation remains in the signature.
|
||||||
const line = tool.description.split("\n", 1)[0]!.trim()
|
const line = tool.description.split("\n", 1)[0]!.trim()
|
||||||
const description = line.length > 120 ? line.slice(0, 119) + "..." : line
|
const description = line.length > 120 ? line.slice(0, 119) + "..." : line
|
||||||
return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
|
return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
|
||||||
@@ -414,10 +475,27 @@ const toSearchEntry = <R>(path: string, definition: Definition<R>, description:
|
|||||||
.toLowerCase(),
|
.toLowerCase(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** The runtime search index over every described tool. Search is always registered. */
|
||||||
export const searchIndex = <R>(tools: HostTools<R>): ReadonlyArray<SearchEntry> =>
|
export const searchIndex = <R>(tools: HostTools<R>): ReadonlyArray<SearchEntry> =>
|
||||||
visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description))
|
visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description))
|
||||||
|
|
||||||
// Budget signatures round-robin so every namespace remains visible.
|
export const assertValidTools = <R>(tools: HostTools<R>): void => {
|
||||||
|
if (Object.hasOwn(tools, reservedNamespace)) {
|
||||||
|
throw new Error(`Tool namespace '${reservedNamespace}' is reserved for CodeMode discovery tools.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Budgeted catalog: every namespace is always listed with its tool count; full call
|
||||||
|
* signatures are inlined against the `catalogBudget` (estimated tokens,
|
||||||
|
* chars/4) round-robin across namespaces - in each round (namespaces alphabetical), every
|
||||||
|
* namespace still holding un-inlined tools attempts to place its next-cheapest line, and
|
||||||
|
* a namespace whose next line does not fit is done while the others keep going - so every
|
||||||
|
* namespace gets some representation before any namespace gets everything. The section
|
||||||
|
* states exactly how comprehensive it is - overall (COMPLETE vs PARTIAL) and per
|
||||||
|
* namespace. Namespace stub lines are never budgeted: every namespace appears with its
|
||||||
|
* tool count even at budget 0.
|
||||||
|
*/
|
||||||
export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
|
export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
|
||||||
if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
|
if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
|
||||||
throw new RangeError("discovery.catalogBudget must be a non-negative safe integer")
|
throw new RangeError("discovery.catalogBudget must be a non-negative safe integer")
|
||||||
@@ -434,6 +512,12 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
|||||||
}
|
}
|
||||||
const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right))
|
const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
|
||||||
|
// Select which signatures fit the budget before emitting, so the list can state
|
||||||
|
// exactly how comprehensive it is. Round-robin fairness: in each round (namespaces
|
||||||
|
// alphabetical), every namespace still holding un-inlined tools tries to place its
|
||||||
|
// next-cheapest line against the shared budget; a namespace whose next line does not
|
||||||
|
// fit is done - the others keep going - so every namespace gets some representation
|
||||||
|
// before any namespace gets everything.
|
||||||
const selections = ordered.map(([namespace, group]) => ({
|
const selections = ordered.map(([namespace, group]) => ({
|
||||||
namespace,
|
namespace,
|
||||||
picked: new Set<ToolDescription>(),
|
picked: new Set<ToolDescription>(),
|
||||||
@@ -465,17 +549,23 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
|||||||
|
|
||||||
const empty = described.length === 0
|
const empty = described.length === 0
|
||||||
|
|
||||||
|
// Section order is deliberate: workflow first (the top is the least likely part of a long
|
||||||
|
// description to be truncated or skimmed away), then rules, then syntax, with the budgeted
|
||||||
|
// catalog at the bottom. Example call forms use placeholders - never a real or fabricated
|
||||||
|
// tool name - and show both dot and bracket notation so non-identifier names are not normalized.
|
||||||
const intro = [
|
const intro = [
|
||||||
empty
|
empty
|
||||||
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime."
|
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime."
|
||||||
: complete
|
: complete
|
||||||
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed below; surrounding agent tools are not available."
|
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed below and internal runtime tools; surrounding agent tools are not available."
|
||||||
: "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed or searchable below; surrounding agent tools are not available.",
|
: "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed or searchable below and internal runtime tools; surrounding agent tools are not available.",
|
||||||
...(empty
|
...(empty
|
||||||
? []
|
? []
|
||||||
: ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
|
: ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE
|
||||||
|
// catalog already shows every signature, so step 1 picks from the list instead.
|
||||||
const workflow = empty
|
const workflow = empty
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
@@ -489,7 +579,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
|||||||
"3. Return only the fields you need from structured results; narrow unknown results before reading fields, and avoid returning large raw payloads.",
|
"3. Return only the fields you need from structured results; narrow unknown results before reading fields, and avoid returning large raw payloads.",
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
'1. If needed, discover tools with the built-in search function: `return search({ query: "<intent + key nouns>" })`.',
|
'1. If needed, discover tools: `return await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
|
||||||
"2. In the next execution, copy a returned path exactly, call it, and return only the needed fields.",
|
"2. In the next execution, copy a returned path exactly, call it, and return only the needed fields.",
|
||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
@@ -501,8 +591,8 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
|||||||
"## Rules",
|
"## Rules",
|
||||||
"",
|
"",
|
||||||
complete
|
complete
|
||||||
? "- Only Code Mode tools listed here are available; surrounding agent tools are not implicitly exposed."
|
? "- Only Code Mode tools listed here and internal runtime tools are available; surrounding agent tools are not implicitly exposed."
|
||||||
: "- Only Code Mode tools listed here or returned by the built-in `search` function are available; surrounding agent tools are not implicitly exposed.",
|
: "- Only Code Mode tools listed here or returned by `tools.$codemode.search` and internal runtime tools are available; surrounding agent tools are not implicitly exposed.",
|
||||||
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
|
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
|
||||||
"- A result typed `Promise<unknown>` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.",
|
"- A result typed `Promise<unknown>` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.",
|
||||||
'- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
|
'- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
|
||||||
@@ -511,7 +601,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
|||||||
...(complete
|
...(complete
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
'- Browse one namespace: `search({ query: "", namespace: "<name>" })`.',
|
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
|
||||||
"- If search returns `next`, repeat the same search with `offset: next.offset`.",
|
"- If search returns `next`, repeat the same search with `offset: next.offset`.",
|
||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
@@ -533,12 +623,14 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
|||||||
toolSection.push(
|
toolSection.push(
|
||||||
complete
|
complete
|
||||||
? "## Available tools (COMPLETE list - every tool is shown below with its full call signature)"
|
? "## Available tools (COMPLETE list - every tool is shown below with its full call signature)"
|
||||||
: `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with search(...))`,
|
: `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with tools.$codemode.search)`,
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
for (const [namespace, group] of ordered) {
|
for (const [namespace, group] of ordered) {
|
||||||
const picked = shown.get(namespace)!
|
const picked = shown.get(namespace)!
|
||||||
const count = `${group.length} tool${group.length === 1 ? "" : "s"}`
|
const count = `${group.length} tool${group.length === 1 ? "" : "s"}`
|
||||||
|
// Annotate only when a namespace is not fully shown, so a comprehensive
|
||||||
|
// namespace reads cleanly and a truncated one is unambiguous.
|
||||||
const label =
|
const label =
|
||||||
picked.size === group.length
|
picked.size === group.length
|
||||||
? count
|
? count
|
||||||
@@ -549,7 +641,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
|||||||
for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool))
|
for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool))
|
||||||
}
|
}
|
||||||
if (!complete) {
|
if (!complete) {
|
||||||
toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`)
|
toolSection.push("", "Search returns complete callable signatures:", `- ${searchDescription.signature}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,6 +653,13 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The enumerable names at one node of the callable tool tree - namespace names at the root,
|
||||||
|
* tool/namespace names below - powering `Object.keys(tools)` and `for...in` over tool
|
||||||
|
* references. A callable tool is a leaf and enumerates as `[]` (like `Object.keys` of a
|
||||||
|
* function in JS). An unknown path is an `UnknownTool` error pointing at the working
|
||||||
|
* discovery idioms, mirroring how calling an unknown tool fails.
|
||||||
|
*/
|
||||||
const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): ReadonlyArray<string> => {
|
const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): ReadonlyArray<string> => {
|
||||||
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
|
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
|
||||||
for (const segment of path) {
|
for (const segment of path) {
|
||||||
@@ -571,7 +670,7 @@ const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): Rea
|
|||||||
!Object.hasOwn(value, segment)
|
!Object.hasOwn(value, segment)
|
||||||
) {
|
) {
|
||||||
throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${path.join(".")}'.`, [
|
throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${path.join(".")}'.`, [
|
||||||
"Object.keys(tools) lists the available namespaces; search({ query }) finds described tools.",
|
"Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.",
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
|
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
|
||||||
@@ -591,7 +690,7 @@ const resolve = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): HostTool<
|
|||||||
!Object.hasOwn(value, segment)
|
!Object.hasOwn(value, segment)
|
||||||
) {
|
) {
|
||||||
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, [
|
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, [
|
||||||
"Use search({ query }) to find available described tools.",
|
"Use tools.$codemode.search({ query }) to find available described tools.",
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
|
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
|
||||||
@@ -608,20 +707,25 @@ export type ToolRuntime<R = never> = {
|
|||||||
readonly root: ToolReference
|
readonly root: ToolReference
|
||||||
readonly calls: Array<ToolCall>
|
readonly calls: Array<ToolCall>
|
||||||
readonly invoke: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
readonly invoke: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||||
readonly search: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
/** Enumerable namespace/tool names at one node of the callable tool tree; see `namespaceKeys`. */
|
||||||
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const make = <R>(
|
export const make = <R>(
|
||||||
tools: HostTools<R>,
|
tools: HostTools<R>,
|
||||||
|
/** Undefined means unlimited tool calls. */
|
||||||
maxToolCalls: number | undefined,
|
maxToolCalls: number | undefined,
|
||||||
searchIndex: ReadonlyArray<SearchEntry>,
|
searchIndex: ReadonlyArray<SearchEntry>,
|
||||||
hooks?: ToolCallHooks<R>,
|
hooks?: ToolCallHooks<R>,
|
||||||
): ToolRuntime<R> => {
|
): ToolRuntime<R> => {
|
||||||
const calls: Array<ToolCall> = []
|
const calls: Array<ToolCall> = []
|
||||||
const searchTool = makeSearchTool(searchIndex)
|
const callableTools = {
|
||||||
|
...tools,
|
||||||
|
[reservedNamespace]: { search: makeSearchTool(searchIndex) },
|
||||||
|
}
|
||||||
|
|
||||||
// End hooks observe settled success or failure; interruption emits neither outcome.
|
// Wraps the settling portion of a tool call so onToolCallEnd observes success and failure
|
||||||
|
// symmetrically. Interruption (e.g. the execution timeout) fires neither outcome.
|
||||||
const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
|
const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
|
||||||
const onEnd = hooks?.onToolCallEnd
|
const onEnd = hooks?.onToolCallEnd
|
||||||
if (onEnd === undefined) return effect
|
if (onEnd === undefined) return effect
|
||||||
@@ -654,59 +758,52 @@ export const make = <R>(
|
|||||||
calls.push(call)
|
calls.push(call)
|
||||||
}
|
}
|
||||||
|
|
||||||
const recordAndObserve = (name: string, input: unknown) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
recordCall({ name })
|
|
||||||
return calls.length - 1
|
|
||||||
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
|
|
||||||
|
|
||||||
const invokeDefinition = (name: string, tool: Definition<R>, externalArgs: Array<unknown>) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (externalArgs.length !== 1)
|
|
||||||
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
|
|
||||||
const input = yield* Effect.try({
|
|
||||||
try: () => decodeToolInput(tool, externalArgs[0]),
|
|
||||||
catch: (cause) =>
|
|
||||||
new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
|
|
||||||
})
|
|
||||||
const index = yield* recordAndObserve(name, input)
|
|
||||||
return yield* observeEnd(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const raw = yield* runHost(Effect.suspend(() => tool.run(input)))
|
|
||||||
const result = yield* Effect.try({
|
|
||||||
try: () => decodeToolOutput(tool, raw),
|
|
||||||
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
|
|
||||||
})
|
|
||||||
return yield* decodeOutput(result, name)
|
|
||||||
}),
|
|
||||||
{ index, name, input },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
root: new ToolReference([]),
|
root: new ToolReference([]),
|
||||||
calls,
|
calls,
|
||||||
keys: (path) => namespaceKeys(tools, path),
|
keys: (path) => namespaceKeys(callableTools, path),
|
||||||
search: (args) =>
|
|
||||||
Effect.suspend(() =>
|
|
||||||
invokeDefinition(
|
|
||||||
"search",
|
|
||||||
searchTool,
|
|
||||||
args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"))),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
invoke: (path, args) =>
|
invoke: (path, args) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const name = path.join(".")
|
const name = path.join(".")
|
||||||
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`)))
|
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`)))
|
||||||
const tool = resolve(tools, path)
|
const call = { name }
|
||||||
if (isDefinition(tool)) return yield* invokeDefinition(name, tool, externalArgs)
|
const recordAndObserve = (input: unknown) =>
|
||||||
const index = yield* recordAndObserve(name, externalArgs)
|
Effect.sync(() => {
|
||||||
|
recordCall(call)
|
||||||
|
return calls.length - 1
|
||||||
|
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
|
||||||
|
const tool = resolve(callableTools, path)
|
||||||
|
let describedInput: unknown
|
||||||
|
if (isDefinition(tool)) {
|
||||||
|
if (externalArgs.length !== 1)
|
||||||
|
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
|
||||||
|
describedInput = yield* Effect.try({
|
||||||
|
try: () => decodeToolInput(tool, externalArgs[0]),
|
||||||
|
catch: (cause) =>
|
||||||
|
new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const input = isDefinition(tool) ? describedInput : externalArgs
|
||||||
|
const index = yield* recordAndObserve(input)
|
||||||
|
const currentCall = { index, name, input }
|
||||||
|
if (isDefinition(tool)) {
|
||||||
|
return yield* observeEnd(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const raw = yield* runHost(Effect.suspend(() => tool.run(describedInput)))
|
||||||
|
const result = yield* Effect.try({
|
||||||
|
try: () => decodeToolOutput(tool, raw),
|
||||||
|
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
|
||||||
|
})
|
||||||
|
return yield* decodeOutput(result, name)
|
||||||
|
}),
|
||||||
|
currentCall,
|
||||||
|
)
|
||||||
|
}
|
||||||
return yield* observeEnd(
|
return yield* observeEnd(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name)
|
return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name)
|
||||||
}),
|
}),
|
||||||
{ index, name, input: externalArgs },
|
currentCall,
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder<unknown> &
|
|||||||
|
|
||||||
const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown"
|
const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bare TypeScript identifier - usable unquoted as an object key (and, in the tool runtime,
|
||||||
|
* with dot access as a tool-path segment). Anything else must be quoted/bracketed.
|
||||||
|
*/
|
||||||
export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
||||||
|
|
||||||
|
/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */
|
||||||
const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name))
|
const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name))
|
||||||
|
|
||||||
const effectNumberSentinel = (schema: JsonSchema) =>
|
const effectNumberSentinel = (schema: JsonSchema) =>
|
||||||
@@ -22,10 +27,16 @@ const intersection = (members: ReadonlyArray<string>): string => {
|
|||||||
return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ")
|
return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursion ceiling for schema rendering. Object, array, and union recursion all increment
|
||||||
|
* depth, so this bounds every recursion path - pathological or structurally cyclic schemas
|
||||||
|
* degrade to `unknown` instead of overflowing the stack (rendering must never throw).
|
||||||
|
*/
|
||||||
const MAX_RENDER_DEPTH = 8
|
const MAX_RENDER_DEPTH = 8
|
||||||
|
|
||||||
type RenderContext = {
|
type RenderContext = {
|
||||||
readonly definitions: Readonly<Record<string, JsonSchema>>
|
readonly definitions: Readonly<Record<string, JsonSchema>>
|
||||||
|
/** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */
|
||||||
readonly pretty: boolean
|
readonly pretty: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +64,10 @@ const hasUnresolvedRef = (
|
|||||||
].some((item) => hasUnresolvedRef(item, definitions, seen, nextVisited))
|
].some((item) => hasUnresolvedRef(item, definitions, seen, nextVisited))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema constraints a TypeScript type cannot express natively but a model benefits from,
|
||||||
|
* surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`).
|
||||||
|
*/
|
||||||
const docTags = (schema: JsonSchema): Array<string> => {
|
const docTags = (schema: JsonSchema): Array<string> => {
|
||||||
const tags: Array<string> = []
|
const tags: Array<string> = []
|
||||||
if (schema.deprecated === true) tags.push("@deprecated")
|
if (schema.deprecated === true) tags.push("@deprecated")
|
||||||
@@ -60,7 +75,9 @@ const docTags = (schema: JsonSchema): Array<string> => {
|
|||||||
try {
|
try {
|
||||||
const rendered = JSON.stringify(schema.default)
|
const rendered = JSON.stringify(schema.default)
|
||||||
if (rendered !== undefined) tags.push(`@default ${rendered}`)
|
if (rendered !== undefined) tags.push(`@default ${rendered}`)
|
||||||
} catch {}
|
} catch {
|
||||||
|
// unserializable default: skip rather than emit a broken tag
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
|
if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
|
||||||
if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
|
if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
|
||||||
@@ -68,7 +85,13 @@ const docTags = (schema: JsonSchema): Array<string> => {
|
|||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
// Neutralize `*\/` so model-provided schema text cannot terminate generated documentation.
|
/**
|
||||||
|
* Format a schema `description` plus `tags` as a JSDoc comment at the given indent,
|
||||||
|
* preserving multi-line text (a single line stays `/** ... *\/`; multiple lines become a
|
||||||
|
* `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and
|
||||||
|
* blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so
|
||||||
|
* callers can prepend it directly to the field line.
|
||||||
|
*/
|
||||||
const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => {
|
const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => {
|
||||||
const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) =>
|
const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) =>
|
||||||
line.replaceAll("*/", "* /").replace(/\s+$/, ""),
|
line.replaceAll("*/", "* /").replace(/\s+$/, ""),
|
||||||
@@ -105,11 +128,17 @@ const renderSchema = (
|
|||||||
if (schema.enum) return schema.enum.map(renderLiteral).join(" | ")
|
if (schema.enum) return schema.enum.map(renderLiteral).join(" | ")
|
||||||
const alternatives = schema.anyOf ?? schema.oneOf
|
const alternatives = schema.anyOf ?? schema.oneOf
|
||||||
if (alternatives) {
|
if (alternatives) {
|
||||||
|
// Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" },
|
||||||
|
// { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact;
|
||||||
|
// real JSON Schema unions such as `string | number` or `number | null` must keep
|
||||||
|
// every branch.
|
||||||
if (
|
if (
|
||||||
alternatives.some((item) => item.type === "number") &&
|
alternatives.some((item) => item.type === "number") &&
|
||||||
alternatives.every((item) => item.type === "number" || effectNumberSentinel(item))
|
alternatives.every((item) => item.type === "number" || effectNumberSentinel(item))
|
||||||
)
|
)
|
||||||
return "number"
|
return "number"
|
||||||
|
// An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]`
|
||||||
|
// (no properties/items); render the bare shape as {} instead of `{} | Array<unknown>`.
|
||||||
if (
|
if (
|
||||||
alternatives.length === 2 &&
|
alternatives.length === 2 &&
|
||||||
alternatives[0]?.type === "object" &&
|
alternatives[0]?.type === "object" &&
|
||||||
@@ -154,6 +183,7 @@ const renderSchema = (
|
|||||||
return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }`
|
return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pretty: an indented block, each described field preceded by its JSDoc comment.
|
||||||
if (properties.length === 0 && indexType === undefined) return "{}"
|
if (properties.length === 0 && indexType === undefined) return "{}"
|
||||||
const pad = " ".repeat(depth + 1)
|
const pad = " ".repeat(depth + 1)
|
||||||
const lines = properties.map(
|
const lines = properties.map(
|
||||||
@@ -178,6 +208,7 @@ export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Renders a raw JSON Schema document as a TypeScript type string. */
|
||||||
export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => {
|
export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => {
|
||||||
try {
|
try {
|
||||||
return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty })
|
return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty })
|
||||||
@@ -186,12 +217,20 @@ export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One input property of a tool, extracted best-effort from its input schema. */
|
||||||
export type InputProperty = {
|
export type InputProperty = {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly description: string | undefined
|
readonly description: string | undefined
|
||||||
readonly required: boolean
|
readonly required: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The property names, descriptions, and required flags of a tool's input schema - the raw
|
||||||
|
* material for search text. Best-effort: Effect Schemas go through their
|
||||||
|
* JSON Schema document (the same emission signature rendering uses); JSON Schemas are read
|
||||||
|
* directly, resolving a trivial top-level `$ref` into `$defs`/`definitions` when present.
|
||||||
|
* Anything unresolvable yields `[]` (search falls back to path + description).
|
||||||
|
*/
|
||||||
export const inputProperties = <R>(definition: Definition<R>): Array<InputProperty> => {
|
export const inputProperties = <R>(definition: Definition<R>): Array<InputProperty> => {
|
||||||
try {
|
try {
|
||||||
const document = isEffectSchema(definition.input)
|
const document = isEffectSchema(definition.input)
|
||||||
@@ -223,11 +262,20 @@ export const inputProperties = <R>(definition: Definition<R>): Array<InputProper
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model-visible TypeScript type of a tool's input. `pretty` renders an indented
|
||||||
|
* multiline block with schema descriptions and constraints as JSDoc comments on the
|
||||||
|
* fields; the default stays the compact single-line form.
|
||||||
|
*/
|
||||||
export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
|
export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
|
||||||
isEffectSchema(definition.input)
|
isEffectSchema(definition.input)
|
||||||
? toTypeScript(definition.input, false, pretty)
|
? toTypeScript(definition.input, false, pretty)
|
||||||
: jsonSchemaToTypeScript(definition.input, pretty)
|
: jsonSchemaToTypeScript(definition.input, pretty)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model-visible TypeScript type of a tool's result; tools without an output schema
|
||||||
|
* return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs.
|
||||||
|
*/
|
||||||
export const outputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
|
export const outputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
|
||||||
definition.output === undefined
|
definition.output === undefined
|
||||||
? "unknown"
|
? "unknown"
|
||||||
@@ -235,9 +283,18 @@ export const outputTypeScript = <R>(definition: Definition<R>, pretty = false):
|
|||||||
? toTypeScript(definition.output, true, pretty)
|
? toTypeScript(definition.output, true, pretty)
|
||||||
: jsonSchemaToTypeScript(definition.output, pretty)
|
: jsonSchemaToTypeScript(definition.output, pretty)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure);
|
||||||
|
* JSON-Schema-described inputs pass through unvalidated (render-only).
|
||||||
|
*/
|
||||||
export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown =>
|
export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown =>
|
||||||
isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value
|
isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a tool result before it is exposed to the program. Effect Schemas validate and
|
||||||
|
* transform (throwing on failure); JSON Schema outputs and tools without an output schema pass
|
||||||
|
* the host value through unchanged.
|
||||||
|
*/
|
||||||
export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unknown =>
|
export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unknown =>
|
||||||
definition.output !== undefined && isEffectSchema(definition.output)
|
definition.output !== undefined && isEffectSchema(definition.output)
|
||||||
? Schema.decodeUnknownSync(definition.output)(value)
|
? Schema.decodeUnknownSync(definition.output)(value)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON Schema subset for model-visible signatures. CodeMode does not validate values against
|
* JSON Schema subset accepted for render-only tool schemas.
|
||||||
* these schemas.
|
*
|
||||||
|
* A JSON-Schema-described side of a tool is used to generate the model-visible TypeScript
|
||||||
|
* signature only - CodeMode performs no validation against it. This is the natural shape for
|
||||||
|
* adapter-provided tools (e.g. MCP definitions) whose schemas arrive as JSON Schema documents.
|
||||||
*/
|
*/
|
||||||
export type JsonSchema = {
|
export type JsonSchema = {
|
||||||
readonly type?: string | ReadonlyArray<string>
|
readonly type?: string | ReadonlyArray<string>
|
||||||
@@ -38,8 +41,10 @@ export type Definition<R = never> = {
|
|||||||
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, R>
|
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, R>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The value `run` receives: the decoded type for Effect Schemas, `unknown` for JSON Schemas. */
|
||||||
type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
|
type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
|
||||||
|
|
||||||
|
/** The value `run` returns: the encoded type for Effect Schemas, `unknown` otherwise. */
|
||||||
type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
|
type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
|
||||||
|
|
||||||
/** Options for defining one CodeMode tool. */
|
/** Options for defining one CodeMode tool. */
|
||||||
@@ -56,9 +61,29 @@ export const isDefinition = <R = never>(value: unknown): value is Definition<R>
|
|||||||
/**
|
/**
|
||||||
* Defines one schema-described tool available to a CodeMode program through `tools.*`.
|
* Defines one schema-described tool available to a CodeMode program through `tools.*`.
|
||||||
*
|
*
|
||||||
* Effect Schemas validate values; JSON Schemas only shape the model-visible signature.
|
* `input` and `output` each accept a validating Effect Schema or a render-only JSON Schema
|
||||||
* Without `output`, results are exposed as `unknown`. Hosts remain responsible for authorization
|
* document. Effect Schema input is decoded before `run` is invoked, and `run` returns the
|
||||||
* and durable side effects.
|
* encoded representation of an Effect Schema `output`, which CodeMode decodes before returning
|
||||||
|
* it to the program. JSON Schemas only shape the model-visible signature; values pass through
|
||||||
|
* unvalidated. `output` is optional - without it the signature advertises `unknown` and the
|
||||||
|
* host result is exposed as-is. The host tool remains responsible for authorization and
|
||||||
|
* durable side-effect handling.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const lookup = Tool.make({
|
||||||
|
* description: "Look up an order",
|
||||||
|
* input: Schema.Struct({ id: Schema.String }),
|
||||||
|
* output: Schema.Struct({ status: Schema.String }),
|
||||||
|
* run: ({ id }) => Effect.succeed({ status: "open" }),
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* const fromJsonSchema = Tool.make({
|
||||||
|
* description: "Call an adapter-described tool",
|
||||||
|
* input: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
|
||||||
|
* run: (input) => callHost(input),
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export const make = <I extends SchemaType, const O extends SchemaType | undefined = undefined, R = never>(
|
export const make = <I extends SchemaType, const O extends SchemaType | undefined = undefined, R = never>(
|
||||||
options: Options<I, O, R>,
|
options: Options<I, O, R>,
|
||||||
|
|||||||
@@ -26,22 +26,6 @@ describe("CodeMode host failure boundary", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not rewrite explicit safe tool failures", async () => {
|
|
||||||
const result = await run(
|
|
||||||
Tool.make({
|
|
||||||
description: "Fail safely",
|
|
||||||
input: Schema.Struct({}),
|
|
||||||
output: Schema.String,
|
|
||||||
run: () => Effect.fail(toolError("File not found: /tmp/report.json")),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.ok ? undefined : result.error).toStrictEqual({
|
|
||||||
kind: "ToolFailure",
|
|
||||||
message: "File not found: /tmp/report.json",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("sanitizes unknown host failures and defects", async () => {
|
test("sanitizes unknown host failures and defects", async () => {
|
||||||
for (const failure of [
|
for (const failure of [
|
||||||
Effect.fail(new UnsafeHostError({ reason: "Authorization: Bearer typed-secret" })),
|
Effect.fail(new UnsafeHostError({ reason: "Authorization: Bearer typed-secret" })),
|
||||||
@@ -557,11 +541,11 @@ describe("CodeMode public contract", () => {
|
|||||||
" - tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}> // Look up an order by ID",
|
" - tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}> // Look up an order by ID",
|
||||||
)
|
)
|
||||||
// A fully inlined catalog does not advertise search in the instructions...
|
// A fully inlined catalog does not advertise search in the instructions...
|
||||||
expect(runtime.instructions()).not.toContain("search(")
|
expect(runtime.instructions()).not.toMatch(/\$codemode/)
|
||||||
|
|
||||||
// ...but the search built-in stays available, so a speculative call still works with the
|
// ...but the search tool stays registered, so a speculative call still works with the
|
||||||
// same signature as the inline catalog.
|
// same signature as the inline catalog.
|
||||||
const result = await Effect.runPromise(runtime.execute(`return search({ query: "order" })`))
|
const result = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "order" })`))
|
||||||
expect(result.ok).toBe(true)
|
expect(result.ok).toBe(true)
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
expect(result.value).toStrictEqual({
|
expect(result.value).toStrictEqual({
|
||||||
@@ -599,7 +583,9 @@ describe("CodeMode public contract", () => {
|
|||||||
'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise<string>',
|
'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise<string>',
|
||||||
)
|
)
|
||||||
|
|
||||||
const search = await Effect.runPromise(runtime.execute(`return search({ query: "resolve library id" })`))
|
const search = await Effect.runPromise(
|
||||||
|
runtime.execute(`return await tools.$codemode.search({ query: "resolve library id" })`),
|
||||||
|
)
|
||||||
expect(search.ok).toBe(true)
|
expect(search.ok).toBe(true)
|
||||||
if (search.ok) {
|
if (search.ok) {
|
||||||
expect(search.value).toStrictEqual({
|
expect(search.value).toStrictEqual({
|
||||||
@@ -622,7 +608,7 @@ describe("CodeMode public contract", () => {
|
|||||||
if (call.ok) expect(call.value).toBe("/resolved/TypeScript")
|
if (call.ok) expect(call.value).toBe("/resolved/TypeScript")
|
||||||
|
|
||||||
const exact = await Effect.runPromise(
|
const exact = await Effect.runPromise(
|
||||||
runtime.execute(`return search({ query: 'tools.context7["resolve-library-id"]' })`),
|
runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`),
|
||||||
)
|
)
|
||||||
expect(exact.ok).toBe(true)
|
expect(exact.ok).toBe(true)
|
||||||
if (exact.ok) expect(exact.value).toMatchObject({ remaining: 0, next: null })
|
if (exact.ok) expect(exact.value).toMatchObject({ remaining: 0, next: null })
|
||||||
@@ -646,7 +632,7 @@ describe("CodeMode public contract", () => {
|
|||||||
expect(instructions).toContain("Do not infer or normalize tool names")
|
expect(instructions).toContain("Do not infer or normalize tool names")
|
||||||
expect(instructions).toContain("bracket notation and quotes are part of the path")
|
expect(instructions).toContain("bracket notation and quotes are part of the path")
|
||||||
expect(instructions).toContain("surrounding agent tools are not available")
|
expect(instructions).toContain("surrounding agent tools are not available")
|
||||||
expect(instructions).toContain("Only Code Mode tools listed here are available")
|
expect(instructions).toContain("Only Code Mode tools listed here and internal runtime tools")
|
||||||
// Placeholders use generic namespace/tool/field names only - no fabricated real tools
|
// Placeholders use generic namespace/tool/field names only - no fabricated real tools
|
||||||
// and no real catalog tools cherry-picked into example lines.
|
// and no real catalog tools cherry-picked into example lines.
|
||||||
expect(instructions).toContain("`const result = await tools.<namespace>.<tool>(input)`")
|
expect(instructions).toContain("`const result = await tools.<namespace>.<tool>(input)`")
|
||||||
@@ -665,11 +651,15 @@ describe("CodeMode public contract", () => {
|
|||||||
// PARTIAL: the workflow starts with search (with query-style guidance that is clearly
|
// PARTIAL: the workflow starts with search (with query-style guidance that is clearly
|
||||||
// a query string, never a tool name) and the browse-namespace rule appears.
|
// a query string, never a tool name) and the browse-namespace rule appears.
|
||||||
expect(partial).toContain(
|
expect(partial).toContain(
|
||||||
'1. If needed, discover tools with the built-in search function: `return search({ query: "<intent + key nouns>" })`.',
|
'1. If needed, discover tools: `return await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
|
||||||
)
|
)
|
||||||
expect(partial).toContain("In the next execution, copy a returned path exactly")
|
expect(partial).toContain("In the next execution, copy a returned path exactly")
|
||||||
expect(partial).toContain("Only Code Mode tools listed here or returned by the built-in `search` function")
|
expect(partial).toContain(
|
||||||
expect(partial).toContain('- Browse one namespace: `search({ query: "", namespace: "<name>" })`.')
|
"Only Code Mode tools listed here or returned by `tools.$codemode.search` and internal runtime tools",
|
||||||
|
)
|
||||||
|
expect(partial).toContain(
|
||||||
|
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
|
||||||
|
)
|
||||||
expect(partial).toContain("repeat the same search with `offset: next.offset`")
|
expect(partial).toContain("repeat the same search with `offset: next.offset`")
|
||||||
expect(partial).toContain(" limit?: number,\n offset?: number,")
|
expect(partial).toContain(" limit?: number,\n offset?: number,")
|
||||||
expect(partial).not.toContain("total_count")
|
expect(partial).not.toContain("total_count")
|
||||||
@@ -706,7 +696,7 @@ describe("CodeMode public contract", () => {
|
|||||||
expect(instructions).toContain("## Available tools")
|
expect(instructions).toContain("## Available tools")
|
||||||
expect(instructions).not.toContain("## Workflow")
|
expect(instructions).not.toContain("## Workflow")
|
||||||
expect(instructions).not.toContain("## Rules")
|
expect(instructions).not.toContain("## Rules")
|
||||||
expect(instructions).not.toContain("search(")
|
expect(instructions).not.toMatch(/\$codemode/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses one ranked search returning complete definitions for large catalogs", async () => {
|
test("uses one ranked search returning complete definitions for large catalogs", async () => {
|
||||||
@@ -726,15 +716,17 @@ describe("CodeMode public contract", () => {
|
|||||||
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
|
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
|
||||||
discovery: { catalogBudget: 0 },
|
discovery: { catalogBudget: 0 },
|
||||||
})
|
})
|
||||||
expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 3 shown; find the rest with search(...))")
|
expect(runtime.instructions()).toContain(
|
||||||
|
"Available tools (PARTIAL - 0 of 3 shown; find the rest with tools.$codemode.search)",
|
||||||
|
)
|
||||||
expect(runtime.instructions()).toContain("- thread (2 tools, none shown)")
|
expect(runtime.instructions()).toContain("- thread (2 tools, none shown)")
|
||||||
expect(runtime.instructions()).toContain("- orders (1 tool, none shown)")
|
expect(runtime.instructions()).toContain("- orders (1 tool, none shown)")
|
||||||
expect(runtime.instructions()).toContain("Search returns complete callable signatures:\n- search(input: {")
|
expect(runtime.instructions()).toMatch(/\$codemode\.search/)
|
||||||
expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/)
|
expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/)
|
||||||
|
|
||||||
const result = await Effect.runPromise(
|
const result = await Effect.runPromise(
|
||||||
runtime.execute(`
|
runtime.execute(`
|
||||||
return search({
|
return await tools.$codemode.search({
|
||||||
query: "send message attachment upload file to current Discord thread",
|
query: "send message attachment upload file to current Discord thread",
|
||||||
limit: 2
|
limit: 2
|
||||||
})
|
})
|
||||||
@@ -758,14 +750,14 @@ describe("CodeMode public contract", () => {
|
|||||||
remaining: 0,
|
remaining: 0,
|
||||||
next: null,
|
next: null,
|
||||||
})
|
})
|
||||||
expect(result.toolCalls).toStrictEqual([{ name: "search" }])
|
expect(result.toolCalls).toStrictEqual([{ name: "$codemode.search" }])
|
||||||
|
|
||||||
const variants = await Effect.runPromise(
|
const variants = await Effect.runPromise(
|
||||||
runtime.execute(`
|
runtime.execute(`
|
||||||
return [
|
return await Promise.all([
|
||||||
search({ query: "file" }),
|
tools.$codemode.search({ query: "file" }),
|
||||||
search({ query: "image" })
|
tools.$codemode.search({ query: "image" })
|
||||||
]
|
])
|
||||||
`),
|
`),
|
||||||
)
|
)
|
||||||
expect(variants.ok).toBe(true)
|
expect(variants.ok).toBe(true)
|
||||||
@@ -777,35 +769,12 @@ describe("CodeMode public contract", () => {
|
|||||||
"tools.thread.generateImage",
|
"tools.thread.generateImage",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
test("search is a counted tool call: it burns maxToolCalls and fires the hooks", async () => {
|
const removed = await Effect.runPromise(
|
||||||
const started: Array<string> = []
|
runtime.execute(`return await tools.$codemode.describe({ path: "thread.uploadFile" })`),
|
||||||
const ended: Array<string> = []
|
)
|
||||||
const limited = CodeMode.make({
|
expect(removed.ok).toBe(false)
|
||||||
tools,
|
if (!removed.ok) expect(removed.error.kind).toBe("UnknownTool")
|
||||||
limits: { maxToolCalls: 1 },
|
|
||||||
onToolCallStart: (call) => Effect.sync(() => void started.push(call.name)),
|
|
||||||
onToolCallEnd: (call) => Effect.sync(() => void ended.push(`${call.name}:${call.outcome}`)),
|
|
||||||
})
|
|
||||||
const result = await Effect.runPromise(limited.execute(`search({}); return search({})`))
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) expect(result.error.kind).toBe("ToolCallLimitExceeded")
|
|
||||||
expect(started).toEqual(["search"])
|
|
||||||
expect(ended).toEqual(["search:success"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("search is an opaque, shadowable global like other built-ins", async () => {
|
|
||||||
const runtime = CodeMode.make({ tools })
|
|
||||||
expect(await Effect.runPromise(runtime.execute(`return typeof search`))).toMatchObject({ value: "function" })
|
|
||||||
// A program-level declaration shadows the global, as JS module scope does.
|
|
||||||
const shadowed = await Effect.runPromise(runtime.execute(`const search = () => "local"; return search()`))
|
|
||||||
expect(shadowed.ok).toBe(true)
|
|
||||||
if (shadowed.ok) expect(shadowed.value).toBe("local")
|
|
||||||
// The reference itself cannot cross the data boundary.
|
|
||||||
const escaped = await Effect.runPromise(runtime.execute(`return { search }`))
|
|
||||||
expect(escaped.ok).toBe(false)
|
|
||||||
if (!escaped.ok) expect(escaped.error.kind).toBe("InvalidDataValue")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("search defaults to 10 results and resolves exact tool paths", async () => {
|
test("search defaults to 10 results and resolves exact tool paths", async () => {
|
||||||
@@ -822,7 +791,7 @@ describe("CodeMode public contract", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const browse = await Effect.runPromise(runtime.execute(`return search({})`))
|
const browse = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({})`))
|
||||||
expect(browse.ok).toBe(true)
|
expect(browse.ok).toBe(true)
|
||||||
if (browse.ok) {
|
if (browse.ok) {
|
||||||
const value = browse.value as {
|
const value = browse.value as {
|
||||||
@@ -836,7 +805,9 @@ describe("CodeMode public contract", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const query of ["many.tool13", "tools.many.tool13"]) {
|
for (const query of ["many.tool13", "tools.many.tool13"]) {
|
||||||
const exact = await Effect.runPromise(runtime.execute(`return search({ query: ${JSON.stringify(query)} })`))
|
const exact = await Effect.runPromise(
|
||||||
|
runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`),
|
||||||
|
)
|
||||||
expect(exact.ok).toBe(true)
|
expect(exact.ok).toBe(true)
|
||||||
if (exact.ok) {
|
if (exact.ok) {
|
||||||
expect(exact.value).toStrictEqual({
|
expect(exact.value).toStrictEqual({
|
||||||
@@ -870,7 +841,9 @@ describe("CodeMode public contract", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Empty query + namespace browses just that namespace, alphabetical by path.
|
// Empty query + namespace browses just that namespace, alphabetical by path.
|
||||||
const browse = await Effect.runPromise(runtime.execute(`return search({ query: "", namespace: "github" })`))
|
const browse = await Effect.runPromise(
|
||||||
|
runtime.execute(`return await tools.$codemode.search({ query: "", namespace: "github" })`),
|
||||||
|
)
|
||||||
expect(browse.ok).toBe(true)
|
expect(browse.ok).toBe(true)
|
||||||
if (browse.ok) {
|
if (browse.ok) {
|
||||||
const value = browse.value as { items: Array<{ path: string }>; remaining: number }
|
const value = browse.value as { items: Array<{ path: string }>; remaining: number }
|
||||||
@@ -882,7 +855,9 @@ describe("CodeMode public contract", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A query + namespace ranks within that namespace only.
|
// A query + namespace ranks within that namespace only.
|
||||||
const scoped = await Effect.runPromise(runtime.execute(`return search({ query: "issues", namespace: "linear" })`))
|
const scoped = await Effect.runPromise(
|
||||||
|
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "linear" })`),
|
||||||
|
)
|
||||||
expect(scoped.ok).toBe(true)
|
expect(scoped.ok).toBe(true)
|
||||||
if (scoped.ok) {
|
if (scoped.ok) {
|
||||||
const value = scoped.value as { items: Array<{ path: string }>; remaining: number }
|
const value = scoped.value as { items: Array<{ path: string }>; remaining: number }
|
||||||
@@ -890,7 +865,9 @@ describe("CodeMode public contract", () => {
|
|||||||
expect(value.items[0]?.path).toBe("tools.linear.list_issues")
|
expect(value.items[0]?.path).toBe("tools.linear.list_issues")
|
||||||
}
|
}
|
||||||
|
|
||||||
const invalid = await Effect.runPromise(runtime.execute(`return search({ query: "issues", namespace: 7 })`))
|
const invalid = await Effect.runPromise(
|
||||||
|
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: 7 })`),
|
||||||
|
)
|
||||||
expect(invalid.ok).toBe(false)
|
expect(invalid.ok).toBe(false)
|
||||||
if (!invalid.ok) expect(invalid.error.kind).toBe("InvalidToolInput")
|
if (!invalid.ok) expect(invalid.error.kind).toBe("InvalidToolInput")
|
||||||
})
|
})
|
||||||
@@ -915,7 +892,9 @@ describe("CodeMode public contract", () => {
|
|||||||
|
|
||||||
// "attachment" appears in neither path nor description - only in the input schema's
|
// "attachment" appears in neither path nor description - only in the input schema's
|
||||||
// property names, which the searchable text includes.
|
// property names, which the searchable text includes.
|
||||||
const byParameter = await Effect.runPromise(runtime.execute(`return search({ query: "attachment" })`))
|
const byParameter = await Effect.runPromise(
|
||||||
|
runtime.execute(`return await tools.$codemode.search({ query: "attachment" })`),
|
||||||
|
)
|
||||||
expect(byParameter.ok).toBe(true)
|
expect(byParameter.ok).toBe(true)
|
||||||
if (byParameter.ok) {
|
if (byParameter.ok) {
|
||||||
const value = byParameter.value as { items: Array<{ path: string }>; remaining: number }
|
const value = byParameter.value as { items: Array<{ path: string }>; remaining: number }
|
||||||
@@ -924,7 +903,9 @@ describe("CodeMode public contract", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Substring matching: a partial word ("docum") still hits the description.
|
// Substring matching: a partial word ("docum") still hits the description.
|
||||||
const bySubstring = await Effect.runPromise(runtime.execute(`return search({ query: "docum" })`))
|
const bySubstring = await Effect.runPromise(
|
||||||
|
runtime.execute(`return await tools.$codemode.search({ query: "docum" })`),
|
||||||
|
)
|
||||||
expect(bySubstring.ok).toBe(true)
|
expect(bySubstring.ok).toBe(true)
|
||||||
if (bySubstring.ok) {
|
if (bySubstring.ok) {
|
||||||
const value = bySubstring.value as { items: Array<{ path: string }>; remaining: number }
|
const value = bySubstring.value as { items: Array<{ path: string }>; remaining: number }
|
||||||
@@ -951,7 +932,9 @@ describe("CodeMode public contract", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// "issues" still finds the singular-only tool (term OR singular(term) per field)...
|
// "issues" still finds the singular-only tool (term OR singular(term) per field)...
|
||||||
const plural = await Effect.runPromise(runtime.execute(`return search({ query: "issues", namespace: "tracker" })`))
|
const plural = await Effect.runPromise(
|
||||||
|
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "tracker" })`),
|
||||||
|
)
|
||||||
expect(plural.ok).toBe(true)
|
expect(plural.ok).toBe(true)
|
||||||
if (plural.ok) {
|
if (plural.ok) {
|
||||||
const value = plural.value as { items: Array<{ path: string }>; remaining: number }
|
const value = plural.value as { items: Array<{ path: string }>; remaining: number }
|
||||||
@@ -960,7 +943,7 @@ describe("CodeMode public contract", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ...while a true "issues" path match still outranks the singular-only description match.
|
// ...while a true "issues" path match still outranks the singular-only description match.
|
||||||
const ranked = await Effect.runPromise(runtime.execute(`return search({ query: "issues" })`))
|
const ranked = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "issues" })`))
|
||||||
expect(ranked.ok).toBe(true)
|
expect(ranked.ok).toBe(true)
|
||||||
if (ranked.ok) {
|
if (ranked.ok) {
|
||||||
const value = ranked.value as { items: Array<{ path: string }>; remaining: number }
|
const value = ranked.value as { items: Array<{ path: string }>; remaining: number }
|
||||||
@@ -987,7 +970,7 @@ describe("CodeMode public contract", () => {
|
|||||||
alpha: { beta: simple("Middle"), aardvark: simple("First") },
|
alpha: { beta: simple("Middle"), aardvark: simple("First") },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const browse = await Effect.runPromise(runtime.execute(`return search({})`))
|
const browse = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({})`))
|
||||||
expect(browse.ok).toBe(true)
|
expect(browse.ok).toBe(true)
|
||||||
if (browse.ok) {
|
if (browse.ok) {
|
||||||
const value = browse.value as { items: Array<{ path: string }>; remaining: number; next: unknown }
|
const value = browse.value as { items: Array<{ path: string }>; remaining: number; next: unknown }
|
||||||
@@ -1000,7 +983,9 @@ describe("CodeMode public contract", () => {
|
|||||||
expect(value.next).toBeNull()
|
expect(value.next).toBeNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
const middle = await Effect.runPromise(runtime.execute(`return search({ limit: 1, offset: 1 })`))
|
const middle = await Effect.runPromise(
|
||||||
|
runtime.execute(`return await tools.$codemode.search({ limit: 1, offset: 1 })`),
|
||||||
|
)
|
||||||
expect(middle.ok).toBe(true)
|
expect(middle.ok).toBe(true)
|
||||||
if (middle.ok) {
|
if (middle.ok) {
|
||||||
expect(middle.value).toMatchObject({
|
expect(middle.value).toMatchObject({
|
||||||
@@ -1010,7 +995,9 @@ describe("CodeMode public contract", () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const exhausted = await Effect.runPromise(runtime.execute(`return search({ limit: 1, offset: 3 })`))
|
const exhausted = await Effect.runPromise(
|
||||||
|
runtime.execute(`return await tools.$codemode.search({ limit: 1, offset: 3 })`),
|
||||||
|
)
|
||||||
expect(exhausted.ok).toBe(true)
|
expect(exhausted.ok).toBe(true)
|
||||||
if (exhausted.ok) expect(exhausted.value).toStrictEqual({ items: [], remaining: 0, next: null })
|
if (exhausted.ok) expect(exhausted.value).toStrictEqual({ items: [], remaining: 0, next: null })
|
||||||
})
|
})
|
||||||
@@ -1041,14 +1028,16 @@ describe("CodeMode public contract", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const instructions = runtime.instructions()
|
const instructions = runtime.instructions()
|
||||||
expect(instructions).toContain("Available tools (PARTIAL - 2 of 3 shown; find the rest with search(...))")
|
expect(instructions).toContain(
|
||||||
|
"Available tools (PARTIAL - 2 of 3 shown; find the rest with tools.$codemode.search)",
|
||||||
|
)
|
||||||
expect(instructions).toContain("- alpha (2 tools, 1 shown)")
|
expect(instructions).toContain("- alpha (2 tools, 1 shown)")
|
||||||
expect(instructions).toContain(" - tools.alpha.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
|
expect(instructions).toContain(" - tools.alpha.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
|
||||||
expect(instructions).not.toContain("tools.alpha.expensive(")
|
expect(instructions).not.toContain("tools.alpha.expensive(")
|
||||||
// Fully shown namespaces read cleanly (no "shown" annotation).
|
// Fully shown namespaces read cleanly (no "shown" annotation).
|
||||||
expect(instructions).toContain("- beta (1 tool)")
|
expect(instructions).toContain("- beta (1 tool)")
|
||||||
expect(instructions).toContain(" - tools.beta.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
|
expect(instructions).toContain(" - tools.beta.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
|
||||||
expect(instructions).toContain("Search returns complete callable signatures:\n- search(input: {")
|
expect(instructions).toMatch(/\$codemode\.search/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("charges inline JSDoc against the catalog token budget", () => {
|
test("charges inline JSDoc against the catalog token budget", () => {
|
||||||
@@ -1069,7 +1058,9 @@ describe("CodeMode public contract", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(runtime.catalog()[0]?.signature).toContain("/** A detailed identifier description.")
|
expect(runtime.catalog()[0]?.signature).toContain("/** A detailed identifier description.")
|
||||||
expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 1 shown; find the rest with search(...))")
|
expect(runtime.instructions()).toContain(
|
||||||
|
"Available tools (PARTIAL - 0 of 1 shown; find the rest with tools.$codemode.search)",
|
||||||
|
)
|
||||||
expect(runtime.instructions()).not.toContain("tools.records.lookup(input:")
|
expect(runtime.instructions()).not.toContain("tools.records.lookup(input:")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1147,7 +1138,7 @@ describe("CodeMode public contract", () => {
|
|||||||
CodeMode.make({
|
CodeMode.make({
|
||||||
tools,
|
tools,
|
||||||
discovery: { catalogBudget: 0 },
|
discovery: { catalogBudget: 0 },
|
||||||
}).execute(`return search({ query: "order", limit: 0.5 })`),
|
}).execute(`return await tools.$codemode.search({ query: "order", limit: 0.5 })`),
|
||||||
)
|
)
|
||||||
expect(result.ok).toBe(false)
|
expect(result.ok).toBe(false)
|
||||||
if (result.ok) return
|
if (result.ok) return
|
||||||
@@ -1155,7 +1146,9 @@ describe("CodeMode public contract", () => {
|
|||||||
|
|
||||||
for (const offset of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, "1"]) {
|
for (const offset of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, "1"]) {
|
||||||
const invalidOffset = await Effect.runPromise(
|
const invalidOffset = await Effect.runPromise(
|
||||||
CodeMode.make({ tools }).execute(`return search({ query: "order", offset: ${JSON.stringify(offset)} })`),
|
CodeMode.make({ tools }).execute(
|
||||||
|
`return await tools.$codemode.search({ query: "order", offset: ${JSON.stringify(offset)} })`,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
expect(invalidOffset.ok).toBe(false)
|
expect(invalidOffset.ok).toBe(false)
|
||||||
if (!invalidOffset.ok) expect(invalidOffset.error.kind).toBe("InvalidToolInput")
|
if (!invalidOffset.ok) expect(invalidOffset.error.kind).toBe("InvalidToolInput")
|
||||||
@@ -1206,4 +1199,8 @@ describe("CodeMode public contract", () => {
|
|||||||
}
|
}
|
||||||
expect(elapsedMs).toBeLessThan(3_000)
|
expect(elapsedMs).toBeLessThan(3_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("reserves the discovery namespace", () => {
|
||||||
|
expect(() => CodeMode.make({ tools: { $codemode: { lookup } } })).toThrow(/reserved for CodeMode discovery tools/)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ describe("Object.keys over tool references", () => {
|
|||||||
const namespaces = Object.keys(tools)
|
const namespaces = Object.keys(tools)
|
||||||
return { namespaces, count: namespaces.length }
|
return { namespaces, count: namespaces.length }
|
||||||
`),
|
`),
|
||||||
).toEqual({ namespaces: ["github", "memory", "playwright"], count: 3 })
|
).toEqual({ namespaces: ["github", "memory", "playwright", "$codemode"], count: 4 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("enumerates tool names at a nested namespace", async () => {
|
test("enumerates tool names at a nested namespace", async () => {
|
||||||
@@ -52,8 +52,8 @@ describe("Object.keys over tool references", () => {
|
|||||||
expect(await value(`return Object.keys(tools.github.list_issues)`)).toEqual([])
|
expect(await value(`return Object.keys(tools.github.list_issues)`)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("search is a global built-in function", async () => {
|
test("the internal discovery namespace enumerates its callable surface", async () => {
|
||||||
expect(await value(`return typeof search`)).toBe("function")
|
expect(await value(`return Object.keys(tools.$codemode)`)).toEqual(["search"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("an unknown namespace is an UnknownTool error pointing at the discovery idioms", async () => {
|
test("an unknown namespace is an UnknownTool error pointing at the discovery idioms", async () => {
|
||||||
@@ -68,7 +68,7 @@ describe("Object.keys over tool references", () => {
|
|||||||
const failure = await error(`return Object.${method}(tools)`)
|
const failure = await error(`return Object.${method}(tools)`)
|
||||||
expect(failure.kind).toBe("InvalidDataValue")
|
expect(failure.kind).toBe("InvalidDataValue")
|
||||||
expect(failure.message).toContain(
|
expect(failure.message).toContain(
|
||||||
`Object.${method}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or search({ query }) for signatures.`,
|
`Object.${method}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const nested = await error(`return Object.entries(tools.github)`)
|
const nested = await error(`return Object.entries(tools.github)`)
|
||||||
@@ -146,7 +146,7 @@ describe("for...in", () => {
|
|||||||
}
|
}
|
||||||
return names
|
return names
|
||||||
`),
|
`),
|
||||||
).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"])
|
).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate", "$codemode.search"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("unsupported values fail with a hint at for...of and Object.keys", async () => {
|
test("unsupported values fail with a hint at for...of and Object.keys", async () => {
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
runtime
|
runtime
|
||||||
.execute(
|
.execute(
|
||||||
`
|
`
|
||||||
return search({ query: "global health", namespace: "opencode", limit: 1 })
|
return await tools.$codemode.search({ query: "global health", namespace: "opencode", limit: 1 })
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.pipe(Effect.provide(layer)),
|
.pipe(Effect.provide(layer)),
|
||||||
|
|||||||
@@ -302,12 +302,9 @@ describe("CodeMode-specific string behavior", () => {
|
|||||||
expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"')
|
expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"')
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not expose obsolete string aliases", async () => {
|
test("trimLeft/trimRight alias trimStart/trimEnd", async () => {
|
||||||
expect(await value(`return [typeof "x".trimLeft, typeof "x".trimRight, typeof "x".substr]`)).toEqual([
|
expect(await value(`return " x ".trimLeft()`)).toBe("x ")
|
||||||
"undefined",
|
expect(await value(`return " x ".trimRight()`)).toBe(" x")
|
||||||
"undefined",
|
|
||||||
"undefined",
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -342,7 +342,9 @@ describe("JSDoc signatures in catalogs and search results", () => {
|
|||||||
const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } })
|
const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } })
|
||||||
|
|
||||||
const search = async (query: string) => {
|
const search = async (query: string) => {
|
||||||
const result = await Effect.runPromise(runtime.execute(`return search({ query: ${JSON.stringify(query)} })`))
|
const result = await Effect.runPromise(
|
||||||
|
runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`),
|
||||||
|
)
|
||||||
expect(result.ok).toBe(true)
|
expect(result.ok).toBe(true)
|
||||||
if (!result.ok) throw new Error("search failed")
|
if (!result.ok) throw new Error("search failed")
|
||||||
return result.value as { items: Array<{ path: string; signature: string }>; remaining: number }
|
return result.value as { items: Array<{ path: string; signature: string }>; remaining: number }
|
||||||
@@ -434,7 +436,9 @@ describe("non-identifier tool paths", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("search results return callable bracket-notation paths and signatures", async () => {
|
test("search results return callable bracket-notation paths and signatures", async () => {
|
||||||
const result = await Effect.runPromise(runtime.execute(`return search({ query: "resolve library" })`))
|
const result = await Effect.runPromise(
|
||||||
|
runtime.execute(`return await tools.$codemode.search({ query: "resolve library" })`),
|
||||||
|
)
|
||||||
expect(result.ok).toBe(true)
|
expect(result.ok).toBe(true)
|
||||||
if (!result.ok) throw new Error("search failed")
|
if (!result.ok) throw new Error("search failed")
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,12 @@
|
|||||||
* - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T8.js
|
* - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T8.js
|
||||||
* - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T9.js
|
* - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T9.js
|
||||||
* - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T10.js
|
* - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T10.js
|
||||||
|
* - test/annexB/built-ins/String/prototype/substr/start-negative.js
|
||||||
|
* - test/annexB/built-ins/String/prototype/substr/length-negative.js
|
||||||
|
* - test/annexB/built-ins/String/prototype/substr/length-positive.js
|
||||||
|
* - test/annexB/built-ins/String/prototype/substr/length-falsey.js
|
||||||
|
* - test/annexB/built-ins/String/prototype/substr/length-undef.js
|
||||||
|
* - test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js
|
||||||
* - test/built-ins/String/prototype/includes/String.prototype.includes_FailMissingLetter.js
|
* - test/built-ins/String/prototype/includes/String.prototype.includes_FailMissingLetter.js
|
||||||
* - test/built-ins/String/prototype/includes/String.prototype.includes_SuccessNoLocation.js
|
* - test/built-ins/String/prototype/includes/String.prototype.includes_SuccessNoLocation.js
|
||||||
* - test/built-ins/String/prototype/includes/String.prototype.includes_FailBadLocation.js
|
* - test/built-ins/String/prototype/includes/String.prototype.includes_FailBadLocation.js
|
||||||
@@ -454,6 +460,67 @@ const cases = [
|
|||||||
expected: ["this_is_"],
|
expected: ["this_is_"],
|
||||||
labels: ['#1: __string.substring(0,8) === "this_is_"'],
|
labels: ['#1: __string.substring(0,8) === "this_is_"'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "test/annexB/built-ins/String/prototype/substr/start-negative.js",
|
||||||
|
code: `return ["abc".substr(-1), "abc".substr(-2), "abc".substr(-3), "abc".substr(-4), "abc".substr(-1.1)]`,
|
||||||
|
expected: ["c", "bc", "abc", "abc", "c"],
|
||||||
|
labels: ["-1", "-2", "-3", "size + intStart < 0", "floating point rounding semantics"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "test/annexB/built-ins/String/prototype/substr/length-negative.js",
|
||||||
|
code: `return [
|
||||||
|
"abc".substr(0, -1), "abc".substr(0, -2), "abc".substr(0, -3), "abc".substr(0, -4),
|
||||||
|
"abc".substr(1, -1), "abc".substr(1, -2), "abc".substr(1, -3), "abc".substr(1, -4),
|
||||||
|
"abc".substr(2, -1), "abc".substr(2, -2), "abc".substr(2, -3), "abc".substr(2, -4),
|
||||||
|
"abc".substr(3, -1), "abc".substr(3, -2), "abc".substr(3, -3), "abc".substr(3, -4),
|
||||||
|
]`,
|
||||||
|
expected: ["", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""],
|
||||||
|
labels: [
|
||||||
|
"0, -1", "0, -2", "0, -3", "0, -4", "1, -1", "1, -2", "1, -3", "1, -4",
|
||||||
|
"2, -1", "2, -2", "2, -3", "2, -4", "3, -1", "3, -2", "3, -3", "3, -4",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "test/annexB/built-ins/String/prototype/substr/length-positive.js",
|
||||||
|
code: `return [
|
||||||
|
"abc".substr(0, 1), "abc".substr(0, 2), "abc".substr(0, 3), "abc".substr(0, 4),
|
||||||
|
"abc".substr(1, 1), "abc".substr(1, 2), "abc".substr(1, 3), "abc".substr(1, 4),
|
||||||
|
"abc".substr(2, 1), "abc".substr(2, 2), "abc".substr(2, 3), "abc".substr(2, 4),
|
||||||
|
"abc".substr(3, 1), "abc".substr(3, 2), "abc".substr(3, 3), "abc".substr(3, 4),
|
||||||
|
]`,
|
||||||
|
expected: ["a", "ab", "abc", "abc", "b", "bc", "bc", "bc", "c", "c", "c", "c", "", "", "", ""],
|
||||||
|
labels: [
|
||||||
|
"0, 1", "0, 1", "0, 1", "0, 1", "1, 1", "1, 1", "1, 1", "1, 1",
|
||||||
|
"2, 1", "2, 1", "2, 1", "2, 1", "3, 1", "3, 1", "3, 1", "3, 1",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "test/annexB/built-ins/String/prototype/substr/length-falsey.js",
|
||||||
|
code: `return ["abc".substr(0, NaN), "abc".substr(1, NaN), "abc".substr(2, NaN), "abc".substr(3, NaN)]`,
|
||||||
|
expected: ["", "", "", ""],
|
||||||
|
labels: ["start: 0, length: NaN", "start: 1, length: NaN", "start: 2, length: NaN", "start: 3, length: NaN"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "test/annexB/built-ins/String/prototype/substr/length-undef.js",
|
||||||
|
code: `return [
|
||||||
|
"abc".substr(0), "abc".substr(1), "abc".substr(2), "abc".substr(3),
|
||||||
|
"abc".substr(0, undefined), "abc".substr(1, undefined), "abc".substr(2, undefined), "abc".substr(3, undefined),
|
||||||
|
]`,
|
||||||
|
expected: ["abc", "bc", "c", "", "abc", "bc", "c", ""],
|
||||||
|
labels: [
|
||||||
|
"start: 0, length: unspecified", "start: 1, length: unspecified", "start: 2, length: unspecified", "start: 3, length: unspecified",
|
||||||
|
"start: 0, length: undefined", "start: 1, length: undefined", "start: 2, length: undefined", "start: 3, length: undefined",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js",
|
||||||
|
code: `return [
|
||||||
|
"\uD834\uDF06".substr(0), "\uD834\uDF06".substr(1), "\uD834\uDF06".substr(2),
|
||||||
|
"\uD834\uDF06".substr(0, 0), "\uD834\uDF06".substr(0, 1), "\uD834\uDF06".substr(0, 2),
|
||||||
|
]`,
|
||||||
|
expected: ["\uD834\uDF06", "\uDF06", "", "", "\uD834", "\uD834\uDF06"],
|
||||||
|
labels: ["start: 0", "start: 1", "start: 2", "end: 0", "end: 1", "end: 2"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "test/built-ins/String/prototype/includes/String.prototype.includes_FailMissingLetter.js",
|
path: "test/built-ins/String/prototype/includes/String.prototype.includes_FailMissingLetter.js",
|
||||||
code: `return ["word".includes("a", 0)]`, expected: [false], labels: ['"word".includes("a", 0)'],
|
code: `return ["word".includes("a", 0)]`, expected: [false], labels: ['"word".includes("a", 0)'],
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"$schema": "https://json.schemastore.org/tsconfig",
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
"extends": "@tsconfig/bun/tsconfig.json",
|
"extends": "@tsconfig/bun/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noUncheckedIndexedAccess": false,
|
"noUncheckedIndexedAccess": false
|
||||||
"noUnusedLocals": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export * as Catalog from "./catalog"
|
export * as Catalog from "./catalog"
|
||||||
|
|
||||||
import { makeLocationNode } from "./effect/app-node"
|
import { makeLocationNode } from "./effect/app-node"
|
||||||
import { Array, Context, Effect, Layer, Order, pipe } from "effect"
|
import { Array, Context, Effect, Layer, Option, Order, pipe } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/schema/catalog"
|
import { Catalog } from "@opencode-ai/schema/catalog"
|
||||||
import { ModelV2 } from "./model"
|
import { ModelV2 } from "./model"
|
||||||
import { ProviderV2 } from "./provider"
|
import { ProviderV2 } from "./provider"
|
||||||
@@ -21,7 +21,6 @@ export const Event = Catalog.Event
|
|||||||
type Data = {
|
type Data = {
|
||||||
providers: Map<ProviderV2.ID, ProviderRecord>
|
providers: Map<ProviderV2.ID, ProviderRecord>
|
||||||
defaultModel?: DefaultModel
|
defaultModel?: DefaultModel
|
||||||
smallModels: Map<ProviderV2.ID, ModelV2.ID>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Draft = {
|
export type Draft = {
|
||||||
@@ -39,11 +38,6 @@ export type Draft = {
|
|||||||
get: () => DefaultModel | undefined
|
get: () => DefaultModel | undefined
|
||||||
set: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
|
set: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
|
||||||
}
|
}
|
||||||
small: {
|
|
||||||
get: (providerID: ProviderV2.ID) => ModelV2.ID | undefined
|
|
||||||
set: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
|
|
||||||
remove: (providerID: ProviderV2.ID) => void
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +83,7 @@ const layer = Layer.effect(
|
|||||||
|
|
||||||
const state = State.create<Data, Draft>({
|
const state = State.create<Data, Draft>({
|
||||||
name: "catalog",
|
name: "catalog",
|
||||||
initial: () => ({ providers: new Map(), smallModels: new Map() }),
|
initial: () => ({ providers: new Map() }),
|
||||||
draft: (draft) => {
|
draft: (draft) => {
|
||||||
const result: Draft = {
|
const result: Draft = {
|
||||||
provider: {
|
provider: {
|
||||||
@@ -137,15 +131,6 @@ const layer = Layer.effect(
|
|||||||
draft.defaultModel = { providerID, modelID }
|
draft.defaultModel = { providerID, modelID }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
small: {
|
|
||||||
get: (providerID) => draft.smallModels.get(providerID),
|
|
||||||
set: (providerID, modelID) => {
|
|
||||||
draft.smallModels.set(providerID, modelID)
|
|
||||||
},
|
|
||||||
remove: (providerID) => {
|
|
||||||
draft.smallModels.delete(providerID)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -232,50 +217,48 @@ const layer = Layer.effect(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plugin transforms may pin a small model (for example picker-disabled utility models).
|
if (providerID === ProviderV2.ID.opencode) {
|
||||||
const configured = state.get().smallModels.get(providerID)
|
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
|
||||||
if (configured) {
|
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
|
||||||
const model = record.models.get(configured)
|
|
||||||
if (model?.status === "active") return projectModel(model, provider)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const priority = providerID.startsWith("opencode")
|
const candidates = pipe(
|
||||||
? ["gpt-nano"]
|
|
||||||
: providerID.startsWith("github-copilot")
|
|
||||||
? ["gpt-mini", ...SMALL_MODEL_FAMILY_PRIORITY]
|
|
||||||
: SMALL_MODEL_FAMILY_PRIORITY
|
|
||||||
|
|
||||||
const models = pipe(
|
|
||||||
Array.fromIterable(record.models.values()),
|
Array.fromIterable(record.models.values()),
|
||||||
Array.filter((model) => model.enabled && model.status === "active"),
|
Array.filter(
|
||||||
Array.sortWith((model) => model.id, Order.flip(Order.String)),
|
(model) =>
|
||||||
Array.sortWith((model) => model.time.released, Order.flip(Order.Number)),
|
model.providerID === providerID &&
|
||||||
|
model.enabled &&
|
||||||
|
model.status === "active" &&
|
||||||
|
model.capabilities.input.some((item) => item.startsWith("text")) &&
|
||||||
|
model.capabilities.output.some((item) => item.startsWith("text")),
|
||||||
|
),
|
||||||
|
Array.map((model) => ({
|
||||||
|
model,
|
||||||
|
cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999,
|
||||||
|
age: (Date.now() - model.time.released) / (1000 * 60 * 60 * 24 * 30),
|
||||||
|
small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()),
|
||||||
|
})),
|
||||||
|
Array.filter((item) => item.cost > 0 && item.age <= 18),
|
||||||
)
|
)
|
||||||
|
|
||||||
for (const family of priority) {
|
const pick = (items: typeof candidates) => {
|
||||||
const candidates = models.filter((model) => model.family === family)
|
const maxCost = Math.max(...items.map((item) => item.cost), 0.01)
|
||||||
if (providerID === ProviderV2.ID.amazonBedrock) {
|
const maxAge = Math.max(...items.map((item) => item.age), 0.01)
|
||||||
const crossRegionPrefixes = ["global.", "us.", "eu."]
|
return pipe(
|
||||||
const globalMatch = candidates.find((model) => model.id.startsWith("global."))
|
items,
|
||||||
if (globalMatch) return projectModel(globalMatch, provider)
|
Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
|
||||||
|
Array.map((item) => projectModel(item.model, provider)),
|
||||||
const region = typeof provider.settings?.region === "string" ? provider.settings.region : undefined
|
Array.head,
|
||||||
if (region) {
|
)
|
||||||
const regionPrefix = region.split("-")[0]
|
|
||||||
if (regionPrefix === "us" || regionPrefix === "eu") {
|
|
||||||
const regionalMatch = candidates.find((model) => model.id.startsWith(`${regionPrefix}.`))
|
|
||||||
if (regionalMatch) return projectModel(regionalMatch, provider)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const unprefixed = candidates.find(
|
|
||||||
(model) => !crossRegionPrefixes.some((prefix) => model.id.startsWith(prefix)),
|
|
||||||
)
|
|
||||||
if (unprefixed) return projectModel(unprefixed, provider)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (candidates[0]) return projectModel(candidates[0], provider)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Option.getOrUndefined(
|
||||||
|
pipe(
|
||||||
|
candidates,
|
||||||
|
Array.filter((item) => item.small),
|
||||||
|
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
|
||||||
|
),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -284,6 +267,6 @@ const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const SMALL_MODEL_FAMILY_PRIORITY = ["gemini-flash", "gpt-nano", "claude-haiku"]
|
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Integration.node] })
|
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Integration.node] })
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export * as EventV2 from "./event"
|
export * as EventV2 from "./event"
|
||||||
|
|
||||||
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
||||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||||
@@ -89,6 +89,11 @@ const decodeSerializedEvent = (event: SerializedEvent): Payload => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
|
||||||
|
"EventV2.SubscriberOverflow",
|
||||||
|
{ capacity: Schema.Int },
|
||||||
|
) {}
|
||||||
|
|
||||||
export const versionedType = Event.versionedType
|
export const versionedType = Event.versionedType
|
||||||
export const durable = Event.durable
|
export const durable = Event.durable
|
||||||
export const ephemeral = Event.ephemeral
|
export const ephemeral = Event.ephemeral
|
||||||
@@ -162,6 +167,27 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
||||||
|
|
||||||
|
export const liveBounded = (
|
||||||
|
events: Interface,
|
||||||
|
options: { readonly capacity: number; readonly accept?: (event: Payload) => boolean },
|
||||||
|
) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(options.capacity)
|
||||||
|
const unsubscribe = yield* events.listen((event) =>
|
||||||
|
options.accept && !options.accept(event)
|
||||||
|
? Effect.void
|
||||||
|
: Queue.offer(queue, event).pipe(
|
||||||
|
Effect.flatMap((accepted) =>
|
||||||
|
accepted
|
||||||
|
? Effect.void
|
||||||
|
: Queue.fail(queue, new SubscriberOverflowError({ capacity: options.capacity })).pipe(Effect.asVoid),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid))
|
||||||
|
return Stream.fromQueue(queue)
|
||||||
|
})
|
||||||
|
|
||||||
export interface LayerOptions {
|
export interface LayerOptions {
|
||||||
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
||||||
/** Maximum durable rows read per page while replaying or tailing an aggregate log. */
|
/** Maximum durable rows read per page while replaying or tailing an aggregate log. */
|
||||||
|
|||||||
@@ -128,8 +128,6 @@ export const fffLayer = Layer.effect(
|
|||||||
Fff.create({
|
Fff.create({
|
||||||
basePath: location.directory,
|
basePath: location.directory,
|
||||||
aiMode: true,
|
aiMode: true,
|
||||||
disableMmapCache: true,
|
|
||||||
disableContentIndexing: true,
|
|
||||||
}),
|
}),
|
||||||
catch: (cause) => cause,
|
catch: (cause) => cause,
|
||||||
}).pipe(
|
}).pipe(
|
||||||
@@ -232,13 +230,6 @@ export const fffLayer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const layer = Layer.unwrap(
|
const layer = Layer.unwrap(Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)))
|
||||||
Effect.gen(function* () {
|
|
||||||
if (Flag.OPENCODE_DISABLE_FFF || !Fff.available()) return ripgrepLayer
|
|
||||||
const location = yield* Location.Service
|
|
||||||
// Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree.
|
|
||||||
return location.vcs ? fffLayer : ripgrepLayer
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] })
|
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] })
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
|
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
|
||||||
Info.make({
|
new Info({
|
||||||
id: entry.ref.id,
|
id: entry.ref.id,
|
||||||
name: entry.ref.name,
|
name: entry.ref.name,
|
||||||
methods: entry.methods,
|
methods: entry.methods,
|
||||||
|
|||||||
@@ -148,12 +148,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
set: (providerID, modelID) =>
|
set: (providerID, modelID) =>
|
||||||
draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
||||||
},
|
},
|
||||||
small: {
|
|
||||||
get: (providerID) => draft.model.small.get(ProviderV2.ID.make(providerID)),
|
|
||||||
set: (providerID, modelID) =>
|
|
||||||
draft.model.small.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
|
||||||
remove: (providerID) => draft.model.small.remove(ProviderV2.ID.make(providerID)),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -119,8 +119,6 @@ function shouldUseResponses(modelID: string) {
|
|||||||
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
|
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
|
||||||
}
|
}
|
||||||
|
|
||||||
const UTILITY_MODELS = ["gpt-5.4-nano", "gpt-4.1", "gpt-4o", "gpt-4o-mini"]
|
|
||||||
|
|
||||||
export const GithubCopilotPlugin = define({
|
export const GithubCopilotPlugin = define({
|
||||||
id: "opencode.provider.github-copilot",
|
id: "opencode.provider.github-copilot",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
@@ -194,13 +192,6 @@ export const GithubCopilotPlugin = define({
|
|||||||
model.enabled = false
|
model.enabled = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// GitHub exposes utility models for title generation without including them in the picker.
|
|
||||||
const utility = UTILITY_MODELS.map((id) => ModelV2.ID.make(id)).find((id) => {
|
|
||||||
const model = evt.model.get(item.provider.id, id)
|
|
||||||
return model?.status === "active"
|
|
||||||
})
|
|
||||||
if (utility) evt.model.small.set(item.provider.id, utility)
|
|
||||||
else evt.model.small.remove(item.provider.id)
|
|
||||||
})
|
})
|
||||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||||
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const layer = Layer.effect(
|
|||||||
if (source.type === "local") {
|
if (source.type === "local") {
|
||||||
materialized.set(
|
materialized.set(
|
||||||
name,
|
name,
|
||||||
Info.make({
|
new Info({
|
||||||
name,
|
name,
|
||||||
path: source.path,
|
path: source.path,
|
||||||
...(source.description === undefined ? {} : { description: source.description }),
|
...(source.description === undefined ? {} : { description: source.description }),
|
||||||
@@ -88,7 +88,7 @@ const layer = Layer.effect(
|
|||||||
seen.set(target, source.branch)
|
seen.set(target, source.branch)
|
||||||
materialized.set(
|
materialized.set(
|
||||||
name,
|
name,
|
||||||
Info.make({
|
new Info({
|
||||||
name,
|
name,
|
||||||
path: AbsolutePath.make(target),
|
path: AbsolutePath.make(target),
|
||||||
...(source.description === undefined ? {} : { description: source.description }),
|
...(source.description === undefined ? {} : { description: source.description }),
|
||||||
|
|||||||
@@ -88,23 +88,12 @@ export interface Resolved {
|
|||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Resolved, Error>
|
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Resolved, Error>
|
||||||
readonly resolveCatalogModel: (
|
|
||||||
session: SessionSchema.Info,
|
|
||||||
model: ModelV2.Info,
|
|
||||||
) => Effect.Effect<Resolved, Error>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunnerModel") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunnerModel") {}
|
||||||
|
|
||||||
/** Test or embedding seam for supplying a model resolver directly. */
|
/** Test or embedding seam for supplying a model resolver directly. */
|
||||||
export const layerWith = (
|
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
||||||
resolve: Interface["resolve"],
|
|
||||||
resolveCatalogModel: Interface["resolveCatalogModel"] = (session, model) =>
|
|
||||||
resolve({
|
|
||||||
...session,
|
|
||||||
model: ModelV2.Ref.make({ id: model.id, providerID: model.providerID }),
|
|
||||||
}),
|
|
||||||
) => Layer.succeed(Service, Service.of({ resolve, resolveCatalogModel }))
|
|
||||||
|
|
||||||
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
|
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
|
||||||
export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({
|
export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({
|
||||||
@@ -318,43 +307,7 @@ const layer = Layer.effect(
|
|||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
const npm = yield* Npm.Service
|
const npm = yield* Npm.Service
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
const resolveCatalogModel = Effect.fn("SessionRunnerModel.resolveCatalogModel")(function* (
|
|
||||||
session: SessionSchema.Info,
|
|
||||||
selected: ModelV2.Info,
|
|
||||||
variant?: ModelV2.VariantID,
|
|
||||||
) {
|
|
||||||
const provider = yield* catalog.provider.get(selected.providerID)
|
|
||||||
const connection = yield* integrations.connection.active(
|
|
||||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
|
||||||
)
|
|
||||||
const model = yield* resolve(
|
|
||||||
{
|
|
||||||
...session,
|
|
||||||
model: ModelV2.Ref.make({
|
|
||||||
id: selected.id,
|
|
||||||
providerID: selected.providerID,
|
|
||||||
...(variant === undefined ? {} : { variant }),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
selected,
|
|
||||||
connection ? yield* integrations.connection.resolve(connection) : undefined,
|
|
||||||
{
|
|
||||||
loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm),
|
|
||||||
loadAISDK: (model) => aisdk.model(model),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
model,
|
|
||||||
ref: ModelV2.Ref.make({
|
|
||||||
id: selected.id,
|
|
||||||
providerID: selected.providerID,
|
|
||||||
...(variant === undefined ? {} : { variant }),
|
|
||||||
}),
|
|
||||||
cost: selected.cost,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
resolveCatalogModel,
|
|
||||||
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
||||||
// Location plugins populate and filter the catalog asynchronously during layer startup.
|
// Location plugins populate and filter the catalog asynchronously during layer startup.
|
||||||
const defaultModel = session.model ? undefined : yield* catalog.model.default()
|
const defaultModel = session.model ? undefined : yield* catalog.model.default()
|
||||||
@@ -371,7 +324,28 @@ const layer = Layer.effect(
|
|||||||
modelID: session.model.id,
|
modelID: session.model.id,
|
||||||
})
|
})
|
||||||
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
||||||
return yield* resolveCatalogModel(session, selected, session.model?.variant)
|
const provider = yield* catalog.provider.get(selected.providerID)
|
||||||
|
const connection = yield* integrations.connection.active(
|
||||||
|
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||||
|
)
|
||||||
|
const model = yield* resolve(
|
||||||
|
session,
|
||||||
|
selected,
|
||||||
|
connection ? yield* integrations.connection.resolve(connection) : undefined,
|
||||||
|
{
|
||||||
|
loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm),
|
||||||
|
loadAISDK: (model) => aisdk.model(model),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
ref: ModelV2.Ref.make({
|
||||||
|
id: selected.id,
|
||||||
|
providerID: selected.providerID,
|
||||||
|
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
|
||||||
|
}),
|
||||||
|
cost: selected.cost,
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -220,31 +220,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
yield* flushFragments()
|
yield* flushFragments()
|
||||||
})
|
})
|
||||||
|
|
||||||
const failTools = Effect.fnUntraced(function* (error: SessionError.Error, mode: "all" | "hosted" | "uncalled") {
|
|
||||||
let failed = false
|
|
||||||
for (const [callID, tool] of tools) {
|
|
||||||
if (
|
|
||||||
tool.settled ||
|
|
||||||
(mode === "hosted" && !tool.providerExecuted) ||
|
|
||||||
(mode === "uncalled" && tool.called)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
tool.settled = true
|
|
||||||
failed = true
|
|
||||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
assistantMessageID: tool.assistantMessageID,
|
|
||||||
callID,
|
|
||||||
error,
|
|
||||||
executed: tool.providerExecuted,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return failed
|
|
||||||
})
|
|
||||||
|
|
||||||
const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error, replace = false) {
|
const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error, replace = false) {
|
||||||
yield* flush()
|
yield* flush()
|
||||||
yield* failTools(error, "uncalled")
|
|
||||||
yield* startAssistant()
|
yield* startAssistant()
|
||||||
if (replace || stepFailure === undefined) stepFailure = error
|
if (replace || stepFailure === undefined) stepFailure = error
|
||||||
})
|
})
|
||||||
@@ -268,7 +245,20 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
error: SessionError.Error,
|
error: SessionError.Error,
|
||||||
hostedOnly = false,
|
hostedOnly = false,
|
||||||
) {
|
) {
|
||||||
return yield* failTools(error, hostedOnly ? "hosted" : "all")
|
let failed = false
|
||||||
|
for (const [callID, tool] of tools) {
|
||||||
|
if (tool.settled || (hostedOnly && !tool.providerExecuted)) continue
|
||||||
|
tool.settled = true
|
||||||
|
failed = true
|
||||||
|
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
assistantMessageID: tool.assistantMessageID,
|
||||||
|
callID,
|
||||||
|
error,
|
||||||
|
executed: tool.providerExecuted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return failed
|
||||||
})
|
})
|
||||||
|
|
||||||
const assistantMessageIDForTool = (callID: string) => {
|
const assistantMessageIDForTool = (callID: string) => {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export * as SessionTitle from "./title"
|
|||||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/llm"
|
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/llm"
|
||||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||||
import { AgentV2 } from "../agent"
|
import { AgentV2 } from "../agent"
|
||||||
import { Catalog } from "../catalog"
|
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
@@ -22,7 +21,6 @@ type Dependencies = {
|
|||||||
}
|
}
|
||||||
readonly agents: AgentV2.Interface
|
readonly agents: AgentV2.Interface
|
||||||
readonly models: SessionRunnerModel.Interface
|
readonly models: SessionRunnerModel.Interface
|
||||||
readonly catalog: Catalog.Interface
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
@@ -47,14 +45,7 @@ const make = (dependencies: Dependencies) => {
|
|||||||
const resolved = yield* (
|
const resolved = yield* (
|
||||||
agent.model
|
agent.model
|
||||||
? dependencies.models.resolve({ ...session, model: agent.model })
|
? dependencies.models.resolve({ ...session, model: agent.model })
|
||||||
: Effect.gen(function* () {
|
: dependencies.models.resolve(session)
|
||||||
const providerID = session.model?.providerID ?? (yield* dependencies.catalog.model.default())?.providerID
|
|
||||||
const small = providerID ? yield* dependencies.catalog.model.small(providerID) : undefined
|
|
||||||
if (!small) return yield* dependencies.models.resolve(session)
|
|
||||||
return yield* dependencies.models
|
|
||||||
.resolveCatalogModel(session, small)
|
|
||||||
.pipe(Effect.catch(() => dependencies.models.resolve(session)))
|
|
||||||
})
|
|
||||||
).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
if (!resolved) return
|
if (!resolved) return
|
||||||
const chunks: string[] = []
|
const chunks: string[] = []
|
||||||
@@ -99,9 +90,8 @@ export const layer = Layer.effect(
|
|||||||
const llm = yield* LLMClient.Service
|
const llm = yield* LLMClient.Service
|
||||||
const agents = yield* AgentV2.Service
|
const agents = yield* AgentV2.Service
|
||||||
const models = yield* SessionRunnerModel.Service
|
const models = yield* SessionRunnerModel.Service
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
const database = yield* Database.Service
|
const database = yield* Database.Service
|
||||||
const title = make({ events, llm, agents, models, catalog })
|
const title = make({ events, llm, agents, models })
|
||||||
return Service.of({
|
return Service.of({
|
||||||
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
|
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
|
||||||
})
|
})
|
||||||
@@ -111,5 +101,5 @@ export const layer = Layer.effect(
|
|||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Catalog.node, Database.node],
|
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import { Tool } from "./tool"
|
|||||||
export const name = "subagent"
|
export const name = "subagent"
|
||||||
|
|
||||||
const NO_TEXT = "Subagent completed without a text response."
|
const NO_TEXT = "Subagent completed without a text response."
|
||||||
const backgroundStarted = (sessionID: SessionSchema.ID) =>
|
const BACKGROUND_STARTED =
|
||||||
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress.`
|
"The subagent is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress."
|
||||||
|
|
||||||
export const Input = Schema.Struct({
|
export const Input = Schema.Struct({
|
||||||
agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }),
|
agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }),
|
||||||
@@ -65,7 +65,6 @@ export const Plugin = {
|
|||||||
const injectCompletion = Effect.fn("SubagentTool.injectCompletion")(function* (
|
const injectCompletion = Effect.fn("SubagentTool.injectCompletion")(function* (
|
||||||
parentID: SessionSchema.ID,
|
parentID: SessionSchema.ID,
|
||||||
childID: SessionSchema.ID,
|
childID: SessionSchema.ID,
|
||||||
agent: string,
|
|
||||||
description: string,
|
description: string,
|
||||||
state: "completed" | "error" | "cancelled",
|
state: "completed" | "error" | "cancelled",
|
||||||
text: string,
|
text: string,
|
||||||
@@ -73,32 +72,22 @@ export const Plugin = {
|
|||||||
yield* runtime.session.synthetic({
|
yield* runtime.session.synthetic({
|
||||||
sessionID: parentID,
|
sessionID: parentID,
|
||||||
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
|
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
|
||||||
description,
|
|
||||||
metadata: { source: "subagent", childID, agent, state },
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* (
|
const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* (
|
||||||
parentID: SessionSchema.ID,
|
parentID: SessionSchema.ID,
|
||||||
childID: SessionSchema.ID,
|
childID: SessionSchema.ID,
|
||||||
agent: string,
|
|
||||||
description: string,
|
description: string,
|
||||||
) {
|
) {
|
||||||
yield* runtime.job.wait({ id: childID }).pipe(
|
yield* runtime.job.wait({ id: childID }).pipe(
|
||||||
Effect.flatMap((result) => {
|
Effect.flatMap((result) => {
|
||||||
if (result.info?.status === "completed")
|
if (result.info?.status === "completed")
|
||||||
return injectCompletion(parentID, childID, agent, description, "completed", result.info.output ?? NO_TEXT)
|
return injectCompletion(parentID, childID, description, "completed", result.info.output ?? NO_TEXT)
|
||||||
if (result.info?.status === "error")
|
if (result.info?.status === "error")
|
||||||
return injectCompletion(
|
return injectCompletion(parentID, childID, description, "error", result.info.error ?? "Subagent failed")
|
||||||
parentID,
|
|
||||||
childID,
|
|
||||||
agent,
|
|
||||||
description,
|
|
||||||
"error",
|
|
||||||
result.info.error ?? "Subagent failed",
|
|
||||||
)
|
|
||||||
if (result.info?.status === "cancelled")
|
if (result.info?.status === "cancelled")
|
||||||
return injectCompletion(parentID, childID, agent, description, "cancelled", "Subagent cancelled")
|
return injectCompletion(parentID, childID, description, "cancelled", "Subagent cancelled")
|
||||||
return Effect.void
|
return Effect.void
|
||||||
}),
|
}),
|
||||||
Effect.forkIn(scope, { startImmediately: true }),
|
Effect.forkIn(scope, { startImmediately: true }),
|
||||||
@@ -178,12 +167,8 @@ export const Plugin = {
|
|||||||
|
|
||||||
if (background) {
|
if (background) {
|
||||||
yield* runtime.job.background(info.id)
|
yield* runtime.job.background(info.id)
|
||||||
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
|
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
||||||
return {
|
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
||||||
sessionID: child.id,
|
|
||||||
status: "running" as const,
|
|
||||||
output: backgroundStarted(child.id),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
|
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
|
||||||
@@ -194,12 +179,8 @@ export const Plugin = {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (result?.type === "backgrounded") {
|
if (result?.type === "backgrounded") {
|
||||||
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
|
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
||||||
return {
|
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
||||||
sessionID: child.id,
|
|
||||||
status: "running" as const,
|
|
||||||
output: backgroundStarted(child.id),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (result?.info.status === "error")
|
if (result?.info.status === "error")
|
||||||
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
|
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
|
||||||
|
|||||||
@@ -290,216 +290,45 @@ describe("CatalogV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("small model selects the latest model in the preferred family", () =>
|
it.effect("small model prefers small keyword candidates before cost scoring", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const providerID = ProviderV2.ID.make("test")
|
const providerID = ProviderV2.ID.make("test")
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
catalog.provider.update(providerID, () => {})
|
catalog.provider.update(providerID, () => {})
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("old-flash"), (model) => {
|
catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
|
||||||
model.family = ModelV2.Family.make("gemini-flash")
|
model.capabilities.input = ["text"]
|
||||||
model.time.released = Date.now() - 1000
|
model.capabilities.output = ["text"]
|
||||||
})
|
model.cost = [
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("new-flash"), (model) => {
|
{
|
||||||
model.family = ModelV2.Family.make("gemini-flash")
|
input: Money.USDPerMillionTokens.make(1),
|
||||||
|
output: Money.USDPerMillionTokens.make(1),
|
||||||
|
cache: {
|
||||||
|
read: Money.USDPerMillionTokens.zero,
|
||||||
|
write: Money.USDPerMillionTokens.zero,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
model.time.released = Date.now()
|
model.time.released = Date.now()
|
||||||
})
|
})
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("newer-haiku"), (model) => {
|
catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
|
||||||
model.family = ModelV2.Family.make("claude-haiku")
|
model.capabilities.input = ["text"]
|
||||||
model.time.released = Date.now() + 1000
|
model.capabilities.output = ["text"]
|
||||||
})
|
model.cost = [
|
||||||
})
|
{
|
||||||
|
input: Money.USDPerMillionTokens.make(10),
|
||||||
expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("new-flash"))
|
output: Money.USDPerMillionTokens.make(10),
|
||||||
}),
|
cache: {
|
||||||
)
|
read: Money.USDPerMillionTokens.zero,
|
||||||
|
write: Money.USDPerMillionTokens.zero,
|
||||||
it.effect("small model matches exact model families", () =>
|
},
|
||||||
Effect.gen(function* () {
|
},
|
||||||
const catalog = yield* Catalog.Service
|
]
|
||||||
const providerID = ProviderV2.ID.make("test")
|
|
||||||
yield* catalog.transform((catalog) => {
|
|
||||||
catalog.provider.update(providerID, () => {})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("glm-flash"), (model) => {
|
|
||||||
model.family = ModelV2.Family.make("glm-flash")
|
|
||||||
model.time.released = Date.now() + 1000
|
|
||||||
})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("claude-haiku"), (model) => {
|
|
||||||
model.family = ModelV2.Family.make("claude-haiku")
|
|
||||||
model.time.released = Date.now()
|
model.time.released = Date.now()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("claude-haiku"))
|
expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("small model ignores model IDs without family metadata", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
const providerID = ProviderV2.ID.make("test")
|
|
||||||
yield* catalog.transform((catalog) => {
|
|
||||||
catalog.provider.update(providerID, () => {})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
|
|
||||||
model.time.released = Date.now()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(yield* catalog.model.small(providerID)).toBeUndefined()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("small model prefers gpt-nano family for opencode providers", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
const providerID = ProviderV2.ID.opencode
|
|
||||||
yield* catalog.transform((catalog) => {
|
|
||||||
catalog.provider.update(providerID, () => {})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("old-nano"), (model) => {
|
|
||||||
model.family = ModelV2.Family.make("gpt-nano")
|
|
||||||
model.time.released = Date.now() - 1000
|
|
||||||
})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("new-nano"), (model) => {
|
|
||||||
model.family = ModelV2.Family.make("gpt-nano")
|
|
||||||
model.time.released = Date.now()
|
|
||||||
})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("flash"), (model) => {
|
|
||||||
model.family = ModelV2.Family.make("gemini-flash")
|
|
||||||
model.time.released = Date.now() + 1000
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("new-nano"))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("small model prefers gpt-mini family for github-copilot providers", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
const providerID = ProviderV2.ID.githubCopilot
|
|
||||||
yield* catalog.transform((catalog) => {
|
|
||||||
catalog.provider.update(providerID, () => {})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("flash"), (model) => {
|
|
||||||
model.family = ModelV2.Family.make("gemini-flash")
|
|
||||||
model.time.released = Date.now()
|
|
||||||
})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("mini"), (model) => {
|
|
||||||
model.family = ModelV2.Family.make("gpt-mini")
|
|
||||||
model.time.released = Date.now() - 1000
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("mini"))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("small model prefers a plugin-configured small model override", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
const providerID = ProviderV2.ID.githubCopilot
|
|
||||||
yield* catalog.transform((catalog) => {
|
|
||||||
catalog.provider.update(providerID, () => {})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("mini"), (model) => {
|
|
||||||
model.family = ModelV2.Family.make("gpt-mini")
|
|
||||||
model.time.released = Date.now()
|
|
||||||
})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("gpt-5.4-nano"), (model) => {
|
|
||||||
model.enabled = false
|
|
||||||
model.time.released = Date.now() - 2000
|
|
||||||
})
|
|
||||||
catalog.model.small.set(providerID, ModelV2.ID.make("gpt-5.4-nano"))
|
|
||||||
})
|
|
||||||
|
|
||||||
expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("gpt-5.4-nano"))
|
|
||||||
expect((yield* catalog.model.available()).some((model) => model.id === ModelV2.ID.make("gpt-5.4-nano"))).toBe(
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("small model prefers global bedrock deployments before regional ones", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
const providerID = ProviderV2.ID.amazonBedrock
|
|
||||||
yield* catalog.transform((catalog) => {
|
|
||||||
catalog.provider.update(providerID, (provider) => {
|
|
||||||
provider.settings = { region: "us-west-2" }
|
|
||||||
})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("us.claude-haiku"), (model) => {
|
|
||||||
model.family = ModelV2.Family.make("claude-haiku")
|
|
||||||
model.time.released = Date.now()
|
|
||||||
})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("global.claude-haiku"), (model) => {
|
|
||||||
model.family = ModelV2.Family.make("claude-haiku")
|
|
||||||
model.time.released = Date.now() - 1000
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("global.claude-haiku"))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("small model prefers regional bedrock deployments when global is missing", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
const providerID = ProviderV2.ID.amazonBedrock
|
|
||||||
yield* catalog.transform((catalog) => {
|
|
||||||
catalog.provider.update(providerID, (provider) => {
|
|
||||||
provider.settings = { region: "us-west-2" }
|
|
||||||
})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("claude-haiku"), (model) => {
|
|
||||||
model.family = ModelV2.Family.make("claude-haiku")
|
|
||||||
model.time.released = Date.now()
|
|
||||||
})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("us.claude-haiku"), (model) => {
|
|
||||||
model.family = ModelV2.Family.make("claude-haiku")
|
|
||||||
model.time.released = Date.now() - 1000
|
|
||||||
})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("eu.claude-haiku"), (model) => {
|
|
||||||
model.family = ModelV2.Family.make("claude-haiku")
|
|
||||||
model.time.released = Date.now() + 1000
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("us.claude-haiku"))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("small model prefers unprefixed bedrock deployments when region prefixes are missing", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
const providerID = ProviderV2.ID.amazonBedrock
|
|
||||||
yield* catalog.transform((catalog) => {
|
|
||||||
catalog.provider.update(providerID, (provider) => {
|
|
||||||
provider.settings = { region: "ap-northeast-1" }
|
|
||||||
})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("eu.claude-haiku"), (model) => {
|
|
||||||
model.family = ModelV2.Family.make("claude-haiku")
|
|
||||||
model.time.released = Date.now()
|
|
||||||
})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("claude-haiku"), (model) => {
|
|
||||||
model.family = ModelV2.Family.make("claude-haiku")
|
|
||||||
model.time.released = Date.now() - 1000
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("claude-haiku"))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("small model skips inferred models for Azure providers", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
for (const providerID of [ProviderV2.ID.azure, ProviderV2.ID.make("azure-cognitive-services")]) {
|
|
||||||
yield* catalog.transform((catalog) => {
|
|
||||||
catalog.provider.update(providerID, () => {})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("small"), (model) => {
|
|
||||||
model.family = ModelV2.Family.make("gemini-flash")
|
|
||||||
model.time.released = Date.now()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
expect(yield* catalog.model.small(providerID)).toBeUndefined()
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
|
|||||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect"
|
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
|
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||||
|
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||||
@@ -361,6 +363,51 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("ends only an overflowing bounded subscriber without blocking other listeners", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const consuming = yield* Deferred.make<void>()
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
const slowStream = yield* EventV2.liveBounded(events, { capacity: 1 })
|
||||||
|
const fastStream = yield* EventV2.liveBounded(events, { capacity: 8 })
|
||||||
|
const slow = yield* slowStream.pipe(
|
||||||
|
Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))),
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
const fast = yield* fastStream.pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
|
||||||
|
|
||||||
|
yield* events.publish(Message, { text: "one" })
|
||||||
|
yield* Deferred.await(consuming)
|
||||||
|
yield* events.publish(Message, { text: "two" })
|
||||||
|
yield* events.publish(Message, { text: "overflow" })
|
||||||
|
const last = yield* events.publish(Message, { text: "still delivered" })
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
|
||||||
|
const slowExit = yield* Fiber.await(slow)
|
||||||
|
expect(Exit.findErrorOption(slowExit).pipe(Option.getOrUndefined)).toBeInstanceOf(EventV2.SubscriberOverflowError)
|
||||||
|
expect(Array.from(yield* Fiber.join(fast))).toEqual([
|
||||||
|
expect.objectContaining({ data: { text: "one" } }),
|
||||||
|
expect.objectContaining({ data: { text: "two" } }),
|
||||||
|
expect.objectContaining({ data: { text: "overflow" } }),
|
||||||
|
last,
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("filters internal events before they enter a bounded server stream", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const stream = yield* EventV2.liveBounded(events, { capacity: 1, accept: EventManifest.isServer })
|
||||||
|
const received = yield* stream.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
|
|
||||||
|
yield* events.publish(McpEvent.ToolsChanged, { server: "one" })
|
||||||
|
yield* events.publish(McpEvent.ToolsChanged, { server: "two" })
|
||||||
|
const published = yield* events.publish(McpEvent.StatusChanged, { server: "example" })
|
||||||
|
|
||||||
|
expect(Array.from(yield* Fiber.join(received))).toEqual([published])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("preserves observer interruption", () =>
|
it.effect("preserves observer interruption", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ describe("Integration", () => {
|
|||||||
.transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI")))
|
.transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI")))
|
||||||
.pipe(Scope.provide(scope))
|
.pipe(Scope.provide(scope))
|
||||||
expect(yield* integrations.get(openai)).toEqual(
|
expect(yield* integrations.get(openai)).toEqual(
|
||||||
Integration.Info.make({ id: openai, name: "OpenAI", methods: [], connections: [] }),
|
new Integration.Info({ id: openai, name: "OpenAI", methods: [], connections: [] }),
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* Scope.close(scope, Exit.void)
|
yield* Scope.close(scope, Exit.void)
|
||||||
|
|||||||
@@ -178,12 +178,6 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"
|
|||||||
set: (providerID, modelID) =>
|
set: (providerID, modelID) =>
|
||||||
draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
||||||
},
|
},
|
||||||
small: {
|
|
||||||
get: (providerID) => draft.model.small.get(ProviderV2.ID.make(providerID)),
|
|
||||||
set: (providerID, modelID) =>
|
|
||||||
draft.model.small.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
|
||||||
remove: (providerID) => draft.model.small.remove(ProviderV2.ID.make(providerID)),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ describe("ModelsDevPlugin", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
expect(yield* integrations.list()).toEqual([
|
expect(yield* integrations.list()).toEqual([
|
||||||
Integration.Info.make({
|
new Integration.Info({
|
||||||
id: Integration.ID.make("acme"),
|
id: Integration.ID.make("acme"),
|
||||||
name: "Acme",
|
name: "Acme",
|
||||||
methods: [
|
methods: [
|
||||||
|
|||||||
@@ -289,28 +289,6 @@ describe("GithubCopilotPlugin", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("pins picker-hidden utility models for internal small-model work", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
const providerID = ProviderV2.ID.githubCopilot
|
|
||||||
yield* catalog.transform((catalog) => {
|
|
||||||
catalog.provider.update(providerID, () => {})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("gpt-4.1"), (model) => {
|
|
||||||
model.enabled = false
|
|
||||||
})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("gpt-5.4-nano"), (model) => {
|
|
||||||
model.enabled = false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
yield* addPlugin()
|
|
||||||
|
|
||||||
expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("gpt-5.4-nano"))
|
|
||||||
expect((yield* catalog.model.available()).some((model) => model.id === ModelV2.ID.make("gpt-5.4-nano"))).toBe(
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () =>
|
it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
|
|||||||
@@ -481,38 +481,30 @@ describe("OpencodePlugin", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("prefers the newest gpt-nano family model for opencode", () =>
|
it.effect("prefers gpt-5-nano as the opencode small model", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const providerID = ProviderV2.ID.opencode
|
const providerID = ProviderV2.ID.opencode
|
||||||
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
catalog.provider.update(providerID, () => {})
|
catalog.provider.update(providerID, () => {})
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
|
catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
|
||||||
model.family = ModelV2.Family.make("gpt-nano")
|
|
||||||
model.capabilities.input = ["text"]
|
|
||||||
model.capabilities.output = ["text"]
|
|
||||||
model.cost = [...cost(10, 10)]
|
|
||||||
model.time.released = Date.now() - 1000
|
|
||||||
})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("gpt-5.4-nano"), (model) => {
|
|
||||||
model.family = ModelV2.Family.make("gpt-nano")
|
|
||||||
model.capabilities.input = ["text"]
|
model.capabilities.input = ["text"]
|
||||||
model.capabilities.output = ["text"]
|
model.capabilities.output = ["text"]
|
||||||
model.cost = [...cost(1, 1)]
|
model.cost = [...cost(1, 1)]
|
||||||
model.time.released = Date.now()
|
model.time.released = Date.now()
|
||||||
})
|
})
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
|
catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
|
||||||
model.capabilities.input = ["text"]
|
model.capabilities.input = ["text"]
|
||||||
model.capabilities.output = ["text"]
|
model.capabilities.output = ["text"]
|
||||||
model.cost = [...cost(1, 1)]
|
model.cost = [...cost(10, 10)]
|
||||||
model.time.released = Date.now() + 1000
|
model.time.released = Date.now()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const selected = yield* catalog.model.small(providerID)
|
const selected = yield* catalog.model.small(providerID)
|
||||||
|
|
||||||
expect(selected?.id).toBe(ModelV2.ID.make("gpt-5.4-nano"))
|
expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe("ReferenceGuidance", () => {
|
|||||||
Layer.mock(Reference.Service, {
|
Layer.mock(Reference.Service, {
|
||||||
list: () =>
|
list: () =>
|
||||||
Effect.succeed([
|
Effect.succeed([
|
||||||
Reference.Info.make({
|
new Reference.Info({
|
||||||
name: "docs",
|
name: "docs",
|
||||||
path: AbsolutePath.make("/docs"),
|
path: AbsolutePath.make("/docs"),
|
||||||
description: "Use for product documentation",
|
description: "Use for product documentation",
|
||||||
@@ -62,7 +62,7 @@ describe("ReferenceGuidance", () => {
|
|||||||
Layer.mock(Reference.Service, {
|
Layer.mock(Reference.Service, {
|
||||||
list: () =>
|
list: () =>
|
||||||
Effect.succeed([
|
Effect.succeed([
|
||||||
Reference.Info.make({
|
new Reference.Info({
|
||||||
name: "docs",
|
name: "docs",
|
||||||
path: AbsolutePath.make("/docs"),
|
path: AbsolutePath.make("/docs"),
|
||||||
source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }),
|
source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }),
|
||||||
@@ -76,7 +76,7 @@ describe("ReferenceGuidance", () => {
|
|||||||
|
|
||||||
it.effect("announces added and removed references as deltas", () => {
|
it.effect("announces added and removed references as deltas", () => {
|
||||||
const reference = (name: string, description: string) =>
|
const reference = (name: string, description: string) =>
|
||||||
Reference.Info.make({
|
new Reference.Info({
|
||||||
name,
|
name,
|
||||||
path: AbsolutePath.make(`/${name}`),
|
path: AbsolutePath.make(`/${name}`),
|
||||||
description,
|
description,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ describe("Reference", () => {
|
|||||||
yield* references.transform((editor) => editor.add("docs", source)).pipe(Scope.provide(scope))
|
yield* references.transform((editor) => editor.add("docs", source)).pipe(Scope.provide(scope))
|
||||||
|
|
||||||
expect(yield* references.list()).toEqual([
|
expect(yield* references.list()).toEqual([
|
||||||
Reference.Info.make({ name: "docs", path, description: "Use for API documentation", hidden: true, source }),
|
new Reference.Info({ name: "docs", path, description: "Use for API documentation", hidden: true, source }),
|
||||||
])
|
])
|
||||||
|
|
||||||
yield* Scope.close(scope, Exit.void)
|
yield* Scope.close(scope, Exit.void)
|
||||||
@@ -45,7 +45,7 @@ describe("Reference", () => {
|
|||||||
yield* references.transform((editor) => editor.add("sdk", source))
|
yield* references.transform((editor) => editor.add("sdk", source))
|
||||||
|
|
||||||
expect(yield* references.list()).toEqual([
|
expect(yield* references.list()).toEqual([
|
||||||
Reference.Info.make({
|
new Reference.Info({
|
||||||
name: "sdk",
|
name: "sdk",
|
||||||
path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)),
|
path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)),
|
||||||
source,
|
source,
|
||||||
@@ -66,7 +66,7 @@ describe("Reference", () => {
|
|||||||
yield* references.transform((editor) => editor.add("sdk", source))
|
yield* references.transform((editor) => editor.add("sdk", source))
|
||||||
|
|
||||||
expect(yield* references.list()).toEqual([
|
expect(yield* references.list()).toEqual([
|
||||||
Reference.Info.make({
|
new Reference.Info({
|
||||||
name: "sdk",
|
name: "sdk",
|
||||||
path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)),
|
path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)),
|
||||||
description: "Use for SDK implementation details",
|
description: "Use for SDK implementation details",
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
Model,
|
Model,
|
||||||
ToolFailure,
|
ToolFailure,
|
||||||
TransportReason,
|
TransportReason,
|
||||||
InvalidProviderOutputReason,
|
|
||||||
InvalidRequestReason,
|
InvalidRequestReason,
|
||||||
RateLimitReason,
|
RateLimitReason,
|
||||||
type LLMClientShape,
|
type LLMClientShape,
|
||||||
@@ -717,18 +716,7 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
|
|||||||
type: "assistant",
|
type: "assistant",
|
||||||
finish: "error",
|
finish: "error",
|
||||||
error: { type: "provider.transport", message: "Provider unavailable" },
|
error: { type: "provider.transport", message: "Provider unavailable" },
|
||||||
content: [
|
content: [fixture.expectedContent],
|
||||||
kind === "tool input"
|
|
||||||
? {
|
|
||||||
type: "tool",
|
|
||||||
id: fragmentID(kind, "partial"),
|
|
||||||
state: {
|
|
||||||
status: "error",
|
|
||||||
error: { type: "provider.transport", message: "Provider unavailable" },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: fixture.expectedContent,
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
expect(requests).toHaveLength(1)
|
expect(requests).toHaveLength(1)
|
||||||
@@ -3888,45 +3876,6 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("settles malformed streamed tool input before the provider failure", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const session = yield* setup
|
|
||||||
yield* admit(session, "Call a malformed tool")
|
|
||||||
const failure = new LLMError({
|
|
||||||
module: "test",
|
|
||||||
method: "stream",
|
|
||||||
reason: new InvalidProviderOutputReason({ message: "Invalid JSON input for tool call echo" }),
|
|
||||||
})
|
|
||||||
responseStream = Stream.fromIterable([
|
|
||||||
LLMEvent.stepStart({ index: 0 }),
|
|
||||||
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
|
|
||||||
LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: '{"text":"partial' }),
|
|
||||||
]).pipe(Stream.concat(Stream.fail(failure)))
|
|
||||||
|
|
||||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
|
||||||
const assistant = requireAssistant(yield* session.context(sessionID))
|
|
||||||
|
|
||||||
response = reply.stop()
|
|
||||||
yield* admit(session, "Continue")
|
|
||||||
yield* session.resume(sessionID)
|
|
||||||
|
|
||||||
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
|
|
||||||
{ type: "session.step.started.1" },
|
|
||||||
{
|
|
||||||
type: "session.tool.failed.1",
|
|
||||||
data: {
|
|
||||||
callID: "call-malformed",
|
|
||||||
error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "session.step.failed.1",
|
|
||||||
data: { error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" } },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("does not continue automatically after a provider error follows a local tool call", () =>
|
it.effect("does not continue automatically after a provider error follows a local tool call", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
|
|||||||
@@ -2,14 +2,11 @@ import { expect } from "bun:test"
|
|||||||
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/llm"
|
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/llm"
|
||||||
import { OpenAIChat } from "@opencode-ai/llm/protocols"
|
import { OpenAIChat } from "@opencode-ai/llm/protocols"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
|
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
|
||||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
@@ -21,12 +18,10 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||||||
import { Project } from "@opencode-ai/core/project"
|
import { Project } from "@opencode-ai/core/project"
|
||||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Effect, Layer, Stream } from "effect"
|
import { DateTime, Effect, Layer, Stream } from "effect"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
let requests: LLMRequest[] = []
|
let requests: LLMRequest[] = []
|
||||||
let resolvedModels: Array<{ id: string; provider: string } | undefined> = []
|
|
||||||
let failSmallResolve = false
|
|
||||||
const model = Model.make({
|
const model = Model.make({
|
||||||
id: "title-model",
|
id: "title-model",
|
||||||
provider: "test",
|
provider: "test",
|
||||||
@@ -41,35 +36,7 @@ const client = Layer.mock(LLMClient.Service)({
|
|||||||
generate: () => Effect.die("unused"),
|
generate: () => Effect.die("unused"),
|
||||||
})
|
})
|
||||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||||
resolve: (session) => {
|
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
|
||||||
resolvedModels.push(session.model ? { id: session.model.id, provider: session.model.providerID } : undefined)
|
|
||||||
if (failSmallResolve && session.model?.id === "mini") {
|
|
||||||
return Effect.fail(new Error("small unavailable") as never)
|
|
||||||
}
|
|
||||||
const selected = session.model
|
|
||||||
? Model.make({
|
|
||||||
id: session.model.id,
|
|
||||||
provider: session.model.providerID,
|
|
||||||
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
|
|
||||||
})
|
|
||||||
: model
|
|
||||||
return Effect.succeed(SessionRunnerModel.resolved(selected))
|
|
||||||
},
|
|
||||||
resolveCatalogModel: (session, selected) => {
|
|
||||||
resolvedModels.push({ id: selected.id, provider: selected.providerID })
|
|
||||||
if (failSmallResolve && selected.id === ModelV2.ID.make("mini")) {
|
|
||||||
return Effect.fail(new Error("small unavailable") as never)
|
|
||||||
}
|
|
||||||
return Effect.succeed(
|
|
||||||
SessionRunnerModel.resolved(
|
|
||||||
Model.make({
|
|
||||||
id: selected.id,
|
|
||||||
provider: selected.providerID,
|
|
||||||
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
AppNodeBuilder.build(
|
AppNodeBuilder.build(
|
||||||
@@ -79,7 +46,6 @@ const it = testEffect(
|
|||||||
SessionProjector.node,
|
SessionProjector.node,
|
||||||
SessionStore.node,
|
SessionStore.node,
|
||||||
AgentV2.node,
|
AgentV2.node,
|
||||||
Catalog.node,
|
|
||||||
SessionTitle.node,
|
SessionTitle.node,
|
||||||
]),
|
]),
|
||||||
[
|
[
|
||||||
@@ -89,10 +55,7 @@ const it = testEffect(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const insertSession = (
|
const insertSession = (id: SessionV2.ID) =>
|
||||||
id: SessionV2.ID,
|
|
||||||
model?: { id: string; providerID: string },
|
|
||||||
) =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
yield* db
|
yield* db
|
||||||
@@ -110,14 +73,6 @@ const insertSession = (
|
|||||||
directory: "/project",
|
directory: "/project",
|
||||||
title: "New session - fake",
|
title: "New session - fake",
|
||||||
version: "test",
|
version: "test",
|
||||||
...(model
|
|
||||||
? {
|
|
||||||
model: {
|
|
||||||
id: ModelV2.ID.make(model.id),
|
|
||||||
providerID: ProviderV2.ID.make(model.providerID),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
})
|
})
|
||||||
.onConflictDoNothing()
|
.onConflictDoNothing()
|
||||||
.run()
|
.run()
|
||||||
@@ -139,47 +94,17 @@ const prompt = (sessionID: SessionV2.ID, text: string) =>
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const enableTitleAgent = (model?: { id: string; providerID: string }) =>
|
it.effect("generates a title from the sole user message and renames the session", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
requests = []
|
||||||
const agentService = yield* AgentV2.Service
|
const agentService = yield* AgentV2.Service
|
||||||
yield* agentService.transform((editor) => {
|
yield* agentService.transform((editor) => {
|
||||||
editor.update(AgentV2.ID.make("title"), (agent) => {
|
editor.update(AgentV2.ID.make("title"), (agent) => {
|
||||||
agent.mode = "primary"
|
agent.mode = "primary"
|
||||||
agent.hidden = true
|
agent.hidden = true
|
||||||
agent.system = "You are a title generator."
|
agent.system = "You are a title generator."
|
||||||
if (model) {
|
|
||||||
agent.model = {
|
|
||||||
id: ModelV2.ID.make(model.id),
|
|
||||||
providerID: ProviderV2.ID.make(model.providerID),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
const seedSmallModel = () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
const providerID = ProviderV2.ID.make("test")
|
|
||||||
yield* catalog.transform((catalog) => {
|
|
||||||
catalog.provider.update(providerID, () => {})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("main"), (model) => {
|
|
||||||
model.time.released = Date.now()
|
|
||||||
})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("mini"), (model) => {
|
|
||||||
model.enabled = false
|
|
||||||
model.family = ModelV2.Family.make("gpt-nano")
|
|
||||||
model.time.released = Date.now()
|
|
||||||
})
|
|
||||||
catalog.model.small.set(providerID, ModelV2.ID.make("mini"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it.effect("generates a title from the sole user message and renames the session", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
requests = []
|
|
||||||
resolvedModels = []
|
|
||||||
yield* enableTitleAgent()
|
|
||||||
const sessionID = SessionV2.ID.make("ses_title_generate")
|
const sessionID = SessionV2.ID.make("ses_title_generate")
|
||||||
yield* insertSession(sessionID)
|
yield* insertSession(sessionID)
|
||||||
yield* prompt(sessionID, "Help me debug the failing build")
|
yield* prompt(sessionID, "Help me debug the failing build")
|
||||||
@@ -198,130 +123,17 @@ it.effect("generates a title from the sole user message and renames the session"
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("prefers the catalog small model over the session model", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
requests = []
|
|
||||||
resolvedModels = []
|
|
||||||
yield* enableTitleAgent()
|
|
||||||
yield* seedSmallModel()
|
|
||||||
const sessionID = SessionV2.ID.make("ses_title_small_model")
|
|
||||||
yield* insertSession(sessionID, { id: "main", providerID: "test" })
|
|
||||||
yield* prompt(sessionID, "Help me debug the failing build")
|
|
||||||
|
|
||||||
const store = yield* SessionStore.Service
|
|
||||||
const session = yield* store
|
|
||||||
.get(sessionID)
|
|
||||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
|
|
||||||
const title = yield* SessionTitle.Service
|
|
||||||
yield* title.generateForFirstPrompt(session)
|
|
||||||
|
|
||||||
expect(resolvedModels).toEqual([{ id: "mini", provider: "test" }])
|
|
||||||
expect(String(requests[0]?.model.id)).toBe("mini")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("prefers the title agent model over the catalog small model", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
requests = []
|
|
||||||
resolvedModels = []
|
|
||||||
yield* enableTitleAgent({ id: "agent-title", providerID: "test" })
|
|
||||||
yield* seedSmallModel()
|
|
||||||
const sessionID = SessionV2.ID.make("ses_title_agent_model")
|
|
||||||
yield* insertSession(sessionID, { id: "main", providerID: "test" })
|
|
||||||
yield* prompt(sessionID, "Help me debug the failing build")
|
|
||||||
|
|
||||||
const store = yield* SessionStore.Service
|
|
||||||
const session = yield* store
|
|
||||||
.get(sessionID)
|
|
||||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
|
|
||||||
const title = yield* SessionTitle.Service
|
|
||||||
yield* title.generateForFirstPrompt(session)
|
|
||||||
|
|
||||||
expect(resolvedModels).toEqual([{ id: "agent-title", provider: "test" }])
|
|
||||||
expect(String(requests[0]?.model.id)).toBe("agent-title")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("falls back to the session model when no small model exists", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
requests = []
|
|
||||||
resolvedModels = []
|
|
||||||
failSmallResolve = false
|
|
||||||
yield* enableTitleAgent()
|
|
||||||
const sessionID = SessionV2.ID.make("ses_title_session_fallback")
|
|
||||||
yield* insertSession(sessionID, { id: "main", providerID: "test" })
|
|
||||||
yield* prompt(sessionID, "Help me debug the failing build")
|
|
||||||
|
|
||||||
const store = yield* SessionStore.Service
|
|
||||||
const session = yield* store
|
|
||||||
.get(sessionID)
|
|
||||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
|
|
||||||
const title = yield* SessionTitle.Service
|
|
||||||
yield* title.generateForFirstPrompt(session)
|
|
||||||
|
|
||||||
expect(resolvedModels).toEqual([{ id: "main", provider: "test" }])
|
|
||||||
expect(String(requests[0]?.model.id)).toBe("main")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("falls back to the session model when small model resolution fails", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
requests = []
|
|
||||||
resolvedModels = []
|
|
||||||
failSmallResolve = true
|
|
||||||
yield* enableTitleAgent()
|
|
||||||
yield* seedSmallModel()
|
|
||||||
const sessionID = SessionV2.ID.make("ses_title_small_resolve_fail")
|
|
||||||
yield* insertSession(sessionID, { id: "main", providerID: "test" })
|
|
||||||
yield* prompt(sessionID, "Help me debug the failing build")
|
|
||||||
|
|
||||||
const store = yield* SessionStore.Service
|
|
||||||
const session = yield* store
|
|
||||||
.get(sessionID)
|
|
||||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
|
|
||||||
const title = yield* SessionTitle.Service
|
|
||||||
yield* title.generateForFirstPrompt(session)
|
|
||||||
|
|
||||||
expect(resolvedModels).toEqual([
|
|
||||||
{ id: "mini", provider: "test" },
|
|
||||||
{ id: "main", provider: "test" },
|
|
||||||
])
|
|
||||||
expect(String(requests[0]?.model.id)).toBe("main")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("uses the catalog default provider when the session has no model", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
requests = []
|
|
||||||
resolvedModels = []
|
|
||||||
failSmallResolve = false
|
|
||||||
yield* enableTitleAgent()
|
|
||||||
yield* seedSmallModel()
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
yield* catalog.transform((catalog) => {
|
|
||||||
catalog.model.default.set(ProviderV2.ID.make("test"), ModelV2.ID.make("main"))
|
|
||||||
})
|
|
||||||
const sessionID = SessionV2.ID.make("ses_title_default_provider")
|
|
||||||
yield* insertSession(sessionID)
|
|
||||||
yield* prompt(sessionID, "Help me debug the failing build")
|
|
||||||
|
|
||||||
const store = yield* SessionStore.Service
|
|
||||||
const session = yield* store
|
|
||||||
.get(sessionID)
|
|
||||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
|
|
||||||
const title = yield* SessionTitle.Service
|
|
||||||
yield* title.generateForFirstPrompt(session)
|
|
||||||
|
|
||||||
expect(resolvedModels).toEqual([{ id: "mini", provider: "test" }])
|
|
||||||
expect(String(requests[0]?.model.id)).toBe("mini")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("does not generate once a second user message exists", () =>
|
it.effect("does not generate once a second user message exists", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
requests = []
|
requests = []
|
||||||
resolvedModels = []
|
const agentService = yield* AgentV2.Service
|
||||||
yield* enableTitleAgent()
|
yield* agentService.transform((editor) => {
|
||||||
|
editor.update(AgentV2.ID.make("title"), (agent) => {
|
||||||
|
agent.mode = "primary"
|
||||||
|
agent.hidden = true
|
||||||
|
agent.system = "You are a title generator."
|
||||||
|
})
|
||||||
|
})
|
||||||
const sessionID = SessionV2.ID.make("ses_title_second_message")
|
const sessionID = SessionV2.ID.make("ses_title_second_message")
|
||||||
yield* insertSession(sessionID)
|
yield* insertSession(sessionID)
|
||||||
yield* prompt(sessionID, "First message")
|
yield* prompt(sessionID, "First message")
|
||||||
@@ -343,8 +155,14 @@ it.effect("does not generate once a second user message exists", () =>
|
|||||||
it.effect("does not generate for a child session", () =>
|
it.effect("does not generate for a child session", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
requests = []
|
requests = []
|
||||||
resolvedModels = []
|
const agentService = yield* AgentV2.Service
|
||||||
yield* enableTitleAgent()
|
yield* agentService.transform((editor) => {
|
||||||
|
editor.update(AgentV2.ID.make("title"), (agent) => {
|
||||||
|
agent.mode = "primary"
|
||||||
|
agent.hidden = true
|
||||||
|
agent.system = "You are a title generator."
|
||||||
|
})
|
||||||
|
})
|
||||||
const sessionID = SessionV2.ID.make("ses_title_child")
|
const sessionID = SessionV2.ID.make("ses_title_child")
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
yield* db
|
yield* db
|
||||||
@@ -383,7 +201,6 @@ it.effect("does not generate for a child session", () =>
|
|||||||
it.effect("does not generate when the title agent is removed", () =>
|
it.effect("does not generate when the title agent is removed", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
requests = []
|
requests = []
|
||||||
resolvedModels = []
|
|
||||||
const sessionID = SessionV2.ID.make("ses_title_no_agent")
|
const sessionID = SessionV2.ID.make("ses_title_no_agent")
|
||||||
yield* insertSession(sessionID)
|
yield* insertSession(sessionID)
|
||||||
yield* prompt(sessionID, "Help me debug the failing build")
|
yield* prompt(sessionID, "Help me debug the failing build")
|
||||||
|
|||||||
@@ -281,22 +281,10 @@ describe("SubagentTool", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const childID = outputSessionID(settled.output?.structured)
|
const childID = outputSessionID(settled.output?.structured)
|
||||||
expect(settled.output?.structured).toMatchObject({
|
expect(settled.output?.structured).toMatchObject({ status: "running" })
|
||||||
status: "running",
|
|
||||||
output: expect.stringContaining(`id: ${childID}`),
|
|
||||||
})
|
|
||||||
|
|
||||||
const admission = Array.from(yield* Fiber.join(admitted))[0]
|
const admission = Array.from(yield* Fiber.join(admitted))[0]
|
||||||
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
|
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
|
||||||
expect(admission?.data.input.data).toMatchObject({
|
|
||||||
description: "background review",
|
|
||||||
metadata: {
|
|
||||||
source: "subagent",
|
|
||||||
childID,
|
|
||||||
agent: "reviewer",
|
|
||||||
state: "completed",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const database = yield* Database.Service
|
const database = yield* Database.Service
|
||||||
yield* SessionPending.promoteSteers(database.db, events, parent.id)
|
yield* SessionPending.promoteSteers(database.db, events, parent.id)
|
||||||
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
|
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1097,27 +1097,6 @@ describe("HttpApiCodegen.generate", () => {
|
|||||||
expect(output.operations[0]?.success).toBe("stream")
|
expect(output.operations[0]?.success).toBe("stream")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("emits opaque Promise SSE fields as any", () => {
|
|
||||||
const output = emitPromise(
|
|
||||||
compileContract(
|
|
||||||
api(
|
|
||||||
HttpApiEndpoint.get("subscribe", "/event", {
|
|
||||||
success: HttpApiSchema.StreamSse({
|
|
||||||
data: Schema.Struct({
|
|
||||||
metadata: Schema.Record(Schema.String, Schema.Unknown),
|
|
||||||
label: Schema.Literal("unknown"),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const types = output.files.find((file) => file.path === "types.ts")?.content
|
|
||||||
|
|
||||||
expect(types).toContain('readonly "metadata": { readonly [x: string]: any }')
|
|
||||||
expect(types).toContain('readonly "label": "unknown"')
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves annotated stream response statuses", () => {
|
test("preserves annotated stream response statuses", () => {
|
||||||
const output = compile(
|
const output = compile(
|
||||||
api(
|
api(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||||
import { TuiConfig } from "@opencode-ai/tui/config/v1"
|
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
|
||||||
type JsonSchema = Record<string, unknown>
|
type JsonSchema = Record<string, unknown>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TuiConfig } from "@opencode-ai/tui/config/v1"
|
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||||
import { isRecord } from "@opencode-ai/tui/util/record"
|
import { isRecord } from "@opencode-ai/tui/util/record"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path"
|
|||||||
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
|
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
|
||||||
import { unique } from "remeda"
|
import { unique } from "remeda"
|
||||||
import { Option, Schema } from "effect"
|
import { Option, Schema } from "effect"
|
||||||
import { TuiConfig } from "@opencode-ai/tui/config/v1"
|
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ import { Global } from "@opencode-ai/core/global"
|
|||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { CurrentWorkingDirectory } from "./tui-cwd"
|
import { CurrentWorkingDirectory } from "./tui-cwd"
|
||||||
import { ConfigPlugin } from "@/config/plugin"
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind"
|
import { TuiKeybind } from "@opencode-ai/tui/config/keybind"
|
||||||
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { ConfigVariable } from "@/config/variable"
|
import { ConfigVariable } from "@/config/variable"
|
||||||
import { Npm } from "@opencode-ai/core/npm"
|
import { Npm } from "@opencode-ai/core/npm"
|
||||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||||
import { TuiConfig } from "@opencode-ai/tui/config/v1"
|
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||||
|
|
||||||
export const Info = TuiConfig.Info
|
export const Info = TuiConfig.Info
|
||||||
export type Info = TuiConfig.Info
|
export type Info = TuiConfig.Info
|
||||||
|
|||||||
@@ -303,13 +303,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
|||||||
input: 272_000,
|
input: 272_000,
|
||||||
output: 128_000,
|
output: 128_000,
|
||||||
}
|
}
|
||||||
: model.id.includes("gpt-5.6")
|
: model.limit,
|
||||||
? {
|
|
||||||
context: 500_000,
|
|
||||||
input: 372_000,
|
|
||||||
output: 128_000,
|
|
||||||
}
|
|
||||||
: model.limit,
|
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { AudioPlayOptions, AudioSound } from "@opentui/core"
|
import type { AudioPlayOptions, AudioSound } from "@opentui/core"
|
||||||
import { createTuiAttention } from "@opencode-ai/tui/attention"
|
import { createTuiAttention } from "@opencode-ai/tui/attention"
|
||||||
import type { TuiConfig } from "@opencode-ai/tui/config/v1"
|
import type { TuiConfig } from "@opencode-ai/tui/config"
|
||||||
|
|
||||||
type FocusEvent = "focus" | "blur"
|
type FocusEvent = "focus" | "blur"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
import { OpenCode } from "@opencode-ai/client/promise"
|
import { OpenCode } from "@opencode-ai/client/promise"
|
||||||
import type { Resolved } from "@opencode-ai/tui/config/v1"
|
import type { Resolved } from "@opencode-ai/tui/config"
|
||||||
import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@opencode-ai/cli/mini/runtime.boot"
|
import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@opencode-ai/cli/mini/runtime.boot"
|
||||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { spyOn } from "bun:test"
|
import { spyOn } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { resolve, type Info, type Resolved } from "@opencode-ai/tui/config/v1"
|
import { resolve, type Info, type Resolved } from "@opencode-ai/tui/config"
|
||||||
import { TuiConfig } from "../../src/config/tui"
|
import { TuiConfig } from "../../src/config/tui"
|
||||||
import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind"
|
import { TuiKeybind } from "@opencode-ai/tui/config/keybind"
|
||||||
|
|
||||||
type PluginSpec = string | [string, Record<string, unknown>]
|
type PluginSpec = string | [string, Record<string, unknown>]
|
||||||
type PluginOrigin = {
|
type PluginOrigin = {
|
||||||
|
|||||||
@@ -149,30 +149,6 @@ describe("plugin.codex", () => {
|
|||||||
await enabled.dispose?.()
|
await enabled.dispose?.()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses Codex context limits for OAuth GPT models", async () => {
|
|
||||||
const hooks = await CodexAuthPlugin({} as never)
|
|
||||||
const limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
|
||||||
const provider = {
|
|
||||||
models: Object.fromEntries(
|
|
||||||
["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"].map((id) => [
|
|
||||||
id,
|
|
||||||
{ id, api: { id }, limit, cost: {} },
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
const models = await hooks.provider!.models!(provider as never, { auth: { type: "oauth" } } as never)
|
|
||||||
|
|
||||||
expect(models["gpt-5.4"]?.limit).toEqual(limit)
|
|
||||||
expect(models["gpt-5.5"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
|
||||||
expect(models["gpt-5.6-sol"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
|
|
||||||
expect(models["gpt-5.6-terra"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
|
|
||||||
expect(models["gpt-5.6-luna"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
|
|
||||||
expect(await hooks.provider!.models!(provider as never, { auth: { type: "api" } } as never)).toBe(
|
|
||||||
provider.models as never,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("deduplicates concurrent Codex token refreshes", async () => {
|
test("deduplicates concurrent Codex token refreshes", async () => {
|
||||||
let auth = {
|
let auth = {
|
||||||
type: "oauth" as const,
|
type: "oauth" as const,
|
||||||
|
|||||||
@@ -161,10 +161,10 @@ it.instance(
|
|||||||
const providers = yield* list
|
const providers = yield* list
|
||||||
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
|
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
|
||||||
const models = Object.keys(providers[ProviderV2.ID.anthropic].models)
|
const models = Object.keys(providers[ProviderV2.ID.anthropic].models)
|
||||||
expect(models).toContain("claude-sonnet-4-6")
|
expect(models).toContain("claude-sonnet-4-20250514")
|
||||||
expect(models.length).toBe(1)
|
expect(models.length).toBe(1)
|
||||||
}),
|
}),
|
||||||
{ config: { provider: { anthropic: { whitelist: ["claude-sonnet-4-6"] } } } },
|
{ config: { provider: { anthropic: { whitelist: ["claude-sonnet-4-20250514"] } } } },
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance(
|
||||||
@@ -303,10 +303,10 @@ it.instance("getModel returns model for valid provider/model", () =>
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const provider = yield* Provider.Service
|
const provider = yield* Provider.Service
|
||||||
const model = yield* provider.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6"))
|
const model = yield* provider.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
|
||||||
expect(model).toBeDefined()
|
expect(model).toBeDefined()
|
||||||
expect(String(model.providerID)).toBe("anthropic")
|
expect(String(model.providerID)).toBe("anthropic")
|
||||||
expect(String(model.id)).toBe("claude-sonnet-4-6")
|
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
|
||||||
const language = yield* provider.getLanguage(model)
|
const language = yield* provider.getLanguage(model)
|
||||||
expect(language).toBeDefined()
|
expect(language).toBeDefined()
|
||||||
}),
|
}),
|
||||||
@@ -437,7 +437,7 @@ it.instance(
|
|||||||
"model options are merged from existing model",
|
"model options are merged from existing model",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const providers = yield* list
|
const providers = yield* list
|
||||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||||
expect(model.options.customOption).toBe("custom-value")
|
expect(model.options.customOption).toBe("custom-value")
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@@ -445,7 +445,7 @@ it.instance(
|
|||||||
provider: {
|
provider: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
options: { apiKey: "test-api-key" },
|
options: { apiKey: "test-api-key" },
|
||||||
models: { "claude-sonnet-4-6": { options: { customOption: "custom-value" } } },
|
models: { "claude-sonnet-4-20250514": { options: { customOption: "custom-value" } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -551,7 +551,7 @@ it.instance(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = yield* list
|
const providers = yield* list
|
||||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||||
expect(model.name).toBe("Custom Name for Sonnet")
|
expect(model.name).toBe("Custom Name for Sonnet")
|
||||||
expect(model.capabilities.toolcall).toBe(true)
|
expect(model.capabilities.toolcall).toBe(true)
|
||||||
expect(model.capabilities.attachment).toBe(true)
|
expect(model.capabilities.attachment).toBe(true)
|
||||||
@@ -559,7 +559,7 @@ it.instance(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
config: {
|
config: {
|
||||||
provider: { anthropic: { models: { "claude-sonnet-4-6": { name: "Custom Name for Sonnet" } } } },
|
provider: { anthropic: { models: { "claude-sonnet-4-20250514": { name: "Custom Name for Sonnet" } } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -592,16 +592,16 @@ it.instance(
|
|||||||
const providers = yield* list
|
const providers = yield* list
|
||||||
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
|
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
|
||||||
const models = Object.keys(providers[ProviderV2.ID.anthropic].models)
|
const models = Object.keys(providers[ProviderV2.ID.anthropic].models)
|
||||||
expect(models).toContain("claude-sonnet-4-6")
|
expect(models).toContain("claude-sonnet-4-20250514")
|
||||||
expect(models).not.toContain("claude-opus-4-6")
|
expect(models).not.toContain("claude-opus-4-20250514")
|
||||||
expect(models.length).toBe(1)
|
expect(models.length).toBe(1)
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
config: {
|
config: {
|
||||||
provider: {
|
provider: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
whitelist: ["claude-sonnet-4-6", "claude-opus-4-6"],
|
whitelist: ["claude-sonnet-4-20250514", "claude-opus-4-20250514"],
|
||||||
blacklist: ["claude-opus-4-6"],
|
blacklist: ["claude-opus-4-20250514"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -775,9 +775,9 @@ it.instance(
|
|||||||
const model = yield* Provider.use.getSmallModel(ProviderV2.ID.anthropic)
|
const model = yield* Provider.use.getSmallModel(ProviderV2.ID.anthropic)
|
||||||
expect(model).toBeDefined()
|
expect(model).toBeDefined()
|
||||||
expect(String(model?.providerID)).toBe("anthropic")
|
expect(String(model?.providerID)).toBe("anthropic")
|
||||||
expect(String(model?.id)).toBe("claude-sonnet-4-6")
|
expect(String(model?.id)).toBe("claude-sonnet-4-20250514")
|
||||||
}),
|
}),
|
||||||
{ config: { small_model: "anthropic/claude-sonnet-4-6" } },
|
{ config: { small_model: "anthropic/claude-sonnet-4-20250514" } },
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance(
|
||||||
@@ -1096,8 +1096,8 @@ it.instance(
|
|||||||
it.instance("getModel returns consistent results", () =>
|
it.instance("getModel returns consistent results", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const model1 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6"))
|
const model1 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
|
||||||
const model2 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6"))
|
const model2 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
|
||||||
expect(model1.providerID).toEqual(model2.providerID)
|
expect(model1.providerID).toEqual(model2.providerID)
|
||||||
expect(model1.id).toEqual(model2.id)
|
expect(model1.id).toEqual(model2.id)
|
||||||
expect(model1).toEqual(model2)
|
expect(model1).toEqual(model2)
|
||||||
@@ -1459,7 +1459,7 @@ it.instance("model variants are generated for reasoning models", () =>
|
|||||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = yield* list
|
const providers = yield* list
|
||||||
// Claude sonnet 4 has reasoning capability
|
// Claude sonnet 4 has reasoning capability
|
||||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||||
expect(model.capabilities.reasoning).toBe(true)
|
expect(model.capabilities.reasoning).toBe(true)
|
||||||
expect(model.variants).toBeDefined()
|
expect(model.variants).toBeDefined()
|
||||||
expect(Object.keys(model.variants!).length).toBeGreaterThan(0)
|
expect(Object.keys(model.variants!).length).toBeGreaterThan(0)
|
||||||
@@ -1471,7 +1471,7 @@ it.instance(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = yield* list
|
const providers = yield* list
|
||||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||||
expect(model.variants).toBeDefined()
|
expect(model.variants).toBeDefined()
|
||||||
expect(model.variants!["high"]).toBeUndefined()
|
expect(model.variants!["high"]).toBeUndefined()
|
||||||
// max variant should still exist
|
// max variant should still exist
|
||||||
@@ -1481,7 +1481,7 @@ it.instance(
|
|||||||
config: {
|
config: {
|
||||||
provider: {
|
provider: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
models: { "claude-sonnet-4-6": { variants: { high: { disabled: true } } } },
|
models: { "claude-sonnet-4-20250514": { variants: { high: { disabled: true } } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1493,7 +1493,7 @@ it.instance(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = yield* list
|
const providers = yield* list
|
||||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||||
expect(model.variants!["high"]).toBeDefined()
|
expect(model.variants!["high"]).toBeDefined()
|
||||||
expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
|
expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
|
||||||
}),
|
}),
|
||||||
@@ -1502,7 +1502,7 @@ it.instance(
|
|||||||
provider: {
|
provider: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
models: {
|
models: {
|
||||||
"claude-sonnet-4-6": {
|
"claude-sonnet-4-20250514": {
|
||||||
variants: { high: { thinking: { type: "enabled", budgetTokens: 20000 } } },
|
variants: { high: { thinking: { type: "enabled", budgetTokens: 20000 } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1517,7 +1517,7 @@ it.instance(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = yield* list
|
const providers = yield* list
|
||||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||||
expect(model.variants!["max"]).toBeDefined()
|
expect(model.variants!["max"]).toBeDefined()
|
||||||
expect(model.variants!["max"].disabled).toBeUndefined()
|
expect(model.variants!["max"].disabled).toBeUndefined()
|
||||||
expect(model.variants!["max"].customField).toBe("test")
|
expect(model.variants!["max"].customField).toBe("test")
|
||||||
@@ -1527,7 +1527,7 @@ it.instance(
|
|||||||
provider: {
|
provider: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
models: {
|
models: {
|
||||||
"claude-sonnet-4-6": {
|
"claude-sonnet-4-20250514": {
|
||||||
variants: { max: { disabled: false, customField: "test" } },
|
variants: { max: { disabled: false, customField: "test" } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1542,7 +1542,7 @@ it.instance(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = yield* list
|
const providers = yield* list
|
||||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||||
expect(model.variants).toBeDefined()
|
expect(model.variants).toBeDefined()
|
||||||
expect(Object.keys(model.variants!).length).toBe(0)
|
expect(Object.keys(model.variants!).length).toBe(0)
|
||||||
}),
|
}),
|
||||||
@@ -1551,13 +1551,8 @@ it.instance(
|
|||||||
provider: {
|
provider: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
models: {
|
models: {
|
||||||
"claude-sonnet-4-6": {
|
"claude-sonnet-4-20250514": {
|
||||||
variants: {
|
variants: { high: { disabled: true }, max: { disabled: true } },
|
||||||
low: { disabled: true },
|
|
||||||
medium: { disabled: true },
|
|
||||||
high: { disabled: true },
|
|
||||||
max: { disabled: true },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1571,7 +1566,7 @@ it.instance(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = yield* list
|
const providers = yield* list
|
||||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||||
expect(model.variants!["high"]).toBeDefined()
|
expect(model.variants!["high"]).toBeDefined()
|
||||||
// Should have both the generated thinking config and the custom option
|
// Should have both the generated thinking config and the custom option
|
||||||
expect(model.variants!["high"].thinking).toBeDefined()
|
expect(model.variants!["high"].thinking).toBeDefined()
|
||||||
@@ -1582,7 +1577,7 @@ it.instance(
|
|||||||
provider: {
|
provider: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
models: {
|
models: {
|
||||||
"claude-sonnet-4-6": { variants: { high: { extraOption: "custom-value" } } },
|
"claude-sonnet-4-20250514": { variants: { high: { extraOption: "custom-value" } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+112722
-168115
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,6 @@
|
|||||||
"./tui": "./src/tui.ts",
|
"./tui": "./src/tui.ts",
|
||||||
"./v2/effect": "./src/v2/effect/index.ts",
|
"./v2/effect": "./src/v2/effect/index.ts",
|
||||||
"./v2/effect/*": "./src/v2/effect/*.ts",
|
"./v2/effect/*": "./src/v2/effect/*.ts",
|
||||||
"./v2/tui/*": "./src/v2/tui/*.ts",
|
|
||||||
"./v2": "./src/v2/promise/index.ts",
|
"./v2": "./src/v2/promise/index.ts",
|
||||||
"./v2/*": "./src/v2/promise/*.ts"
|
"./v2/*": "./src/v2/promise/*.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,11 +23,6 @@ export interface CatalogDraft {
|
|||||||
get(): { providerID: string; modelID: string } | undefined
|
get(): { providerID: string; modelID: string } | undefined
|
||||||
set(providerID: string, modelID: string): void
|
set(providerID: string, modelID: string): void
|
||||||
}
|
}
|
||||||
readonly small: {
|
|
||||||
get(providerID: string): string | undefined
|
|
||||||
set(providerID: string, modelID: string): void
|
|
||||||
remove(providerID: string): void
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import type {
|
|
||||||
AgentInfo,
|
|
||||||
CommandInfo,
|
|
||||||
FormInfo,
|
|
||||||
IntegrationInfo,
|
|
||||||
LocationRef,
|
|
||||||
McpResource,
|
|
||||||
McpServer,
|
|
||||||
ModelInfo,
|
|
||||||
OpenCodeClient,
|
|
||||||
OpenCodeEvent,
|
|
||||||
PermissionSavedInfo,
|
|
||||||
PermissionV2Request,
|
|
||||||
ProviderV2Info,
|
|
||||||
ReferenceInfo,
|
|
||||||
SessionInfo,
|
|
||||||
SessionMessageInfo,
|
|
||||||
SessionPendingInfo,
|
|
||||||
ShellInfo,
|
|
||||||
SkillInfo,
|
|
||||||
} from "@opencode-ai/client"
|
|
||||||
import type { JSX } from "@opentui/solid"
|
|
||||||
|
|
||||||
interface LocationCollection<Value> {
|
|
||||||
list(location?: LocationRef): Value[] | undefined
|
|
||||||
refresh(location?: LocationRef): Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Data {
|
|
||||||
readonly on: <Type extends OpenCodeEvent["type"]>(
|
|
||||||
type: Type,
|
|
||||||
handler: (event: Extract<OpenCodeEvent, { type: Type }>) => void,
|
|
||||||
) => () => void
|
|
||||||
readonly listen: (handler: (event: { details: OpenCodeEvent }) => void) => () => void
|
|
||||||
readonly session: {
|
|
||||||
list(): SessionInfo[]
|
|
||||||
get(sessionID: string): SessionInfo | undefined
|
|
||||||
root(sessionID: string): string
|
|
||||||
family(sessionID: string): string[]
|
|
||||||
cost(sessionID: string): number
|
|
||||||
status(sessionID: string): "idle" | "running"
|
|
||||||
readonly pending: {
|
|
||||||
list(sessionID: string): SessionPendingInfo[]
|
|
||||||
refresh(sessionID: string): Promise<void>
|
|
||||||
}
|
|
||||||
refresh(sessionID: string): Promise<void>
|
|
||||||
readonly message: {
|
|
||||||
list(sessionID: string): SessionMessageInfo[]
|
|
||||||
get(sessionID: string, messageID: string): SessionMessageInfo | undefined
|
|
||||||
refresh(sessionID: string): Promise<void>
|
|
||||||
}
|
|
||||||
readonly permission: {
|
|
||||||
list(sessionID: string): PermissionV2Request[] | undefined
|
|
||||||
refresh(sessionID: string): Promise<void>
|
|
||||||
}
|
|
||||||
readonly form: {
|
|
||||||
list(sessionID: string, location?: LocationRef): Array<FormInfo & { readonly location?: LocationRef }> | undefined
|
|
||||||
refresh(sessionID: string, location?: LocationRef): Promise<void>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
readonly project: {
|
|
||||||
readonly permission: {
|
|
||||||
list(projectID: string): PermissionSavedInfo[] | undefined
|
|
||||||
refresh(projectID: string): Promise<void>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
readonly shell: {
|
|
||||||
list(location?: LocationRef): ShellInfo[]
|
|
||||||
get(id: string): ShellInfo | undefined
|
|
||||||
refresh(location?: LocationRef): Promise<void>
|
|
||||||
}
|
|
||||||
readonly location: {
|
|
||||||
default(): LocationRef
|
|
||||||
refresh(location?: LocationRef): Promise<void>
|
|
||||||
readonly agent: LocationCollection<AgentInfo>
|
|
||||||
readonly command: LocationCollection<CommandInfo>
|
|
||||||
readonly integration: LocationCollection<IntegrationInfo>
|
|
||||||
readonly mcp: {
|
|
||||||
readonly server: LocationCollection<McpServer>
|
|
||||||
readonly resource: LocationCollection<McpResource>
|
|
||||||
}
|
|
||||||
readonly model: LocationCollection<ModelInfo>
|
|
||||||
readonly provider: LocationCollection<ProviderV2Info>
|
|
||||||
readonly reference: LocationCollection<ReferenceInfo>
|
|
||||||
readonly skill: LocationCollection<SkillInfo>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RouteDefinition {
|
|
||||||
readonly name: string
|
|
||||||
readonly render: (input: { readonly params: any }) => JSX.Element
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Route {
|
|
||||||
register(definition: RouteDefinition): () => void
|
|
||||||
navigate(input: { readonly name: string; readonly params?: any }): void
|
|
||||||
current(): {
|
|
||||||
readonly name: string
|
|
||||||
readonly params: any
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UI {
|
|
||||||
readonly route: Route
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Context {
|
|
||||||
readonly options: Record<string, any>
|
|
||||||
readonly client: OpenCodeClient
|
|
||||||
readonly data: Data
|
|
||||||
readonly ui: UI
|
|
||||||
}
|
|
||||||
@@ -54,7 +54,6 @@ export const groupNames = {
|
|||||||
"server.event": "event",
|
"server.event": "event",
|
||||||
"server.pty": "pty",
|
"server.pty": "pty",
|
||||||
"server.shell": "shell",
|
"server.shell": "shell",
|
||||||
"server.mcp": "mcp",
|
|
||||||
"server.question": "question",
|
"server.question": "question",
|
||||||
"server.reference": "reference",
|
"server.reference": "reference",
|
||||||
"server.project": "project",
|
"server.project": "project",
|
||||||
|
|||||||
@@ -92,13 +92,12 @@ export const Ref = Schema.Struct({
|
|||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
}).annotate({ identifier: "Integration.Ref" })
|
}).annotate({ identifier: "Integration.Ref" })
|
||||||
|
|
||||||
export const Info = Schema.Struct({
|
export class Info extends Schema.Class<Info>("Integration.Info")({
|
||||||
id: ID,
|
id: ID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
methods: Schema.Array(Method),
|
methods: Schema.Array(Method),
|
||||||
connections: Schema.Array(Connection.Info),
|
connections: Schema.Array(Connection.Info),
|
||||||
}).annotate({ identifier: "Integration.Info" })
|
}) {}
|
||||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
|
||||||
|
|
||||||
export const AttemptID = Schema.String.pipe(
|
export const AttemptID = Schema.String.pipe(
|
||||||
Schema.brand("Integration.AttemptID"),
|
Schema.brand("Integration.AttemptID"),
|
||||||
|
|||||||
@@ -30,11 +30,10 @@ export const Source = Schema.Union([LocalSource, GitSource])
|
|||||||
.annotate({ identifier: "Reference.Source" })
|
.annotate({ identifier: "Reference.Source" })
|
||||||
export type Source = typeof Source.Type
|
export type Source = typeof Source.Type
|
||||||
|
|
||||||
export const Info = Schema.Struct({
|
export class Info extends Schema.Class<Info>("Reference.Info")({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
path: AbsolutePath,
|
path: AbsolutePath,
|
||||||
description: Schema.String.pipe(optional),
|
description: Schema.String.pipe(optional),
|
||||||
hidden: Schema.Boolean.pipe(optional),
|
hidden: Schema.Boolean.pipe(optional),
|
||||||
source: Source,
|
source: Source,
|
||||||
}).annotate({ identifier: "Reference.Info" })
|
}) {}
|
||||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export const Status = Schema.Literals(["running", "exited", "timeout", "killed"]
|
|||||||
export type Status = typeof Status.Type
|
export type Status = typeof Status.Type
|
||||||
|
|
||||||
export const Time = Schema.Struct({
|
export const Time = Schema.Struct({
|
||||||
started: Schema.Finite,
|
started: Schema.Number,
|
||||||
completed: optional(Schema.Finite),
|
completed: optional(Schema.Number),
|
||||||
})
|
})
|
||||||
export interface Time extends Schema.Schema.Type<typeof Time> {}
|
export interface Time extends Schema.Schema.Type<typeof Time> {}
|
||||||
|
|
||||||
@@ -42,15 +42,15 @@ export const Info = Schema.Struct({
|
|||||||
// Absolute path of the file capturing combined stdout/stderr. Page through it via `output`.
|
// Absolute path of the file capturing combined stdout/stderr. Page through it via `output`.
|
||||||
file: Schema.String,
|
file: Schema.String,
|
||||||
pid: optional(NonNegativeInt),
|
pid: optional(NonNegativeInt),
|
||||||
exit: optional(Schema.Finite),
|
exit: optional(Schema.Number),
|
||||||
// Always present; defaults to an empty object when the creator supplies no metadata.
|
// Always present; defaults to an empty object when the creator supplies no metadata.
|
||||||
metadata: Metadata,
|
metadata: Metadata,
|
||||||
time: Time,
|
time: Time,
|
||||||
}).annotate({ identifier: "Shell.Info" })
|
}).annotate({ identifier: "Shell" })
|
||||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||||
|
|
||||||
const Created = ephemeral({ type: "shell.created", schema: { info: Info } })
|
const Created = ephemeral({ type: "shell.created", schema: { info: Info } })
|
||||||
const Exited = ephemeral({ type: "shell.exited", schema: { id: ID, exit: optional(Schema.Finite), status: Status } })
|
const Exited = ephemeral({ type: "shell.exited", schema: { id: ID, exit: optional(Schema.Number), status: Status } })
|
||||||
const Deleted = ephemeral({ type: "shell.deleted", schema: { id: ID } })
|
const Deleted = ephemeral({ type: "shell.deleted", schema: { id: ID } })
|
||||||
export const Event = { Created, Exited, Deleted, Definitions: inventory(Created, Exited, Deleted) }
|
export const Event = { Created, Exited, Deleted, Definitions: inventory(Created, Exited, Deleted) }
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"./*": "./src/*.ts"
|
"./*": "./src/*.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test --only-failures",
|
|
||||||
"typecheck": "tsgo --noEmit"
|
"typecheck": "tsgo --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
export * as EventFeed from "./event-feed"
|
|
||||||
|
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
|
||||||
import { isOpenCodeEvent, OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
|
||||||
import { Cause, Context, Effect, Layer, Queue, Schema, Scope, Stream } from "effect"
|
|
||||||
|
|
||||||
export const SubscriberCapacity = 4_096
|
|
||||||
|
|
||||||
export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
|
|
||||||
"EventFeed.SubscriberOverflow",
|
|
||||||
{ capacity: Schema.Int },
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class EncodingError extends Schema.TaggedErrorClass<EncodingError>()("EventFeed.EncodingError", {
|
|
||||||
eventID: EventV2.ID,
|
|
||||||
eventType: Schema.String,
|
|
||||||
cause: Schema.Defect(),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export type Error = SubscriberOverflowError | EncodingError
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly subscribe: Effect.Effect<Stream.Stream<string, Error>, never, Scope.Scope>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/server/EventFeed") {}
|
|
||||||
|
|
||||||
const encode = Schema.encodeUnknownSync(OpenCodeEvent)
|
|
||||||
|
|
||||||
export function frame(event: OpenCodeEvent) {
|
|
||||||
return `data: ${JSON.stringify(encode(event))}\n\n`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const make = Effect.fn("EventFeed.make")(function* (
|
|
||||||
observe: (subscriber: EventV2.Subscriber) => Effect.Effect<EventV2.Unsubscribe>,
|
|
||||||
options?: { readonly capacity?: number; readonly encode?: (event: OpenCodeEvent) => string },
|
|
||||||
) {
|
|
||||||
const capacity = options?.capacity ?? SubscriberCapacity
|
|
||||||
const render = options?.encode ?? frame
|
|
||||||
const subscribers = new Set<Queue.Queue<string, Error>>()
|
|
||||||
|
|
||||||
const fail = (error: Error) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
const current = Array.from(subscribers)
|
|
||||||
subscribers.clear()
|
|
||||||
for (const subscriber of current) Queue.failCauseUnsafe(subscriber, Cause.fail(error))
|
|
||||||
})
|
|
||||||
|
|
||||||
const publish = Effect.fnUntraced(function* (event: EventV2.Payload) {
|
|
||||||
if (!isOpenCodeEvent(event)) return
|
|
||||||
if (subscribers.size === 0) return
|
|
||||||
const encoded = yield* Effect.try({
|
|
||||||
try: () => render(event),
|
|
||||||
catch: (cause) => new EncodingError({ eventID: event.id, eventType: event.type, cause }),
|
|
||||||
}).pipe(
|
|
||||||
Effect.catch((error) =>
|
|
||||||
Effect.logError("Failed to encode public event", {
|
|
||||||
eventID: error.eventID,
|
|
||||||
eventType: error.eventType,
|
|
||||||
cause: error.cause,
|
|
||||||
}).pipe(Effect.andThen(fail(error)), Effect.as(undefined)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (encoded === undefined) return
|
|
||||||
for (const subscriber of subscribers) {
|
|
||||||
if (Queue.offerUnsafe(subscriber, encoded)) continue
|
|
||||||
subscribers.delete(subscriber)
|
|
||||||
Queue.failCauseUnsafe(subscriber, Cause.fail(new SubscriberOverflowError({ capacity })))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const unsubscribe = yield* observe(publish)
|
|
||||||
yield* Effect.addFinalizer(() => unsubscribe)
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
subscribe: Effect.acquireRelease(
|
|
||||||
Queue.dropping<string, Error>(capacity).pipe(Effect.tap((queue) => Effect.sync(() => subscribers.add(queue)))),
|
|
||||||
(queue) =>
|
|
||||||
Effect.sync(() => subscribers.delete(queue)).pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid),
|
|
||||||
).pipe(Effect.map(Stream.fromQueue)),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
return yield* make(events.listen)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
@@ -26,7 +26,6 @@ import { CredentialHandler } from "./handlers/credential"
|
|||||||
import { ProjectHandler } from "./handlers/project"
|
import { ProjectHandler } from "./handlers/project"
|
||||||
import { ProjectCopyHandler } from "./handlers/project-copy"
|
import { ProjectCopyHandler } from "./handlers/project-copy"
|
||||||
import { VcsHandler } from "./handlers/vcs"
|
import { VcsHandler } from "./handlers/vcs"
|
||||||
import { EventFeed } from "./event-feed"
|
|
||||||
|
|
||||||
export const handlers = Layer.mergeAll(
|
export const handlers = Layer.mergeAll(
|
||||||
HealthHandler,
|
HealthHandler,
|
||||||
@@ -49,7 +48,7 @@ export const handlers = Layer.mergeAll(
|
|||||||
FileSystemHandler,
|
FileSystemHandler,
|
||||||
CommandHandler,
|
CommandHandler,
|
||||||
SkillHandler,
|
SkillHandler,
|
||||||
EventHandler.pipe(Layer.provide(EventFeed.layer)),
|
EventHandler,
|
||||||
PtyHandler,
|
PtyHandler,
|
||||||
ShellHandler,
|
ShellHandler,
|
||||||
QuestionHandler,
|
QuestionHandler,
|
||||||
|
|||||||
@@ -1,23 +1,44 @@
|
|||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { Effect, Stream } from "effect"
|
import { isOpenCodeEvent, OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||||
|
import { Effect, Schema, Stream } from "effect"
|
||||||
|
import { Sse } from "effect/unstable/encoding"
|
||||||
import { HttpServerResponse } from "effect/unstable/http"
|
import { HttpServerResponse } from "effect/unstable/http"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import { Api } from "../api"
|
import { Api } from "../api"
|
||||||
import { EventFeed } from "../event-feed"
|
|
||||||
|
// Session execution emits dense event bursts; allow healthy SSE clients enough
|
||||||
|
// time to absorb one without weakening the bounded slow-subscriber failure.
|
||||||
|
const subscriberCapacity = 4_096
|
||||||
|
|
||||||
|
function eventData(data: unknown): Sse.Event {
|
||||||
|
return {
|
||||||
|
_tag: "Event",
|
||||||
|
event: "message",
|
||||||
|
id: undefined,
|
||||||
|
data: JSON.stringify(Schema.encodeUnknownSync(OpenCodeEvent)(data)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers) =>
|
export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const feed = yield* EventFeed.Service
|
const events = yield* EventV2.Service
|
||||||
return handlers.handleRaw("event.subscribe", () =>
|
return handlers.handleRaw("event.subscribe", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const connected = {
|
const connected = {
|
||||||
id: EventV2.ID.create(),
|
id: EventV2.ID.create(),
|
||||||
type: "server.connected",
|
type: "server.connected",
|
||||||
data: {},
|
data: {},
|
||||||
} as const
|
}
|
||||||
const output = Stream.unwrap(
|
const output = Stream.unwrap(
|
||||||
feed.subscribe.pipe(Effect.map((live) => Stream.make(EventFeed.frame(connected)).pipe(Stream.concat(live)))),
|
Effect.gen(function* () {
|
||||||
)
|
// Acquiring the bounded stream installs its listener before readiness is observable.
|
||||||
|
const live = yield* EventV2.liveBounded(events, {
|
||||||
|
capacity: subscriberCapacity,
|
||||||
|
accept: isOpenCodeEvent,
|
||||||
|
})
|
||||||
|
return Stream.make(connected).pipe(Stream.concat(live))
|
||||||
|
}),
|
||||||
|
).pipe(Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()))
|
||||||
const heartbeat = Stream.tick("15 seconds").pipe(Stream.map(() => ": heartbeat\n\n"))
|
const heartbeat = Stream.tick("15 seconds").pipe(Stream.map(() => ": heartbeat\n\n"))
|
||||||
return HttpServerResponse.stream(
|
return HttpServerResponse.stream(
|
||||||
output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }), Stream.encodeText),
|
output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }), Stream.encodeText),
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
|
||||||
import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
|
||||||
import { DateTime, Deferred, Effect, Exit, Fiber, Option, Schema, Stream } from "effect"
|
|
||||||
import { it } from "../../core/test/lib/effect"
|
|
||||||
import { EventFeed } from "../src/event-feed"
|
|
||||||
|
|
||||||
const Internal = EventV2.ephemeral({ type: "test.internal", schema: { value: Schema.String } })
|
|
||||||
|
|
||||||
const event = (id: string): EventV2.Payload<typeof AgentV2.Event.Updated> => ({
|
|
||||||
id: EventV2.ID.make(`evt_${id}`),
|
|
||||||
created: DateTime.makeUnsafe(Date.now()),
|
|
||||||
type: AgentV2.Event.Updated.type,
|
|
||||||
data: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
const internal = (value: string): EventV2.Payload<typeof Internal> => ({
|
|
||||||
id: EventV2.ID.create(),
|
|
||||||
created: DateTime.makeUnsafe(Date.now()),
|
|
||||||
type: Internal.type,
|
|
||||||
data: { value },
|
|
||||||
})
|
|
||||||
|
|
||||||
function makeSource() {
|
|
||||||
let subscriber: EventV2.Subscriber | undefined
|
|
||||||
return {
|
|
||||||
observe: (next: EventV2.Subscriber) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
subscriber = next
|
|
||||||
return Effect.sync(() => {
|
|
||||||
if (subscriber === next) subscriber = undefined
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
publish: (event: EventV2.Payload) => Effect.suspend(() => (subscriber ? subscriber(event) : Effect.void)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("EventFeed", () => {
|
|
||||||
test("preserves the public SSE frame encoding", () => {
|
|
||||||
const payload = event("wire")
|
|
||||||
expect(EventFeed.frame(payload)).toBe(
|
|
||||||
`data: ${JSON.stringify(Schema.encodeUnknownSync(OpenCodeEvent)(payload))}\n\n`,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.effect("encodes once and delivers the same frame to every subscriber", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
let encodes = 0
|
|
||||||
const source = makeSource()
|
|
||||||
const feed = yield* EventFeed.make(source.observe, {
|
|
||||||
encode: (event) => {
|
|
||||||
encodes += 1
|
|
||||||
return event.type
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const first = yield* feed.subscribe
|
|
||||||
const second = yield* feed.subscribe
|
|
||||||
const left = yield* first.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
|
||||||
const right = yield* second.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
|
||||||
|
|
||||||
yield* source.publish(event("example"))
|
|
||||||
|
|
||||||
expect([Array.from(yield* Fiber.join(left)), Array.from(yield* Fiber.join(right))]).toEqual([
|
|
||||||
[AgentV2.Event.Updated.type],
|
|
||||||
[AgentV2.Event.Updated.type],
|
|
||||||
])
|
|
||||||
expect(encodes).toBe(1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("fails only the subscriber that exceeds its lag capacity", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const source = makeSource()
|
|
||||||
const feed = yield* EventFeed.make(source.observe, {
|
|
||||||
capacity: 1,
|
|
||||||
encode: (event) => event.id,
|
|
||||||
})
|
|
||||||
const slow = yield* feed.subscribe
|
|
||||||
const fast = yield* feed.subscribe
|
|
||||||
const first = yield* Deferred.make<void>()
|
|
||||||
const second = yield* Deferred.make<void>()
|
|
||||||
const received = new Array<string>()
|
|
||||||
const fastFiber = yield* fast.pipe(
|
|
||||||
Stream.take(3),
|
|
||||||
Stream.runForEach((frame) =>
|
|
||||||
Effect.sync(() => received.push(frame)).pipe(
|
|
||||||
Effect.andThen(
|
|
||||||
frame === "evt_one"
|
|
||||||
? Deferred.succeed(first, undefined)
|
|
||||||
: frame === "evt_two"
|
|
||||||
? Deferred.succeed(second, undefined)
|
|
||||||
: Effect.void,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Effect.forkScoped,
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* source.publish(event("one"))
|
|
||||||
yield* Deferred.await(first)
|
|
||||||
yield* source.publish(event("two"))
|
|
||||||
yield* Deferred.await(second)
|
|
||||||
yield* source.publish(event("three"))
|
|
||||||
|
|
||||||
yield* Fiber.join(fastFiber)
|
|
||||||
|
|
||||||
const result = yield* slow.pipe(Stream.runCollect, Effect.exit)
|
|
||||||
expect(received).toEqual(["evt_one", "evt_two", "evt_three"])
|
|
||||||
expect(Exit.isFailure(result)).toBeTrue()
|
|
||||||
if (Exit.isSuccess(result)) return
|
|
||||||
expect(Option.getOrUndefined(Exit.findErrorOption(result))).toBeInstanceOf(EventFeed.SubscriberOverflowError)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("filters internal events before they consume subscriber capacity", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const source = makeSource()
|
|
||||||
const feed = yield* EventFeed.make(source.observe, { capacity: 1, encode: (event) => event.type })
|
|
||||||
const stream = yield* feed.subscribe
|
|
||||||
|
|
||||||
yield* source.publish(internal("one"))
|
|
||||||
yield* source.publish(internal("two"))
|
|
||||||
yield* source.publish(event("public"))
|
|
||||||
|
|
||||||
expect(Array.from(yield* stream.pipe(Stream.take(1), Stream.runCollect))).toEqual([AgentV2.Event.Updated.type])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("disconnects current subscribers after an encoding failure and continues for later subscribers", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const source = makeSource()
|
|
||||||
const feed = yield* EventFeed.make(source.observe, {
|
|
||||||
encode: (event) => {
|
|
||||||
if (event.id === EventV2.ID.make("evt_bad")) throw new Error("invalid event")
|
|
||||||
return event.id
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const current = yield* feed.subscribe
|
|
||||||
const failed = yield* current.pipe(Stream.runCollect, Effect.exit, Effect.forkScoped)
|
|
||||||
|
|
||||||
yield* source.publish(event("bad"))
|
|
||||||
const exit = yield* Fiber.join(failed)
|
|
||||||
|
|
||||||
const next = yield* feed.subscribe
|
|
||||||
const received = yield* next.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
|
||||||
yield* source.publish(event("good"))
|
|
||||||
|
|
||||||
expect(Exit.isFailure(exit)).toBeTrue()
|
|
||||||
if (Exit.isSuccess(exit)) return
|
|
||||||
expect(Option.getOrUndefined(Exit.findErrorOption(exit))).toBeInstanceOf(EventFeed.EncodingError)
|
|
||||||
expect(Array.from(yield* Fiber.join(received))).toEqual(["evt_good"])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -12,10 +12,7 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.tsx",
|
".": "./src/index.tsx",
|
||||||
"./builtins": "./src/feature-plugins/builtins.ts",
|
"./builtins": "./src/feature-plugins/builtins.ts",
|
||||||
"./config/v1": "./src/config/v1/index.tsx",
|
"./config": "./src/config/index.tsx",
|
||||||
"./config/v1/keybind": "./src/config/v1/keybind.ts",
|
|
||||||
"./config/v2": "./src/config/v2/index.ts",
|
|
||||||
"./config/v2/keybind": "./src/config/v2/keybind.ts",
|
|
||||||
"./context/args": "./src/context/args.tsx",
|
"./context/args": "./src/context/args.tsx",
|
||||||
"./context/epilogue": "./src/context/epilogue.tsx",
|
"./context/epilogue": "./src/context/epilogue.tsx",
|
||||||
"./context/exit": "./src/context/exit.tsx",
|
"./context/exit": "./src/context/exit.tsx",
|
||||||
@@ -33,6 +30,7 @@
|
|||||||
"./editor-zed": "./src/editor-zed.ts",
|
"./editor-zed": "./src/editor-zed.ts",
|
||||||
"./runtime": "./src/runtime.tsx",
|
"./runtime": "./src/runtime.tsx",
|
||||||
"./terminal-win32": "./src/terminal-win32.ts",
|
"./terminal-win32": "./src/terminal-win32.ts",
|
||||||
|
"./config/keybind": "./src/config/keybind.ts",
|
||||||
"./keymap": "./src/keymap.tsx",
|
"./keymap": "./src/keymap.tsx",
|
||||||
"./prompt/content": "./src/prompt/content.ts",
|
"./prompt/content": "./src/prompt/content.ts",
|
||||||
"./prompt/display": "./src/prompt/display.ts",
|
"./prompt/display": "./src/prompt/display.ts",
|
||||||
@@ -57,6 +55,7 @@
|
|||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/simulation": "workspace:*",
|
"@opencode-ai/simulation": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
|||||||
+65
-36
@@ -3,7 +3,7 @@ import { registerOpencodeSpinner } from "./component/register-spinner"
|
|||||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||||
import { Deferred, Effect } from "effect"
|
import { Deferred, Effect } from "effect"
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect"
|
||||||
import { OpenCode } from "@opencode-ai/client"
|
import { OpenCode } from "@opencode-ai/client/promise"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
@@ -12,14 +12,7 @@ import { LogProvider, useLog, type LogSink } from "./context/log"
|
|||||||
import { ExitProvider, useExit } from "./context/exit"
|
import { ExitProvider, useExit } from "./context/exit"
|
||||||
import { EpilogueProvider } from "./context/epilogue"
|
import { EpilogueProvider } from "./context/epilogue"
|
||||||
import * as Selection from "./util/selection"
|
import * as Selection from "./util/selection"
|
||||||
import {
|
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||||
CliRenderEvents,
|
|
||||||
createCliRenderer,
|
|
||||||
MouseButton,
|
|
||||||
type CliRenderer,
|
|
||||||
type CliRendererConfig,
|
|
||||||
type ThemeMode,
|
|
||||||
} from "@opentui/core"
|
|
||||||
import { RouteProvider, useRoute } from "./context/route"
|
import { RouteProvider, useRoute } from "./context/route"
|
||||||
import {
|
import {
|
||||||
Switch,
|
Switch,
|
||||||
@@ -60,6 +53,8 @@ import { DialogThemeList } from "./component/dialog-theme-list"
|
|||||||
import { DialogHelp } from "./ui/dialog-help"
|
import { DialogHelp } from "./ui/dialog-help"
|
||||||
import { DialogAgent } from "./component/dialog-agent"
|
import { DialogAgent } from "./component/dialog-agent"
|
||||||
import { DialogSessionList } from "./component/dialog-session-list"
|
import { DialogSessionList } from "./component/dialog-session-list"
|
||||||
|
import { DialogWorkspaceList } from "./component/dialog-workspace-list"
|
||||||
|
import { DialogConsoleOrg } from "./component/dialog-console-org"
|
||||||
import { ThemeProvider, useTheme } from "./context/theme"
|
import { ThemeProvider, useTheme } from "./context/theme"
|
||||||
import { Home } from "./routes/home"
|
import { Home } from "./routes/home"
|
||||||
import { Session } from "./routes/session"
|
import { Session } from "./routes/session"
|
||||||
@@ -75,7 +70,7 @@ import * as Model from "./util/model"
|
|||||||
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||||
import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config/v1"
|
import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config"
|
||||||
import { createTuiApiAdapters } from "./plugin/adapters"
|
import { createTuiApiAdapters } from "./plugin/adapters"
|
||||||
import { createTuiApi } from "./plugin/api"
|
import { createTuiApi } from "./plugin/api"
|
||||||
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime"
|
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime"
|
||||||
@@ -126,6 +121,7 @@ const appBindingCommands = [
|
|||||||
"variant.cycle",
|
"variant.cycle",
|
||||||
"variant.list",
|
"variant.list",
|
||||||
"provider.connect",
|
"provider.connect",
|
||||||
|
"console.org.switch",
|
||||||
"opencode.status",
|
"opencode.status",
|
||||||
"server.pair",
|
"server.pair",
|
||||||
"opencode.debug",
|
"opencode.debug",
|
||||||
@@ -135,6 +131,7 @@ const appBindingCommands = [
|
|||||||
"help.show",
|
"help.show",
|
||||||
"docs.open",
|
"docs.open",
|
||||||
"diff.open",
|
"diff.open",
|
||||||
|
"workspace.list",
|
||||||
"app.debug",
|
"app.debug",
|
||||||
"app.console",
|
"app.console",
|
||||||
"app.heap_snapshot",
|
"app.heap_snapshot",
|
||||||
@@ -157,14 +154,6 @@ export type TuiInput = {
|
|||||||
config: TuiConfig.Resolved
|
config: TuiConfig.Resolved
|
||||||
onSnapshot?: () => Promise<string[]>
|
onSnapshot?: () => Promise<string[]>
|
||||||
pluginHost: TuiPluginHost
|
pluginHost: TuiPluginHost
|
||||||
terminalHandoff?: () => Promise<
|
|
||||||
| {
|
|
||||||
readonly renderer: CliRenderer
|
|
||||||
readonly mode: ThemeMode | null
|
|
||||||
readonly complete: () => void
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
>
|
|
||||||
log?: LogSink
|
log?: LogSink
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +198,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
Effect.map((response) => response.location.directory),
|
Effect.map((response) => response.location.directory),
|
||||||
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
|
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
|
||||||
)
|
)
|
||||||
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
|
|
||||||
const reconnectEndpoint = input.server.reconnect
|
const reconnectEndpoint = input.server.reconnect
|
||||||
const reconnect = reconnectEndpoint
|
const reconnect = reconnectEndpoint
|
||||||
? async (attempt: number) => {
|
? async (attempt: number) => {
|
||||||
@@ -241,11 +229,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
},
|
},
|
||||||
} satisfies CliRendererConfig
|
} satisfies CliRendererConfig
|
||||||
|
|
||||||
if (handoff) {
|
|
||||||
handoff.renderer.useMouse = options.useMouse
|
|
||||||
return handoff.renderer
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.OPENCODE_DRIVE) {
|
if (process.env.OPENCODE_DRIVE) {
|
||||||
const { Drive } = await import("@opencode-ai/simulation/frontend")
|
const { Drive } = await import("@opencode-ai/simulation/frontend")
|
||||||
return Drive.create(options)
|
return Drive.create(options)
|
||||||
@@ -288,7 +271,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
yield* Effect.tryPromise(async () => {
|
yield* Effect.tryPromise(async () => {
|
||||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||||
const mode = handoff?.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark"
|
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
|
||||||
if (renderer.isDestroyed) return
|
if (renderer.isDestroyed) return
|
||||||
|
|
||||||
await render(() => {
|
await render(() => {
|
||||||
@@ -413,10 +396,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
</LogProvider>
|
</LogProvider>
|
||||||
)
|
)
|
||||||
}, renderer)
|
}, renderer)
|
||||||
if (handoff) {
|
|
||||||
renderer.once(CliRenderEvents.FRAME, handoff.complete)
|
|
||||||
renderer.requestRender()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
yield* Deferred.await(shutdown)
|
yield* Deferred.await(shutdown)
|
||||||
return { epilogue: exit.epilogue, reason: exit.reason }
|
return { epilogue: exit.epilogue, reason: exit.reason }
|
||||||
@@ -443,10 +422,10 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||||||
const keymap = useOpencodeKeymap()
|
const keymap = useOpencodeKeymap()
|
||||||
const event = useEvent()
|
const event = useEvent()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const themeState = useTheme()
|
const themeState = useTheme()
|
||||||
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
||||||
|
const sync = useSync()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const project = useProject()
|
const project = useProject()
|
||||||
const exit = useExit()
|
const exit = useExit()
|
||||||
@@ -460,7 +439,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||||||
// the same problem on every refresh while still re-alerting if the state changes.
|
// the same problem on every refresh while still re-alerting if the state changes.
|
||||||
const mcpAlerted: Record<string, string> = {}
|
const mcpAlerted: Record<string, string> = {}
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
for (const server of data.location.mcp.server.list() ?? []) {
|
for (const server of data.location.mcp.list() ?? []) {
|
||||||
const status = server.status
|
const status = server.status
|
||||||
if (status.status !== "failed" && status.status !== "needs_auth") {
|
if (status.status !== "failed" && status.status !== "needs_auth") {
|
||||||
delete mcpAlerted[server.name]
|
delete mcpAlerted[server.name]
|
||||||
@@ -545,7 +524,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||||||
}
|
}
|
||||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
||||||
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
|
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
|
||||||
kv.get("paste_summary_enabled", true),
|
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update terminal window title based on current route and session
|
// Update terminal window title based on current route and session
|
||||||
@@ -599,7 +578,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||||||
|
|
||||||
let continued = false
|
let continued = false
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (continued || !args.continue) return
|
if (continued || sync.status === "loading" || !args.continue) return
|
||||||
continued = true
|
continued = true
|
||||||
const location = data.location.default()
|
const location = data.location.default()
|
||||||
void sdk.api.session
|
void sdk.api.session
|
||||||
@@ -625,10 +604,12 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||||||
.catch(toast.error)
|
.catch(toast.error)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle --session with --fork once.
|
// Handle --session with --fork: wait for sync to be fully complete before forking
|
||||||
|
// (session list loads in non-blocking phase for --session, so we must wait for "complete"
|
||||||
|
// to avoid a race where reconcile overwrites the newly forked session)
|
||||||
let forked = false
|
let forked = false
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (forked || !args.sessionID || !args.fork) return
|
if (forked || sync.status !== "complete" || !args.sessionID || !args.fork) return
|
||||||
forked = true
|
forked = true
|
||||||
void sdk.api.session
|
void sdk.api.session
|
||||||
.fork({ sessionID: args.sessionID })
|
.fork({ sessionID: args.sessionID })
|
||||||
@@ -637,6 +618,13 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||||||
})
|
})
|
||||||
|
|
||||||
const connected = useConnected()
|
const connected = useConnected()
|
||||||
|
const currentWorktreeWorkspace = createMemo(() => {
|
||||||
|
const workspaceID = project.workspace.current()
|
||||||
|
if (!workspaceID) return
|
||||||
|
const workspace = project.workspace.get(workspaceID)
|
||||||
|
if (workspace?.type !== "worktree" || !workspace.directory) return
|
||||||
|
return workspace
|
||||||
|
})
|
||||||
const appCommands = createMemo(() =>
|
const appCommands = createMemo(() =>
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -673,6 +661,31 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "workspace.copy_path",
|
||||||
|
title: "Copy worktree path",
|
||||||
|
category: "Workspace",
|
||||||
|
enabled: () => currentWorktreeWorkspace() !== undefined,
|
||||||
|
run: async () => {
|
||||||
|
const workspace = currentWorktreeWorkspace()
|
||||||
|
if (!workspace?.directory) return
|
||||||
|
await clipboard
|
||||||
|
.write?.(workspace.directory)
|
||||||
|
.then(() => toast.show({ message: "Copied worktree path", variant: "info" }))
|
||||||
|
.catch(toast.error)
|
||||||
|
dialog.clear()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "workspace.list",
|
||||||
|
title: "Manage workspaces",
|
||||||
|
category: "Workspace",
|
||||||
|
hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||||
|
slashName: "workspaces",
|
||||||
|
run: () => {
|
||||||
|
dialog.replace(() => <DialogWorkspaceList />)
|
||||||
|
},
|
||||||
|
},
|
||||||
...Array.from({ length: 9 }, (_, i) => ({
|
...Array.from({ length: 9 }, (_, i) => ({
|
||||||
name: `session.quick_switch.${i + 1}`,
|
name: `session.quick_switch.${i + 1}`,
|
||||||
title: `Switch to session in quick slot ${i + 1}`,
|
title: `Switch to session in quick slot ${i + 1}`,
|
||||||
@@ -741,7 +754,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mcp.list",
|
name: "mcp.list",
|
||||||
title: "MCP Servers",
|
title: "Toggle MCPs",
|
||||||
category: "Agent",
|
category: "Agent",
|
||||||
slashName: "mcps",
|
slashName: "mcps",
|
||||||
run: () => {
|
run: () => {
|
||||||
@@ -805,6 +818,21 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||||||
},
|
},
|
||||||
category: "Integration",
|
category: "Integration",
|
||||||
},
|
},
|
||||||
|
...(sync.data.console_state.switchableOrgCount > 1
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: "console.org.switch",
|
||||||
|
title: "Switch org",
|
||||||
|
suggested: Boolean(sync.data.console_state.activeOrgName),
|
||||||
|
slashName: "org",
|
||||||
|
slashAliases: ["orgs", "switch-org"],
|
||||||
|
run: () => {
|
||||||
|
dialog.replace(() => <DialogConsoleOrg />)
|
||||||
|
},
|
||||||
|
category: "Provider",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
name: "opencode.status",
|
name: "opencode.status",
|
||||||
title: "View status",
|
title: "View status",
|
||||||
@@ -1012,6 +1040,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||||||
category: "System",
|
category: "System",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
|
kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
|
||||||
|
await sync.session.refresh()
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
TuiAttentionSoundPack,
|
TuiAttentionSoundPack,
|
||||||
TuiAttentionSoundPackInfo,
|
TuiAttentionSoundPackInfo,
|
||||||
} from "@opencode-ai/plugin/tui"
|
} from "@opencode-ai/plugin/tui"
|
||||||
import { AttentionSoundName, type TuiConfig } from "./config/v1"
|
import { AttentionSoundName, type TuiConfig } from "./config"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import stripAnsi from "strip-ansi"
|
import stripAnsi from "strip-ansi"
|
||||||
import * as TuiAudio from "./audio"
|
import * as TuiAudio from "./audio"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
useKeymapSelector,
|
useKeymapSelector,
|
||||||
useOpencodeKeymap,
|
useOpencodeKeymap,
|
||||||
} from "../keymap"
|
} from "../keymap"
|
||||||
import { useTuiConfig } from "../config/v1"
|
import { useTuiConfig } from "../config"
|
||||||
|
|
||||||
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
|
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { createResource, createMemo, createSignal } from "solid-js"
|
||||||
|
import { TextAttributes } from "@opentui/core"
|
||||||
|
import { DialogSelect } from "../ui/dialog-select"
|
||||||
|
import { useSDK } from "../context/sdk"
|
||||||
|
import { useDialog } from "../ui/dialog"
|
||||||
|
import { useToast } from "../ui/toast"
|
||||||
|
import { useTheme } from "../context/theme"
|
||||||
|
import { errorMessage } from "../util/error"
|
||||||
|
import type { ExperimentalConsoleListOrgsResponse } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
type OrgOption = ExperimentalConsoleListOrgsResponse["orgs"][number]
|
||||||
|
|
||||||
|
const accountHost = (url: string) => {
|
||||||
|
try {
|
||||||
|
return new URL(url).host
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountLabel = (item: Pick<OrgOption, "accountEmail" | "accountUrl">) =>
|
||||||
|
`${item.accountEmail} ${accountHost(item.accountUrl)}`
|
||||||
|
|
||||||
|
export function DialogConsoleOrg() {
|
||||||
|
const sdk = useSDK()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const toast = useToast()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
const [loadError, setLoadError] = createSignal<unknown>()
|
||||||
|
|
||||||
|
const [orgs] = createResource(() =>
|
||||||
|
sdk.client.experimental.console
|
||||||
|
.listOrgs({}, { throwOnError: true })
|
||||||
|
.then((result) => result.data?.orgs ?? [])
|
||||||
|
// Catch so the rejected resource never reaches the memos below: reading
|
||||||
|
// orgs() in an errored state re-throws and tears down the dialog.
|
||||||
|
.catch((error) => {
|
||||||
|
setLoadError(error)
|
||||||
|
return undefined
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const showError = createMemo(() => Boolean(loadError()))
|
||||||
|
|
||||||
|
const current = createMemo(() => orgs()?.find((item) => item.active))
|
||||||
|
|
||||||
|
const options = createMemo(() => {
|
||||||
|
if (showError()) return []
|
||||||
|
const listed = orgs()
|
||||||
|
if (listed === undefined) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "Loading orgs...",
|
||||||
|
value: "loading",
|
||||||
|
onSelect: () => {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listed.length === 0) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "No orgs found",
|
||||||
|
value: "empty",
|
||||||
|
onSelect: () => {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return listed
|
||||||
|
.toSorted((a, b) => {
|
||||||
|
const activeAccountA = a.active ? 0 : 1
|
||||||
|
const activeAccountB = b.active ? 0 : 1
|
||||||
|
if (activeAccountA !== activeAccountB) return activeAccountA - activeAccountB
|
||||||
|
|
||||||
|
const accountCompare = accountLabel(a).localeCompare(accountLabel(b))
|
||||||
|
if (accountCompare !== 0) return accountCompare
|
||||||
|
|
||||||
|
return a.orgName.localeCompare(b.orgName)
|
||||||
|
})
|
||||||
|
.map((item) => ({
|
||||||
|
title: item.orgName,
|
||||||
|
value: item,
|
||||||
|
category: accountLabel(item),
|
||||||
|
categoryView: (
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text fg={theme.accent}>{item.accountEmail}</text>
|
||||||
|
<text fg={theme.textMuted}>{accountHost(item.accountUrl)}</text>
|
||||||
|
</box>
|
||||||
|
),
|
||||||
|
onSelect: async () => {
|
||||||
|
if (item.active) {
|
||||||
|
dialog.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await sdk.client.experimental.console.switchOrg(
|
||||||
|
{
|
||||||
|
accountID: item.accountID,
|
||||||
|
orgID: item.orgID,
|
||||||
|
},
|
||||||
|
{ throwOnError: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
await sdk.client.instance.dispose()
|
||||||
|
toast.show({
|
||||||
|
message: `Switched to ${item.orgName}`,
|
||||||
|
variant: "info",
|
||||||
|
})
|
||||||
|
dialog.clear()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogSelect<string | OrgOption>
|
||||||
|
title="Switch org"
|
||||||
|
options={options()}
|
||||||
|
current={current()}
|
||||||
|
renderFilter={!showError()}
|
||||||
|
locked={showError()}
|
||||||
|
emptyView={
|
||||||
|
showError() ? (
|
||||||
|
<box paddingLeft={4} paddingRight={4}>
|
||||||
|
<text fg={theme.error} attributes={TextAttributes.BOLD}>
|
||||||
|
Could not load orgs
|
||||||
|
</text>
|
||||||
|
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
|
||||||
|
</box>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import type {
|
import type { IntegrationConnectOauthOutput } from "@opencode-ai/client/promise"
|
||||||
ConnectionInfo,
|
import type { ConnectionInfo, IntegrationInfo, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2"
|
||||||
IntegrationConnectOauthOutput,
|
|
||||||
IntegrationInfo,
|
|
||||||
IntegrationOAuthMethod,
|
|
||||||
} from "@opencode-ai/client"
|
|
||||||
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||||
import { useClipboard } from "../context/clipboard"
|
import { useClipboard } from "../context/clipboard"
|
||||||
import { useData } from "../context/data"
|
import { useData } from "../context/data"
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { DialogSelect } from "../ui/dialog-select"
|
|||||||
import { useDialog } from "../ui/dialog"
|
import { useDialog } from "../ui/dialog"
|
||||||
import { useTheme, type Theme } from "../context/theme"
|
import { useTheme, type Theme } from "../context/theme"
|
||||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||||
import type { McpServer } from "@opencode-ai/client"
|
import type { McpServer } from "@opencode-ai/sdk/v2"
|
||||||
import { useClipboard } from "../context/clipboard"
|
import { useClipboard } from "../context/clipboard"
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { useTuiConfig } from "../config/v1"
|
import { useTuiConfig } from "../config"
|
||||||
import { getScrollAcceleration } from "../util/scroll"
|
import { getScrollAcceleration } from "../util/scroll"
|
||||||
import { useBindings } from "../keymap"
|
import { useBindings } from "../keymap"
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ export function DialogMcp() {
|
|||||||
|
|
||||||
const servers = createMemo(() =>
|
const servers = createMemo(() =>
|
||||||
pipe(
|
pipe(
|
||||||
data.location.mcp.server.list() ?? [],
|
data.location.mcp.list() ?? [],
|
||||||
sortBy(
|
sortBy(
|
||||||
(server) => statusMeta(server.status, theme).rank,
|
(server) => statusMeta(server.status, theme).rank,
|
||||||
(server) => server.name,
|
(server) => server.name,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
|||||||
import { useDialog } from "../ui/dialog"
|
import { useDialog } from "../ui/dialog"
|
||||||
import { useSDK } from "../context/sdk"
|
import { useSDK } from "../context/sdk"
|
||||||
import { useTheme } from "../context/theme"
|
import { useTheme } from "../context/theme"
|
||||||
import { useData } from "../context/data"
|
import { useSync } from "../context/sync"
|
||||||
import { abbreviateHome } from "../runtime"
|
import { abbreviateHome } from "../runtime"
|
||||||
import { useTuiPaths } from "../context/runtime"
|
import { useTuiPaths } from "../context/runtime"
|
||||||
import { Locale } from "../util/locale"
|
import { Locale } from "../util/locale"
|
||||||
@@ -17,7 +17,7 @@ import { useCommandShortcut } from "../keymap"
|
|||||||
import { useProject } from "../context/project"
|
import { useProject } from "../context/project"
|
||||||
import { Spinner } from "./spinner"
|
import { Spinner } from "./spinner"
|
||||||
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
|
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
|
||||||
import type { ProjectDirectoriesOutput } from "@opencode-ai/client"
|
import type { ProjectDirectoriesOutput } from "@opencode-ai/client/promise"
|
||||||
import { useRoute } from "../context/route"
|
import { useRoute } from "../context/route"
|
||||||
import { DialogProjectCopyName } from "./dialog-project-copy-name"
|
import { DialogProjectCopyName } from "./dialog-project-copy-name"
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const sessionData = useData()
|
const sync = useSync()
|
||||||
const projectContext = useProject()
|
const projectContext = useProject()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -132,13 +132,9 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||||||
})
|
})
|
||||||
if (roots.length === 0) return [{ title: "No project directories found", value: undefined }]
|
if (roots.length === 0) return [{ title: "No project directories found", value: undefined }]
|
||||||
|
|
||||||
const subdirectories = sessionData.session
|
const subdirectories = sync.data.session
|
||||||
.list()
|
.filter((session) => session.projectID === props.projectID && session.path && ![".", "/"].includes(session.path))
|
||||||
.filter(
|
.map((session) => session.directory)
|
||||||
(session) =>
|
|
||||||
session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath),
|
|
||||||
)
|
|
||||||
.map((session) => session.location.directory)
|
|
||||||
.filter((directory) => !roots.some((root) => root.directory === directory))
|
.filter((directory) => !roots.some((root) => root.directory === directory))
|
||||||
.filter((directory, index, directories) => directories.indexOf(directory) === index)
|
.filter((directory, index, directories) => directories.indexOf(directory) === index)
|
||||||
.map((location) => ({
|
.map((location) => ({
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user