Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 180109d8b6 |
@@ -1,4 +1 @@
|
||||
preload = ["@opentui/solid/preload"]
|
||||
|
||||
[test]
|
||||
preload = ["@opentui/solid/preload"]
|
||||
|
||||
@@ -2,13 +2,12 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { run } from "@opencode-ai/tui"
|
||||
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { TuiConfig } from "../../tui-config"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Server } from "../../services/server"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
|
||||
export default Runtime.handler(Commands, (input) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -16,33 +15,23 @@ export default Runtime.handler(Commands, (input) =>
|
||||
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater.check().pipe(Effect.forkScoped)
|
||||
const preflight = UpdatePreflight.make()
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
||||
const server = yield* Server.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
onStart: (reason, existing) => {
|
||||
if (reason === "version-mismatch" && preflight.begin(existing?.version)) return
|
||||
onStart: (reason) =>
|
||||
process.stderr.write(
|
||||
reason === "version-mismatch"
|
||||
? "Restarting background server (version mismatch)...\n"
|
||||
: "Starting background server...\n",
|
||||
)
|
||||
},
|
||||
}).pipe(
|
||||
Effect.tapError(() =>
|
||||
Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")),
|
||||
),
|
||||
)
|
||||
preflight.loading()
|
||||
const config = yield* TuiConfig.load()
|
||||
),
|
||||
})
|
||||
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||
let disposeSlots: (() => void) | undefined
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
yield* run({
|
||||
server,
|
||||
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||
config,
|
||||
terminalHandoff: () => preflight.finish(),
|
||||
log: (level, message, tags) => {
|
||||
const effect =
|
||||
level === "debug"
|
||||
|
||||
@@ -778,26 +778,19 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
if (!area || area.isDestroyed) return false
|
||||
|
||||
const endOffset = Bun.stringWidth(area.plainText)
|
||||
if (dir === -1) {
|
||||
if (area.cursorOffset === 0) return false
|
||||
if (area.visualCursor.visualRow === 0) {
|
||||
area.cursorOffset = 0
|
||||
return
|
||||
}
|
||||
area.moveCursorUp()
|
||||
return
|
||||
if (dir === -1 && area.visualCursor.visualRow === 0) {
|
||||
area.cursorOffset = 0
|
||||
}
|
||||
|
||||
const end =
|
||||
typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0
|
||||
? area.height - 1
|
||||
: Math.max(0, (area.virtualLineCount ?? 1) - 1)
|
||||
if (area.cursorOffset === endOffset) return false
|
||||
if (area.visualCursor.visualRow === end) {
|
||||
if (dir === 1 && area.visualCursor.visualRow === end) {
|
||||
area.cursorOffset = endOffset
|
||||
return
|
||||
}
|
||||
area.moveCursorDown()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const requestExit = () => {
|
||||
@@ -1044,7 +1037,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
priority: 1,
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: input.prompt() && !visible(),
|
||||
commands: [
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// history ring. All are async because they read config or hit the SDK, but
|
||||
// none block each other.
|
||||
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 { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// → OpenTUI split-footer renderer writes to terminal
|
||||
import type { OpenCodeClient, ReferenceListOutput } from "@opencode-ai/client/promise"
|
||||
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 = {
|
||||
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" },
|
||||
)
|
||||
})
|
||||
@@ -1,106 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
|
||||
import { resolve } from "@opencode-ai/tui/config/v1"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createComponent, createSignal } from "solid-js"
|
||||
import { RunFooterView } from "../src/mini/footer.view"
|
||||
import { RUN_THEME_FALLBACK } from "../src/mini/theme"
|
||||
import type { FooterState, FooterSubagentState, FooterView } from "../src/mini/types"
|
||||
|
||||
test("down opens subagents from an empty prompt", async () => {
|
||||
const [state] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
model: "gpt-5",
|
||||
duration: "",
|
||||
usage: "",
|
||||
first: false,
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
})
|
||||
const [view] = createSignal<FooterView>({ type: "prompt" })
|
||||
const [subagents] = createSignal<FooterSubagentState>({
|
||||
tabs: [
|
||||
{
|
||||
sessionID: "subagent-1",
|
||||
partID: "part-1",
|
||||
callID: "call-1",
|
||||
label: "Explore",
|
||||
description: "Inspect the keymap",
|
||||
status: "running",
|
||||
lastUpdatedAt: 1,
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
permissions: [],
|
||||
questions: [],
|
||||
})
|
||||
const config = resolve(
|
||||
{ keybinds: { editor_open: "none", session_queued_prompts: "none" } },
|
||||
{ terminalSuspend: true },
|
||||
)
|
||||
let offKeymap: (() => void) | undefined
|
||||
|
||||
function Harness() {
|
||||
const renderer = useRenderer()
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
||||
|
||||
return createComponent(OpencodeKeymapProvider, {
|
||||
keymap,
|
||||
get children() {
|
||||
return (
|
||||
<RunFooterView
|
||||
directory="/tmp"
|
||||
findFiles={async () => []}
|
||||
agents={() => []}
|
||||
references={() => []}
|
||||
commands={() => []}
|
||||
providers={() => undefined}
|
||||
currentModel={() => undefined}
|
||||
variants={() => []}
|
||||
currentVariant={() => undefined}
|
||||
state={state}
|
||||
view={view}
|
||||
subagent={subagents}
|
||||
theme={() => RUN_THEME_FALLBACK}
|
||||
tuiConfig={config}
|
||||
agent="opencode"
|
||||
onSubmit={() => true}
|
||||
onPermissionReply={() => {}}
|
||||
onQuestionReply={() => {}}
|
||||
onQuestionReject={() => {}}
|
||||
onCycle={() => {}}
|
||||
onInterrupt={() => false}
|
||||
onEditorOpen={async () => undefined}
|
||||
onInputClear={() => {}}
|
||||
onExit={() => {}}
|
||||
onModelSelect={() => {}}
|
||||
onVariantSelect={() => {}}
|
||||
onRows={() => {}}
|
||||
onLayout={() => {}}
|
||||
onStatus={() => {}}
|
||||
onQueuedRemove={async () => true}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />, { width: 100, height: 8, kittyKeyboard: true })
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe("")
|
||||
app.mockInput.pressArrow("down")
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Select subagent")
|
||||
} finally {
|
||||
app.renderer.currentFocusedRenderable?.blur()
|
||||
app.renderer.currentFocusedEditor?.blur()
|
||||
offKeymap?.()
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
})
|
||||
@@ -406,7 +406,6 @@ export type Endpoint10_2Input = {
|
||||
readonly integrationID: Endpoint10_2Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint10_2Request["query"]["location"]
|
||||
readonly key: Endpoint10_2Request["payload"]["key"]
|
||||
readonly inputs?: Endpoint10_2Request["payload"]["inputs"]
|
||||
readonly label?: Endpoint10_2Request["payload"]["label"]
|
||||
}
|
||||
export type Endpoint10_2Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.connect.key"]>>
|
||||
@@ -477,16 +476,18 @@ export interface IntegrationApi<E = never> {
|
||||
type Endpoint11_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
|
||||
export type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
|
||||
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]
|
||||
export type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] }
|
||||
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> {
|
||||
readonly list: McpListOperation<E>
|
||||
readonly resource: { readonly catalog: McpResourceCatalogOperation<E> }
|
||||
export interface ServerMcpApi<E = never> {
|
||||
readonly list: ServerMcpListOperation<E>
|
||||
readonly resource: { readonly catalog: ServerMcpResourceCatalogOperation<E> }
|
||||
}
|
||||
|
||||
type Endpoint12_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
|
||||
@@ -954,7 +955,7 @@ export interface AppApi<E = never> {
|
||||
readonly generate: GenerateApi<E>
|
||||
readonly provider: ProviderApi<E>
|
||||
readonly integration: IntegrationApi<E>
|
||||
readonly mcp: McpApi<E>
|
||||
readonly "server.mcp": ServerMcpApi<E>
|
||||
readonly credential: CredentialApi<E>
|
||||
readonly project: ProjectApi<E>
|
||||
readonly form: FormApi<E>
|
||||
|
||||
@@ -504,14 +504,13 @@ type Endpoint10_2Input = {
|
||||
readonly integrationID: Endpoint10_2Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint10_2Request["query"]["location"]
|
||||
readonly key: Endpoint10_2Request["payload"]["key"]
|
||||
readonly inputs?: Endpoint10_2Request["payload"]["inputs"]
|
||||
readonly label?: Endpoint10_2Request["payload"]["label"]
|
||||
}
|
||||
const Endpoint10_2 = (raw: RawClient["server.integration"]) => (input: Endpoint10_2Input) =>
|
||||
raw["integration.connect.key"]({
|
||||
params: { integrationID: input["integrationID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { key: input["key"], inputs: input["inputs"], label: input["label"] },
|
||||
payload: { key: input["key"], label: input["label"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint10_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
|
||||
@@ -1135,7 +1134,7 @@ const adaptClient = (raw: RawClient) => ({
|
||||
generate: adaptGroup8(raw["server.generate"]),
|
||||
provider: adaptGroup9(raw["server.provider"]),
|
||||
integration: adaptGroup10(raw["server.integration"]),
|
||||
mcp: adaptGroup11(raw["server.mcp"]),
|
||||
"server.mcp": adaptGroup11(raw["server.mcp"]),
|
||||
credential: adaptGroup12(raw["server.credential"]),
|
||||
project: adaptGroup13(raw["server.project"]),
|
||||
form: adaptGroup14(raw["server.form"]),
|
||||
|
||||
@@ -35,10 +35,7 @@ export type Options = {
|
||||
export type StartReason = "missing" | "version-mismatch"
|
||||
|
||||
export type StartOptions = Options & {
|
||||
// Called once when start() decides it must spawn: either no service was
|
||||
// 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
|
||||
readonly onStart?: (reason: StartReason) => void
|
||||
}
|
||||
|
||||
// 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)
|
||||
if (compatible !== undefined) return compatible
|
||||
const mismatched = yield* find(options)
|
||||
yield* Effect.sync(() =>
|
||||
options.onStart?.(mismatched === undefined ? "missing" : "version-mismatch", mismatched?.info),
|
||||
)
|
||||
yield* Effect.sync(() => options.onStart?.(mismatched === undefined ? "missing" : "version-mismatch"))
|
||||
if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
|
||||
|
||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
export type ClientErrorReason =
|
||||
| "Transport"
|
||||
| "UnexpectedStatus"
|
||||
| "UnsupportedContentType"
|
||||
| "MalformedResponse"
|
||||
| "SseEventTooLarge"
|
||||
export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"
|
||||
|
||||
export class ClientError extends Error {
|
||||
override readonly name = "ClientError"
|
||||
|
||||
@@ -90,10 +90,10 @@ import type {
|
||||
IntegrationAttemptCompleteOutput,
|
||||
IntegrationAttemptCancelInput,
|
||||
IntegrationAttemptCancelOutput,
|
||||
McpListInput,
|
||||
McpListOutput,
|
||||
McpResourceCatalogInput,
|
||||
McpResourceCatalogOutput,
|
||||
ServerMcpListInput,
|
||||
ServerMcpListOutput,
|
||||
ServerMcpResourceCatalogInput,
|
||||
ServerMcpResourceCatalogOutput,
|
||||
CredentialUpdateInput,
|
||||
CredentialUpdateOutput,
|
||||
CredentialRemoveInput,
|
||||
@@ -213,8 +213,6 @@ interface RequestDescriptor {
|
||||
readonly binary?: true
|
||||
}
|
||||
|
||||
const maxSseEventBytes = 16 * 1024 * 1024
|
||||
|
||||
export function make(options: ClientOptions) {
|
||||
const fetch = options.fetch ?? globalThis.fetch
|
||||
|
||||
@@ -291,7 +289,7 @@ export function make(options: ClientOptions) {
|
||||
throw new ClientError("Transport", { cause })
|
||||
}
|
||||
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")
|
||||
if (trailingCarriageReturn) buffer = buffer.slice(0, -1)
|
||||
buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
|
||||
@@ -880,7 +878,7 @@ export function make(options: ClientOptions) {
|
||||
method: "POST",
|
||||
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
|
||||
query: { location: input["location"] },
|
||||
body: { key: input["key"], inputs: input["inputs"], label: input["label"] },
|
||||
body: { key: input["key"], label: input["label"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
@@ -941,9 +939,9 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
list: (input?: McpListInput, requestOptions?: RequestOptions) =>
|
||||
request<McpListOutput>(
|
||||
"server.mcp": {
|
||||
list: (input?: ServerMcpListInput, requestOptions?: RequestOptions) =>
|
||||
request<ServerMcpListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/mcp`,
|
||||
@@ -955,8 +953,8 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
),
|
||||
resource: {
|
||||
catalog: (input?: McpResourceCatalogInput, requestOptions?: RequestOptions) =>
|
||||
request<McpResourceCatalogOutput>(
|
||||
catalog: (input?: ServerMcpResourceCatalogInput, requestOptions?: RequestOptions) =>
|
||||
request<ServerMcpResourceCatalogOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/mcp/resource`,
|
||||
|
||||
@@ -130,9 +130,9 @@ export type SessionMessageCompactionCompleted = {
|
||||
|
||||
export type InstructionEntryKey = string
|
||||
|
||||
export type SessionPendingSyntheticData1 = { text: string; description?: string; metadata?: { [x: string]: any } }
|
||||
export type SessionPendingSyntheticData1 = { text: string; description?: string; metadata?: { [x: string]: unknown } }
|
||||
|
||||
export type ShellInfo = {
|
||||
export type Shell = {
|
||||
id: string
|
||||
status: "running" | "exited" | "timeout" | "killed"
|
||||
command: string
|
||||
@@ -141,19 +141,19 @@ export type ShellInfo = {
|
||||
file: string
|
||||
pid?: number
|
||||
exit?: number
|
||||
metadata: { [x: string]: any }
|
||||
metadata: { [x: string]: unknown }
|
||||
time: { started: number; completed?: number }
|
||||
}
|
||||
|
||||
export type SessionMessageProviderState3 = { [x: string]: any }
|
||||
export type SessionMessageProviderState3 = { [x: string]: unknown }
|
||||
|
||||
export type SessionMessageProviderState4 = { [x: string]: any }
|
||||
export type SessionMessageProviderState4 = { [x: string]: unknown }
|
||||
|
||||
export type SessionMessageProviderState5 = { [x: string]: any }
|
||||
export type SessionMessageProviderState5 = { [x: string]: unknown }
|
||||
|
||||
export type SessionMessageProviderState6 = { [x: string]: any }
|
||||
export type SessionMessageProviderState6 = { [x: string]: unknown }
|
||||
|
||||
export type SessionMessageProviderState7 = { [x: string]: any }
|
||||
export type SessionMessageProviderState7 = { [x: string]: unknown }
|
||||
|
||||
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
|
||||
|
||||
@@ -183,6 +183,8 @@ export type ProviderV2Info = {
|
||||
|
||||
export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string }
|
||||
|
||||
export type IntegrationKeyMethod = { type: "key"; label?: string }
|
||||
|
||||
export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
|
||||
|
||||
export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string }
|
||||
@@ -443,7 +445,7 @@ export type QuestionV2Tool = { messageID: string; callID: string }
|
||||
|
||||
export type QuestionV2Answer = Array<string>
|
||||
|
||||
export type FormMetadata1 = { [x: string]: any }
|
||||
export type FormMetadata1 = { [x: string]: unknown }
|
||||
|
||||
export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean }
|
||||
|
||||
@@ -464,7 +466,7 @@ export type QuestionTool = { messageID: string; callID: string }
|
||||
|
||||
export type QuestionAnswer = Array<string>
|
||||
|
||||
export type ShellInfo1 = {
|
||||
export type Shell1 = {
|
||||
id: string
|
||||
status: "running" | "exited" | "timeout" | "killed"
|
||||
command: string
|
||||
@@ -472,9 +474,9 @@ export type ShellInfo1 = {
|
||||
shell: string
|
||||
file: string
|
||||
pid?: number
|
||||
exit?: number
|
||||
exit?: number | "Infinity" | "-Infinity" | "NaN"
|
||||
metadata: { [x: string]: JsonValue }
|
||||
time: { started: number; completed?: number }
|
||||
time: { started: number | "Infinity" | "-Infinity" | "NaN"; completed?: number | "Infinity" | "-Infinity" | "NaN" }
|
||||
}
|
||||
|
||||
export type ReferenceLocalSource = { type: "local"; path: string; description?: string; hidden?: boolean }
|
||||
@@ -525,7 +527,7 @@ export type PermissionV2Rule = { action: string; resource: string; effect: Permi
|
||||
export type SessionAgentSelected = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.agent.selected"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -535,7 +537,7 @@ export type SessionAgentSelected = {
|
||||
export type SessionModelSelected = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.model.selected"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -545,7 +547,7 @@ export type SessionModelSelected = {
|
||||
export type SessionMoved = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.moved"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -555,7 +557,7 @@ export type SessionMoved = {
|
||||
export type SessionRenamed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.renamed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -565,7 +567,7 @@ export type SessionRenamed = {
|
||||
export type SessionDeleted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.deleted"
|
||||
durable: { aggregateID: string; seq: number; version: 2 }
|
||||
location?: LocationRef
|
||||
@@ -575,7 +577,7 @@ export type SessionDeleted = {
|
||||
export type SessionForked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.forked"
|
||||
durable: { aggregateID: string; seq: number; version: 2 }
|
||||
location?: LocationRef
|
||||
@@ -585,7 +587,7 @@ export type SessionForked = {
|
||||
export type SessionInputPromoted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.input.promoted"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -595,7 +597,7 @@ export type SessionInputPromoted = {
|
||||
export type SessionExecutionStarted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.execution.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -605,7 +607,7 @@ export type SessionExecutionStarted = {
|
||||
export type SessionExecutionSucceeded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.execution.succeeded"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -615,7 +617,7 @@ export type SessionExecutionSucceeded = {
|
||||
export type SessionExecutionInterrupted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.execution.interrupted"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -625,7 +627,7 @@ export type SessionExecutionInterrupted = {
|
||||
export type SessionInstructionsUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.instructions.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 2 }
|
||||
location?: LocationRef
|
||||
@@ -635,17 +637,17 @@ export type SessionInstructionsUpdated = {
|
||||
export type SessionSynthetic = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.synthetic"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; text: string; description?: string; metadata?: { [x: string]: any } }
|
||||
data: { sessionID: string; text: string; description?: string; metadata?: { [x: string]: unknown } }
|
||||
}
|
||||
|
||||
export type SessionSkillActivated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.skill.activated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -655,7 +657,7 @@ export type SessionSkillActivated = {
|
||||
export type SessionStepStarted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.step.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -665,7 +667,7 @@ export type SessionStepStarted = {
|
||||
export type SessionStepEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.step.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -683,7 +685,7 @@ export type SessionStepEnded = {
|
||||
export type SessionTextStarted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.text.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -693,7 +695,7 @@ export type SessionTextStarted = {
|
||||
export type SessionTextEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.text.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -703,7 +705,7 @@ export type SessionTextEnded = {
|
||||
export type SessionToolInputStarted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.tool.input.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -713,7 +715,7 @@ export type SessionToolInputStarted = {
|
||||
export type SessionToolInputEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.tool.input.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -723,7 +725,7 @@ export type SessionToolInputEnded = {
|
||||
export type SessionCompactionAdmitted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.compaction.admitted"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -733,7 +735,7 @@ export type SessionCompactionAdmitted = {
|
||||
export type SessionCompactionStarted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.compaction.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -743,7 +745,7 @@ export type SessionCompactionStarted = {
|
||||
export type SessionCompactionEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.compaction.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -753,7 +755,7 @@ export type SessionCompactionEnded = {
|
||||
export type SessionRevertCleared = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.revert.cleared"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -763,7 +765,7 @@ export type SessionRevertCleared = {
|
||||
export type SessionRevertCommitted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.revert.committed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -773,7 +775,7 @@ export type SessionRevertCommitted = {
|
||||
export type ModelsDevRefreshed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "models-dev.refreshed"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
@@ -782,7 +784,7 @@ export type ModelsDevRefreshed = {
|
||||
export type IntegrationUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "integration.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
@@ -791,7 +793,7 @@ export type IntegrationUpdated = {
|
||||
export type IntegrationConnectionUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "integration.connection.updated"
|
||||
location?: LocationRef
|
||||
data: { integrationID: string }
|
||||
@@ -800,7 +802,7 @@ export type IntegrationConnectionUpdated = {
|
||||
export type CatalogUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "catalog.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
@@ -809,7 +811,7 @@ export type CatalogUpdated = {
|
||||
export type AgentUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "agent.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
@@ -818,7 +820,7 @@ export type AgentUpdated = {
|
||||
export type MessageRemoved = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "message.removed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -828,7 +830,7 @@ export type MessageRemoved = {
|
||||
export type MessagePartRemoved = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "message.part.removed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -838,7 +840,7 @@ export type MessagePartRemoved = {
|
||||
export type SessionUsageUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.usage.updated"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; cost: MoneyUSD; tokens: TokenUsageInfo }
|
||||
@@ -847,7 +849,7 @@ export type SessionUsageUpdated = {
|
||||
export type SessionTextDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.text.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
|
||||
@@ -856,7 +858,7 @@ export type SessionTextDelta = {
|
||||
export type SessionReasoningDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.reasoning.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
|
||||
@@ -865,7 +867,7 @@ export type SessionReasoningDelta = {
|
||||
export type SessionToolInputDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.tool.input.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; callID: string; delta: string }
|
||||
@@ -874,7 +876,7 @@ export type SessionToolInputDelta = {
|
||||
export type SessionCompactionDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.compaction.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; text: string }
|
||||
@@ -883,7 +885,7 @@ export type SessionCompactionDelta = {
|
||||
export type FilesystemChanged = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "filesystem.changed"
|
||||
location?: LocationRef
|
||||
data: { file: string; event: "add" | "change" | "unlink" }
|
||||
@@ -892,7 +894,7 @@ export type FilesystemChanged = {
|
||||
export type ReferenceUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "reference.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
@@ -901,7 +903,7 @@ export type ReferenceUpdated = {
|
||||
export type PluginAdded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "plugin.added"
|
||||
location?: LocationRef
|
||||
data: { id: string }
|
||||
@@ -910,7 +912,7 @@ export type PluginAdded = {
|
||||
export type PluginUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "plugin.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
@@ -919,7 +921,7 @@ export type PluginUpdated = {
|
||||
export type ProjectDirectoriesUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "project.directories.updated"
|
||||
location?: LocationRef
|
||||
data: { projectID: string }
|
||||
@@ -928,7 +930,7 @@ export type ProjectDirectoriesUpdated = {
|
||||
export type CommandUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "command.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
@@ -937,7 +939,7 @@ export type CommandUpdated = {
|
||||
export type ConfigUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "config.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
@@ -946,7 +948,7 @@ export type ConfigUpdated = {
|
||||
export type SkillUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "skill.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
@@ -955,7 +957,7 @@ export type SkillUpdated = {
|
||||
export type PtyExited = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "pty.exited"
|
||||
location?: LocationRef
|
||||
data: { id: string; exitCode: number }
|
||||
@@ -964,7 +966,7 @@ export type PtyExited = {
|
||||
export type PtyDeleted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "pty.deleted"
|
||||
location?: LocationRef
|
||||
data: { id: string }
|
||||
@@ -973,7 +975,7 @@ export type PtyDeleted = {
|
||||
export type ShellExited = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "shell.exited"
|
||||
location?: LocationRef
|
||||
data: { id: string; exit?: number; status: "running" | "exited" | "timeout" | "killed" }
|
||||
@@ -982,7 +984,7 @@ export type ShellExited = {
|
||||
export type ShellDeleted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "shell.deleted"
|
||||
location?: LocationRef
|
||||
data: { id: string }
|
||||
@@ -991,7 +993,7 @@ export type ShellDeleted = {
|
||||
export type QuestionV2Rejected = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "question.v2.rejected"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string }
|
||||
@@ -1000,7 +1002,7 @@ export type QuestionV2Rejected = {
|
||||
export type FormCancelled = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "form.cancelled"
|
||||
location?: LocationRef
|
||||
data: { id: string; sessionID: string }
|
||||
@@ -1009,7 +1011,7 @@ export type FormCancelled = {
|
||||
export type SessionIdle = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.idle"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string }
|
||||
@@ -1018,7 +1020,7 @@ export type SessionIdle = {
|
||||
export type TuiPromptAppend = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "tui.prompt.append"
|
||||
location?: LocationRef
|
||||
data: { text: string }
|
||||
@@ -1027,7 +1029,7 @@ export type TuiPromptAppend = {
|
||||
export type TuiCommandExecute = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "tui.command.execute"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
@@ -1056,7 +1058,7 @@ export type TuiCommandExecute = {
|
||||
export type TuiToastShow = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "tui.toast.show"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
@@ -1070,7 +1072,7 @@ export type TuiToastShow = {
|
||||
export type TuiSessionSelect = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "tui.session.select"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string }
|
||||
@@ -1079,7 +1081,7 @@ export type TuiSessionSelect = {
|
||||
export type InstallationUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "installation.updated"
|
||||
location?: LocationRef
|
||||
data: { version: string }
|
||||
@@ -1088,7 +1090,7 @@ export type InstallationUpdated = {
|
||||
export type InstallationUpdateAvailable = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "installation.update-available"
|
||||
location?: LocationRef
|
||||
data: { version: string }
|
||||
@@ -1097,7 +1099,7 @@ export type InstallationUpdateAvailable = {
|
||||
export type VcsBranchUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "vcs.branch.updated"
|
||||
location?: LocationRef
|
||||
data: { branch?: string }
|
||||
@@ -1106,7 +1108,7 @@ export type VcsBranchUpdated = {
|
||||
export type McpStatusChanged = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "mcp.status.changed"
|
||||
location?: LocationRef
|
||||
data: { server: string }
|
||||
@@ -1115,7 +1117,7 @@ export type McpStatusChanged = {
|
||||
export type McpResourcesChanged = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "mcp.resources.changed"
|
||||
location?: LocationRef
|
||||
data: { server: string }
|
||||
@@ -1124,7 +1126,7 @@ export type McpResourcesChanged = {
|
||||
export type PermissionAsked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "permission.asked"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
@@ -1132,7 +1134,7 @@ export type PermissionAsked = {
|
||||
sessionID: string
|
||||
permission: string
|
||||
patterns: Array<string>
|
||||
metadata: { [x: string]: any }
|
||||
metadata: { [x: string]: unknown }
|
||||
always: Array<string>
|
||||
tool?: { messageID: string; callID: string } | undefined
|
||||
}
|
||||
@@ -1141,7 +1143,7 @@ export type PermissionAsked = {
|
||||
export type PermissionReplied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "permission.replied"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string; reply: "once" | "always" | "reject" }
|
||||
@@ -1150,7 +1152,7 @@ export type PermissionReplied = {
|
||||
export type QuestionRejected = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "question.rejected"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string }
|
||||
@@ -1158,7 +1160,7 @@ export type QuestionRejected = {
|
||||
|
||||
export type V2EventServerConnected = {
|
||||
id: string
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
metadata?: { [x: string]: unknown } | undefined
|
||||
location?: LocationRef | undefined
|
||||
type: "server.connected"
|
||||
data: {}
|
||||
@@ -1211,7 +1213,7 @@ export type SessionMessageCompactionFailed = {
|
||||
export type SessionExecutionFailed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.execution.failed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1221,7 +1223,7 @@ export type SessionExecutionFailed = {
|
||||
export type SessionStepFailed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.step.failed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1237,7 +1239,7 @@ export type SessionStepFailed = {
|
||||
export type SessionRetryScheduled = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.retry.scheduled"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1247,7 +1249,7 @@ export type SessionRetryScheduled = {
|
||||
export type SessionCompactionFailed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.compaction.failed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1265,23 +1267,23 @@ export type SessionPendingSyntheticMessage = {
|
||||
export type SessionShellStarted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.shell.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; shell: ShellInfo }
|
||||
data: { sessionID: string; shell: Shell }
|
||||
}
|
||||
|
||||
export type SessionShellEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.shell.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
shell: ShellInfo
|
||||
shell: Shell
|
||||
output: { output: string; cursor: number; size: number; truncated: boolean }
|
||||
}
|
||||
}
|
||||
@@ -1289,16 +1291,16 @@ export type SessionShellEnded = {
|
||||
export type ShellCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "shell.created"
|
||||
location?: LocationRef
|
||||
data: { info: ShellInfo }
|
||||
data: { info: Shell }
|
||||
}
|
||||
|
||||
export type SessionReasoningStarted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.reasoning.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1308,7 +1310,7 @@ export type SessionReasoningStarted = {
|
||||
export type SessionReasoningEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.reasoning.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1324,7 +1326,7 @@ export type SessionReasoningEnded = {
|
||||
export type SessionToolCalled = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.tool.called"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1332,7 +1334,7 @@ export type SessionToolCalled = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
input: { [x: string]: any }
|
||||
input: { [x: string]: unknown }
|
||||
executed: boolean
|
||||
state?: SessionMessageProviderState5
|
||||
}
|
||||
@@ -1341,7 +1343,7 @@ export type SessionToolCalled = {
|
||||
export type SessionToolFailed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.tool.failed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1350,7 +1352,7 @@ export type SessionToolFailed = {
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
error: SessionStructuredError
|
||||
result?: any
|
||||
result?: unknown
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState7
|
||||
}
|
||||
@@ -1488,7 +1490,7 @@ export type PermissionV2Request = {
|
||||
export type PermissionV2Asked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "permission.v2.asked"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
@@ -1497,7 +1499,7 @@ export type PermissionV2Asked = {
|
||||
action: string
|
||||
resources: Array<string>
|
||||
save?: Array<string>
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
source?: PermissionV2Source
|
||||
}
|
||||
}
|
||||
@@ -1556,7 +1558,7 @@ export type RetryPart = {
|
||||
export type SessionError = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.error"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
@@ -1590,7 +1592,7 @@ export type SymbolSource = {
|
||||
export type PermissionV2Replied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "permission.v2.replied"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string; reply: PermissionV2Reply }
|
||||
@@ -1599,7 +1601,7 @@ export type PermissionV2Replied = {
|
||||
export type PtyCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "pty.created"
|
||||
location?: LocationRef
|
||||
data: { info: Pty }
|
||||
@@ -1608,7 +1610,7 @@ export type PtyCreated = {
|
||||
export type PtyUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "pty.updated"
|
||||
location?: LocationRef
|
||||
data: { info: Pty }
|
||||
@@ -1625,7 +1627,7 @@ export type QuestionV2Info = {
|
||||
export type QuestionV2Replied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "question.v2.replied"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string; answers: Array<QuestionV2Answer> }
|
||||
@@ -1699,7 +1701,7 @@ export type FormMultiselectField1 = {
|
||||
export type SessionStatus2 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.status"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; status: SessionStatus }
|
||||
@@ -1716,7 +1718,7 @@ export type QuestionInfo = {
|
||||
export type QuestionReplied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "question.replied"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string; answers: Array<QuestionAnswer> }
|
||||
@@ -1745,7 +1747,7 @@ export type SessionInfo = {
|
||||
export type SessionRevertStaged = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.revert.staged"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1773,7 +1775,7 @@ export type SessionPendingUserData1 = {
|
||||
text: string
|
||||
files?: Array<PromptFileAttachment>
|
||||
agents?: Array<PromptAgentAttachment>
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
}
|
||||
|
||||
export type SessionMessageToolStateRunning = {
|
||||
@@ -1803,7 +1805,7 @@ export type SessionMessageToolStateError = {
|
||||
export type SessionToolProgress = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.tool.progress"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1811,7 +1813,7 @@ export type SessionToolProgress = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
structured: { [x: string]: any }
|
||||
structured: { [x: string]: unknown }
|
||||
content: Array<LLMToolContent>
|
||||
}
|
||||
}
|
||||
@@ -1819,7 +1821,7 @@ export type SessionToolProgress = {
|
||||
export type SessionToolSuccess = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.tool.success"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1827,9 +1829,9 @@ export type SessionToolSuccess = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
structured: { [x: string]: any }
|
||||
structured: { [x: string]: unknown }
|
||||
content: Array<LLMToolContent>
|
||||
result?: any
|
||||
result?: unknown
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState6
|
||||
}
|
||||
@@ -1866,12 +1868,6 @@ export type IntegrationOAuthMethod = {
|
||||
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
|
||||
}
|
||||
|
||||
export type IntegrationKeyMethod = {
|
||||
type: "key"
|
||||
label?: string
|
||||
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
|
||||
}
|
||||
|
||||
export type FormField =
|
||||
| FormStringField
|
||||
| FormNumberField
|
||||
@@ -1885,7 +1881,7 @@ export type FormState = { status: "pending" } | { status: "answered"; answer: Fo
|
||||
export type FormReplied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "form.replied"
|
||||
location?: LocationRef
|
||||
data: { id: string; sessionID: string; answer: FormAnswer }
|
||||
@@ -1911,7 +1907,7 @@ export type FilePartSource = FileSource | SymbolSource | ResourceSource
|
||||
export type QuestionV2Asked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "question.v2.asked"
|
||||
location?: LocationRef
|
||||
data: { id: string; sessionID: string; questions: Array<QuestionV2Info>; tool?: QuestionV2Tool }
|
||||
@@ -1935,20 +1931,12 @@ export type FormField1 =
|
||||
export type QuestionAsked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "question.asked"
|
||||
location?: LocationRef
|
||||
data: { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool | undefined }
|
||||
}
|
||||
|
||||
export type ReferenceInfo = {
|
||||
name: string
|
||||
path: string
|
||||
description?: string
|
||||
hidden?: boolean
|
||||
source: ReferenceSource
|
||||
}
|
||||
|
||||
export type AgentInfo = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -2053,19 +2041,12 @@ export type SessionMessageAssistant = {
|
||||
retry?: SessionMessageAssistantRetry
|
||||
}
|
||||
|
||||
export type IntegrationInfo = {
|
||||
id: string
|
||||
name: string
|
||||
methods: Array<IntegrationMethod>
|
||||
connections: Array<ConnectionInfo>
|
||||
}
|
||||
|
||||
export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields }
|
||||
|
||||
export type SessionCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.created"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -2075,7 +2056,7 @@ export type SessionCreated = {
|
||||
export type SessionUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -2085,7 +2066,7 @@ export type SessionUpdated = {
|
||||
export type SessionDeleted1 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.deleted"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -2095,7 +2076,7 @@ export type SessionDeleted1 = {
|
||||
export type MessageUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "message.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -2117,7 +2098,7 @@ export type FormInfo1 = { id: string; sessionID: string; title: string; metadata
|
||||
export type SessionInputAdmitted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.input.admitted"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -2140,7 +2121,7 @@ export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted
|
||||
export type FormCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "form.created"
|
||||
location?: LocationRef
|
||||
data: { form: FormInfo1 }
|
||||
@@ -2221,7 +2202,7 @@ export type Part =
|
||||
export type MessagePartUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "message.part.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -3260,7 +3241,7 @@ export type IntegrationListInput = {
|
||||
|
||||
export type IntegrationListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<IntegrationInfo>
|
||||
data: Array<{ id: string; name: string; methods: Array<IntegrationMethod>; connections: Array<ConnectionInfo> }>
|
||||
}
|
||||
|
||||
export type IntegrationGetInput = {
|
||||
@@ -3272,7 +3253,7 @@ export type IntegrationGetInput = {
|
||||
|
||||
export type IntegrationGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: IntegrationInfo | null
|
||||
data: { id: string; name: string; methods: Array<IntegrationMethod>; connections: Array<ConnectionInfo> } | null
|
||||
}
|
||||
|
||||
export type IntegrationConnectKeyInput = {
|
||||
@@ -3280,21 +3261,8 @@ export type IntegrationConnectKeyInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly key: {
|
||||
readonly key: string
|
||||
readonly inputs?: { readonly [x: string]: string } | undefined
|
||||
readonly label?: string | undefined
|
||||
}["key"]
|
||||
readonly inputs?: {
|
||||
readonly key: string
|
||||
readonly inputs?: { readonly [x: string]: string } | undefined
|
||||
readonly label?: string | undefined
|
||||
}["inputs"]
|
||||
readonly label?: {
|
||||
readonly key: string
|
||||
readonly inputs?: { readonly [x: string]: string } | undefined
|
||||
readonly label?: string | undefined
|
||||
}["label"]
|
||||
readonly key: { readonly key: string; readonly label?: string | undefined }["key"]
|
||||
readonly label?: { readonly key: string; readonly label?: string | undefined }["label"]
|
||||
}
|
||||
|
||||
export type IntegrationConnectKeyOutput = void
|
||||
@@ -3363,24 +3331,24 @@ export type IntegrationAttemptCancelInput = {
|
||||
|
||||
export type IntegrationAttemptCancelOutput = void
|
||||
|
||||
export type McpListInput = {
|
||||
export type ServerMcpListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type McpListOutput = {
|
||||
export type ServerMcpListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<McpServer>
|
||||
}
|
||||
|
||||
export type McpResourceCatalogInput = {
|
||||
export type ServerMcpResourceCatalogInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type McpResourceCatalogOutput = {
|
||||
export type ServerMcpResourceCatalogOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: McpResourceCatalog
|
||||
}
|
||||
@@ -4571,7 +4539,7 @@ export type ShellListInput = {
|
||||
|
||||
export type ShellListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<ShellInfo1>
|
||||
data: Array<Shell1>
|
||||
}
|
||||
|
||||
export type ShellCreateInput = {
|
||||
@@ -4606,7 +4574,7 @@ export type ShellCreateInput = {
|
||||
|
||||
export type ShellCreateOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: ShellInfo1
|
||||
data: Shell1
|
||||
}
|
||||
|
||||
export type ShellGetInput = {
|
||||
@@ -4618,7 +4586,7 @@ export type ShellGetInput = {
|
||||
|
||||
export type ShellGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: ShellInfo1
|
||||
data: Shell1
|
||||
}
|
||||
|
||||
export type ShellTimeoutInput = {
|
||||
@@ -4631,7 +4599,7 @@ export type ShellTimeoutInput = {
|
||||
|
||||
export type ShellTimeoutOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: ShellInfo1
|
||||
data: Shell1
|
||||
}
|
||||
|
||||
export type ShellOutputInput = {
|
||||
@@ -4705,7 +4673,7 @@ export type ReferenceListInput = {
|
||||
|
||||
export type ReferenceListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<ReferenceInfo>
|
||||
data: Array<{ name: string; path: string; description?: string; hidden?: boolean; source: ReferenceSource }>
|
||||
}
|
||||
|
||||
export type ProjectCopyCreateInput = {
|
||||
|
||||
@@ -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 () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||
const client = OpenCode.make({
|
||||
|
||||
+261
-92
@@ -1,40 +1,37 @@
|
||||
# @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
|
||||
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.
|
||||
Effect-native confined code execution over explicit, schema-described tools.
|
||||
|
||||
[Cloudflare's post](https://blog.cloudflare.com/code-mode/) introduced the idea. Their implementation executes
|
||||
generated code in isolate sandboxes. We took a lighter route: a pure interpreter that runs wherever your application
|
||||
runs, no sandbox required.
|
||||
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.
|
||||
|
||||
## 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
|
||||
library and the `tools` tree.
|
||||
- **No dynamic code.** No `eval`, `Function`, or module loading.
|
||||
- **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`.
|
||||
// A reusable runtime
|
||||
const runtime = CodeMode.make({ tools, limits })
|
||||
yield * runtime.execute(code)
|
||||
```
|
||||
|
||||
Beyond these, the language is a growing subset rather than a divergent one: unsupported syntax returns an
|
||||
`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
|
||||
[interpreter support checklist](./interpreter-support.md).
|
||||
## Install
|
||||
|
||||
Within this workspace:
|
||||
|
||||
```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
|
||||
|
||||
The package is workspace-private (`"@opencode-ai/codemode": "workspace:*"`). Hosts interact with it through `effect`
|
||||
and should depend on `effect` themselves. Define tools with Effect Schema, then place them in the object tree exposed
|
||||
to programs as `tools`:
|
||||
Define tools with Effect Schema, then place them in the object tree exposed to programs as `tools`:
|
||||
|
||||
```ts
|
||||
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
|
||||
rather than failing the Effect; host interruption remains interruption.
|
||||
`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.
|
||||
|
||||
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
|
||||
|
||||
### `Tool.make`
|
||||
|
||||
`input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document. Effect Schema input
|
||||
is decoded before `run` is invoked; an Effect Schema `output` is decoded and copied before the program sees it. JSON
|
||||
Schemas only shape the model-visible signature. Without `output` the signature advertises `Promise<unknown>`.
|
||||
Descriptions and schemas are model-visible contract; keep authorization in `run`.
|
||||
```ts
|
||||
const tool = Tool.make({
|
||||
description,
|
||||
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
|
||||
runtime from `make` reuses the tool set and policy:
|
||||
`output` is optional. Without it the tool's signature advertises `Promise<unknown>` and the host result is exposed as-is.
|
||||
|
||||
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
|
||||
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.instructions() // model-facing syntax and tool guide
|
||||
runtime.execute(source) // CodeMode.Result
|
||||
```
|
||||
|
||||
The Effect environment is inferred from the supplied tools; service requirements are not erased. Optional
|
||||
`onToolCallStart` / `onToolCallEnd` hooks observe admitted calls with decoded input, outcome, and duration; both are
|
||||
Effect-returning and must not fail.
|
||||
`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.
|
||||
|
||||
### 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
|
||||
`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`:
|
||||
### Results
|
||||
|
||||
```ts
|
||||
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,
|
||||
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.
|
||||
`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).
|
||||
|
||||
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 |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
@@ -152,45 +306,58 @@ Failure `error` and success `warnings` share one diagnostic vocabulary:
|
||||
| `ExecutionFailure` | The program threw or another execution error occurred. |
|
||||
| `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
|
||||
for a model-visible refusal; its optional cause never crosses the boundary.
|
||||
Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`:
|
||||
|
||||
## Discovery
|
||||
```ts
|
||||
import { toolError } from "@opencode-ai/codemode"
|
||||
|
||||
The generated instructions inline a budgeted catalog (default 2,000 estimated tokens, override with
|
||||
`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.
|
||||
run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable")))
|
||||
```
|
||||
|
||||
## 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 |
|
||||
| ---------------- | -------------------: | ---------------------------------------------------- |
|
||||
| `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. |
|
||||
## Authority Boundary
|
||||
|
||||
No limit has a default, on purpose: execution budgets are host policy. A host without its own truncation or
|
||||
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.
|
||||
CodeMode confines programs to the supplied tool tree, but it does not decide what those tools may do.
|
||||
|
||||
## Boundaries and Non-Goals
|
||||
The host owns:
|
||||
|
||||
The host owns authentication, authorization, tool selection, credentials, persistence, approval, and logging policy.
|
||||
CodeMode owns interpretation, schema and plain-data boundaries, resource limits, diagnostics, and discovery. A program
|
||||
can only exercise authority already present in the supplied tools - do not expose a broad tool and expect the prompt
|
||||
to restrict it.
|
||||
- Authentication and authorization.
|
||||
- Tool selection and immutable scope.
|
||||
- Credentials and network clients.
|
||||
- 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,
|
||||
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
|
||||
the currently authorized tools.
|
||||
CodeMode owns:
|
||||
|
||||
- Parsing and interpreting the supported subset without `eval`.
|
||||
- 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
|
||||
|
||||
@@ -200,3 +367,5 @@ From the package directory:
|
||||
bun test
|
||||
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:
|
||||
|
||||
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
|
||||
executes it without `eval`.
|
||||
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
|
||||
|
||||
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
|
||||
always callable - synchronously, counted as an admitted tool call - and is advertised when the inline catalog is
|
||||
partial.
|
||||
round-robin across namespaces so one large namespace cannot starve the others. `$codemode.search` is always callable
|
||||
and is advertised when the inline catalog is partial.
|
||||
|
||||
The intended workflow is:
|
||||
|
||||
1. Pick an exact signature from the inline catalog, or return `search(...)` results and use a selected path in the
|
||||
next execution.
|
||||
1. Pick an exact signature from the inline catalog, or return `$codemode.search(...)` results and use a selected path
|
||||
in the next execution.
|
||||
2. Call the exact returned path without guessing or normalizing segments.
|
||||
3. Narrow `Promise<unknown>` results before reading fields.
|
||||
4. Start independent calls together and await them with `Promise.all`.
|
||||
@@ -65,16 +64,10 @@ path lookup, namespace browsing, deterministic ranking, and pagination.
|
||||
### Tool execution
|
||||
|
||||
Every sandbox promise starts eagerly on a run-once fiber owned by the whole CodeMode execution, including tool calls,
|
||||
async functions, chained `.then`/`.catch`/`.finally` reactions, `new Promise(executor)` constructions, and the
|
||||
`Promise.all`/`allSettled`/`race`/`any`/`resolve`/`reject` statics. Nested functions therefore cannot end the lifetime
|
||||
of work they started.
|
||||
Independent aggregate batches overlap, and rejection is observed at the eventual `await` or chained rejection handler.
|
||||
`Promise.race` and `Promise.any` use native non-cancelling settlement semantics: the deciding member wins while losers
|
||||
continue running, and an all-rejected `Promise.any` rejects with an `AggregateError`. `new Promise(...)` hands the
|
||||
executor first-class resolve/reject callables that may escape and settle the promise later, exactly once.
|
||||
Reaction ordering matches what V8 makes observable - handlers and await continuations are deferred and run in attach
|
||||
order, and a combinator settles one reaction turn after its deciding member - without promising exact microtask-count
|
||||
parity beyond that. At normal completion CodeMode interrupts everything still running - race losers,
|
||||
async functions, `Promise.all`, `Promise.allSettled`, `Promise.race`, `Promise.resolve`, and `Promise.reject`. Nested
|
||||
functions therefore cannot end the lifetime of work they started. Independent aggregate batches overlap, and rejection
|
||||
is observed at the eventual `await`. `Promise.race` uses native non-cancelling settlement semantics: its first result
|
||||
wins while losers continue running. At normal completion CodeMode interrupts everything still running - race losers,
|
||||
fail-fast `Promise.all` stragglers, and fire-and-forget calls alike: the program has returned, so no future await can
|
||||
exist, and work whose completion matters must be awaited by the program. Waiting for any class of leftover instead
|
||||
would let it hold the execution open, or deadlock it when queued work needs tool-call permits the leftovers occupy.
|
||||
|
||||
@@ -22,8 +22,6 @@ ultimate source of truth.
|
||||
- [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] 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.
|
||||
- [ ] Full JavaScript or TypeScript compatibility. CodeMode is a bounded orchestration language.
|
||||
|
||||
@@ -94,9 +92,8 @@ ultimate source of truth.
|
||||
- [x] Optional property access and optional calls.
|
||||
- [x] Function/tool calls and spread arguments.
|
||||
- [x] Sequence expressions (the comma operator).
|
||||
- [x] `await` for sandbox promises; a plain value passes through unchanged, though every `await` still defers its
|
||||
continuation one reaction turn.
|
||||
- [x] `new` for Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
|
||||
- [x] `await` for sandbox promises; awaiting a plain value is a no-op.
|
||||
- [x] `new` for Error types, Date, RegExp, Map, Set, URL, and URLSearchParams.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
||||
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
|
||||
@@ -105,41 +102,27 @@ ultimate source of truth.
|
||||
- [x] Prefix and postfix `++` and `--`.
|
||||
- [x] Plain, arithmetic, bitwise, and logical assignment operators.
|
||||
- [ ] Unary `void` and `delete`.
|
||||
- [ ] Arbitrary constructors.
|
||||
- [ ] Arbitrary constructors and `new Promise(...)`.
|
||||
|
||||
## Promises and tools
|
||||
|
||||
- [x] Tool calls start eagerly and return supervised, run-once sandbox promises.
|
||||
- [x] Direct `await`, repeated awaits, and implicit resolution when a promise is returned from a function/program.
|
||||
- [x] `Promise.resolve` and `Promise.reject`.
|
||||
- [x] `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` over supported collections containing
|
||||
promises and plain values.
|
||||
- [x] `Promise.all`, `Promise.allSettled`, and `Promise.race` over supported collections containing promises and plain
|
||||
values.
|
||||
- [x] `Promise.all` preserves result order and rejects on the first observed failure without cancelling siblings.
|
||||
- [x] `Promise.allSettled` returns plain fulfilled/rejected outcome records.
|
||||
- [x] `Promise.race` settles from the first result without cancelling losers at settlement time.
|
||||
- [x] Real promise values from `Promise.all`, `Promise.allSettled`, and `Promise.race`; separately constructed
|
||||
combinator batches overlap as in normal JavaScript.
|
||||
- [x] Promise chaining with `.then`, `.catch`, and `.finally`: handlers run deferred in attach order, returned
|
||||
promises are adopted, handler throws reject the derived promise, `.finally` preserves the original settlement
|
||||
unless its cleanup fails, and direct self-resolution rejects with a `TypeError`.
|
||||
- [x] Every `await` (including of plain values and already-settled promises) defers its continuation one reaction
|
||||
turn, so concurrent async functions interleave at await points as in JavaScript.
|
||||
- [x] Combinators settle one reaction turn after their deciding member (V8-observable ordering): reactions already
|
||||
attached to members run first, and an aggregate cannot beat a plain value settling in the same turn into a
|
||||
`Promise.race`. Exact microtask-count parity beyond this observable ordering is not a documented guarantee.
|
||||
- [x] All still-pending work (race losers, fail-fast `Promise.all` stragglers, and un-awaited calls alike) is
|
||||
interrupted when the program returns; rejections that settled un-awaited become `Success.warnings`
|
||||
diagnostics. A combinator abandoned inside its final settlement turn counts as pending and is interrupted
|
||||
without a warning.
|
||||
diagnostics.
|
||||
- [x] `try`/`catch` can handle awaited tool and promise failures.
|
||||
- [x] `Promise.any`: first fulfillment wins; all-rejected rejects with an `AggregateError` whose `errors` array holds
|
||||
the catch-normalized reasons in input order, and empty input rejects with an empty `AggregateError`.
|
||||
- [x] `new Promise((resolve, reject) => ...)`: the executor runs synchronously and receives first-class resolve/reject
|
||||
callables that settle the promise exactly once (they may escape the executor and settle later); an executor
|
||||
throw rejects unless the promise already settled, resolving with a promise adopts it, and resolving with the
|
||||
promise itself rejects with a `TypeError`. Resolver callables work as `.then`/`.catch` handlers and collection
|
||||
callbacks but remain opaque references that cannot cross the data boundary.
|
||||
- [ ] Thenable assimilation (objects with a `then` method are plain data, not promises).
|
||||
- [ ] `Promise.any`.
|
||||
- [ ] Promise chaining with `.then`, `.catch`, and `.finally`.
|
||||
- [ ] Custom promise construction with `new Promise(...)`.
|
||||
- [ ] Async iterables, host streams, and stream consumption.
|
||||
|
||||
## Objects and properties
|
||||
@@ -170,7 +153,7 @@ ultimate source of truth.
|
||||
- [ ] The mapper and `thisArg` forms of `Array.from`.
|
||||
- [ ] `Array.prototype.toSpliced`.
|
||||
- [ ] Canonical index handling: a key such as `"01"` must not alias index `1`.
|
||||
- [ ] Complete sparse-array parity. Promise combinators do consume holes as `undefined` members, as in JS.
|
||||
- [ ] Complete sparse-array parity.
|
||||
- [ ] Correct `findLast` return behavior when its predicate mutates the examined element.
|
||||
|
||||
## Strings
|
||||
@@ -275,8 +258,6 @@ ultimate source of truth.
|
||||
|
||||
- [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with
|
||||
or without `new`.
|
||||
- [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by
|
||||
an all-rejected `Promise.any`.
|
||||
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization.
|
||||
- [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types.
|
||||
- [x] Catchable interpreter failures and awaited tool failures.
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
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"
|
||||
|
||||
/** A tool call admitted during an execution. */
|
||||
@@ -125,7 +130,11 @@ export type Runtime<R = never> = {
|
||||
readonly execute: (code: string) => Effect.Effect<Result, never, R>
|
||||
}
|
||||
|
||||
const validateLimit = (name: keyof ExecutionLimits, value: number | undefined, minimum: number): number | undefined => {
|
||||
const validateLimit = <Value extends number | undefined>(
|
||||
name: keyof ExecutionLimits,
|
||||
value: Value,
|
||||
minimum: number,
|
||||
): Value => {
|
||||
if (value !== undefined && (!Number.isSafeInteger(value) || value < minimum)) {
|
||||
throw new RangeError(`${name} must be a safe integer greater than or equal to ${minimum}.`)
|
||||
}
|
||||
@@ -143,6 +152,7 @@ export const execute = <const Tools extends Record<string, unknown>>(
|
||||
options: ExecuteOptions<Tools>,
|
||||
): Effect.Effect<Result, never, Services<Tools>> => {
|
||||
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
|
||||
ToolRuntime.assertValidTools(tools)
|
||||
return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools))
|
||||
}
|
||||
|
||||
@@ -151,6 +161,7 @@ export const make = <const Tools extends Record<string, unknown> = {}>(
|
||||
options: Options<Tools> = {} as Options<Tools>,
|
||||
): Runtime<Services<Tools>> => {
|
||||
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
|
||||
ToolRuntime.assertValidTools(tools)
|
||||
const limits = resolveExecutionLimits(options.limits)
|
||||
const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SafeObject } from "../tool-runtime.js"
|
||||
import type { SandboxPromise, SandboxURL } from "../values.js"
|
||||
import type { SandboxURL } from "../values.js"
|
||||
|
||||
export type SourcePosition = {
|
||||
line: number
|
||||
@@ -61,27 +61,12 @@ export class ComputedValue {
|
||||
|
||||
export class PromiseNamespace {}
|
||||
|
||||
export type PromiseMethodName = "all" | "allSettled" | "race" | "any" | "resolve" | "reject"
|
||||
export type PromiseMethodName = "all" | "allSettled" | "race" | "resolve" | "reject"
|
||||
|
||||
export class PromiseMethodReference {
|
||||
constructor(readonly name: PromiseMethodName) {}
|
||||
}
|
||||
|
||||
export type PromiseInstanceMethodName = "then" | "catch" | "finally"
|
||||
|
||||
export class PromiseInstanceMethodReference {
|
||||
constructor(
|
||||
readonly promise: SandboxPromise,
|
||||
readonly name: PromiseInstanceMethodName,
|
||||
) {}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
constructor(readonly settle: (value: unknown) => void) {}
|
||||
}
|
||||
|
||||
export type GlobalNamespaceName =
|
||||
| "Object"
|
||||
| "Math"
|
||||
@@ -114,10 +99,6 @@ export class UriFunction {
|
||||
constructor(readonly name: "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent") {}
|
||||
}
|
||||
|
||||
// The global `search` built-in: synchronous tool discovery that shares the tool admission
|
||||
// pipeline (budget, audit, hooks) without living in the `tools` tree.
|
||||
export class SearchFunction {}
|
||||
|
||||
export class ProgramThrow {
|
||||
constructor(readonly value: unknown) {}
|
||||
}
|
||||
@@ -141,11 +122,11 @@ export type DiagnosticKind =
|
||||
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
|
||||
|
||||
export const supportedSyntaxMessage =
|
||||
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, Promise.all/allSettled/race/any/resolve/reject over arrays mixing promises and plain values for parallel tool calls, promise chaining with .then/.catch/.finally, and new Promise((resolve, reject) => ...) construction."
|
||||
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)."
|
||||
|
||||
export class InterpreterRuntimeError extends Error {
|
||||
readonly node?: AstNode
|
||||
errorName = "Error"
|
||||
errorName: string = "Error"
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { parse } from "acorn"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Scope, Semaphore } from "effect"
|
||||
import { Cause, Effect, Exit, Fiber, Scope, Semaphore } from "effect"
|
||||
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
||||
import {
|
||||
copyIn,
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
ErrorConstructorReference,
|
||||
GlobalMethodReference,
|
||||
GlobalNamespace,
|
||||
type GlobalNamespaceName,
|
||||
formatLocation,
|
||||
getArray,
|
||||
getBoolean,
|
||||
@@ -42,14 +43,11 @@ import {
|
||||
isRecord,
|
||||
type MemberReference,
|
||||
OptionalShortCircuit,
|
||||
PromiseCapabilityFunction,
|
||||
PromiseInstanceMethodReference,
|
||||
PromiseMethodReference,
|
||||
type PromiseMethodName,
|
||||
PromiseNamespace,
|
||||
ProgramThrow,
|
||||
type ProgramNode,
|
||||
SearchFunction,
|
||||
type StatementResult,
|
||||
sourceLocation,
|
||||
supportedSyntaxMessage,
|
||||
@@ -58,7 +56,7 @@ import {
|
||||
} from "./model.js"
|
||||
import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js"
|
||||
import { consoleMethods, MAX_CONSOLE_DEPTH } from "../stdlib/console.js"
|
||||
import { dateMethods, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
|
||||
import { dateMethods, dateStatics, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
|
||||
import { invokeJsonMethod } from "../stdlib/json.js"
|
||||
import { invokeMathMethod, mathConstants } from "../stdlib/math.js"
|
||||
import {
|
||||
@@ -84,6 +82,7 @@ import {
|
||||
urlMethods,
|
||||
urlProperties,
|
||||
urlSearchParamsMethods,
|
||||
urlStatics,
|
||||
urlWritableProperties,
|
||||
invokeUriFunction,
|
||||
invokeURLMethod,
|
||||
@@ -96,7 +95,6 @@ import {
|
||||
coerceToNumber,
|
||||
coerceToString,
|
||||
compoundOperators,
|
||||
createAggregateErrorValue,
|
||||
createErrorValue,
|
||||
errorBrandName,
|
||||
errorConstructors,
|
||||
@@ -221,41 +219,6 @@ const normalizeError = (error: unknown): Diagnostic => {
|
||||
}
|
||||
}
|
||||
|
||||
// V8 parity: a combinator settles one reaction turn after the deciding member, never
|
||||
// before reactions already attached to it.
|
||||
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))
|
||||
|
||||
const selfResolutionError = (node?: AstNode): InterpreterRuntimeError =>
|
||||
new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError")
|
||||
|
||||
// Short-circuit marker for Promise.any: the first fulfillment travels the error channel of
|
||||
// the flipped members so fail-fast Effect.all stops observing on it.
|
||||
class PromiseAnyFulfilled {
|
||||
constructor(readonly value: unknown) {}
|
||||
}
|
||||
|
||||
type ReactionHandler = CodeModeFunction | CoercionFunction | UriFunction | PromiseCapabilityFunction
|
||||
|
||||
// Non-callables are ignored as in JS: `.then(undefined, f)` relies on the passthrough.
|
||||
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
|
||||
}
|
||||
|
||||
// Shared by catch bindings and Promise.allSettled rejection reasons.
|
||||
const caughtErrorValue = (thrown: unknown): unknown => {
|
||||
if (thrown instanceof ProgramThrow) return thrown.value
|
||||
@@ -264,21 +227,6 @@ const caughtErrorValue = (thrown: unknown): unknown => {
|
||||
return createErrorValue(name, normalizeError(thrown).message)
|
||||
}
|
||||
|
||||
// `new Error("msg")` and the no-new call form share this; AggregateError alone takes
|
||||
// (errors, message?) with a required errors collection, as in JS.
|
||||
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")
|
||||
}
|
||||
// Copy: spreadItems returns array input itself, and the error value must not alias caller data.
|
||||
return createAggregateErrorValue([...errors], args[1] === undefined ? "" : coerceToString(args[1]))
|
||||
}
|
||||
|
||||
const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof CodeModeFunction ||
|
||||
value instanceof ToolReference ||
|
||||
@@ -287,12 +235,9 @@ const isRuntimeReference = (value: unknown): boolean =>
|
||||
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)
|
||||
|
||||
@@ -334,13 +279,11 @@ const typeofValue = (value: unknown): string => {
|
||||
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 UriFunction) 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"
|
||||
@@ -448,7 +391,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
break
|
||||
}
|
||||
if (args[0] instanceof SandboxRegExp) {
|
||||
result = value.split(args[0].regex, optNum(1))
|
||||
result = value.split((args[0] as SandboxRegExp).regex, optNum(1))
|
||||
break
|
||||
}
|
||||
const requestedLimit = optNum(1)
|
||||
@@ -476,7 +419,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
case "replace":
|
||||
case "replaceAll": {
|
||||
if (args[0] instanceof SandboxRegExp) {
|
||||
const pattern = args[0].regex
|
||||
const pattern = (args[0] as SandboxRegExp).regex
|
||||
const replacement = str(1)
|
||||
if (name === "replaceAll" && !pattern.global) {
|
||||
throw new InterpreterRuntimeError(
|
||||
@@ -581,8 +524,9 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
|
||||
)
|
||||
}
|
||||
// Map/Set materialize directly (the data checkpoint would serialize them to {}).
|
||||
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 SandboxMap)
|
||||
return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item])
|
||||
if (args[0] instanceof SandboxSet) return Array.from((args[0] as SandboxSet).set.values())
|
||||
if (args[0] instanceof SandboxURLSearchParams) {
|
||||
return Array.from(args[0].params.entries(), ([key, value]) => [key, value])
|
||||
}
|
||||
@@ -624,7 +568,11 @@ const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, no
|
||||
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 === "Date") {
|
||||
if (!dateStatics.has(ref.name))
|
||||
throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node)
|
||||
return invokeDateStatic(ref.name, args, node)
|
||||
}
|
||||
if (
|
||||
ref.namespace === "RegExp" ||
|
||||
ref.namespace === "Map" ||
|
||||
@@ -740,8 +688,6 @@ class PromiseRuntime<R> {
|
||||
class Interpreter<R> {
|
||||
private scopes: Array<Map<string, Binding>>
|
||||
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
// The built-in `search` global, threaded from ToolRuntime.make like invokeTool.
|
||||
private readonly invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
// Enumerable namespace/tool names at a node of the host tool tree, threaded from
|
||||
// ToolRuntime.make like invokeTool: the interpreter never holds the tree itself.
|
||||
private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
||||
@@ -752,7 +698,6 @@ class Interpreter<R> {
|
||||
|
||||
constructor(
|
||||
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
||||
invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
||||
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
|
||||
promises: PromiseRuntime<R>,
|
||||
logs: Array<string> = [],
|
||||
@@ -761,13 +706,11 @@ class Interpreter<R> {
|
||||
const globalScope = new Map<string, Binding>()
|
||||
this.scopes = [globalScope]
|
||||
this.invokeTool = invokeTool
|
||||
this.invokeSearch = invokeSearch
|
||||
this.toolKeys = toolKeys
|
||||
this.logs = logs
|
||||
this.callPermits = callPermits
|
||||
this.promises = promises
|
||||
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
|
||||
globalScope.set("search", { mutable: false, value: new SearchFunction() })
|
||||
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
|
||||
globalScope.set("undefined", { mutable: false, value: undefined })
|
||||
globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
|
||||
@@ -825,6 +768,7 @@ class Interpreter<R> {
|
||||
if (result.kind === "break" || result.kind === "continue") {
|
||||
throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// The program body runs inside an implicit async function, so a returned promise
|
||||
@@ -850,14 +794,16 @@ class Interpreter<R> {
|
||||
return this.promises.create(effect)
|
||||
}
|
||||
|
||||
// Settlement is idempotent (fiber exits replay), so awaiting the same promise repeatedly
|
||||
// never re-runs the call. The post-settlement yield defers the continuation one reaction
|
||||
// turn, as in JS: awaiters never resume inline, so they interleave in attach order.
|
||||
// `await promise`: succeed with the fulfilled value or re-raise the failure so try/catch
|
||||
// observes it exactly like a synchronous throw at the await site. Settlement is idempotent
|
||||
// (fiber exits replay), so awaiting the same promise repeatedly never re-runs the call.
|
||||
private settlePromise(promise: SandboxPromise): Effect.Effect<unknown, unknown, never> {
|
||||
const promises = this.promises
|
||||
return Effect.suspend(() => {
|
||||
promises.markObserved(promise)
|
||||
return Effect.flatMap(promises.await(promise), (exit) => Effect.andThen(Effect.yieldNow, exit))
|
||||
return Effect.flatMap(promises.await(promise), (exit) =>
|
||||
Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1034,6 +980,7 @@ class Interpreter<R> {
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
@@ -1060,6 +1007,7 @@ class Interpreter<R> {
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
} while (yield* self.evaluateExpression(testNode))
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
@@ -1089,13 +1037,16 @@ class Interpreter<R> {
|
||||
: []
|
||||
|
||||
while (testNode ? yield* self.evaluateExpression(testNode) : true) {
|
||||
const iterationScope =
|
||||
perIterationBindings.length > 0
|
||||
? new Map(
|
||||
perIterationBindings.map((name): [string, Binding] => [name, { ...self.currentScope().get(name)! }]),
|
||||
)
|
||||
: undefined
|
||||
if (iterationScope) self.scopes.push(iterationScope)
|
||||
let iterationScope: Map<string, Binding> | undefined
|
||||
if (perIterationBindings.length > 0) {
|
||||
iterationScope = new Map(
|
||||
perIterationBindings.map((name) => {
|
||||
const binding = self.currentScope().get(name)!
|
||||
return [name, { ...binding }]
|
||||
}),
|
||||
)
|
||||
self.scopes.push(iterationScope)
|
||||
}
|
||||
const result = yield* self.evaluateStatement(bodyNode).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
@@ -1145,7 +1096,7 @@ class Interpreter<R> {
|
||||
|
||||
// Arrays iterate in place; strings iterate code points; Maps iterate [key, value]
|
||||
// pairs and Sets iterate values over a snapshot (mutation during iteration is safe).
|
||||
const iterable = spreadItems(right)
|
||||
const iterable = Array.isArray(right) ? right : spreadItems(right)
|
||||
if (iterable === undefined) {
|
||||
throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node)
|
||||
}
|
||||
@@ -1599,10 +1550,11 @@ class Interpreter<R> {
|
||||
case "UpdateExpression":
|
||||
return this.evaluateUpdateExpression(node)
|
||||
case "AwaitExpression": {
|
||||
// In JS every await suspends, even on a non-promise.
|
||||
// `await` resolves a promise value; awaiting anything else is a passthrough no-op,
|
||||
// matching real JS semantics for non-thenables.
|
||||
const self = this
|
||||
return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) =>
|
||||
value instanceof SandboxPromise ? self.settlePromise(value) : Effect.as(Effect.yieldNow, value),
|
||||
value instanceof SandboxPromise ? self.settlePromise(value) : Effect.succeed(value),
|
||||
)
|
||||
}
|
||||
case "NewExpression":
|
||||
@@ -1621,10 +1573,19 @@ class Interpreter<R> {
|
||||
const argNodes = getArray(node, "arguments")
|
||||
const self = this
|
||||
if (name === "Promise") {
|
||||
return Effect.flatMap(this.evaluateCallArguments(argNodes), (args) => self.constructPromise(args[0], node))
|
||||
throw new InterpreterRuntimeError(
|
||||
"new Promise(...) is not supported in CodeMode; tool calls already return promises - call the tool and await the result.",
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
if (errorConstructors.has(name)) {
|
||||
return Effect.map(this.evaluateCallArguments(argNodes), (args) => constructErrorValue(name, args, node))
|
||||
return Effect.gen(function* () {
|
||||
const arg =
|
||||
argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined
|
||||
return createErrorValue(name, arg === undefined ? "" : coerceToString(arg))
|
||||
})
|
||||
}
|
||||
if (valueConstructors.has(name)) {
|
||||
return Effect.gen(function* () {
|
||||
@@ -1962,7 +1923,7 @@ class Interpreter<R> {
|
||||
return self.setIdentifierValue(name, next, left)
|
||||
}
|
||||
const rightValue = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
return self.setIdentifierValue(name, rightValue, left)
|
||||
if (operator === "=") return self.setIdentifierValue(name, rightValue, left)
|
||||
}
|
||||
if (left.type === "MemberExpression") {
|
||||
return yield* self.modifyMember(left, (current) =>
|
||||
@@ -2064,9 +2025,6 @@ class Interpreter<R> {
|
||||
if (callable instanceof PromiseMethodReference) {
|
||||
return yield* self.invokePromiseMethod(callable, args, node)
|
||||
}
|
||||
if (callable instanceof PromiseInstanceMethodReference) {
|
||||
return yield* self.invokePromiseInstanceMethod(callable, args, node)
|
||||
}
|
||||
if (callable instanceof CodeModeFunction) {
|
||||
return yield* self.invokeFunction(callable, args)
|
||||
}
|
||||
@@ -2092,18 +2050,9 @@ class Interpreter<R> {
|
||||
if (callable instanceof UriFunction) {
|
||||
return invokeUriFunction(callable, args, node)
|
||||
}
|
||||
if (callable instanceof SearchFunction) {
|
||||
// The built-in search is synchronous in-memory matching: the call returns its
|
||||
// result directly (await still works, as with any plain value).
|
||||
return yield* self.invokeSearch(args)
|
||||
}
|
||||
// `Error("msg")` without `new` constructs an error exactly like `new Error("msg")`, as in JS.
|
||||
if (callable instanceof ErrorConstructorReference) {
|
||||
return constructErrorValue(callable.name, args, node)
|
||||
}
|
||||
if (callable instanceof PromiseCapabilityFunction) {
|
||||
callable.settle(args[0])
|
||||
return undefined
|
||||
return createErrorValue(callable.name, args[0] === undefined ? "" : coerceToString(args[0]))
|
||||
}
|
||||
throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee)
|
||||
})
|
||||
@@ -2118,7 +2067,7 @@ class Interpreter<R> {
|
||||
return boundedData(this.enumerableKeys(ref)!, "Object.keys result")
|
||||
}
|
||||
throw new InterpreterRuntimeError(
|
||||
`Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or search({ query }) for signatures.`,
|
||||
`Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`,
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
@@ -2292,19 +2241,17 @@ class Interpreter<R> {
|
||||
return this.createPromise(Effect.fail(new ProgramThrow(args[0])))
|
||||
}
|
||||
|
||||
const spread = spreadItems(args[0])
|
||||
if (spread === undefined) {
|
||||
const items = Array.isArray(args[0]) ? args[0] : spreadItems(args[0])
|
||||
if (items === undefined) {
|
||||
return this.createPromise(
|
||||
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"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
// Densify: JS combinator iteration reads sparse holes as undefined members; .map would skip them.
|
||||
const items = Array.from(spread)
|
||||
|
||||
// JS makes combinator members "handled" synchronously at the call - their rejections
|
||||
// belong to the aggregate from this moment, even ones settling before it runs.
|
||||
@@ -2314,42 +2261,47 @@ class Interpreter<R> {
|
||||
|
||||
switch (ref.name) {
|
||||
case "all": {
|
||||
// Rejects on the first failure; sibling fibers stay execution-owned and keep running, as in JS.
|
||||
// Each observation re-raises its member's failure, so Effect.all rejects on the first
|
||||
// failure without waiting for the rest and preserves input order when all fulfill.
|
||||
// Its failure-time interruption only unsubscribes the sibling waiters: the underlying
|
||||
// fibers stay execution-owned and keep running, as in JS.
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise ? Effect.flatten(this.promises.await(item)) : Effect.succeed(item),
|
||||
item instanceof SandboxPromise
|
||||
? Effect.flatMap(this.promises.await(item), (exit) =>
|
||||
Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause),
|
||||
)
|
||||
: Effect.succeed(item),
|
||||
)
|
||||
return this.createPromise(settleAfterTurn(Effect.all(observations, { concurrency: "unbounded" })))
|
||||
return this.createPromise(Effect.all(observations, { concurrency: "unbounded" }))
|
||||
}
|
||||
case "allSettled": {
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item)),
|
||||
item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item as unknown)),
|
||||
)
|
||||
return this.createPromise(
|
||||
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)) {
|
||||
// Execution teardown (timeout/host interruption), not a program-level rejection.
|
||||
return yield* Effect.failCause(exit.cause)
|
||||
}
|
||||
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: "rejected",
|
||||
reason: caughtErrorValue(Cause.squash(exit.cause)),
|
||||
}),
|
||||
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
|
||||
)
|
||||
continue
|
||||
}
|
||||
return outcomes
|
||||
}),
|
||||
),
|
||||
if (Cause.hasInterruptsOnly(exit.cause)) {
|
||||
// Execution teardown (timeout/host interruption), 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": {
|
||||
@@ -2364,155 +2316,21 @@ class Interpreter<R> {
|
||||
)
|
||||
}
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item)),
|
||||
item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item as unknown)),
|
||||
)
|
||||
// First settlement (fulfilled OR rejected) wins; losing work stays execution-owned
|
||||
// and is interrupted at normal completion (already observed) or by teardown.
|
||||
return this.createPromise(settleAfterTurn(Effect.flatten(Effect.raceAll(observations))))
|
||||
}
|
||||
case "any": {
|
||||
// De Morgan dual of Promise.all: members are flipped so the first fulfillment
|
||||
// short-circuits fail-fast Effect.all, and all-rejected completes with the reasons
|
||||
// in input order for the AggregateError.
|
||||
const flipped = items.map((item) =>
|
||||
item instanceof SandboxPromise
|
||||
? Effect.flatMap(this.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 this.createPromise(
|
||||
Effect.flatMap(Effect.raceAll(observations), (exit) =>
|
||||
Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause),
|
||||
),
|
||||
)
|
||||
return this.createPromise(settleAfterTurn(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Teardown interruption propagates without running handlers; a real settlement defers
|
||||
// one reaction turn so handlers never run inline.
|
||||
private reactionExit(source: SandboxPromise): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> {
|
||||
const promises = this.promises
|
||||
return 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
|
||||
})
|
||||
}
|
||||
|
||||
private invokePromiseInstanceMethod(
|
||||
ref: PromiseInstanceMethodReference,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, never, R> {
|
||||
const method = `Promise.prototype.${ref.name}`
|
||||
this.promises.markObserved(ref.promise)
|
||||
if (ref.name === "finally") {
|
||||
return this.chainFinally(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 this.chainReaction(ref.promise, onFulfilled, onRejected, method, node)
|
||||
}
|
||||
|
||||
private chainReaction(
|
||||
source: SandboxPromise,
|
||||
onFulfilled: ReactionHandler | undefined,
|
||||
onRejected: ReactionHandler | undefined,
|
||||
method: string,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, never, R> {
|
||||
const self = this
|
||||
const box: { derived?: SandboxPromise } = {}
|
||||
const body = Effect.gen(function* () {
|
||||
const exit = yield* self.reactionExit(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* self.applyCollectionCallback(handler, method, node)([input])
|
||||
if (result === box.derived) return yield* Effect.fail(selfResolutionError(node))
|
||||
if (result instanceof SandboxPromise) return yield* self.settlePromise(result)
|
||||
return result
|
||||
})
|
||||
return Effect.map(this.createPromise(body), (derived) => {
|
||||
box.derived = derived
|
||||
return derived
|
||||
})
|
||||
}
|
||||
|
||||
private chainFinally(
|
||||
source: SandboxPromise,
|
||||
cleanup: ReactionHandler | undefined,
|
||||
method: string,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, never, R> {
|
||||
const self = this
|
||||
return this.createPromise(
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* self.reactionExit(source)
|
||||
if (cleanup !== undefined) {
|
||||
const result = yield* self.applyCollectionCallback(cleanup, method, node)([])
|
||||
if (result instanceof SandboxPromise) yield* self.settlePromise(result)
|
||||
}
|
||||
return yield* exit
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// new Promise(executor): the promise's fiber awaits a Deferred that resolve/reject settle
|
||||
// exactly once. The executor runs synchronously; its throw rejects the promise unless it
|
||||
// already settled (JS swallows post-settlement executor throws).
|
||||
private constructPromise(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")
|
||||
}
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const deferred = Deferred.makeUnsafe<unknown, unknown>()
|
||||
const box: { own?: SandboxPromise } = {}
|
||||
const promise = yield* self.createPromise(
|
||||
Effect.flatMap(Deferred.await(deferred), (value) => {
|
||||
if (!(value instanceof SandboxPromise)) return Effect.succeed(value)
|
||||
if (value === box.own) return Effect.fail(selfResolutionError(node))
|
||||
return self.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(self.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
|
||||
})
|
||||
}
|
||||
|
||||
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
|
||||
const invocation = new Interpreter(
|
||||
this.invokeTool,
|
||||
this.invokeSearch,
|
||||
this.toolKeys,
|
||||
this.promises,
|
||||
this.logs,
|
||||
this.callPermits,
|
||||
)
|
||||
const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.promises, this.logs, this.callPermits)
|
||||
invocation.scopes = [...fn.capturedScopes, new Map<string, Binding>()]
|
||||
const run = Effect.gen(function* () {
|
||||
// Seed every parameter name into the scope as a TDZ slot first, so a default that
|
||||
@@ -2540,20 +2358,10 @@ class Interpreter<R> {
|
||||
return yield* invocation.evaluateExpression(fn.body)
|
||||
})
|
||||
if (!fn.async) return run
|
||||
// Every await yields, so `box.own` is assigned before the body can return a reference to it.
|
||||
const box: { own?: SandboxPromise } = {}
|
||||
return Effect.map(
|
||||
this.createPromise(
|
||||
Effect.flatMap(run, (value) => {
|
||||
if (!(value instanceof SandboxPromise)) return Effect.succeed(value)
|
||||
if (value === box.own) return Effect.fail(selfResolutionError())
|
||||
return invocation.settlePromise(value)
|
||||
}),
|
||||
return this.createPromise(
|
||||
Effect.flatMap(run, (value) =>
|
||||
value instanceof SandboxPromise ? invocation.settlePromise(value) : Effect.succeed(value),
|
||||
),
|
||||
(promise) => {
|
||||
box.own = promise
|
||||
return promise
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2664,8 +2472,8 @@ class Interpreter<R> {
|
||||
})
|
||||
}
|
||||
|
||||
// Accepts a user function or supported builtin callable, so idioms such as
|
||||
// `filter(Boolean)`, `map(String)`, and `map(encodeURIComponent)` work as in JS.
|
||||
// Runs a collection callback accepting a user function or supported builtin callable,
|
||||
// mirroring the array-method callback contract.
|
||||
private applyCollectionCallback(
|
||||
callback: unknown,
|
||||
name: string,
|
||||
@@ -2674,8 +2482,7 @@ class Interpreter<R> {
|
||||
if (
|
||||
!(callback instanceof CodeModeFunction) &&
|
||||
!(callback instanceof CoercionFunction) &&
|
||||
!(callback instanceof UriFunction) &&
|
||||
!(callback instanceof PromiseCapabilityFunction)
|
||||
!(callback instanceof UriFunction)
|
||||
) {
|
||||
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
|
||||
}
|
||||
@@ -2684,9 +2491,7 @@ class Interpreter<R> {
|
||||
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
|
||||
: callback instanceof UriFunction
|
||||
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
|
||||
: callback instanceof PromiseCapabilityFunction
|
||||
? Effect.sync(() => callback.settle(callbackArgs[0]))
|
||||
: this.invokeFunction(callback, callbackArgs)
|
||||
: this.invokeFunction(callback, callbackArgs)
|
||||
}
|
||||
|
||||
private invokeMapMethod(
|
||||
@@ -2956,7 +2761,24 @@ class Interpreter<R> {
|
||||
return Effect.succeed(Array.from(target.entries(), ([index, item]): Array<unknown> => [index, item]))
|
||||
}
|
||||
|
||||
const apply = this.applyCollectionCallback(args[0], `Array.${name}`, node)
|
||||
const callback = args[0]
|
||||
if (
|
||||
!(callback instanceof CodeModeFunction) &&
|
||||
!(callback instanceof CoercionFunction) &&
|
||||
!(callback instanceof UriFunction)
|
||||
) {
|
||||
throw new InterpreterRuntimeError(`Array.${name} expects a function callback.`, node)
|
||||
}
|
||||
const self = this
|
||||
// Accept a user function or supported builtin callable, so idioms such as
|
||||
// `filter(Boolean)`, `map(String)`, and `map(encodeURIComponent)` work as in JS. Builtins
|
||||
// are synchronous; only CodeModeFunctions can await tool calls.
|
||||
const apply = (callbackArgs: Array<unknown>): Effect.Effect<unknown, unknown, R> =>
|
||||
callback instanceof CoercionFunction
|
||||
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
|
||||
: callback instanceof UriFunction
|
||||
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
|
||||
: self.invokeFunction(callback, callbackArgs)
|
||||
return Effect.gen(function* () {
|
||||
// Capture the initial length, but read the receiver live so callbacks observe mutations
|
||||
// without visiting elements appended after iteration begins.
|
||||
@@ -3257,7 +3079,6 @@ class Interpreter<R> {
|
||||
| MemberReference
|
||||
| ToolReference
|
||||
| PromiseMethodReference
|
||||
| PromiseInstanceMethodReference
|
||||
| IntrinsicReference
|
||||
| GlobalMethodReference
|
||||
| ComputedValue
|
||||
@@ -3294,7 +3115,7 @@ class Interpreter<R> {
|
||||
return new PromiseMethodReference(key as PromiseMethodName)
|
||||
}
|
||||
throw new InterpreterRuntimeError(
|
||||
`Promise.${String(key)} is not available in CodeMode. Available: Promise.all, Promise.allSettled, Promise.race, Promise.any, Promise.resolve, and Promise.reject; consume promises with await.`,
|
||||
`Promise.${String(key)} is not available in CodeMode. Available: Promise.all, Promise.allSettled, Promise.race, Promise.resolve, and Promise.reject; consume promises with await.`,
|
||||
propertyNode,
|
||||
)
|
||||
}
|
||||
@@ -3378,13 +3199,20 @@ class Interpreter<R> {
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
|
||||
// Error instead of `undefined` so a missing await never hides.
|
||||
// Any property access on a promise is a confused program (`p.then(...)`, `p.value`);
|
||||
// reading `undefined` here would hide the missing await, so both paths get an explicit,
|
||||
// await-hinting error instead of the forgiving unknown-property fallthrough.
|
||||
if (objectValue instanceof SandboxPromise) {
|
||||
if (key === "then" || key === "catch" || key === "finally") {
|
||||
return new PromiseInstanceMethodReference(objectValue, key)
|
||||
throw new InterpreterRuntimeError(
|
||||
`Promise.prototype.${String(key)} is not supported in CodeMode; use await instead (with try/catch to handle failures) - e.g. \`const result = await tools.ns.tool(...)\`.`,
|
||||
propertyNode,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
throw new InterpreterRuntimeError(
|
||||
"This value is an un-awaited Promise; await it first - e.g. `const result = await tools.ns.tool(...)`.",
|
||||
"This value is an un-awaited Promise and has no readable properties; await it first - e.g. `const result = await tools.ns.tool(...)`.",
|
||||
objectNode,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
@@ -3437,7 +3265,6 @@ class Interpreter<R> {
|
||||
reference === undefined ||
|
||||
reference instanceof ToolReference ||
|
||||
reference instanceof PromiseMethodReference ||
|
||||
reference instanceof PromiseInstanceMethodReference ||
|
||||
reference instanceof IntrinsicReference ||
|
||||
reference instanceof GlobalMethodReference
|
||||
)
|
||||
@@ -3475,7 +3302,6 @@ class Interpreter<R> {
|
||||
reference === undefined ||
|
||||
reference instanceof ToolReference ||
|
||||
reference instanceof PromiseMethodReference ||
|
||||
reference instanceof PromiseInstanceMethodReference ||
|
||||
reference instanceof IntrinsicReference ||
|
||||
reference instanceof GlobalMethodReference
|
||||
) {
|
||||
@@ -3663,14 +3489,15 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
|
||||
// and the timeout path's completed value - binds at run time: a reused Effect must start
|
||||
// from a clean slate instead of observing a previous run's state.
|
||||
return Effect.suspend(() => {
|
||||
const hooks = {
|
||||
...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }),
|
||||
...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }),
|
||||
}
|
||||
const tools = ToolRuntime.make(
|
||||
(options.tools ?? {}) as HostTools<Services<Tools>>,
|
||||
limits.maxToolCalls,
|
||||
searchIndex,
|
||||
{
|
||||
onToolCallStart: options.onToolCallStart,
|
||||
onToolCallEnd: options.onToolCallEnd,
|
||||
},
|
||||
hooks,
|
||||
)
|
||||
const logs: Array<string> = []
|
||||
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
|
||||
@@ -3685,7 +3512,7 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
|
||||
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 interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.keys, promises, logs)
|
||||
const value = yield* interpreter.run(program)
|
||||
// Validate the result first so an invalid value is a fatal completion that closes
|
||||
// the promise scope directly instead of taking the normal-completion path.
|
||||
|
||||
@@ -46,7 +46,9 @@ export const invoke = (plan: Plan, input: unknown): Effect.Effect<unknown, unkno
|
||||
)
|
||||
}
|
||||
if (json && Option.isNone(decoded)) {
|
||||
return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} returned malformed JSON.`))
|
||||
return yield* Effect.fail(
|
||||
toolError(`${plan.operation.method} ${plan.operation.path} returned malformed JSON.`),
|
||||
)
|
||||
}
|
||||
return parsed
|
||||
})
|
||||
@@ -206,9 +208,8 @@ const buildUrl = (plan: Plan, input: Readonly<Record<string, unknown>>): string
|
||||
return toolError(`Missing required path parameter '${field.inputName}'.`)
|
||||
}
|
||||
const fieldValue = serializeSimple(field, item, (value) =>
|
||||
encodeURIComponent(value).replace(
|
||||
/[!'()*]/g,
|
||||
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
encodeURIComponent(value).replace(/[!'()*]/g, (character) =>
|
||||
`%${character.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
),
|
||||
)
|
||||
if (fieldValue instanceof ToolError) return fieldValue
|
||||
@@ -270,7 +271,10 @@ const serializeQuery = (
|
||||
if (value.some((item) => item === undefined || (item !== null && typeof item === "object"))) {
|
||||
return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`)
|
||||
}
|
||||
return value.reduce((current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)), request)
|
||||
return value.reduce(
|
||||
(current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)),
|
||||
request,
|
||||
)
|
||||
}
|
||||
if (isRecord(value) && field.explode) {
|
||||
return Object.entries(value).reduce<HttpClientRequest.HttpClientRequest | ToolError>((current, [name, item]) => {
|
||||
@@ -285,15 +289,11 @@ const serializeQuery = (
|
||||
return rendered instanceof ToolError ? rendered : HttpClientRequest.appendUrlParam(request, field.name, rendered)
|
||||
}
|
||||
|
||||
const readResponseBody = (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
plan: Plan,
|
||||
): Effect.Effect<string, ToolError> =>
|
||||
const readResponseBody = (response: HttpClientResponse.HttpClientResponse, plan: Plan): Effect.Effect<string, ToolError> =>
|
||||
Effect.gen(function* () {
|
||||
const contentLength = response.headers["content-length"]
|
||||
const parsedSize = contentLength === undefined ? undefined : Number.parseInt(contentLength, 10)
|
||||
const declaredSize =
|
||||
parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined
|
||||
const declaredSize = parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined
|
||||
if (declaredSize !== undefined && declaredSize > maxResponseBodyBytes) {
|
||||
return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
|
||||
}
|
||||
@@ -304,9 +304,7 @@ const readResponseBody = (
|
||||
return Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
|
||||
}
|
||||
if (size + chunk.byteLength > body.byteLength) {
|
||||
const grown = Buffer.allocUnsafe(
|
||||
Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)),
|
||||
)
|
||||
const grown = Buffer.allocUnsafe(Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)))
|
||||
body.copy(grown, 0, 0, size)
|
||||
body = grown
|
||||
}
|
||||
|
||||
@@ -78,9 +78,7 @@ const isBinaryMediaType = (document: Document, mediaType: string, value: unknown
|
||||
return isRecord(schema) && schema.format === "binary"
|
||||
}
|
||||
|
||||
const jsonContent = (
|
||||
content: Record<string, unknown>,
|
||||
): { readonly mediaType: string; readonly schema: unknown } | undefined => {
|
||||
const jsonContent = (content: Record<string, unknown>): { readonly mediaType: string; readonly schema: unknown } | undefined => {
|
||||
const entry = Object.entries(content).find(([mediaType]) => isJsonMediaType(mediaType))
|
||||
return entry !== undefined && isRecord(entry[1]) ? { mediaType: entry[0], schema: entry[1].schema } : undefined
|
||||
}
|
||||
@@ -346,7 +344,7 @@ export const operationOutput = (
|
||||
if (outcomes.length === 0) return { ok: true, value: undefined }
|
||||
return {
|
||||
ok: true,
|
||||
value: withDefinitions(outcomes.length === 1 ? (outcomes[0] ?? {}) : { anyOf: outcomes }, definitions),
|
||||
value: withDefinitions(outcomes.length === 1 ? outcomes[0] ?? {} : { anyOf: outcomes }, definitions),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,9 +380,7 @@ export const operationPath = (
|
||||
namespaces: ReadonlySet<string>,
|
||||
): ReadonlyArray<string> => {
|
||||
const raw = nonEmptyString(operation.operationId)
|
||||
const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map(
|
||||
sanitizeOperationSegment,
|
||||
)
|
||||
const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map(sanitizeOperationSegment)
|
||||
if (isOperationPathAvailable(segments, used, namespaces)) return segments
|
||||
const conflict = segments.slice(0, -1).findIndex((_, index) => used.has(segments.slice(0, index + 1).join(".")))
|
||||
if (conflict >= 0 && conflict + 1 < segments.length) {
|
||||
|
||||
@@ -23,6 +23,8 @@ export const dateMethods = new Set([
|
||||
"getTimezoneOffset",
|
||||
])
|
||||
|
||||
export const dateStatics = new Set(["now", "parse", "UTC"])
|
||||
|
||||
export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNode): number => {
|
||||
switch (name) {
|
||||
case "now":
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
} from "../interpreter/model.js"
|
||||
import { copyIn, copyOut } from "../tool-runtime.js"
|
||||
|
||||
export const jsonStatics = new Set(["stringify", "parse"])
|
||||
|
||||
export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
if (!jsonStatics.has(name)) throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node)
|
||||
switch (name) {
|
||||
case "stringify": {
|
||||
const replacer = args[1]
|
||||
|
||||
@@ -3,9 +3,11 @@ import { isBlockedMember } from "../tool-runtime.js"
|
||||
import { isSandboxValue, SandboxMap, SandboxPromise, SandboxSet, SandboxURLSearchParams } from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
|
||||
export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "assign", "fromEntries"])
|
||||
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
|
||||
|
||||
export const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
|
||||
const requireObject = (): Record<string, unknown> => {
|
||||
const input = args[0]
|
||||
if (Array.isArray(input)) return input as unknown as Record<string, unknown>
|
||||
@@ -51,11 +53,13 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||
}
|
||||
const out = target as Record<string, unknown>
|
||||
for (const source of args.slice(1)) {
|
||||
if (source === null || source === undefined || isSandboxValue(source)) continue
|
||||
if (typeof source !== "object" || Array.isArray(source)) {
|
||||
if (source === null || source === undefined) continue
|
||||
const value = source
|
||||
if (isSandboxValue(value)) continue
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
|
||||
}
|
||||
for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
|
||||
for (const [key, item] of Object.entries(value)) guardedSet(out, key, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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", "resolve", "reject"])
|
||||
|
||||
/** Maximum number of eagerly forked tool calls that may run concurrently. */
|
||||
export const TOOL_CALL_CONCURRENCY = 8
|
||||
|
||||
@@ -6,7 +6,6 @@ export const errorConstructors = new Set([
|
||||
"ReferenceError",
|
||||
"EvalError",
|
||||
"URIError",
|
||||
"AggregateError",
|
||||
])
|
||||
|
||||
export const valueConstructors = new Set(["Date", "RegExp", "Map", "Set", "URL", "URLSearchParams"])
|
||||
@@ -21,9 +20,6 @@ export const createErrorValue = (name: string, message: string): SafeObject => {
|
||||
return value
|
||||
}
|
||||
|
||||
export const createAggregateErrorValue = (errors: Array<unknown>, message: string): SafeObject =>
|
||||
Object.assign(createErrorValue("AggregateError", message), { errors })
|
||||
|
||||
export const errorBrandName = (value: unknown): string | undefined =>
|
||||
value !== null && typeof value === "object"
|
||||
? ((value as Record<PropertyKey, unknown>)[ErrorBrand] as string | undefined)
|
||||
@@ -64,7 +60,7 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
|
||||
if (ref.name === "parseInt") return parseInt(coerceToString(raw))
|
||||
return parseFloat(coerceToString(raw))
|
||||
}
|
||||
const value = boundedData(raw, `${ref.name} input`)
|
||||
const value = boundedData(args[0], `${ref.name} input`)
|
||||
if (ref.name === "Number") return coerceToNumber(value)
|
||||
if (ref.name === "Boolean") return Boolean(value)
|
||||
if (ref.name === "parseInt") {
|
||||
@@ -77,7 +73,11 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
|
||||
if (ref.name === "parseFloat") return parseFloat(coerceToString(value))
|
||||
return coerceToString(value)
|
||||
}
|
||||
import { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import {
|
||||
type AstNode,
|
||||
CoercionFunction,
|
||||
InterpreterRuntimeError,
|
||||
} from "../interpreter/model.js"
|
||||
import { copyIn, type SafeObject } from "../tool-runtime.js"
|
||||
import {
|
||||
isSandboxValue,
|
||||
|
||||
@@ -82,6 +82,7 @@ export type ToolDescription = {
|
||||
|
||||
export type SafeObject = Record<string, unknown>
|
||||
|
||||
const reservedNamespace = "$codemode"
|
||||
const defaultCatalogBudget = 2_000
|
||||
const defaultSearchLimit = 10
|
||||
const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
|
||||
@@ -325,12 +326,15 @@ export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
|
||||
const definitions = <R>(
|
||||
tools: HostTools<R>,
|
||||
path: ReadonlyArray<string> = [],
|
||||
): Array<{ path: string; definition: Definition<R> }> =>
|
||||
Object.entries(tools).flatMap(([name, value]) => {
|
||||
): Array<{ path: string; definition: Definition<R> }> => {
|
||||
const entries: Array<{ path: string; definition: Definition<R> }> = []
|
||||
for (const [name, value] of Object.entries(tools)) {
|
||||
const next = [...path, name]
|
||||
if (isDefinition(value)) return [{ path: next.join("."), definition: value }]
|
||||
return typeof value === "function" ? [] : definitions(value, next)
|
||||
})
|
||||
if (isDefinition(value)) entries.push({ path: next.join("."), definition: value })
|
||||
else if (typeof value !== "function") entries.push(...definitions(value, next))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDescription => ({
|
||||
path,
|
||||
@@ -345,6 +349,9 @@ const visibleDefinitions = <R>(tools: HostTools<R>) =>
|
||||
description: describeDefinition(path, definition),
|
||||
}))
|
||||
|
||||
export const catalog = <R>(tools: HostTools<R>): ReadonlyArray<ToolDescription> =>
|
||||
visibleDefinitions(tools).map(({ description }) => description)
|
||||
|
||||
export type DiscoveryPlan = {
|
||||
readonly catalog: ReadonlyArray<ToolDescription>
|
||||
readonly instructions: string
|
||||
@@ -451,12 +458,7 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
|
||||
}),
|
||||
})
|
||||
|
||||
// The built-in `search` is a synchronous global function, not a tool-tree entry, so its
|
||||
// advertised signature is rendered by hand instead of through `describeDefinition`.
|
||||
const searchSignature = (() => {
|
||||
const definition = makeSearchTool([])
|
||||
return `search(input: ${inputTypeScript(definition, true)}): ${outputTypeScript(definition, true)}`
|
||||
})()
|
||||
const searchDescription = describeDefinition(`${reservedNamespace}.search`, makeSearchTool([]))
|
||||
|
||||
const catalogLine = (tool: ToolDescription) => {
|
||||
// Keep the tool description concise; the full schema documentation remains in the signature.
|
||||
@@ -483,6 +485,12 @@ const toSearchEntry = <R>(path: string, definition: Definition<R>, description:
|
||||
export const searchIndex = <R>(tools: HostTools<R>): ReadonlyArray<SearchEntry> =>
|
||||
visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description))
|
||||
|
||||
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,
|
||||
@@ -555,8 +563,8 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
empty
|
||||
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime."
|
||||
: 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 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 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 and internal runtime tools; surrounding agent tools are not available.",
|
||||
...(empty
|
||||
? []
|
||||
: ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
|
||||
@@ -577,7 +585,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.",
|
||||
]
|
||||
: [
|
||||
'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.",
|
||||
]),
|
||||
]
|
||||
@@ -589,8 +597,8 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
"## Rules",
|
||||
"",
|
||||
complete
|
||||
? "- Only Code Mode tools listed here 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 and internal runtime tools 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.",
|
||||
"- 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.',
|
||||
@@ -599,7 +607,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
...(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`.",
|
||||
]),
|
||||
]
|
||||
@@ -609,7 +617,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
"## Language",
|
||||
"",
|
||||
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.",
|
||||
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
|
||||
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
|
||||
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
|
||||
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
|
||||
]
|
||||
@@ -621,7 +629,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
toolSection.push(
|
||||
complete
|
||||
? "## 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) {
|
||||
@@ -639,7 +647,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool))
|
||||
}
|
||||
if (!complete) {
|
||||
toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`)
|
||||
toolSection.push("", "Search returns complete callable signatures:", `- ${searchDescription.signature}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,7 +676,7 @@ const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): Rea
|
||||
!Object.hasOwn(value, segment)
|
||||
) {
|
||||
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>
|
||||
@@ -688,7 +696,7 @@ const resolve = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): HostTool<
|
||||
!Object.hasOwn(value, segment)
|
||||
) {
|
||||
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>
|
||||
@@ -705,11 +713,6 @@ export type ToolRuntime<R = never> = {
|
||||
readonly root: ToolReference
|
||||
readonly calls: Array<ToolCall>
|
||||
readonly invoke: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
/**
|
||||
* The built-in `search` global: a synchronous discovery call that shares the tool
|
||||
* admission pipeline (budget, audit, hooks) without living in the `tools` tree.
|
||||
*/
|
||||
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>
|
||||
}
|
||||
@@ -722,7 +725,10 @@ export const make = <R>(
|
||||
hooks?: ToolCallHooks<R>,
|
||||
): ToolRuntime<R> => {
|
||||
const calls: Array<ToolCall> = []
|
||||
const searchTool = makeSearchTool(searchIndex)
|
||||
const callableTools = {
|
||||
...tools,
|
||||
[reservedNamespace]: { search: makeSearchTool(searchIndex) },
|
||||
}
|
||||
|
||||
// Wraps the settling portion of a tool call so onToolCallEnd observes success and failure
|
||||
// symmetrically. Interruption (e.g. the execution timeout) fires neither outcome.
|
||||
@@ -758,59 +764,52 @@ export const make = <R>(
|
||||
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 {
|
||||
root: new ToolReference([]),
|
||||
calls,
|
||||
keys: (path) => namespaceKeys(tools, path),
|
||||
search: (args) =>
|
||||
Effect.suspend(() =>
|
||||
invokeDefinition(
|
||||
"search",
|
||||
searchTool,
|
||||
args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"))),
|
||||
),
|
||||
),
|
||||
keys: (path) => namespaceKeys(callableTools, path),
|
||||
invoke: (path, args) =>
|
||||
Effect.gen(function* () {
|
||||
const name = path.join(".")
|
||||
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`)))
|
||||
const tool = resolve(tools, path)
|
||||
if (isDefinition(tool)) return yield* invokeDefinition(name, tool, externalArgs)
|
||||
const index = yield* recordAndObserve(name, externalArgs)
|
||||
const call = { name }
|
||||
const recordAndObserve = (input: unknown) =>
|
||||
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(
|
||||
Effect.gen(function* () {
|
||||
return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name)
|
||||
}),
|
||||
{ index, name, input: externalArgs },
|
||||
currentCall,
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const effectNumberSentinel = (schema: JsonSchema) =>
|
||||
const intersection = (members: ReadonlyArray<string>): string => {
|
||||
const concrete = members.filter((member) => member !== "unknown")
|
||||
if (concrete.length === 0) return "unknown"
|
||||
if (concrete.length === 1) return concrete[0]
|
||||
if (concrete.length === 1) return concrete[0] ?? "unknown"
|
||||
return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ")
|
||||
}
|
||||
|
||||
|
||||
@@ -541,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",
|
||||
)
|
||||
// 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.
|
||||
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)
|
||||
if (result.ok) {
|
||||
expect(result.value).toStrictEqual({
|
||||
@@ -583,7 +583,9 @@ describe("CodeMode public contract", () => {
|
||||
'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)
|
||||
if (search.ok) {
|
||||
expect(search.value).toStrictEqual({
|
||||
@@ -606,7 +608,7 @@ describe("CodeMode public contract", () => {
|
||||
if (call.ok) expect(call.value).toBe("/resolved/TypeScript")
|
||||
|
||||
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)
|
||||
if (exact.ok) expect(exact.value).toMatchObject({ remaining: 0, next: null })
|
||||
@@ -630,7 +632,7 @@ describe("CodeMode public contract", () => {
|
||||
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("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
|
||||
// and no real catalog tools cherry-picked into example lines.
|
||||
expect(instructions).toContain("`const result = await tools.<namespace>.<tool>(input)`")
|
||||
@@ -649,11 +651,15 @@ describe("CodeMode public contract", () => {
|
||||
// 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.
|
||||
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("Only Code Mode tools listed here or returned by the built-in `search` function")
|
||||
expect(partial).toContain('- Browse one namespace: `search({ query: "", namespace: "<name>" })`.')
|
||||
expect(partial).toContain(
|
||||
"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(" limit?: number,\n offset?: number,")
|
||||
expect(partial).not.toContain("total_count")
|
||||
@@ -666,11 +672,9 @@ describe("CodeMode public contract", () => {
|
||||
expect(instructions).toContain("not a general-purpose runtime")
|
||||
expect(instructions).not.toContain("Standard modern JavaScript works")
|
||||
expect(instructions).not.toContain("TypeScript type annotations")
|
||||
for (const missing of ["Modules/imports", "classes", "generators", "fetch"]) {
|
||||
for (const missing of ["Modules/imports", "classes", "generators", "fetch", "promise chaining"]) {
|
||||
expect(instructions).toContain(missing)
|
||||
}
|
||||
expect(instructions).not.toContain("new Promise(...) are unavailable")
|
||||
expect(instructions).not.toContain("promise chaining")
|
||||
expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers")
|
||||
expect(instructions).not.toContain("host globals")
|
||||
expect(instructions).toContain("Use Code Mode tools for external operations")
|
||||
@@ -690,7 +694,7 @@ describe("CodeMode public contract", () => {
|
||||
expect(instructions).toContain("## Available tools")
|
||||
expect(instructions).not.toContain("## Workflow")
|
||||
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 () => {
|
||||
@@ -710,15 +714,17 @@ describe("CodeMode public contract", () => {
|
||||
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
|
||||
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("- 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/)
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
return search({
|
||||
return await tools.$codemode.search({
|
||||
query: "send message attachment upload file to current Discord thread",
|
||||
limit: 2
|
||||
})
|
||||
@@ -742,14 +748,14 @@ describe("CodeMode public contract", () => {
|
||||
remaining: 0,
|
||||
next: null,
|
||||
})
|
||||
expect(result.toolCalls).toStrictEqual([{ name: "search" }])
|
||||
expect(result.toolCalls).toStrictEqual([{ name: "$codemode.search" }])
|
||||
|
||||
const variants = await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
return [
|
||||
search({ query: "file" }),
|
||||
search({ query: "image" })
|
||||
]
|
||||
return await Promise.all([
|
||||
tools.$codemode.search({ query: "file" }),
|
||||
tools.$codemode.search({ query: "image" })
|
||||
])
|
||||
`),
|
||||
)
|
||||
expect(variants.ok).toBe(true)
|
||||
@@ -761,35 +767,12 @@ describe("CodeMode public contract", () => {
|
||||
"tools.thread.generateImage",
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("search is a counted tool call: it burns maxToolCalls and fires the hooks", async () => {
|
||||
const started: Array<string> = []
|
||||
const ended: Array<string> = []
|
||||
const limited = CodeMode.make({
|
||||
tools,
|
||||
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")
|
||||
const removed = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.describe({ path: "thread.uploadFile" })`),
|
||||
)
|
||||
expect(removed.ok).toBe(false)
|
||||
if (!removed.ok) expect(removed.error.kind).toBe("UnknownTool")
|
||||
})
|
||||
|
||||
test("search defaults to 10 results and resolves exact tool paths", async () => {
|
||||
@@ -806,7 +789,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)
|
||||
if (browse.ok) {
|
||||
const value = browse.value as {
|
||||
@@ -820,7 +803,9 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
|
||||
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)
|
||||
if (exact.ok) {
|
||||
expect(exact.value).toStrictEqual({
|
||||
@@ -854,7 +839,9 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
|
||||
// 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)
|
||||
if (browse.ok) {
|
||||
const value = browse.value as { items: Array<{ path: string }>; remaining: number }
|
||||
@@ -866,7 +853,9 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
|
||||
// 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)
|
||||
if (scoped.ok) {
|
||||
const value = scoped.value as { items: Array<{ path: string }>; remaining: number }
|
||||
@@ -874,7 +863,9 @@ describe("CodeMode public contract", () => {
|
||||
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)
|
||||
if (!invalid.ok) expect(invalid.error.kind).toBe("InvalidToolInput")
|
||||
})
|
||||
@@ -899,7 +890,9 @@ describe("CodeMode public contract", () => {
|
||||
|
||||
// "attachment" appears in neither path nor description - only in the input schema's
|
||||
// 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)
|
||||
if (byParameter.ok) {
|
||||
const value = byParameter.value as { items: Array<{ path: string }>; remaining: number }
|
||||
@@ -908,7 +901,9 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
|
||||
// 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)
|
||||
if (bySubstring.ok) {
|
||||
const value = bySubstring.value as { items: Array<{ path: string }>; remaining: number }
|
||||
@@ -935,7 +930,9 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
|
||||
// "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)
|
||||
if (plural.ok) {
|
||||
const value = plural.value as { items: Array<{ path: string }>; remaining: number }
|
||||
@@ -944,7 +941,7 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
|
||||
// ...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)
|
||||
if (ranked.ok) {
|
||||
const value = ranked.value as { items: Array<{ path: string }>; remaining: number }
|
||||
@@ -971,7 +968,7 @@ describe("CodeMode public contract", () => {
|
||||
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)
|
||||
if (browse.ok) {
|
||||
const value = browse.value as { items: Array<{ path: string }>; remaining: number; next: unknown }
|
||||
@@ -984,7 +981,9 @@ describe("CodeMode public contract", () => {
|
||||
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)
|
||||
if (middle.ok) {
|
||||
expect(middle.value).toMatchObject({
|
||||
@@ -994,7 +993,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)
|
||||
if (exhausted.ok) expect(exhausted.value).toStrictEqual({ items: [], remaining: 0, next: null })
|
||||
})
|
||||
@@ -1025,14 +1026,16 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
|
||||
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(" - tools.alpha.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
|
||||
expect(instructions).not.toContain("tools.alpha.expensive(")
|
||||
// Fully shown namespaces read cleanly (no "shown" annotation).
|
||||
expect(instructions).toContain("- beta (1 tool)")
|
||||
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", () => {
|
||||
@@ -1053,7 +1056,9 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
|
||||
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:")
|
||||
})
|
||||
|
||||
@@ -1131,7 +1136,7 @@ describe("CodeMode public contract", () => {
|
||||
CodeMode.make({
|
||||
tools,
|
||||
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)
|
||||
if (result.ok) return
|
||||
@@ -1139,7 +1144,9 @@ describe("CodeMode public contract", () => {
|
||||
|
||||
for (const offset of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, "1"]) {
|
||||
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)
|
||||
if (!invalidOffset.ok) expect(invalidOffset.error.kind).toBe("InvalidToolInput")
|
||||
@@ -1190,4 +1197,8 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
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)
|
||||
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 () => {
|
||||
@@ -52,8 +52,8 @@ describe("Object.keys over tool references", () => {
|
||||
expect(await value(`return Object.keys(tools.github.list_issues)`)).toEqual([])
|
||||
})
|
||||
|
||||
test("search is a global built-in function", async () => {
|
||||
expect(await value(`return typeof search`)).toBe("function")
|
||||
test("the internal discovery namespace enumerates its callable surface", async () => {
|
||||
expect(await value(`return Object.keys(tools.$codemode)`)).toEqual(["search"])
|
||||
})
|
||||
|
||||
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)`)
|
||||
expect(failure.kind).toBe("InvalidDataValue")
|
||||
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)`)
|
||||
@@ -146,7 +146,7 @@ describe("for...in", () => {
|
||||
}
|
||||
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 () => {
|
||||
|
||||
@@ -377,7 +377,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
runtime
|
||||
.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)),
|
||||
|
||||
@@ -16,7 +16,8 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CodeMode } from "../src/index.js"
|
||||
|
||||
const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {}, limits: { timeoutMs: 1_000 } }))
|
||||
const execute = (code: string) =>
|
||||
Effect.runPromise(CodeMode.execute({ code, tools: {}, limits: { timeoutMs: 1_000 } }))
|
||||
|
||||
const value = async (code: string) => {
|
||||
const result = await execute(code)
|
||||
@@ -449,10 +450,7 @@ describe("Test262 async functions and await", () => {
|
||||
const promises = [declaration(), expression(), arrow()]
|
||||
return [promises.map((item) => item instanceof Promise), await Promise.all(promises)]
|
||||
`),
|
||||
).toEqual([
|
||||
[true, true, true],
|
||||
[1, 2, 3],
|
||||
])
|
||||
).toEqual([[true, true, true], [1, 2, 3]])
|
||||
})
|
||||
|
||||
test("async bodies adopt returns and reject throws before and after await", async () => {
|
||||
@@ -481,7 +479,13 @@ describe("Test262 async functions and await", () => {
|
||||
await observe(throwsAfter()),
|
||||
]
|
||||
`),
|
||||
).toEqual([["body"], ["fulfilled", 42], ["fulfilled", 43], ["rejected", 1], ["rejected", 2]])
|
||||
).toEqual([
|
||||
["body"],
|
||||
["fulfilled", 42],
|
||||
["fulfilled", 43],
|
||||
["rejected", 1],
|
||||
["rejected", 2],
|
||||
])
|
||||
})
|
||||
|
||||
test("default-parameter throws reject instead of escaping the call", async () => {
|
||||
@@ -573,14 +577,13 @@ describe("Test262 async functions and await", () => {
|
||||
})
|
||||
|
||||
describe("Test262 expected Promise conformance", () => {
|
||||
for (const name of ["all", "allSettled", "race", "any"] as const) {
|
||||
test(`Promise.${name} rejects invalid input with TypeError`, async () => {
|
||||
for (const name of ["all", "allSettled", "race"] as const) {
|
||||
test.failing(`Promise.${name} rejects invalid input with TypeError`, async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/all/S25.4.4.1_A3.1_T1.js
|
||||
// test/built-ins/Promise/all/S25.4.4.1_A3.1_T2.js
|
||||
// test/built-ins/Promise/allSettled/iter-arg-is-number-reject.js
|
||||
// test/built-ins/Promise/race/iter-arg-is-number-reject.js
|
||||
// test/built-ins/Promise/any/iter-arg-is-number-reject.js
|
||||
expect(
|
||||
await value(`
|
||||
try {
|
||||
@@ -596,7 +599,7 @@ describe("Test262 expected Promise conformance", () => {
|
||||
})
|
||||
}
|
||||
|
||||
test("Promise.all consumes sparse positions as undefined", async () => {
|
||||
test.failing("Promise.all consumes sparse positions as undefined", async () => {
|
||||
// Source: test/built-ins/Array/from/from-array.js (array iterator hole behavior)
|
||||
expect(
|
||||
await value(`
|
||||
@@ -608,7 +611,7 @@ describe("Test262 expected Promise conformance", () => {
|
||||
).toEqual([2, true, 1])
|
||||
})
|
||||
|
||||
test("Promise.allSettled consumes sparse positions as undefined", async () => {
|
||||
test.failing("Promise.allSettled consumes sparse positions as undefined", async () => {
|
||||
// Source: test/built-ins/Array/from/from-array.js (array iterator hole behavior)
|
||||
expect(
|
||||
await value(`
|
||||
@@ -620,7 +623,7 @@ describe("Test262 expected Promise conformance", () => {
|
||||
).toEqual([2, "fulfilled", true, { status: "fulfilled", value: 1 }])
|
||||
})
|
||||
|
||||
test("Promise.race consumes a sparse first position as undefined", async () => {
|
||||
test.failing("Promise.race consumes a sparse first position as undefined", async () => {
|
||||
// Source: test/built-ins/Array/from/from-array.js (array iterator hole behavior)
|
||||
expect(
|
||||
await value(`
|
||||
@@ -631,18 +634,7 @@ describe("Test262 expected Promise conformance", () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("Promise.any consumes a sparse first position as an undefined fulfillment", async () => {
|
||||
// Source: test/built-ins/Array/from/from-array.js (array iterator hole behavior)
|
||||
expect(
|
||||
await value(`
|
||||
const input = []
|
||||
input[1] = Promise.reject("loses")
|
||||
return (await Promise.any(input)) === undefined
|
||||
`),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("Promise.all settles after reactions attached to its inputs", async () => {
|
||||
test.failing("Promise.all settles after reactions attached to its inputs", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/all/S25.4.4.1_A7.2_T1.js
|
||||
// test/built-ins/Promise/all/S25.4.4.1_A8.1_T1.js
|
||||
@@ -661,7 +653,7 @@ describe("Test262 expected Promise conformance", () => {
|
||||
).toEqual([1, 2, 3, 4, 5])
|
||||
})
|
||||
|
||||
test("Promise.allSettled settles after reactions attached to its inputs", async () => {
|
||||
test.failing("Promise.allSettled settles after reactions attached to its inputs", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/allSettled/resolved-sequence.js
|
||||
// test/built-ins/Promise/allSettled/resolved-sequence-extra-ticks.js
|
||||
@@ -682,7 +674,7 @@ describe("Test262 expected Promise conformance", () => {
|
||||
).toEqual([1, 2, 3, 4, 5])
|
||||
})
|
||||
|
||||
test("Promise.race settles in a reaction after its winning input", async () => {
|
||||
test.failing("Promise.race settles in a reaction after its winning input", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/race/S25.4.4.3_A6.1_T1.js
|
||||
// test/built-ins/Promise/race/resolved-sequence-extra-ticks.js
|
||||
@@ -700,7 +692,7 @@ describe("Test262 expected Promise conformance", () => {
|
||||
).toEqual([1, 2, 3, 4, 5])
|
||||
})
|
||||
|
||||
test("then reactions route and propagate fulfillment and rejection", async () => {
|
||||
test.failing("then reactions route and propagate fulfillment and rejection", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/prototype/then/prfm-fulfilled.js
|
||||
// test/built-ins/Promise/prototype/then/prfm-rejected.js
|
||||
@@ -734,7 +726,7 @@ describe("Test262 expected Promise conformance", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("then reactions preserve breadth-first queue order", async () => {
|
||||
test.failing("then reactions preserve breadth-first queue order", async () => {
|
||||
// Source: test/built-ins/Promise/prototype/then/S25.4.4_A1.1_T1.js
|
||||
expect(
|
||||
await value(`
|
||||
@@ -749,7 +741,7 @@ describe("Test262 expected Promise conformance", () => {
|
||||
).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
|
||||
})
|
||||
|
||||
test("then rejects direct self-resolution for fulfilled and rejected sources", async () => {
|
||||
test.failing("then rejects direct self-resolution for fulfilled and rejected sources", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/prototype/then/resolve-settled-fulfilled-self.js
|
||||
// test/built-ins/Promise/prototype/then/resolve-settled-rejected-self.js
|
||||
@@ -769,7 +761,7 @@ describe("Test262 expected Promise conformance", () => {
|
||||
).toEqual(["TypeError", "TypeError"])
|
||||
})
|
||||
|
||||
test("catch delegates rejection handling and preserves fulfillment", async () => {
|
||||
test.failing("catch delegates rejection handling and preserves fulfillment", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/prototype/catch/S25.4.5.1_A2.1_T1.js
|
||||
// test/built-ins/Promise/prototype/catch/S25.4.5.1_A3.1_T1.js
|
||||
@@ -784,7 +776,7 @@ describe("Test262 expected Promise conformance", () => {
|
||||
).toEqual([1, 4])
|
||||
})
|
||||
|
||||
test("finally preserves or replaces the original settlement", async () => {
|
||||
test.failing("finally preserves or replaces the original settlement", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/prototype/finally/resolution-value-no-override.js
|
||||
// test/built-ins/Promise/prototype/finally/rejection-reason-no-fulfill.js
|
||||
@@ -807,109 +799,7 @@ describe("Test262 expected Promise conformance", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("then ignores non-callable handlers", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/prototype/then/S25.4.5.3_A4.1_T1.js
|
||||
// test/built-ins/Promise/prototype/then/S25.4.5.3_A4.1_T2.js
|
||||
// test/built-ins/Promise/prototype/then/S25.4.5.3_A5.1_T1.js
|
||||
// test/built-ins/Promise/prototype/then/S25.4.5.3_A5.2_T1.js
|
||||
// (adapted: only non-callable handlers are probed; callables that are not plain
|
||||
// functions, such as tool references, intentionally throw in CodeMode)
|
||||
expect(
|
||||
await value(`
|
||||
const observe = async (promise) => {
|
||||
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
|
||||
}
|
||||
return await Promise.all([
|
||||
observe(Promise.resolve(1).then(2)),
|
||||
observe(Promise.resolve(4).then(null, null)),
|
||||
observe(Promise.resolve(5).then({}, "x")),
|
||||
observe(Promise.reject(3).then(null, "x")),
|
||||
observe(Promise.reject(6).then(7, {})),
|
||||
])
|
||||
`),
|
||||
).toEqual([
|
||||
["fulfilled", 1],
|
||||
["fulfilled", 4],
|
||||
["fulfilled", 5],
|
||||
["rejected", 3],
|
||||
["rejected", 6],
|
||||
])
|
||||
})
|
||||
|
||||
test("finally waits for a returned promise and preserves or replaces settlement", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/prototype/finally/resolved-observable-then-calls.js
|
||||
// test/built-ins/Promise/prototype/finally/rejected-observable-then-calls.js
|
||||
// test/built-ins/Promise/prototype/finally/resolution-value-no-override.js
|
||||
expect(
|
||||
await value(`
|
||||
const observe = async (promise) => {
|
||||
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
|
||||
}
|
||||
const order = []
|
||||
const cleanup = async () => {
|
||||
await Promise.resolve()
|
||||
order.push("cleanup")
|
||||
}
|
||||
const settled = await Promise.resolve("kept").finally(() => cleanup())
|
||||
order.push("settled:" + settled)
|
||||
return [
|
||||
await observe(Promise.resolve(1).finally(() => Promise.resolve(99))),
|
||||
order,
|
||||
await observe(Promise.resolve(2).finally(() => Promise.reject(3))),
|
||||
await observe(Promise.reject(4).finally(() => Promise.resolve(99))),
|
||||
]
|
||||
`),
|
||||
).toEqual([
|
||||
["fulfilled", 1],
|
||||
["cleanup", "settled:kept"],
|
||||
["rejected", 3],
|
||||
["rejected", 4],
|
||||
])
|
||||
})
|
||||
|
||||
test("then adopts a returned rejected promise", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/prototype/then/rxn-handler-fulfilled-return-abrupt.js
|
||||
// test/built-ins/Promise/resolve/resolve-promise.js
|
||||
// (adapted: the fulfillment handler returns an already-rejected promise instead of
|
||||
// throwing, and the rejection handler recovers with a fulfilled promise)
|
||||
expect(
|
||||
await value(`
|
||||
const observe = async (promise) => {
|
||||
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
|
||||
}
|
||||
return await Promise.all([
|
||||
observe(Promise.resolve(1).then(() => Promise.reject("bad"))),
|
||||
observe(Promise.reject(2).then(undefined, () => Promise.resolve("ok"))),
|
||||
])
|
||||
`),
|
||||
).toEqual([
|
||||
["rejected", "bad"],
|
||||
["fulfilled", "ok"],
|
||||
])
|
||||
})
|
||||
|
||||
test("independent reactions on one source each observe the same settlement", async () => {
|
||||
// Source: test/built-ins/Promise/prototype/then/S25.4.4_A2.1_T1.js
|
||||
// (adapted: the multiple-reactions family is asserted through the values every
|
||||
// reaction returns instead of a shared completion counter)
|
||||
expect(
|
||||
await value(`
|
||||
const fulfilled = Promise.resolve(7)
|
||||
const rejected = Promise.reject(8)
|
||||
return await Promise.all([
|
||||
fulfilled.then((value) => "first:" + value),
|
||||
fulfilled.then((value) => "second:" + value),
|
||||
rejected.catch((reason) => "first:" + reason),
|
||||
rejected.catch((reason) => "second:" + reason),
|
||||
])
|
||||
`),
|
||||
).toEqual(["first:7", "second:7", "first:8", "second:8"])
|
||||
})
|
||||
|
||||
test("await always resumes in a later reaction and interleaves async functions", async () => {
|
||||
test.failing("await always resumes in a later reaction and interleaves async functions", async () => {
|
||||
// Sources:
|
||||
// test/language/expressions/await/async-await-interleaved.js
|
||||
// test/language/expressions/await/await-non-promise.js
|
||||
@@ -924,7 +814,7 @@ describe("Test262 expected Promise conformance", () => {
|
||||
).toEqual(["first:1", "second:1", "first:2", "second:2"])
|
||||
})
|
||||
|
||||
test("an async function rejects when it resolves with its own promise", async () => {
|
||||
test.failing("an async function rejects when it resolves with its own promise", async () => {
|
||||
// Adapted from the self-resolution requirement represented by:
|
||||
// test/built-ins/Promise/resolve-self.js
|
||||
// test/built-ins/Promise/resolve/S25.4.4.5_A4.1_T1.js
|
||||
@@ -1014,410 +904,3 @@ describe("Test262 expected Promise conformance", () => {
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Test262 Promise.any", () => {
|
||||
test("is a callable static that returns a promise", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/any/is-function.js
|
||||
// test/built-ins/Promise/any/returns-promise.js
|
||||
expect(
|
||||
await value(`
|
||||
const promise = Promise.any([1])
|
||||
return [typeof Promise.any, promise instanceof Promise, await promise]
|
||||
`),
|
||||
).toEqual(["function", true, 1])
|
||||
})
|
||||
|
||||
test("fulfills with the first fulfilled member, ignoring rejections", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/any/resolved-sequence-mixed.js
|
||||
// test/built-ins/Promise/any/resolved-sequence-with-rejections.js
|
||||
// test/built-ins/Promise/any/reject-ignored-immed.js
|
||||
expect(
|
||||
await value(`
|
||||
return [
|
||||
await Promise.any([Promise.reject("a"), Promise.resolve(1), Promise.resolve(2)]),
|
||||
await Promise.any([Promise.reject("a"), "plain", Promise.reject("b")]),
|
||||
]
|
||||
`),
|
||||
).toEqual([1, "plain"])
|
||||
})
|
||||
|
||||
test("a fulfillment wins over a later rejection of another member", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/any/resolve-ignores-late-rejection.js
|
||||
// test/built-ins/Promise/any/resolve-ignores-late-rejection-deferred.js
|
||||
expect(
|
||||
await value(`
|
||||
let rejectLate
|
||||
const late = new Promise((_, reject) => { rejectLate = reject })
|
||||
const result = await Promise.any([late, Promise.resolve("won")])
|
||||
rejectLate("too late")
|
||||
return result
|
||||
`),
|
||||
).toBe("won")
|
||||
})
|
||||
|
||||
test("rejects with an AggregateError carrying the reasons in input order", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/any/reject-all-mixed.js
|
||||
// test/built-ins/Promise/any/reject-immed.js
|
||||
// test/built-ins/Promise/any/reject-deferred.js
|
||||
expect(
|
||||
await value(`
|
||||
let rejectLate
|
||||
const late = new Promise((_, reject) => { rejectLate = reject })
|
||||
const aggregate = Promise.any([Promise.reject("first"), late, Promise.reject("third")])
|
||||
rejectLate("second")
|
||||
try {
|
||||
await aggregate
|
||||
return "fulfilled"
|
||||
} catch (error) {
|
||||
return {
|
||||
isAggregate: error instanceof AggregateError,
|
||||
isError: error instanceof Error,
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
errors: error.errors,
|
||||
}
|
||||
}
|
||||
`),
|
||||
).toEqual({
|
||||
isAggregate: true,
|
||||
isError: true,
|
||||
name: "AggregateError",
|
||||
message: "All promises were rejected",
|
||||
errors: ["first", "second", "third"],
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects an empty input with an empty AggregateError", async () => {
|
||||
// Source: test/built-ins/Promise/any/iter-arg-is-empty-iterable-reject.js
|
||||
expect(
|
||||
await value(`
|
||||
try {
|
||||
await Promise.any([])
|
||||
return "fulfilled"
|
||||
} catch (error) {
|
||||
return [error instanceof AggregateError, error.errors.length]
|
||||
}
|
||||
`),
|
||||
).toEqual([true, 0])
|
||||
})
|
||||
|
||||
test("consumes a string input as its characters", async () => {
|
||||
// Source: test/built-ins/Promise/any/iter-arg-is-string-resolve.js
|
||||
expect(await value(`return await Promise.any("abc")`)).toBe("a")
|
||||
})
|
||||
|
||||
test("rejects an empty string input with an empty AggregateError", async () => {
|
||||
// Source: test/built-ins/Promise/any/iter-arg-is-empty-string-reject.js
|
||||
expect(
|
||||
await value(`
|
||||
try {
|
||||
await Promise.any("")
|
||||
return "fulfilled"
|
||||
} catch (error) {
|
||||
return [error instanceof AggregateError, error.errors.length]
|
||||
}
|
||||
`),
|
||||
).toEqual([true, 0])
|
||||
})
|
||||
|
||||
test("fulfills with the first member that does not reject", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/any/resolve-from-reject-catch.js
|
||||
// test/built-ins/Promise/any/resolve-from-resolve-reject-catch.js
|
||||
expect(
|
||||
await value(`
|
||||
return await Promise.any([
|
||||
Promise.reject("a"),
|
||||
new Promise((resolve, reject) => reject("b")),
|
||||
Promise.all([Promise.reject("c")]),
|
||||
Promise.resolve(Promise.reject("d").catch((reason) => reason)),
|
||||
])
|
||||
`),
|
||||
).toBe("d")
|
||||
})
|
||||
|
||||
test("settles after reactions attached to its inputs", async () => {
|
||||
// Source: test/built-ins/Promise/any/resolved-sequence.js
|
||||
expect(
|
||||
await value(`
|
||||
const sequence = [1]
|
||||
const input = Promise.resolve(1)
|
||||
const aggregate = Promise.any([input])
|
||||
aggregate.then(() => sequence.push(4))
|
||||
input.then(() => sequence.push(3)).then(() => sequence.push(5))
|
||||
sequence.push(2)
|
||||
await aggregate
|
||||
await Promise.resolve()
|
||||
return sequence
|
||||
`),
|
||||
).toEqual([1, 2, 3, 4, 5])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Test262 AggregateError", () => {
|
||||
test("constructs from an errors collection and an optional message", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/AggregateError/errors-iterabletolist.js
|
||||
// test/built-ins/AggregateError/message-undefined-no-prop.js
|
||||
expect(
|
||||
await value(`
|
||||
const input = ["x", "y"]
|
||||
const withMessage = new AggregateError(input, "msg")
|
||||
const bare = new AggregateError([])
|
||||
return [
|
||||
withMessage.name,
|
||||
withMessage.message,
|
||||
withMessage.errors,
|
||||
withMessage.errors !== input,
|
||||
withMessage instanceof AggregateError,
|
||||
withMessage instanceof Error,
|
||||
bare.message,
|
||||
bare.errors,
|
||||
]
|
||||
`),
|
||||
).toEqual(["AggregateError", "msg", ["x", "y"], true, true, true, "", []])
|
||||
})
|
||||
|
||||
test("rejects a non-collection errors argument with TypeError", async () => {
|
||||
// Source: test/built-ins/AggregateError/errors-iterabletolist-failures.js
|
||||
expect(
|
||||
await value(`
|
||||
try {
|
||||
new AggregateError(42)
|
||||
return "constructed"
|
||||
} catch (error) {
|
||||
return error.name
|
||||
}
|
||||
`),
|
||||
).toBe("TypeError")
|
||||
})
|
||||
|
||||
test("is callable without new", async () => {
|
||||
// Source: test/built-ins/AggregateError/newtarget-is-undefined.js
|
||||
expect(
|
||||
await value(`
|
||||
const error = AggregateError(["x"], "m")
|
||||
return [error instanceof AggregateError, error instanceof Error, error.name, error.message, error.errors]
|
||||
`),
|
||||
).toEqual([true, true, "AggregateError", "m", ["x"]])
|
||||
})
|
||||
|
||||
test("coerces a non-string message to a string", async () => {
|
||||
// Source: test/built-ins/AggregateError/message-method-prop-cast.js (value coercion only; the
|
||||
// upstream object-with-toString case is omitted because the sandbox has no user toString dispatch)
|
||||
expect(
|
||||
await value(`
|
||||
return [
|
||||
new AggregateError([], 42).message,
|
||||
new AggregateError([], false).message,
|
||||
new AggregateError([], true).message,
|
||||
new AggregateError([], null).message,
|
||||
]
|
||||
`),
|
||||
).toEqual(["42", "false", "true", "null"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Test262 Promise constructor", () => {
|
||||
test("constructs a promise, handing the executor callable resolve/reject", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/constructor.js
|
||||
// test/built-ins/Promise/exec-args.js
|
||||
expect(
|
||||
await value(`
|
||||
let observed
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
observed = [typeof resolve, typeof reject]
|
||||
resolve("done")
|
||||
})
|
||||
return [promise instanceof Promise, observed, await promise]
|
||||
`),
|
||||
).toEqual([true, ["function", "function"], "done"])
|
||||
})
|
||||
|
||||
test("a missing or non-callable executor is a TypeError", async () => {
|
||||
// Source: test/built-ins/Promise/executor-not-callable.js
|
||||
expect(
|
||||
await value(`
|
||||
const outcomes = []
|
||||
for (const make of [() => new Promise(), () => new Promise(1), () => new Promise({})]) {
|
||||
try {
|
||||
make()
|
||||
outcomes.push("constructed")
|
||||
} catch (error) {
|
||||
outcomes.push(error.name)
|
||||
}
|
||||
}
|
||||
return outcomes
|
||||
`),
|
||||
).toEqual(["TypeError", "TypeError", "TypeError"])
|
||||
})
|
||||
|
||||
test("resolves immediately or later through an escaping resolver", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/resolve-non-thenable-immed.js
|
||||
// test/built-ins/Promise/resolve-non-thenable-deferred.js
|
||||
// test/built-ins/Promise/create-resolving-functions-resolve.js
|
||||
expect(
|
||||
await value(`
|
||||
let settle
|
||||
const deferred = new Promise((resolve) => { settle = resolve })
|
||||
const immediate = new Promise((resolve) => resolve("now"))
|
||||
settle("later")
|
||||
return [await immediate, await deferred]
|
||||
`),
|
||||
).toEqual(["now", "later"])
|
||||
})
|
||||
|
||||
test("rejects through reject and through an abrupt executor completion", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/reject-via-fn-immed.js
|
||||
// test/built-ins/Promise/reject-via-abrupt.js
|
||||
expect(
|
||||
await value(`
|
||||
const observe = async (promise) => {
|
||||
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason.message ?? reason] }
|
||||
}
|
||||
return [
|
||||
await observe(new Promise((_, reject) => reject("nope"))),
|
||||
await observe(new Promise(() => { throw new Error("boom") })),
|
||||
]
|
||||
`),
|
||||
).toEqual([
|
||||
["rejected", "nope"],
|
||||
["rejected", "boom"],
|
||||
])
|
||||
})
|
||||
|
||||
test("only the first settlement counts", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/reject-ignored-via-fn-immed.js
|
||||
// test/built-ins/Promise/resolve-ignored-via-fn-immed.js
|
||||
expect(
|
||||
await value(`
|
||||
return [
|
||||
await new Promise((resolve, reject) => { resolve("first"); reject("second"); resolve("third") }),
|
||||
await new Promise((resolve) => { resolve(resolve("inner") === undefined ? "unreached" : "also unreached") }),
|
||||
]
|
||||
`),
|
||||
).toEqual(["first", "inner"])
|
||||
})
|
||||
|
||||
test("escaped resolvers keep first-settle-wins in both directions", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/reject-ignored-via-fn-deferred.js
|
||||
// test/built-ins/Promise/resolve-ignored-via-fn-deferred.js
|
||||
expect(
|
||||
await value(`
|
||||
let resolveRejected, rejectRejected
|
||||
const rejected = new Promise((resolve, reject) => { resolveRejected = resolve; rejectRejected = reject })
|
||||
rejectRejected("first")
|
||||
const lateResolve = resolveRejected("late")
|
||||
let resolveFulfilled, rejectFulfilled
|
||||
const fulfilled = new Promise((resolve, reject) => { resolveFulfilled = resolve; rejectFulfilled = reject })
|
||||
resolveFulfilled()
|
||||
const lateReject = rejectFulfilled(new Promise(() => {}))
|
||||
try {
|
||||
await rejected
|
||||
return "fulfilled"
|
||||
} catch (reason) {
|
||||
return [reason, lateResolve === undefined, (await fulfilled) === undefined, lateReject === undefined]
|
||||
}
|
||||
`),
|
||||
).toEqual(["first", true, true, true])
|
||||
})
|
||||
|
||||
test("a queued reaction chain observes a later rejection through a handler-less then", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/reject-via-fn-immed-queue.js
|
||||
// test/built-ins/Promise/reject-via-fn-deferred-queue.js
|
||||
// test/built-ins/Promise/reject-via-abrupt-queue.js
|
||||
expect(
|
||||
await value(`
|
||||
const observe = (promise) => promise.then(() => "wrong").then(() => "also wrong", (reason) => "caught:" + reason)
|
||||
let reject
|
||||
const deferred = new Promise((_, r) => { reject = r })
|
||||
const chained = observe(deferred)
|
||||
reject("boom")
|
||||
return [
|
||||
await observe(new Promise((_, r) => r("immed"))),
|
||||
await chained,
|
||||
await observe(new Promise(() => { throw "abrupt" })),
|
||||
]
|
||||
`),
|
||||
).toEqual(["caught:immed", "caught:boom", "caught:abrupt"])
|
||||
})
|
||||
|
||||
test("an exception after resolve is ignored", async () => {
|
||||
// Source: test/built-ins/Promise/exception-after-resolve-in-executor.js
|
||||
expect(await value(`return await new Promise((resolve) => { resolve("kept"); throw new Error("dropped") })`)).toBe(
|
||||
"kept",
|
||||
)
|
||||
})
|
||||
|
||||
test("resolving with a promise adopts its settlement", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/resolve-thenable-immed.js (promise-adoption portion)
|
||||
// test/built-ins/Promise/all/S25.4.4.1_A2.3_T1.js (resolution adoption semantics)
|
||||
expect(
|
||||
await value(`
|
||||
const adoptedValue = await new Promise((resolve) => resolve(Promise.resolve("adopted")))
|
||||
try {
|
||||
await new Promise((resolve) => resolve(Promise.reject("bad")))
|
||||
return [adoptedValue, "fulfilled"]
|
||||
} catch (reason) {
|
||||
return [adoptedValue, reason]
|
||||
}
|
||||
`),
|
||||
).toEqual(["adopted", "bad"])
|
||||
})
|
||||
|
||||
test("resolving with the promise itself rejects with TypeError", async () => {
|
||||
// Source: test/built-ins/Promise/resolve-self.js
|
||||
expect(
|
||||
await value(`
|
||||
let settle
|
||||
const promise = new Promise((resolve) => { settle = resolve })
|
||||
settle(promise)
|
||||
try {
|
||||
await promise
|
||||
return "fulfilled"
|
||||
} catch (error) {
|
||||
return error.name
|
||||
}
|
||||
`),
|
||||
).toBe("TypeError")
|
||||
})
|
||||
|
||||
test("executor runs synchronously before the constructor returns", async () => {
|
||||
// Source: test/built-ins/Promise/executor-call-context-strict.js (synchronous Call(executor) step)
|
||||
expect(
|
||||
await value(`
|
||||
const sequence = []
|
||||
sequence.push("before")
|
||||
new Promise((resolve) => { sequence.push("executor"); resolve() })
|
||||
sequence.push("after")
|
||||
return sequence
|
||||
`),
|
||||
).toEqual(["before", "executor", "after"])
|
||||
})
|
||||
|
||||
test.failing("calling Promise without new throws TypeError", async () => {
|
||||
// Source: test/built-ins/Promise/undefined-newtarget.js
|
||||
// The sandbox currently reports a generic Error ("Only tools are callable in CodeMode.").
|
||||
expect(
|
||||
await value(`
|
||||
try {
|
||||
Promise(() => {})
|
||||
return "called"
|
||||
} catch (error) {
|
||||
return error.name
|
||||
}
|
||||
`),
|
||||
).toBe("TypeError")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,9 +3,8 @@ import { Effect, Schema } from "effect"
|
||||
import { CodeMode, Tool, toolError } from "../src/index.js"
|
||||
|
||||
// Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on
|
||||
// supervised fibers, `await` settles them, Promise.all/allSettled/race/resolve/reject are
|
||||
// ordinary functions over arbitrary arrays mixing promises and plain values, and
|
||||
// .then/.catch/.finally chain reactions onto any promise.
|
||||
// supervised fibers, `await` settles them, and Promise.all/allSettled/race/resolve/reject are
|
||||
// ordinary functions over arbitrary arrays mixing promises and plain values.
|
||||
|
||||
type Trace = {
|
||||
starts: Array<number>
|
||||
@@ -185,7 +184,7 @@ describe("first-class promise values", () => {
|
||||
expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
|
||||
})
|
||||
|
||||
test("await of a non-promise value passes it through unchanged", async () => {
|
||||
test("await of a non-promise value is a passthrough no-op", async () => {
|
||||
expect(await value(`return await 42`)).toBe(42)
|
||||
expect(await value(`const x = await "s"; return x`)).toBe("s")
|
||||
expect(await value(`return await null`)).toBeNull()
|
||||
@@ -936,124 +935,16 @@ describe("timeout interruption of forked calls", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("promise chaining", () => {
|
||||
test("then transforms tool results and adopts returned promises across a chain", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return await tools.host
|
||||
.sleepy({ id: 2 })
|
||||
.then((id) => tools.host.sleepy({ id: id + 1 }))
|
||||
.then((id) => id * 10)
|
||||
`),
|
||||
).toBe(30)
|
||||
})
|
||||
|
||||
test("handlers are deferred and run in attach order", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const order = []
|
||||
const promise = Promise.resolve(1)
|
||||
promise.then(() => order.push("h1"))
|
||||
promise.then(() => order.push("h2"))
|
||||
order.push("sync")
|
||||
await promise
|
||||
return order
|
||||
`),
|
||||
).toEqual(["sync", "h1", "h2"])
|
||||
})
|
||||
|
||||
test("catch recovers a tool failure and preserves fulfillment", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [
|
||||
await tools.host.fail({}).catch((error) => error.message),
|
||||
await tools.host.sleepy({ id: 4 }).catch(() => "unused"),
|
||||
]
|
||||
`),
|
||||
).toEqual(["Lookup refused", 4])
|
||||
})
|
||||
|
||||
test("finally observes settlement without changing the value", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const events = []
|
||||
const result = await tools.host.sleepy({ id: 5 }).finally(() => events.push("cleanup"))
|
||||
return [result, events]
|
||||
`),
|
||||
).toEqual([5, ["cleanup"]])
|
||||
})
|
||||
|
||||
test("a settled, un-awaited rejected chain tail warns exactly once", async () => {
|
||||
const result = await run(`
|
||||
Promise.reject(new Error("boom")).then((value) => value)
|
||||
await Promise.resolve()
|
||||
return "done"
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toBe("done")
|
||||
// The source rejection belongs to the chain (no warning); only the derived tail warns.
|
||||
expect(result.warnings).toStrictEqual([
|
||||
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
|
||||
])
|
||||
})
|
||||
|
||||
test("a catch handler silences the chain's rejection warning", async () => {
|
||||
const result = await run(`
|
||||
Promise.reject(new Error("boom")).catch(() => "handled")
|
||||
await Promise.resolve()
|
||||
return "done"
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.warnings).toBeUndefined()
|
||||
})
|
||||
|
||||
test("non-plain-function handlers fail loudly instead of being ignored", async () => {
|
||||
const diagnostic = await error(`return await tools.host.sleepy({ id: 1 }).then(tools.host.completed)`)
|
||||
expect(diagnostic.message).toContain("Promise.prototype.then handlers must be plain functions")
|
||||
})
|
||||
|
||||
test("chaining methods are opaque references until called", async () => {
|
||||
expect(await value(`return typeof tools.host.sleepy({ id: 1 }).then`)).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("combinator settlement timing", () => {
|
||||
test("a combinator settling one reaction turn after the program returns is interrupted silently", async () => {
|
||||
// The aggregate's one-turn settlement delay (V8 parity) means an immediately-returning
|
||||
// program abandons it while still pending: interrupted like any pending work, so no
|
||||
// rejection warning survives - the member itself was observed by the combinator.
|
||||
const result = await run(`
|
||||
Promise.all([Promise.reject(new Error("boom"))])
|
||||
return "done"
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toBe("done")
|
||||
expect(result.warnings).toBeUndefined()
|
||||
})
|
||||
|
||||
test("a combinator settles one reaction turn after its members, as in V8", async () => {
|
||||
// Regression for the race winner flip: Promise.all's settlement burns a reaction turn,
|
||||
// so a plain resolved value entered in the same race wins, and a fail-fast aggregate
|
||||
// cannot beat it into rejection.
|
||||
expect(
|
||||
await value(`
|
||||
const pending = tools.host.sleepy({ id: 9, ms: 60000 })
|
||||
const winner = await Promise.race([Promise.all([Promise.resolve(1)]), Promise.resolve(2)])
|
||||
try {
|
||||
const raced = await Promise.race([Promise.all([Promise.reject("x"), pending]), Promise.resolve("ok")])
|
||||
return [winner, "fulfilled", raced]
|
||||
} catch (reason) {
|
||||
return [winner, "rejected", reason]
|
||||
}
|
||||
`),
|
||||
).toEqual([2, "fulfilled", "ok"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("unsupported promise surface", () => {
|
||||
test(".then/.catch/.finally give a clear await-instead error", async () => {
|
||||
for (const method of ["then", "catch", "finally"]) {
|
||||
const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).${method}((x) => x)`)
|
||||
expect(diagnostic.kind).toBe("UnsupportedSyntax")
|
||||
expect(diagnostic.message).toContain(`Promise.prototype.${method} is not supported`)
|
||||
expect(diagnostic.message).toContain("await")
|
||||
}
|
||||
})
|
||||
|
||||
test("other property reads on a promise hint at the missing await", async () => {
|
||||
const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).value`)
|
||||
expect(diagnostic.kind).toBe("InvalidDataValue")
|
||||
@@ -1062,179 +953,15 @@ describe("unsupported promise surface", () => {
|
||||
})
|
||||
|
||||
test("unknown Promise statics list what is available", async () => {
|
||||
const diagnostic = await error(`return await Promise.withResolvers()`)
|
||||
expect(diagnostic.message).toContain("Promise.withResolvers is not available")
|
||||
expect(diagnostic.message).toContain("Promise.any")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Promise.any", () => {
|
||||
test("first tool success wins; failing and losing calls are handled silently", async () => {
|
||||
const trace = makeTrace()
|
||||
const result = await run(
|
||||
`
|
||||
const winner = await Promise.any([
|
||||
tools.host.fail({}),
|
||||
tools.host.sleepy({ id: 1, ms: 5 }),
|
||||
tools.host.sleepy({ id: 2, ms: 60000 }),
|
||||
])
|
||||
return winner
|
||||
`,
|
||||
{ trace },
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toBe(1)
|
||||
// The slow loser stays execution-owned and is interrupted at completion; the tool
|
||||
// failure was observed by the aggregate, so no rejection warning survives.
|
||||
expect(result.warnings).toBeUndefined()
|
||||
expect(trace.interrupted).toBe(1)
|
||||
})
|
||||
|
||||
test("all members failing rejects with catch-normalized reasons in input order", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
try {
|
||||
await Promise.any([tools.host.fail({}), Promise.reject("plain")])
|
||||
return "fulfilled"
|
||||
} catch (error) {
|
||||
return [error.name, error.errors.map((reason) => reason.message ?? reason)]
|
||||
}
|
||||
`),
|
||||
).toEqual(["AggregateError", ["Lookup refused", "plain"]])
|
||||
})
|
||||
|
||||
test("settles one reaction turn after its deciding member, as in V8", async () => {
|
||||
expect(await value(`return await Promise.race([Promise.any([Promise.resolve(1)]), Promise.resolve(2)])`)).toBe(2)
|
||||
})
|
||||
|
||||
test("a tie is decided by settlement order, not input order", async () => {
|
||||
// Handlers run in attach order, so `first` settles before `second` and wins
|
||||
// despite its later input position - as in real JS.
|
||||
expect(
|
||||
await value(`
|
||||
const first = Promise.resolve().then(() => "one")
|
||||
const second = Promise.resolve().then(() => "two")
|
||||
return await Promise.any([second, first])
|
||||
`),
|
||||
).toBe("one")
|
||||
})
|
||||
|
||||
test("an abandoned rejecting aggregate is interrupted silently at the return", async () => {
|
||||
const result = await run(`
|
||||
Promise.any([Promise.reject(new Error("boom"))])
|
||||
return "done"
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toBe("done")
|
||||
expect(result.warnings).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("promise construction", () => {
|
||||
test("a deferred gate coordinates tool results across async functions", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let openGate
|
||||
const gate = new Promise((resolve) => { openGate = resolve })
|
||||
const worker = (async () => {
|
||||
const id = await gate
|
||||
return id * 2
|
||||
})()
|
||||
openGate(await tools.host.sleepy({ id: 21, ms: 5 }))
|
||||
return await worker
|
||||
`),
|
||||
).toBe(42)
|
||||
})
|
||||
|
||||
test("the .then(resolve) bridge settles a constructed promise", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const bridged = new Promise((resolve, reject) => {
|
||||
tools.host.sleepy({ id: 7, ms: 5 }).then(resolve, reject)
|
||||
})
|
||||
return await bridged
|
||||
`),
|
||||
).toBe(7)
|
||||
})
|
||||
|
||||
test("constructed promises participate in combinators", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let settle
|
||||
const manual = new Promise((resolve) => { settle = resolve })
|
||||
const race = Promise.race([manual, tools.host.sleepy({ id: 3, ms: 60000 })])
|
||||
const all = Promise.all([manual, "plain"])
|
||||
const any = Promise.any([manual, new Promise(() => {})])
|
||||
settle("manual")
|
||||
return [await race, await all, await any]
|
||||
`),
|
||||
).toEqual(["manual", ["manual", "plain"], "manual"])
|
||||
})
|
||||
|
||||
test("resolving with a pending promise adopts its later settlement", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let innerResolve, innerReject
|
||||
const adopted = new Promise((resolve) => resolve(new Promise((resolve) => { innerResolve = resolve })))
|
||||
const adoptedRejection = new Promise((resolve) => resolve(new Promise((_, reject) => { innerReject = reject })))
|
||||
innerResolve("later")
|
||||
innerReject("bad")
|
||||
try {
|
||||
return [await adopted, await adoptedRejection]
|
||||
} catch (reason) {
|
||||
return [await adopted, reason]
|
||||
}
|
||||
`),
|
||||
).toEqual(["later", "bad"])
|
||||
})
|
||||
|
||||
test("an async executor's post-await resolve settles the promise", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const result = new Promise(async (resolve) => {
|
||||
const id = await tools.host.sleepy({ id: 5, ms: 5 })
|
||||
resolve(id * 2)
|
||||
})
|
||||
return await result
|
||||
`),
|
||||
).toBe(10)
|
||||
})
|
||||
|
||||
test("a never-settled promise is abandoned silently at the return", async () => {
|
||||
const result = await run(`
|
||||
const forever = new Promise(() => {})
|
||||
forever.then(() => {})
|
||||
return "done"
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toBe("done")
|
||||
expect(result.warnings).toBeUndefined()
|
||||
})
|
||||
|
||||
test("an un-awaited constructed rejection is reported like any unhandled rejection", async () => {
|
||||
const result = await run(`
|
||||
new Promise((_, reject) => reject(new Error("dropped")))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
return "done"
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toBe("done")
|
||||
expect(result.warnings).toHaveLength(1)
|
||||
expect(result.warnings?.[0].message).toContain("Unhandled rejection")
|
||||
expect(result.warnings?.[0].message).toContain("dropped")
|
||||
})
|
||||
|
||||
test("resolver functions cannot cross the data boundary", async () => {
|
||||
const diagnostic = await error(`
|
||||
let escaped
|
||||
new Promise((resolve) => { escaped = resolve })
|
||||
return { escaped }
|
||||
`)
|
||||
expect(diagnostic.kind).toBe("InvalidDataValue")
|
||||
const diagnostic = await error(`return await Promise.any([tools.host.sleepy({ id: 1 })])`)
|
||||
expect(diagnostic.message).toContain("Promise.any is not available")
|
||||
expect(diagnostic.message).toContain("Promise.allSettled")
|
||||
})
|
||||
|
||||
test("new Promise(...) points at tool calls instead", async () => {
|
||||
const diagnostic = await error(`return new Promise((resolve) => resolve(1))`)
|
||||
expect(diagnostic.kind).toBe("UnsupportedSyntax")
|
||||
expect(diagnostic.message).toContain("new Promise(...) is not supported")
|
||||
expect(diagnostic.message).toContain("already return promises")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 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)
|
||||
if (!result.ok) throw new Error("search failed")
|
||||
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 () => {
|
||||
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)
|
||||
if (!result.ok) throw new Error("search failed")
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
||||
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 durable = Event.durable
|
||||
export const ephemeral = Event.ephemeral
|
||||
@@ -162,6 +167,27 @@ export interface Interface {
|
||||
|
||||
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 {
|
||||
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
||||
/** Maximum durable rows read per page while replaying or tailing an aggregate log. */
|
||||
|
||||
@@ -128,8 +128,6 @@ export const fffLayer = Layer.effect(
|
||||
Fff.create({
|
||||
basePath: location.directory,
|
||||
aiMode: true,
|
||||
disableMmapCache: true,
|
||||
disableContentIndexing: true,
|
||||
}),
|
||||
catch: (cause) => cause,
|
||||
}).pipe(
|
||||
@@ -232,13 +230,6 @@ export const fffLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const layer = Layer.unwrap(
|
||||
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
|
||||
}),
|
||||
)
|
||||
const layer = Layer.unwrap(Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)))
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] })
|
||||
|
||||
@@ -189,14 +189,16 @@ const layer = Layer.effect(
|
||||
if (!dotgit) return undefined
|
||||
|
||||
const cwd = path.dirname(dotgit)
|
||||
const result = yield* run(cwd, proc)(["rev-parse", "--git-dir", "--git-common-dir", "--show-toplevel"])
|
||||
const [gitDir, commonDir, topLevel] = result.text.split(/\r?\n/)
|
||||
if (!gitDir || !commonDir) return undefined
|
||||
const git = run(cwd, proc)
|
||||
const topLevel = yield* git(["rev-parse", "--show-toplevel"])
|
||||
const gitDir = yield* git(["rev-parse", "--git-dir"])
|
||||
const commonDir = yield* git(["rev-parse", "--git-common-dir"])
|
||||
if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) return undefined
|
||||
|
||||
return new Repository({
|
||||
worktree: AbsolutePath.make(topLevel ? resolvePath(cwd, topLevel) : cwd),
|
||||
gitDirectory: AbsolutePath.make(resolvePath(cwd, gitDir)),
|
||||
commonDirectory: AbsolutePath.make(resolvePath(cwd, commonDir)),
|
||||
worktree: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd),
|
||||
gitDirectory: AbsolutePath.make(resolvePath(cwd, gitDir.text)),
|
||||
commonDirectory: AbsolutePath.make(resolvePath(cwd, commonDir.text)),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
export * as CopilotModels from "./models"
|
||||
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Option, Schema } from "effect"
|
||||
import { ModelV2 } from "../model"
|
||||
import { ProviderV2 } from "../provider"
|
||||
|
||||
const RemoteModel = Schema.Struct({
|
||||
model_picker_enabled: Schema.Boolean,
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
version: Schema.String,
|
||||
supported_endpoints: Schema.optional(Schema.Array(Schema.String)),
|
||||
policy: Schema.optional(Schema.Struct({ state: Schema.optional(Schema.String) })),
|
||||
billing: Schema.optional(
|
||||
Schema.Struct({
|
||||
token_prices: Schema.optional(
|
||||
Schema.Struct({
|
||||
batch_size: Schema.Number,
|
||||
default: Schema.Struct({
|
||||
cache_price: Schema.Number,
|
||||
input_price: Schema.Number,
|
||||
output_price: Schema.Number,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
capabilities: Schema.Struct({
|
||||
family: Schema.String,
|
||||
limits: Schema.optional(
|
||||
Schema.Struct({
|
||||
max_context_window_tokens: Schema.optional(Schema.Number),
|
||||
max_output_tokens: Schema.optional(Schema.Number),
|
||||
max_prompt_tokens: Schema.optional(Schema.Number),
|
||||
vision: Schema.optional(
|
||||
Schema.Struct({
|
||||
max_prompt_image_size: Schema.Number,
|
||||
max_prompt_images: Schema.Number,
|
||||
supported_media_types: Schema.Array(Schema.String),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
supports: Schema.Struct({
|
||||
adaptive_thinking: Schema.optional(Schema.Boolean),
|
||||
max_thinking_budget: Schema.optional(Schema.Number),
|
||||
min_thinking_budget: Schema.optional(Schema.Number),
|
||||
reasoning_effort: Schema.optional(Schema.Array(Schema.String)),
|
||||
streaming: Schema.optional(Schema.Boolean),
|
||||
structured_outputs: Schema.optional(Schema.Boolean),
|
||||
tool_calls: Schema.optional(Schema.Boolean),
|
||||
vision: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const Response = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
|
||||
const decodeResponse = Schema.decodeUnknownSync(Response)
|
||||
const decodeModel = Schema.decodeUnknownOption(RemoteModel)
|
||||
|
||||
type RemoteModel = typeof RemoteModel.Type
|
||||
type UsableModel = RemoteModel & {
|
||||
capabilities: RemoteModel["capabilities"] & {
|
||||
limits: NonNullable<RemoteModel["capabilities"]["limits"]> & {
|
||||
max_output_tokens: number
|
||||
max_prompt_tokens: number
|
||||
}
|
||||
supports: RemoteModel["capabilities"]["supports"] & { tool_calls: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
export async function get(baseURL: string, headers: RequestInit["headers"], existing: readonly ModelV2.Info[]) {
|
||||
const response = await fetch(`${baseURL}/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Failed to fetch Copilot models: ${response.status}`)
|
||||
|
||||
const remote = new Map(
|
||||
decodeResponse(await response.json()).data.flatMap((raw) => {
|
||||
const model = Option.getOrUndefined(decodeModel(raw))
|
||||
return model && usable(model) ? ([[model.id, model]] as const) : []
|
||||
}),
|
||||
)
|
||||
const result = new Map(existing.map((model) => [model.id, model]))
|
||||
|
||||
// Keep aliases and local metadata, but only when their advertised API model
|
||||
// still exists. A partial or malformed item cannot create a broken model.
|
||||
for (const [id, model] of result) {
|
||||
const current = remote.get(model.modelID)
|
||||
if (!current) {
|
||||
result.delete(id)
|
||||
continue
|
||||
}
|
||||
result.set(id, build(id, current, baseURL, model))
|
||||
}
|
||||
|
||||
for (const [id, model] of remote) {
|
||||
const key = ModelV2.ID.make(id)
|
||||
if (result.has(key)) continue
|
||||
result.set(key, build(key, model, baseURL))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function usable(model: RemoteModel): model is UsableModel {
|
||||
return (
|
||||
model.policy?.state !== "disabled" &&
|
||||
model.capabilities.limits?.max_output_tokens !== undefined &&
|
||||
model.capabilities.limits.max_prompt_tokens !== undefined &&
|
||||
model.capabilities.supports.tool_calls !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
function build(id: ModelV2.ID, remote: UsableModel, baseURL: string, previous?: ModelV2.Info) {
|
||||
const messages = remote.supported_endpoints?.includes("/v1/messages") ?? false
|
||||
const endpoint = messages
|
||||
? "messages"
|
||||
: remote.supported_endpoints?.includes("/responses")
|
||||
? "responses"
|
||||
: remote.supported_endpoints?.includes("/chat/completions")
|
||||
? "chat"
|
||||
: undefined
|
||||
const image =
|
||||
(remote.capabilities.supports.vision ?? false) ||
|
||||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
|
||||
const prices = remote.billing?.token_prices
|
||||
// Copilot reports AIC per billing batch; OpenCode stores USD per million tokens.
|
||||
const usdPerMillion = prices && prices.batch_size > 0 ? 10_000 / prices.batch_size : 0
|
||||
const version = remote.version.startsWith(`${remote.id}-`)
|
||||
? remote.version.slice(remote.id.length + 1)
|
||||
: remote.version
|
||||
const released = previous?.time.released || Date.parse(version)
|
||||
|
||||
return ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, id),
|
||||
id,
|
||||
modelID: ModelV2.ID.make(remote.id),
|
||||
providerID: ProviderV2.ID.githubCopilot,
|
||||
family: previous?.family ?? ModelV2.Family.make(remote.capabilities.family),
|
||||
name: previous?.name ?? remote.name,
|
||||
package: ProviderV2.aisdk(messages ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot"),
|
||||
settings: ProviderV2.mergeOverlay(previous?.settings, {
|
||||
baseURL: messages ? `${baseURL}/v1` : baseURL,
|
||||
...(endpoint ? { endpoint } : {}),
|
||||
}),
|
||||
headers: previous?.headers,
|
||||
body: previous?.body,
|
||||
capabilities: {
|
||||
tools: remote.capabilities.supports.tool_calls,
|
||||
input: image ? ["text", "image"] : ["text"],
|
||||
output: ["text"],
|
||||
},
|
||||
variants: variants(remote, messages),
|
||||
time: { released: Number.isFinite(released) ? released : 0 },
|
||||
cost: [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make((prices?.default.input_price ?? 0) * usdPerMillion),
|
||||
output: Money.USDPerMillionTokens.make((prices?.default.output_price ?? 0) * usdPerMillion),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.make((prices?.default.cache_price ?? 0) * usdPerMillion),
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
],
|
||||
status: "active",
|
||||
enabled: remote.model_picker_enabled,
|
||||
limit: {
|
||||
context: remote.capabilities.limits.max_context_window_tokens ?? remote.capabilities.limits.max_prompt_tokens,
|
||||
input: remote.capabilities.limits.max_prompt_tokens,
|
||||
output: remote.capabilities.limits.max_output_tokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function variants(remote: UsableModel, messages: boolean): ModelV2.Info["variants"] {
|
||||
const efforts = remote.capabilities.supports.reasoning_effort ?? []
|
||||
if (!messages && efforts.length) {
|
||||
return efforts.map((effort) => ({
|
||||
id: ModelV2.VariantID.make(effort),
|
||||
settings: {
|
||||
reasoningEffort: effort,
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
}))
|
||||
}
|
||||
if (efforts.length && remote.capabilities.supports.adaptive_thinking) {
|
||||
return efforts.map((effort) => ({
|
||||
id: ModelV2.VariantID.make(effort),
|
||||
settings: {
|
||||
thinking: {
|
||||
type: "adaptive",
|
||||
...(remote.id.includes("opus-4.7") ? { display: "summarized" } : {}),
|
||||
},
|
||||
effort,
|
||||
},
|
||||
}))
|
||||
}
|
||||
const max = remote.capabilities.supports.max_thinking_budget
|
||||
if (max === undefined) return []
|
||||
return [
|
||||
{
|
||||
id: ModelV2.VariantID.make("max"),
|
||||
settings: { thinking: { type: "enabled", budgetTokens: max - 1 } },
|
||||
},
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: { thinking: { type: "enabled", budgetTokens: Math.floor(max / 2) } },
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -85,7 +85,6 @@ export interface OAuthImplementation {
|
||||
export interface KeyImplementation {
|
||||
readonly integrationID: ID
|
||||
readonly method: KeyMethod
|
||||
readonly authorize?: (key: string, inputs: Inputs) => Effect.Effect<Credential.Key, unknown>
|
||||
}
|
||||
|
||||
export interface EnvImplementation {
|
||||
@@ -120,7 +119,6 @@ type Entry = {
|
||||
ref: Types.DeepMutable<Ref>
|
||||
methods: Types.DeepMutable<Method>[]
|
||||
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
|
||||
key?: Types.DeepMutable<KeyImplementation>
|
||||
}
|
||||
|
||||
type Data = {
|
||||
@@ -158,8 +156,6 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
readonly integrationID: ID
|
||||
/** Secret entered by the user. */
|
||||
readonly key: string
|
||||
/** Answers to the method's optional prompts. */
|
||||
readonly inputs?: Inputs
|
||||
/** User-facing label for the stored credential. */
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<void, AuthorizationError>
|
||||
@@ -241,7 +237,6 @@ const layer = Layer.effect(
|
||||
ref: { id, name: id },
|
||||
methods: [],
|
||||
implementations: new Map(),
|
||||
key: undefined,
|
||||
}
|
||||
if (!draft.integrations.has(id)) draft.integrations.set(id, current)
|
||||
update(current.ref)
|
||||
@@ -258,7 +253,6 @@ const layer = Layer.effect(
|
||||
},
|
||||
methods: [],
|
||||
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
|
||||
key: undefined,
|
||||
}
|
||||
if (!draft.integrations.has(implementation.integrationID)) {
|
||||
draft.integrations.set(implementation.integrationID, current)
|
||||
@@ -276,9 +270,6 @@ const layer = Layer.effect(
|
||||
implementation as Types.DeepMutable<OAuthImplementation>,
|
||||
)
|
||||
}
|
||||
if (implementation.method.type === "key") {
|
||||
current.key = implementation as Types.DeepMutable<KeyImplementation>
|
||||
}
|
||||
},
|
||||
remove: (integrationID, method) => {
|
||||
const current = draft.integrations.get(integrationID)
|
||||
@@ -290,7 +281,6 @@ const layer = Layer.effect(
|
||||
})
|
||||
if (index !== -1) current.methods.splice(index, 1)
|
||||
if (method.type === "oauth") current.implementations.delete(method.id)
|
||||
if (method.type === "key") current.key = undefined
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -313,7 +303,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
|
||||
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
|
||||
Info.make({
|
||||
new Info({
|
||||
id: entry.ref.id,
|
||||
name: entry.ref.name,
|
||||
methods: entry.methods,
|
||||
@@ -375,10 +365,10 @@ const layer = Layer.effect(
|
||||
? { status: "complete", time: attempt.time, removeAt: settledAt + terminalRetention }
|
||||
: {
|
||||
status: "failed",
|
||||
message: message(persistence.cause),
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
message: message(persistence.cause),
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
// Persisting attempts cannot be cancelled, expired, or claimed again.
|
||||
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
|
||||
@@ -448,16 +438,15 @@ const layer = Layer.effect(
|
||||
return value
|
||||
}),
|
||||
key: Effect.fn("Integration.connection.key")(function* (input) {
|
||||
const entry = state.get().integrations.get(input.integrationID)
|
||||
const method = entry?.methods.some((method) => method.type === "key")
|
||||
if (!entry || !method) return yield* Effect.die(new Error(`Key method not found: ${input.integrationID}`))
|
||||
const value = entry.key?.authorize
|
||||
? yield* authorize(entry.key.authorize(input.key, input.inputs ?? {}))
|
||||
: Credential.Key.make({ type: "key", key: input.key })
|
||||
const method = state
|
||||
.get()
|
||||
.integrations.get(input.integrationID)
|
||||
?.methods.some((method) => method.type === "key")
|
||||
if (!method) return yield* Effect.die(new Error(`Key method not found: ${input.integrationID}`))
|
||||
yield* credentials.create({
|
||||
integrationID: input.integrationID,
|
||||
label: input.label,
|
||||
value,
|
||||
value: Credential.Key.make({ type: "key", key: input.key }),
|
||||
})
|
||||
yield* events.publish(Event.ConnectionUpdated, { integrationID: input.integrationID })
|
||||
yield* events.publish(Event.Updated, {})
|
||||
|
||||
@@ -21,14 +21,10 @@ import { ToolHooks } from "./tool/hooks"
|
||||
import { PluginHooks } from "./plugin/hooks"
|
||||
|
||||
export interface Interface {
|
||||
readonly activate: (plugins: readonly Versioned[]) => Effect.Effect<void>
|
||||
readonly activate: (plugins: readonly Plugin[]) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
export interface Versioned extends Plugin {
|
||||
readonly version: string
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
@@ -36,11 +32,11 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.make()
|
||||
const active = new Map<typeof ID.Type, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
|
||||
const active = new Map<typeof ID.Type, { readonly plugin: Plugin; readonly scope: Scope.Closeable }>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let host: Parameters<Plugin["effect"]>[0]
|
||||
|
||||
const load = Effect.fnUntraced(function* (plugin: Versioned) {
|
||||
const load = Effect.fnUntraced(function* (plugin: Plugin) {
|
||||
const child = yield* Scope.fork(scope)
|
||||
const inherit = yield* State.inherit()
|
||||
const loaded = yield* Effect.suspend(() => plugin.effect(host)).pipe(
|
||||
@@ -59,7 +55,7 @@ const layer = Layer.effect(
|
||||
return undefined
|
||||
})
|
||||
|
||||
const activate = Effect.fn("Plugin.activate")(function* (plugins: readonly Versioned[]) {
|
||||
const activate = Effect.fn("Plugin.activate")(function* (plugins: readonly Plugin[]) {
|
||||
const definitions = plugins.map((plugin) => ({ ...plugin, id: ID.make(plugin.id) }))
|
||||
const ids = new Set<typeof ID.Type>()
|
||||
for (const definition of definitions) {
|
||||
@@ -69,20 +65,6 @@ const layer = Layer.effect(
|
||||
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const next = definitions.map((definition) => ({ id: definition.id, version: definition.version }))
|
||||
const current = Array.from(active.values(), (entry) => ({
|
||||
id: entry.plugin.id,
|
||||
version: entry.plugin.version,
|
||||
}))
|
||||
if (
|
||||
current.length === next.length &&
|
||||
current.every((definition, index) => {
|
||||
const candidate = next[index]
|
||||
return definition.id === candidate?.id && definition.version === candidate.version
|
||||
})
|
||||
)
|
||||
return
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
for (const definition of definitions) {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
export * as PluginHost from "./host"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import type {
|
||||
IntegrationInputs,
|
||||
IntegrationKeyMethodRegistration,
|
||||
IntegrationOAuthMethodRegistration,
|
||||
} from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
@@ -176,7 +171,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
integration.connection.key({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
key: input.key,
|
||||
inputs: input.inputs,
|
||||
label: input.label,
|
||||
}),
|
||||
oauth: (input) =>
|
||||
@@ -213,15 +207,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
method: {
|
||||
list: (id) => mutable(draft.method.list(Integration.ID.make(id))),
|
||||
update: (input) => {
|
||||
if (input.method.type === "oauth") {
|
||||
const oauth = input as IntegrationOAuthMethodRegistration
|
||||
const methodID = Integration.MethodID.make(oauth.method.id)
|
||||
const refresh = oauth.refresh
|
||||
if ("authorize" in input) {
|
||||
const methodID = Integration.MethodID.make(input.method.id)
|
||||
const refresh = input.refresh
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(oauth.integrationID),
|
||||
method: { ...oauth.method, id: methodID },
|
||||
authorize: (inputs: IntegrationInputs) =>
|
||||
oauth.authorize(inputs).pipe(
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { ...input.method, id: methodID },
|
||||
authorize: (inputs) =>
|
||||
input.authorize(inputs).pipe(
|
||||
Effect.map((authorization) => {
|
||||
if (authorization.mode === "auto") {
|
||||
return {
|
||||
@@ -263,7 +256,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(oauth.label ? { label: oauth.label } : {}),
|
||||
...(input.label ? { label: input.label } : {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -274,18 +267,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
})
|
||||
return
|
||||
}
|
||||
const registration = input as IntegrationKeyMethodRegistration
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(registration.integrationID),
|
||||
method: { type: "key", label: registration.method.label, prompts: registration.method.prompts },
|
||||
...(registration.authorize
|
||||
? {
|
||||
authorize: (key, inputs) =>
|
||||
registration.authorize!(key, inputs).pipe(
|
||||
Effect.map((credential) => Schema.decodeUnknownSync(Credential.Key)(credential)),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "key", label: input.method.label },
|
||||
})
|
||||
},
|
||||
remove: (id, method) =>
|
||||
|
||||
@@ -38,6 +38,7 @@ import { QuestionTool } from "../tool/question"
|
||||
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
||||
import { ReadTool } from "../tool/read"
|
||||
import { ShellTool } from "../tool/shell"
|
||||
import { SearchFixtureTool } from "../tool/search-fixture"
|
||||
import { SkillTool } from "../tool/skill"
|
||||
import { SubagentTool } from "../tool/subagent"
|
||||
import { Tools } from "../tool/tools"
|
||||
@@ -129,6 +130,7 @@ const pre = [
|
||||
GrepTool.Plugin,
|
||||
QuestionTool.Plugin,
|
||||
ReadTool.Plugin,
|
||||
SearchFixtureTool.Plugin,
|
||||
ShellTool.Plugin,
|
||||
SkillTool.Plugin,
|
||||
SubagentTool.Plugin,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway"
|
||||
import { CloudflareWorkersAIPlugin } from "./provider/cloudflare-workers-ai"
|
||||
import { CoherePlugin } from "./provider/cohere"
|
||||
import { DeepInfraPlugin } from "./provider/deepinfra"
|
||||
import { DigitalOceanPlugin } from "./provider/digitalocean"
|
||||
import { DynamicProviderPlugin } from "./provider/dynamic"
|
||||
import { GatewayPlugin } from "./provider/gateway"
|
||||
import { GithubCopilotPlugin } from "./provider/github-copilot"
|
||||
@@ -25,7 +24,6 @@ import { OpenAICompatiblePlugin } from "./provider/openai-compatible"
|
||||
import { OpencodePlugin } from "./provider/opencode"
|
||||
import { OpenRouterPlugin } from "./provider/openrouter"
|
||||
import { PerplexityPlugin } from "./provider/perplexity"
|
||||
import { PoePlugin } from "./provider/poe"
|
||||
import { SapAICorePlugin } from "./provider/sap-ai-core"
|
||||
import { TogetherAIPlugin } from "./provider/togetherai"
|
||||
import { VercelPlugin } from "./provider/vercel"
|
||||
@@ -45,7 +43,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
CloudflareWorkersAIPlugin,
|
||||
CoherePlugin,
|
||||
DeepInfraPlugin,
|
||||
DigitalOceanPlugin,
|
||||
GatewayPlugin,
|
||||
GithubCopilotPlugin,
|
||||
GitLabPlugin,
|
||||
@@ -63,7 +60,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
OpenAIPlugin,
|
||||
OpenRouterPlugin,
|
||||
PerplexityPlugin,
|
||||
PoePlugin,
|
||||
SapAICorePlugin,
|
||||
TogetherAIPlugin,
|
||||
VercelPlugin,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
|
||||
@@ -15,34 +13,6 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
|
||||
export const AzurePlugin = define({
|
||||
id: "opencode.provider.azure",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const integration = yield* Integration.Service
|
||||
yield* integration.transform((draft) => {
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make("azure"),
|
||||
method: {
|
||||
type: "key",
|
||||
label: "API key",
|
||||
prompts: process.env.AZURE_RESOURCE_NAME
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "text",
|
||||
key: "resourceName",
|
||||
message: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
},
|
||||
],
|
||||
},
|
||||
authorize: (key, inputs) =>
|
||||
Effect.succeed(
|
||||
Credential.Key.make({
|
||||
type: "key",
|
||||
key,
|
||||
...(inputs.resourceName ? { metadata: { resourceName: inputs.resourceName } } : {}),
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
||||
|
||||
@@ -2,54 +2,10 @@ import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
|
||||
const integrationID = Integration.ID.make("cloudflare-ai-gateway")
|
||||
|
||||
export const CloudflareAIGatewayPlugin = define({
|
||||
id: "opencode.provider.cloudflare-ai-gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const integration = yield* Integration.Service
|
||||
yield* integration.transform((draft) => {
|
||||
draft.method.update({
|
||||
integrationID,
|
||||
method: {
|
||||
type: "key",
|
||||
label: "Gateway API token",
|
||||
prompts: [
|
||||
...(process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "text" as const,
|
||||
key: "accountId",
|
||||
message: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
]),
|
||||
...(process.env.CLOUDFLARE_GATEWAY_ID
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "text" as const,
|
||||
key: "gatewayId",
|
||||
message: "Enter your Cloudflare AI Gateway ID",
|
||||
placeholder: "e.g. my-gateway",
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
authorize: (key, inputs) =>
|
||||
Effect.succeed(
|
||||
Credential.Key.make({
|
||||
type: "key",
|
||||
key,
|
||||
...(Object.keys(inputs).length ? { metadata: inputs } : {}),
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
|
||||
@@ -2,44 +2,13 @@ import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
|
||||
const integrationID = Integration.ID.make("cloudflare-workers-ai")
|
||||
|
||||
export const CloudflareWorkersAIPlugin = define({
|
||||
id: "opencode.provider.cloudflare-workers-ai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const integration = yield* Integration.Service
|
||||
yield* integration.transform((draft) => {
|
||||
draft.method.update({
|
||||
integrationID,
|
||||
method: {
|
||||
type: "key",
|
||||
label: "API key",
|
||||
prompts: process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "text",
|
||||
key: "accountId",
|
||||
message: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
],
|
||||
},
|
||||
authorize: (key, inputs) =>
|
||||
Effect.succeed(
|
||||
Credential.Key.make({
|
||||
type: "key",
|
||||
key,
|
||||
...(inputs.accountId ? { metadata: { accountId: inputs.accountId } } : {}),
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
const item = evt.provider.get(providerID)
|
||||
if (!item) return
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { createServer } from "node:http"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { Credential } from "../../credential"
|
||||
import { EventV2 } from "../../event"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { OauthCallbackPage } from "../../oauth/page"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import type { PluginInternal } from "../internal"
|
||||
|
||||
const clientID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82"
|
||||
const authorizeURL = "https://cloud.digitalocean.com/v1/oauth/authorize"
|
||||
const genaiURL = "https://api.digitalocean.com/v2/gen-ai"
|
||||
const inferenceURL = "https://inference.do-ai.run/v1"
|
||||
const callbackPort = 1456
|
||||
const callbackPath = "/auth/callback"
|
||||
const tokenPath = "/auth/token"
|
||||
const scopes = "genai:read inference:query"
|
||||
const refreshInterval = 5 * 60 * 1000
|
||||
const integrationID = Integration.ID.make("digitalocean")
|
||||
const methodID = Integration.MethodID.make("implicit")
|
||||
|
||||
const Token = Schema.Struct({
|
||||
access_token: Schema.String,
|
||||
expires_in: Schema.NumberFromString,
|
||||
state: Schema.String,
|
||||
})
|
||||
const Router = Schema.Struct({
|
||||
name: Schema.String,
|
||||
uuid: Schema.optional(Schema.String),
|
||||
description: Schema.optional(Schema.String),
|
||||
})
|
||||
type Router = typeof Router.Type
|
||||
const RouterResponse = Schema.Struct({ model_routers: Schema.optional(Schema.Array(Router)) })
|
||||
|
||||
const oauth = {
|
||||
integrationID,
|
||||
method: {
|
||||
id: methodID,
|
||||
type: "oauth",
|
||||
label: "Login with DigitalOcean",
|
||||
},
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex")
|
||||
const token = yield* Deferred.make<typeof Token.Type, Error>()
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
|
||||
if (request.method === "GET" && url.pathname === callbackPath) {
|
||||
response
|
||||
.writeHead(200, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.bootstrap({ tokenPath, provider: "DigitalOcean" }))
|
||||
return
|
||||
}
|
||||
if (request.method !== "POST" || url.pathname !== tokenPath) {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
request.on("data", (chunk: Buffer) => chunks.push(chunk))
|
||||
request.on("end", () => {
|
||||
const decoded = Schema.decodeUnknownOption(Schema.fromJsonString(Token))(Buffer.concat(chunks).toString())
|
||||
if (Option.isNone(decoded) || decoded.value.state !== state) {
|
||||
const error = Option.isNone(decoded) ? "Invalid OAuth callback" : "Invalid OAuth state"
|
||||
Effect.runFork(Deferred.fail(token, new Error(error)))
|
||||
response.writeHead(400, { "Content-Type": "application/json" }).end(JSON.stringify({ error }))
|
||||
return
|
||||
}
|
||||
Effect.runFork(Deferred.succeed(token, decoded.value))
|
||||
response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ ok: true }))
|
||||
})
|
||||
})
|
||||
yield* Effect.callback<void, Error>((resume) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(callbackPort, "localhost", () => resume(Effect.void))
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||
const redirect = `http://localhost:${callbackPort}${callbackPath}`
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: `${authorizeURL}?${new URLSearchParams({
|
||||
response_type: "token",
|
||||
client_id: clientID,
|
||||
redirect_uri: redirect,
|
||||
scope: scopes,
|
||||
state,
|
||||
})}`,
|
||||
instructions:
|
||||
"Sign in to DigitalOcean in your browser. OpenCode will use the resulting token for inference and load your Inference Routers.",
|
||||
callback: Deferred.await(token).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
listRouters(value.access_token).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to sync DigitalOcean inference routers", { cause }).pipe(Effect.as([])),
|
||||
),
|
||||
Effect.map((routers) =>
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
refresh: value.access_token,
|
||||
access: value.access_token,
|
||||
expires: Date.now() + value.expires_in * 1000,
|
||||
metadata: { routers, routersFetchedAt: Date.now() },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
export const DigitalOceanPlugin = define({
|
||||
id: "opencode.provider.digitalocean",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const events = yield* EventV2.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
const loaded: { routers: readonly Router[] } = { routers: [] }
|
||||
|
||||
const load = Effect.fn("DigitalOceanPlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active(integrationID)
|
||||
const saved = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
if (saved?.type !== "oauth") {
|
||||
loaded.routers = []
|
||||
return
|
||||
}
|
||||
const cached = Schema.decodeUnknownOption(Schema.Array(Router))(saved.metadata?.routers)
|
||||
loaded.routers = cached._tag === "Some" ? cached.value : []
|
||||
const fetchedAt = saved.metadata?.routersFetchedAt
|
||||
if (typeof fetchedAt === "number" && Date.now() - fetchedAt <= refreshInterval) return
|
||||
if (saved.expires <= Date.now()) return
|
||||
const routers = yield* listRouters(saved.access).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to refresh DigitalOcean inference routers", { cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
if (!routers) return
|
||||
loaded.routers = routers
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.update(integrationID, (integration) => {
|
||||
integration.name = "DigitalOcean"
|
||||
})
|
||||
draft.method.update(oauth)
|
||||
draft.method.update({
|
||||
integrationID,
|
||||
method: { type: "key", label: "Paste Model Access Key" },
|
||||
})
|
||||
})
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
if (!catalog.provider.get(ProviderV2.ID.make(integrationID))) return
|
||||
for (const router of loaded.routers) {
|
||||
const id = ModelV2.ID.make(`router:${router.name}`)
|
||||
catalog.model.update(ProviderV2.ID.make(integrationID), id, (model) => {
|
||||
model.modelID = id
|
||||
model.name = router.name
|
||||
model.family = ModelV2.Family.make("digitalocean-inference-routers")
|
||||
model.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
model.settings = ProviderV2.mergeOverlay(model.settings, { baseURL: inferenceURL })
|
||||
model.capabilities = { tools: true, input: ["text"], output: ["text"] }
|
||||
model.limit = { context: 128_000, output: 8_192 }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === integrationID),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh()
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
|
||||
function listRouters(access: string) {
|
||||
return Effect.tryPromise({
|
||||
try: async (signal) => {
|
||||
const response = await fetch(`${genaiURL}/models/routers`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${access}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
if (!response.ok) throw new Error(`DigitalOcean router request failed: ${response.status}`)
|
||||
const body = Schema.decodeUnknownSync(RouterResponse)(await response.json())
|
||||
return body.model_routers ?? []
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
@@ -1,115 +1,7 @@
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Credential } from "../../credential"
|
||||
import { EventV2 } from "../../event"
|
||||
import { CopilotModels } from "../../github-copilot/models"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Integration } from "../../integration"
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import type { PluginInternal } from "../internal"
|
||||
|
||||
const clientID = "Ov23li8tweQw6odWQebz"
|
||||
const apiVersion = "2026-06-01"
|
||||
const pollingSafetyMargin = 3000
|
||||
const methodID = Integration.MethodID.make("device")
|
||||
|
||||
const Device = Schema.Struct({
|
||||
verification_uri: Schema.String,
|
||||
user_code: Schema.String,
|
||||
device_code: Schema.String,
|
||||
interval: Schema.Number,
|
||||
})
|
||||
const Token = Schema.Struct({
|
||||
access_token: Schema.optional(Schema.String),
|
||||
error: Schema.optional(Schema.String),
|
||||
interval: Schema.optional(Schema.Number),
|
||||
})
|
||||
const JsonBody = Schema.UnknownFromJsonString
|
||||
const decodeBody = Schema.decodeUnknownOption(JsonBody)
|
||||
|
||||
const oauth = {
|
||||
integrationID: Integration.ID.make("github-copilot"),
|
||||
method: {
|
||||
id: methodID,
|
||||
type: "oauth",
|
||||
label: "Login with GitHub Copilot",
|
||||
prompts: [
|
||||
{
|
||||
type: "select",
|
||||
key: "deploymentType",
|
||||
message: "Select GitHub deployment type",
|
||||
options: [
|
||||
{ label: "GitHub.com", value: "github.com", hint: "Public" },
|
||||
{ label: "GitHub Enterprise", value: "enterprise", hint: "Data residency or self-hosted" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
key: "enterpriseUrl",
|
||||
message: "Enter your GitHub Enterprise URL or domain",
|
||||
placeholder: "company.ghe.com or https://company.ghe.com",
|
||||
when: { key: "deploymentType", op: "eq", value: "enterprise" },
|
||||
},
|
||||
],
|
||||
},
|
||||
authorize: (inputs) =>
|
||||
Effect.gen(function* () {
|
||||
const enterprise = inputs.deploymentType === "enterprise"
|
||||
if (enterprise && !inputs.enterpriseUrl) return yield* Effect.fail(new Error("Enterprise URL is required"))
|
||||
const domain = enterprise ? normalizeDomain(inputs.enterpriseUrl ?? "") : "github.com"
|
||||
const urls = oauthURLs(domain)
|
||||
const device = yield* request(urls.device, {
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ client_id: clientID, scope: "read:user" }),
|
||||
}).pipe(Effect.map(Schema.decodeUnknownSync(Device)))
|
||||
const interval = Math.max(device.interval, 1) * 1000
|
||||
|
||||
const poll = (wait: number): Effect.Effect<Credential.OAuth, unknown> =>
|
||||
request(urls.token, {
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
body: JSON.stringify({
|
||||
client_id: clientID,
|
||||
device_code: device.device_code,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}),
|
||||
}).pipe(
|
||||
Effect.map(Schema.decodeUnknownSync(Token)),
|
||||
Effect.flatMap((token) => {
|
||||
if (token.access_token) {
|
||||
return Effect.succeed(
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
refresh: token.access_token,
|
||||
access: token.access_token,
|
||||
expires: 0,
|
||||
...(enterprise ? { metadata: { enterpriseUrl: domain } } : {}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (token.error === "authorization_pending")
|
||||
return Effect.sleep(wait + pollingSafetyMargin).pipe(Effect.andThen(poll(wait)))
|
||||
if (token.error === "slow_down") {
|
||||
const next = token.interval && token.interval > 0 ? token.interval * 1000 : wait + 5000
|
||||
return Effect.sleep(next + pollingSafetyMargin).pipe(Effect.andThen(poll(next)))
|
||||
}
|
||||
return Effect.fail(new Error(`Device authorization failed${token.error ? `: ${token.error}` : ""}`))
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: device.verification_uri,
|
||||
instructions: `Enter code: ${device.user_code}`,
|
||||
callback: poll(interval),
|
||||
}
|
||||
}),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
function shouldUseResponses(modelID: string) {
|
||||
// Copilot supports Responses for GPT-5 class models, except mini variants
|
||||
@@ -122,103 +14,19 @@ function shouldUseResponses(modelID: string) {
|
||||
export const GithubCopilotPlugin = define({
|
||||
id: "opencode.provider.github-copilot",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const catalog = yield* Catalog.Service
|
||||
const events = yield* EventV2.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
const loaded: {
|
||||
baseURL?: string
|
||||
models?: Map<ModelV2.ID, ModelV2.Info>
|
||||
} = {}
|
||||
|
||||
const load = Effect.fn("GithubCopilotPlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("github-copilot")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
if (credential?.type !== "oauth") {
|
||||
loaded.baseURL = undefined
|
||||
loaded.models = undefined
|
||||
return
|
||||
}
|
||||
|
||||
const enterprise = credential.metadata?.enterpriseUrl
|
||||
loaded.baseURL = baseURL(typeof enterprise === "string" ? enterprise : undefined)
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.githubCopilot)
|
||||
const existing = (yield* catalog.model.all()).filter((model) => model.providerID === ProviderV2.ID.githubCopilot)
|
||||
loaded.models = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
CopilotModels.get(
|
||||
loaded.baseURL ?? baseURL(),
|
||||
{
|
||||
...provider?.headers,
|
||||
Authorization: `Bearer ${credential.refresh}`,
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
"X-GitHub-Api-Version": apiVersion,
|
||||
},
|
||||
existing,
|
||||
),
|
||||
catch: (cause) => cause,
|
||||
}).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to sync GitHub Copilot models", { cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.method.update(oauth)
|
||||
})
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
|
||||
if (!item) return
|
||||
if (loaded.models) {
|
||||
for (const id of item.models.keys()) {
|
||||
if (!loaded.models.has(ModelV2.ID.make(id))) evt.model.remove(item.provider.id, id)
|
||||
}
|
||||
for (const [id, model] of loaded.models) {
|
||||
evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model)))
|
||||
}
|
||||
} else if (loaded.baseURL) {
|
||||
for (const id of item.models.keys()) {
|
||||
evt.model.update(item.provider.id, id, (model) => {
|
||||
model.settings = ProviderV2.mergeOverlay(model.settings, { baseURL: loaded.baseURL })
|
||||
})
|
||||
}
|
||||
}
|
||||
if (item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) {
|
||||
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
||||
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
|
||||
// so hide it only for Copilot rather than for every provider catalog.
|
||||
model.enabled = false
|
||||
})
|
||||
}
|
||||
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
|
||||
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
||||
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
|
||||
// so hide it only for Copilot rather than for every provider catalog.
|
||||
model.enabled = false
|
||||
})
|
||||
})
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("github-copilot")),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh().pipe(Effect.forkScoped)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
|
||||
if (evt.package !== "@ai-sdk/github-copilot" && evt.package !== "@ai-sdk/anthropic") return
|
||||
evt.options.fetch = copilotFetch(
|
||||
typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined,
|
||||
evt.options.fetch,
|
||||
evt.package === "@ai-sdk/anthropic",
|
||||
)
|
||||
if (evt.package === "@ai-sdk/anthropic") {
|
||||
evt.options.headers = {
|
||||
...evt.options.headers,
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14",
|
||||
}
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic"))
|
||||
evt.sdk = mod.createAnthropic(evt.options)
|
||||
return
|
||||
}
|
||||
if (evt.package !== "@ai-sdk/github-copilot") return
|
||||
const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
|
||||
evt.sdk = mod.createOpenaiCompatible(evt.options)
|
||||
}),
|
||||
@@ -244,114 +52,4 @@ export const GithubCopilotPlugin = define({
|
||||
}),
|
||||
)
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
|
||||
function normalizeDomain(input: string) {
|
||||
return input.replace(/^https?:\/\//, "").replace(/\/$/, "")
|
||||
}
|
||||
|
||||
function oauthURLs(domain: string) {
|
||||
return {
|
||||
device: `https://${domain}/login/device/code`,
|
||||
token: `https://${domain}/login/oauth/access_token`,
|
||||
}
|
||||
}
|
||||
|
||||
function baseURL(enterprise?: string) {
|
||||
return enterprise ? `https://copilot-api.${normalizeDomain(enterprise)}` : "https://api.githubcopilot.com"
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
}
|
||||
}
|
||||
|
||||
function request(url: string, init: RequestInit) {
|
||||
return Effect.tryPromise({
|
||||
try: async (signal) => {
|
||||
const response = await fetch(url, { ...init, signal })
|
||||
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
|
||||
return response.json()
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
|
||||
type Fetch = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response>
|
||||
|
||||
export function copilotFetch(token: string | undefined, upstream: Fetch | undefined, anthropic: boolean): Fetch {
|
||||
const send = upstream ?? fetch
|
||||
return async (input, init) => {
|
||||
const requestHeaders = new Headers(init?.headers)
|
||||
if (token) {
|
||||
requestHeaders.delete("authorization")
|
||||
requestHeaders.delete("x-api-key")
|
||||
requestHeaders.set("Authorization", `Bearer ${token}`)
|
||||
}
|
||||
requestHeaders.set("User-Agent", `opencode/${InstallationVersion}`)
|
||||
requestHeaders.set("Openai-Intent", "conversation-edits")
|
||||
requestHeaders.set("X-GitHub-Api-Version", apiVersion)
|
||||
if (anthropic) requestHeaders.set("anthropic-beta", "interleaved-thinking-2025-05-14")
|
||||
|
||||
const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url
|
||||
const body = typeof init?.body === "string" ? Option.getOrUndefined(decodeBody(init.body)) : undefined
|
||||
const metadata = requestMetadata(url, body)
|
||||
requestHeaders.set("x-initiator", metadata.agent ? "agent" : "user")
|
||||
if (metadata.vision) requestHeaders.set("Copilot-Vision-Request", "true")
|
||||
return send(input, { ...init, headers: requestHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
function requestMetadata(url: string, body: unknown) {
|
||||
if (!record(body)) return { agent: false, vision: false }
|
||||
if (Array.isArray(body.input)) {
|
||||
const last = body.input.at(-1)
|
||||
return {
|
||||
agent: !record(last) || last.role !== "user",
|
||||
vision: body.input.some(
|
||||
(item) =>
|
||||
record(item) &&
|
||||
Array.isArray(item.content) &&
|
||||
item.content.some((part) => record(part) && part.type === "input_image"),
|
||||
),
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(body.messages)) return { agent: false, vision: false }
|
||||
const last = body.messages.at(-1)
|
||||
if (url.includes("completions")) {
|
||||
return {
|
||||
agent: !record(last) || last.role !== "user",
|
||||
vision: body.messages.some(
|
||||
(message) =>
|
||||
record(message) &&
|
||||
Array.isArray(message.content) &&
|
||||
message.content.some((part) => record(part) && part.type === "image_url"),
|
||||
),
|
||||
}
|
||||
}
|
||||
const content = record(last) && Array.isArray(last.content) ? last.content : []
|
||||
return {
|
||||
agent:
|
||||
!record(last) || last.role !== "user" || !content.some((part) => record(part) && part.type !== "tool_result"),
|
||||
vision: body.messages.some(
|
||||
(message) =>
|
||||
record(message) &&
|
||||
Array.isArray(message.content) &&
|
||||
message.content.some(
|
||||
(part) =>
|
||||
record(part) &&
|
||||
(part.type === "image" ||
|
||||
(part.type === "tool_result" &&
|
||||
Array.isArray(part.content) &&
|
||||
part.content.some((nested) => record(nested) && nested.type === "image"))),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function record(input: unknown): input is Record<string, unknown> {
|
||||
return typeof input === "object" && input !== null && !Array.isArray(input)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,153 +1,12 @@
|
||||
import { createServer } from "node:http"
|
||||
import os from "os"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Deferred, Effect, Schema } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
import { OauthCallbackPage } from "../../oauth/page"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
const defaultInstanceUrl = "https://gitlab.com"
|
||||
const bundledClientID = "1d89f9fdb23ee96d4e603201f6861dab6e143c5c3c00469a018a2d94bdc03d4e"
|
||||
const callbackHost = "127.0.0.1"
|
||||
const callbackPort = 8080
|
||||
const callbackPath = "/callback"
|
||||
const redirectURI = `http://${callbackHost}:${callbackPort}${callbackPath}`
|
||||
const methodID = Integration.MethodID.make("oauth")
|
||||
|
||||
const Token = Schema.Struct({
|
||||
access_token: Schema.String,
|
||||
refresh_token: Schema.optional(Schema.String),
|
||||
expires_in: Schema.optional(Schema.Number),
|
||||
})
|
||||
type Token = typeof Token.Type
|
||||
|
||||
const oauth = {
|
||||
integrationID: Integration.ID.make("gitlab"),
|
||||
method: {
|
||||
id: methodID,
|
||||
type: "oauth",
|
||||
label: "GitLab OAuth",
|
||||
prompts: [
|
||||
{
|
||||
type: "text",
|
||||
key: "instanceUrl",
|
||||
message: "GitLab instance URL",
|
||||
placeholder: defaultInstanceUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
authorize: (inputs) =>
|
||||
Effect.gen(function* () {
|
||||
const instanceUrl = normalizeInstanceUrl(inputs.instanceUrl)
|
||||
const pkce = yield* Effect.promise(generatePKCE)
|
||||
const state = randomString(32)
|
||||
const code = yield* Deferred.make<string, Error>()
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", redirectURI)
|
||||
if (url.pathname !== callbackPath) {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
||||
const value = url.searchParams.get("code")
|
||||
if (error) {
|
||||
Effect.runFork(Deferred.fail(code, new Error(error)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(error, { provider: "GitLab" }))
|
||||
return
|
||||
}
|
||||
if (!value || url.searchParams.get("state") !== state) {
|
||||
const message = value ? "Invalid OAuth state" : "Missing authorization code"
|
||||
Effect.runFork(Deferred.fail(code, new Error(message)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(message, { provider: "GitLab" }))
|
||||
return
|
||||
}
|
||||
Effect.runFork(Deferred.succeed(code, value))
|
||||
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "GitLab" }))
|
||||
})
|
||||
yield* Effect.callback<void, Error>((resume) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(callbackPort, callbackHost, () => resume(Effect.void))
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: `${instanceUrl}/oauth/authorize?${new URLSearchParams({
|
||||
client_id: clientID(),
|
||||
redirect_uri: redirectURI,
|
||||
response_type: "code",
|
||||
state,
|
||||
scope: "api",
|
||||
code_challenge: pkce.challenge,
|
||||
code_challenge_method: "S256",
|
||||
})}`,
|
||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||
callback: Deferred.await(code).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
token(instanceUrl, {
|
||||
grant_type: "authorization_code",
|
||||
code: value,
|
||||
redirect_uri: redirectURI,
|
||||
client_id: clientID(),
|
||||
code_verifier: pkce.verifier,
|
||||
}),
|
||||
),
|
||||
Effect.flatMap((value) => credential(value, instanceUrl)),
|
||||
),
|
||||
}
|
||||
}),
|
||||
refresh: (value) => {
|
||||
const instanceUrl = normalizeMetadataInstanceUrl(value.metadata?.instanceUrl)
|
||||
return token(instanceUrl, {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: value.refresh,
|
||||
client_id: clientID(),
|
||||
}).pipe(Effect.flatMap((next) => credential(next, instanceUrl, next.refresh_token ?? value.refresh)))
|
||||
},
|
||||
label: (value) => (typeof value.metadata?.instanceUrl === "string" ? value.metadata.instanceUrl : undefined),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
export const GitLabPlugin = define({
|
||||
id: "opencode.provider.gitlab",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.method.update(oauth)
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make("gitlab"),
|
||||
method: {
|
||||
type: "key",
|
||||
label: "GitLab Personal Access Token",
|
||||
prompts: [
|
||||
{
|
||||
type: "text",
|
||||
key: "instanceUrl",
|
||||
message: "GitLab instance URL",
|
||||
placeholder: defaultInstanceUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
authorize: (key, inputs) =>
|
||||
Effect.gen(function* () {
|
||||
const instanceUrl = normalizeInstanceUrl(inputs.instanceUrl)
|
||||
const response = yield* send(`${instanceUrl}/api/v4/user`, {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
})
|
||||
if (!response.ok)
|
||||
return yield* Effect.fail(new Error(`GitLab token validation failed (${response.status})`))
|
||||
return Credential.Key.make({ type: "key", key, metadata: { instanceUrl } })
|
||||
}),
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make("gitlab"),
|
||||
method: { type: "env", names: ["GITLAB_TOKEN"] },
|
||||
})
|
||||
})
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
@@ -204,79 +63,3 @@ export const GitLabPlugin = define({
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function clientID() {
|
||||
return process.env.GITLAB_OAUTH_CLIENT_ID ?? bundledClientID
|
||||
}
|
||||
|
||||
function normalizeInstanceUrl(value?: string) {
|
||||
const input = value?.trim() || process.env.GITLAB_INSTANCE_URL || defaultInstanceUrl
|
||||
return normalizeURL(input)
|
||||
}
|
||||
|
||||
function normalizeMetadataInstanceUrl(value: unknown) {
|
||||
if (typeof value !== "string" || !value) throw new Error("GitLab OAuth credential is missing instanceUrl metadata")
|
||||
return normalizeURL(value)
|
||||
}
|
||||
|
||||
function normalizeURL(value: string) {
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("GitLab instance URL must use http or https")
|
||||
}
|
||||
return `${url.protocol}//${url.host}`
|
||||
}
|
||||
|
||||
function token(instanceUrl: string, body: Record<string, string>) {
|
||||
return send(`${instanceUrl}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
||||
body: new URLSearchParams(body).toString(),
|
||||
}).pipe(
|
||||
Effect.flatMap((response) => {
|
||||
if (response.ok) {
|
||||
return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(Token)))
|
||||
}
|
||||
return Effect.promise(() => response.text()).pipe(
|
||||
Effect.flatMap((detail) =>
|
||||
Effect.fail(new Error(`GitLab token request failed (${response.status})${detail ? `: ${detail}` : ""}`)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function send(url: string, init: RequestInit) {
|
||||
return Effect.tryPromise({
|
||||
try: (signal) => fetch(url, { ...init, signal }),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
|
||||
function credential(tokens: Token, instanceUrl: string, currentRefresh?: string) {
|
||||
const refresh = tokens.refresh_token ?? currentRefresh
|
||||
if (!refresh) return Effect.fail(new Error("GitLab token response is missing refresh_token"))
|
||||
return Effect.succeed(
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: tokens.access_token,
|
||||
refresh,
|
||||
expires: Date.now() + (tokens.expires_in ?? 7200) * 1000,
|
||||
metadata: { instanceUrl },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function generatePKCE() {
|
||||
const verifier = randomString(64)
|
||||
const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString(
|
||||
"base64url",
|
||||
)
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
function randomString(length: number) {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => chars[byte % chars.length]).join("")
|
||||
}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { createServer } from "node:http"
|
||||
import type { AddressInfo } from "node:net"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Deferred, Effect, Schema } from "effect"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
import { OauthCallbackPage } from "../../oauth/page"
|
||||
|
||||
const clientID = "client_728290227fc048cc9262091a1ea197ea"
|
||||
const authorizationEndpoint = "https://poe.com/oauth/authorize"
|
||||
const tokenEndpoint = "https://api.poe.com/token"
|
||||
const callbackPath = "/callback"
|
||||
const integrationID = Integration.ID.make("poe")
|
||||
const methodID = Integration.MethodID.make("browser")
|
||||
|
||||
const Token = Schema.Struct({
|
||||
api_key: Schema.String,
|
||||
api_key_expires_in: Schema.optional(Schema.NullOr(Schema.Number)),
|
||||
})
|
||||
|
||||
const oauth = {
|
||||
integrationID,
|
||||
method: {
|
||||
id: methodID,
|
||||
type: "oauth",
|
||||
label: "Login with Poe (browser)",
|
||||
},
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
|
||||
const challenge = Buffer.from(
|
||||
yield* Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))),
|
||||
).toString("base64url")
|
||||
const code = yield* Deferred.make<string, Error>()
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1")
|
||||
if (url.pathname !== callbackPath) {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
const error = url.searchParams.get("error")
|
||||
if (error) {
|
||||
const description = url.searchParams.get("error_description") ?? error
|
||||
Effect.runFork(Deferred.fail(code, new Error(`OAuth authorization failed: ${error} - ${description}`)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(description, { provider: "Poe" }))
|
||||
return
|
||||
}
|
||||
const value = url.searchParams.get("code")
|
||||
if (!value) {
|
||||
Effect.runFork(Deferred.fail(code, new Error("OAuth callback missing authorization code")))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error("Missing authorization code", { provider: "Poe" }))
|
||||
return
|
||||
}
|
||||
Effect.runFork(Deferred.succeed(code, value))
|
||||
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "Poe" }))
|
||||
})
|
||||
yield* Effect.callback<void, Error>((resume) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(0, "127.0.0.1", () => resume(Effect.void))
|
||||
})
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
server.closeAllConnections()
|
||||
server.close()
|
||||
}),
|
||||
)
|
||||
const address = server.address() as AddressInfo
|
||||
const redirectURI = `http://127.0.0.1:${address.port}${callbackPath}`
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: `${authorizationEndpoint}?${new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: clientID,
|
||||
scope: "apikey:create",
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
redirect_uri: redirectURI,
|
||||
})}`,
|
||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||
callback: Deferred.await(code).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
fetch(tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: value,
|
||||
code_verifier: verifier,
|
||||
client_id: clientID,
|
||||
redirect_uri: redirectURI,
|
||||
}).toString(),
|
||||
signal,
|
||||
}),
|
||||
catch: (cause) => cause,
|
||||
}),
|
||||
),
|
||||
Effect.flatMap((response) => {
|
||||
if (response.ok)
|
||||
return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(Token)))
|
||||
return Effect.promise(() => response.text()).pipe(
|
||||
Effect.flatMap((detail) =>
|
||||
Effect.fail(new Error(`Poe token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
Effect.map((token) =>
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: token.api_key,
|
||||
refresh: token.api_key,
|
||||
expires:
|
||||
token.api_key_expires_in == null
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: Date.now() + token.api_key_expires_in * 1000,
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
export const PoePlugin = define({
|
||||
id: "opencode.provider.poe",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.update(integrationID, (integration) => {
|
||||
integration.name = "Poe"
|
||||
})
|
||||
draft.method.update(oauth)
|
||||
draft.method.update({ integrationID, method: { type: "key", label: "Manually enter API Key" } })
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -1,46 +1,12 @@
|
||||
import { createServer } from "node:http"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Deferred, Effect, Schema } from "effect"
|
||||
import { Credential } from "../../credential"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Integration } from "../../integration"
|
||||
import { OauthCallbackPage } from "../../oauth/page"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
|
||||
|
||||
const integrationID = Integration.ID.make("snowflake-cortex")
|
||||
const browserMethodID = Integration.MethodID.make("snowflake-browser")
|
||||
const clientID = "LOCAL_APPLICATION"
|
||||
const callbackHost = "127.0.0.1"
|
||||
|
||||
type TokenResponse = {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
}
|
||||
|
||||
const TokenResponse = Schema.Struct({
|
||||
access_token: Schema.String,
|
||||
refresh_token: Schema.optional(Schema.String),
|
||||
expires_in: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
export function oauthScope(role: string | undefined) {
|
||||
if (!role) return "refresh_token"
|
||||
return /^[-_A-Za-z0-9]+$/.test(role)
|
||||
? `refresh_token session:role:${role}`
|
||||
: `refresh_token session:role-encoded:${encodeURIComponent(role)}`
|
||||
}
|
||||
|
||||
// Exported for testing: intercepts Cortex-specific request/response quirks.
|
||||
export function cortexFetch(upstream: FetchLike = fetch) {
|
||||
return async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
const headers = new Headers(url instanceof Request ? url.headers : undefined)
|
||||
if (init?.headers) new Headers(init.headers).forEach((value, key) => headers.set(key, value))
|
||||
headers.set("User-Agent", `opencode/${InstallationVersion}`)
|
||||
init = { ...init, headers }
|
||||
if (init?.body && typeof init.body === "string") {
|
||||
try {
|
||||
const body = JSON.parse(init.body)
|
||||
@@ -101,24 +67,10 @@ export function cortexFetch(upstream: FetchLike = fetch) {
|
||||
export const SnowflakeCortexPlugin = define({
|
||||
id: "opencode.provider.snowflake-cortex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.method.update(browser)
|
||||
draft.method.update({
|
||||
integrationID: "snowflake-cortex",
|
||||
method: { type: "key", label: "Paste PAT or bearer token manually" },
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: "snowflake-cortex",
|
||||
method: { type: "env", names: ["SNOWFLAKE_CORTEX_TOKEN", "SNOWFLAKE_CORTEX_PAT"] },
|
||||
})
|
||||
})
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
|
||||
const account = normalizeAccount(
|
||||
process.env.SNOWFLAKE_ACCOUNT ?? (typeof evt.options.account === "string" ? evt.options.account : ""),
|
||||
)
|
||||
const token =
|
||||
process.env.SNOWFLAKE_CORTEX_TOKEN ??
|
||||
process.env.SNOWFLAKE_CORTEX_PAT ??
|
||||
@@ -126,7 +78,6 @@ export const SnowflakeCortexPlugin = define({
|
||||
(typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined)
|
||||
const upstream = typeof evt.options.fetch === "function" ? (evt.options.fetch as FetchLike) : undefined
|
||||
if (evt.options.includeUsage !== false) evt.options.includeUsage = true
|
||||
if (account) evt.options.baseURL = `https://${account}.snowflakecomputing.com/api/v2/cortex/v1`
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
|
||||
evt.sdk = mod.createOpenAICompatible({
|
||||
...evt.options,
|
||||
@@ -137,180 +88,3 @@ export const SnowflakeCortexPlugin = define({
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
const accountPrompt = {
|
||||
type: "text" as const,
|
||||
key: "account",
|
||||
message: "Snowflake Account Identifier",
|
||||
placeholder: "myorg-myaccount",
|
||||
}
|
||||
|
||||
const browser = {
|
||||
integrationID,
|
||||
method: {
|
||||
id: browserMethodID,
|
||||
type: "oauth",
|
||||
label: "Login with Snowflake (External Browser)",
|
||||
prompts: [
|
||||
accountPrompt,
|
||||
{
|
||||
type: "text",
|
||||
key: "role",
|
||||
message: "Snowflake Role (optional)",
|
||||
placeholder: "PUBLIC",
|
||||
},
|
||||
],
|
||||
},
|
||||
authorize: (inputs) =>
|
||||
Effect.gen(function* () {
|
||||
const account = normalizeAccount(inputs.account ?? "")
|
||||
if (!account) return yield* Effect.fail(new Error("Snowflake account is required"))
|
||||
|
||||
const pkce = yield* Effect.promise(generatePKCE)
|
||||
const state = randomString(64)
|
||||
const code = yield* Deferred.make<string, Error>()
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", `http://${callbackHost}`)
|
||||
if (url.pathname !== "/") {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
||||
const value = url.searchParams.get("code")
|
||||
if (error) {
|
||||
Effect.runFork(Deferred.fail(code, new Error(error)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(error, { provider: "Snowflake" }))
|
||||
return
|
||||
}
|
||||
if (!value || url.searchParams.get("state") !== state) {
|
||||
const message = value ? "Invalid state - potential CSRF attack" : "Missing authorization code"
|
||||
Effect.runFork(Deferred.fail(code, new Error(message)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
|
||||
return
|
||||
}
|
||||
Effect.runFork(Deferred.succeed(code, value))
|
||||
response
|
||||
.writeHead(200, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.success({ provider: "Snowflake" }))
|
||||
})
|
||||
const port = yield* Effect.callback<number, Error>((resume) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(0, callbackHost, () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") {
|
||||
resume(Effect.fail(new Error("Unable to resolve Snowflake OAuth callback port")))
|
||||
return
|
||||
}
|
||||
resume(Effect.succeed(address.port))
|
||||
})
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||
const redirect = `http://${callbackHost}:${port}/`
|
||||
const role = inputs.role?.trim() || undefined
|
||||
const url = `https://${account}.snowflakecomputing.com/oauth/authorize?${new URLSearchParams({
|
||||
client_id: clientID,
|
||||
response_type: "code",
|
||||
redirect_uri: redirect,
|
||||
scope: oauthScope(role),
|
||||
state,
|
||||
code_challenge: pkce.challenge,
|
||||
code_challenge_method: "S256",
|
||||
})}`
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url,
|
||||
instructions:
|
||||
"Complete Snowflake sign-in in your browser. OpenCode will capture the OAuth callback and store the bearer token automatically.",
|
||||
callback: Deferred.await(code).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
token(account, {
|
||||
grant_type: "authorization_code",
|
||||
code: value,
|
||||
redirect_uri: redirect,
|
||||
client_id: clientID,
|
||||
code_verifier: pkce.verifier,
|
||||
}),
|
||||
),
|
||||
Effect.flatMap((value) =>
|
||||
value.refresh_token
|
||||
? Effect.succeed(credential(value, account, value.refresh_token))
|
||||
: Effect.fail(
|
||||
new Error(
|
||||
"Snowflake token response did not include refresh_token. Ensure integration issues refresh tokens and scope includes refresh_token.",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
refresh: (value) => {
|
||||
const account = typeof value.metadata?.account === "string" ? normalizeAccount(value.metadata.account) : ""
|
||||
if (!account) return Effect.fail(new Error("Snowflake OAuth credential is missing account metadata"))
|
||||
return token(account, {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: value.refresh,
|
||||
client_id: clientID,
|
||||
}).pipe(Effect.map((next) => credential(next, account, next.refresh_token ?? value.refresh)))
|
||||
},
|
||||
label: (value) => (typeof value.metadata?.account === "string" ? value.metadata.account : undefined),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
function token(account: string, body: Record<string, string>) {
|
||||
return Effect.tryPromise({
|
||||
try: async (signal) => {
|
||||
const response = await fetch(`https://${account}.snowflakecomputing.com/oauth/token-request`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
Authorization: `Basic ${Buffer.from(`${clientID}:${clientID}`).toString("base64")}`,
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
},
|
||||
body: new URLSearchParams(body).toString(),
|
||||
signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "")
|
||||
throw new Error(`Snowflake token request failed (${response.status})${detail ? `: ${detail}` : ""}`)
|
||||
}
|
||||
return Schema.decodeUnknownSync(TokenResponse)(await response.json())
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
|
||||
function credential(tokens: TokenResponse, account: string, refresh: string) {
|
||||
return Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: browserMethodID,
|
||||
access: tokens.access_token,
|
||||
refresh,
|
||||
expires: Date.now() + (tokens.expires_in ?? 600) * 1000,
|
||||
metadata: { account },
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeAccount(input: string) {
|
||||
return input
|
||||
.trim()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\.snowflakecomputing\.com\/?$/, "")
|
||||
.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
async function generatePKCE() {
|
||||
const verifier = randomString(64)
|
||||
const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString(
|
||||
"base64url",
|
||||
)
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
function randomString(length: number) {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => chars[byte % chars.length]).join("")
|
||||
}
|
||||
|
||||
@@ -1,146 +1,10 @@
|
||||
import { createServer } from "node:http"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Clock, Deferred, Effect, Option, Schema } from "effect"
|
||||
import { Credential } from "../../credential"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Integration } from "../../integration"
|
||||
import { OauthCallbackPage } from "../../oauth/page"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
const clientID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
const issuer = "https://auth.x.ai/oauth2"
|
||||
const deviceGrant = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
const scope = "openid profile email offline_access grok-cli:access api:access"
|
||||
const callbackHost = "127.0.0.1"
|
||||
const callbackPort = 56121
|
||||
const callbackPath = "/callback"
|
||||
const redirectURI = `http://${callbackHost}:${callbackPort}${callbackPath}`
|
||||
const pollingSafetyMargin = 3000
|
||||
const browserMethodID = Integration.MethodID.make("browser")
|
||||
const deviceMethodID = Integration.MethodID.make("device")
|
||||
|
||||
type Pkce = {
|
||||
verifier: string
|
||||
challenge: string
|
||||
}
|
||||
|
||||
const Token = Schema.Struct({
|
||||
access_token: Schema.String,
|
||||
refresh_token: Schema.optional(Schema.String),
|
||||
expires_in: Schema.optional(Schema.Number),
|
||||
})
|
||||
type Token = typeof Token.Type
|
||||
|
||||
const Device = Schema.Struct({
|
||||
device_code: Schema.String,
|
||||
user_code: Schema.String,
|
||||
verification_uri: Schema.String,
|
||||
verification_uri_complete: Schema.optional(Schema.String),
|
||||
expires_in: Schema.optional(Schema.Number),
|
||||
interval: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
const DeviceError = Schema.Struct({
|
||||
error: Schema.optional(Schema.String),
|
||||
error_description: Schema.optional(Schema.String),
|
||||
})
|
||||
const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError))
|
||||
|
||||
const browser = {
|
||||
integrationID: Integration.ID.make("xai"),
|
||||
method: {
|
||||
id: browserMethodID,
|
||||
type: "oauth",
|
||||
label: "xAI Grok OAuth (SuperGrok Subscription)",
|
||||
},
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const pkce = yield* Effect.promise(generatePKCE)
|
||||
const state = randomString(32)
|
||||
const code = yield* Deferred.make<string, Error>()
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", redirectURI)
|
||||
if (url.pathname !== callbackPath) {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
||||
const value = url.searchParams.get("code")
|
||||
if (error) {
|
||||
Effect.runFork(Deferred.fail(code, new Error(error)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(error, { provider: "xAI" }))
|
||||
return
|
||||
}
|
||||
if (!value || url.searchParams.get("state") !== state) {
|
||||
const message = value ? "Invalid OAuth state" : "Missing authorization code"
|
||||
Effect.runFork(Deferred.fail(code, new Error(message)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(message, { provider: "xAI" }))
|
||||
return
|
||||
}
|
||||
Effect.runFork(Deferred.succeed(code, value))
|
||||
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "xAI" }))
|
||||
})
|
||||
yield* Effect.callback<void, Error>((resume) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(callbackPort, callbackHost, () => resume(Effect.void))
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: authorizeURL(pkce, state, randomString(32)),
|
||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||
callback: Deferred.await(code).pipe(
|
||||
Effect.flatMap((value) => exchange(value, pkce)),
|
||||
Effect.flatMap((tokens) => credential(browserMethodID, tokens)),
|
||||
),
|
||||
}
|
||||
}),
|
||||
refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID })),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
const device = {
|
||||
integrationID: Integration.ID.make("xai"),
|
||||
method: {
|
||||
id: deviceMethodID,
|
||||
type: "oauth",
|
||||
label: "xAI Grok OAuth (Headless / Remote / VPS)",
|
||||
},
|
||||
authorize: () =>
|
||||
request(
|
||||
`${issuer}/device/code`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
body: new URLSearchParams({ client_id: clientID, scope }).toString(),
|
||||
},
|
||||
Device,
|
||||
).pipe(
|
||||
Effect.map((value) => ({
|
||||
mode: "auto" as const,
|
||||
url: value.verification_uri_complete ?? value.verification_uri,
|
||||
instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`,
|
||||
callback: poll(value).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
|
||||
})),
|
||||
),
|
||||
refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID })),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
export const XAIPlugin = define({
|
||||
id: "opencode.provider.xai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.update("xai", (integration) => {
|
||||
integration.name = "xAI"
|
||||
})
|
||||
draft.method.update(browser)
|
||||
draft.method.update(device)
|
||||
draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
|
||||
})
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
@@ -158,167 +22,3 @@ export const XAIPlugin = define({
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function exchange(code: string, pkce: Pkce) {
|
||||
return request(
|
||||
`${issuer}/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectURI,
|
||||
client_id: clientID,
|
||||
code_verifier: pkce.verifier,
|
||||
}).toString(),
|
||||
},
|
||||
Token,
|
||||
)
|
||||
}
|
||||
|
||||
function refresh(methodID: Integration.MethodID, value: Credential.OAuth) {
|
||||
return request(
|
||||
`${issuer}/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: value.refresh,
|
||||
client_id: clientID,
|
||||
}).toString(),
|
||||
},
|
||||
Token,
|
||||
).pipe(Effect.flatMap((tokens) => credential(methodID, tokens, value.refresh, value.metadata)))
|
||||
}
|
||||
|
||||
function poll(device: typeof Device.Type): Effect.Effect<Token, unknown> {
|
||||
return Effect.gen(function* () {
|
||||
const started = yield* Clock.currentTimeMillis
|
||||
const expires = started + positiveSeconds(device.expires_in, 300) * 1000
|
||||
const loop = (interval: number): Effect.Effect<Token, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
if ((yield* Clock.currentTimeMillis) >= expires) {
|
||||
return yield* Effect.fail(new Error("xAI device authorization timed out"))
|
||||
}
|
||||
const response = yield* send(`${issuer}/token`, {
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
body: new URLSearchParams({
|
||||
grant_type: deviceGrant,
|
||||
client_id: clientID,
|
||||
device_code: device.device_code,
|
||||
}).toString(),
|
||||
})
|
||||
if (response.ok) return yield* decode(response, Token)
|
||||
const error = yield* Effect.promise(() => response.text()).pipe(
|
||||
Effect.map((body) => Option.getOrUndefined(decodeDeviceError(body))),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
if (error?.error === "authorization_pending") {
|
||||
return yield* Effect.sleep(interval + pollingSafetyMargin).pipe(Effect.andThen(loop(interval)))
|
||||
}
|
||||
if (error?.error === "slow_down") {
|
||||
const next = interval + 5000
|
||||
return yield* Effect.sleep(next + pollingSafetyMargin).pipe(Effect.andThen(loop(next)))
|
||||
}
|
||||
if (error?.error === "access_denied" || error?.error === "authorization_denied") {
|
||||
return yield* Effect.fail(new Error("xAI device authorization was denied"))
|
||||
}
|
||||
if (error?.error === "expired_token") {
|
||||
return yield* Effect.fail(new Error("xAI device code expired - please re-run login"))
|
||||
}
|
||||
const detail = error?.error_description ?? error?.error
|
||||
return yield* Effect.fail(
|
||||
new Error(`xAI device token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`),
|
||||
)
|
||||
})
|
||||
return yield* loop(Math.max(positiveSeconds(device.interval, 5) * 1000, 1000))
|
||||
})
|
||||
}
|
||||
|
||||
function request<S extends Schema.Decoder<unknown>>(url: string, init: RequestInit, schema: S) {
|
||||
return send(url, init).pipe(
|
||||
Effect.flatMap((response) => {
|
||||
if (response.ok) return decode(response, schema)
|
||||
return Effect.promise(() => response.text()).pipe(
|
||||
Effect.flatMap((detail) =>
|
||||
Effect.fail(new Error(`xAI request failed (${response.status})${detail ? `: ${detail}` : ""}`)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function send(url: string, init: RequestInit) {
|
||||
return Effect.tryPromise({
|
||||
try: (signal) => fetch(url, { ...init, signal }),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
|
||||
function decode<S extends Schema.Decoder<unknown>>(response: Response, schema: S) {
|
||||
return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(schema)))
|
||||
}
|
||||
|
||||
function credential(
|
||||
methodID: Integration.MethodID,
|
||||
tokens: Token,
|
||||
currentRefresh?: string,
|
||||
metadata?: Readonly<Record<string, unknown>>,
|
||||
) {
|
||||
const refresh = tokens.refresh_token ?? currentRefresh
|
||||
if (!refresh) return Effect.fail(new Error("xAI token response is missing refresh_token"))
|
||||
return Effect.succeed(
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
refresh,
|
||||
access: tokens.access_token,
|
||||
expires: Date.now() + positiveSeconds(tokens.expires_in, 3600) * 1000,
|
||||
metadata,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
}
|
||||
}
|
||||
|
||||
function positiveSeconds(value: unknown, fallback: number) {
|
||||
const seconds = Number(value)
|
||||
return Number.isFinite(seconds) && seconds > 0 ? seconds : fallback
|
||||
}
|
||||
|
||||
async function generatePKCE(): Promise<Pkce> {
|
||||
const verifier = randomString(64)
|
||||
const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString(
|
||||
"base64url",
|
||||
)
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
function randomString(length: number) {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => chars[byte % chars.length]).join("")
|
||||
}
|
||||
|
||||
function authorizeURL(pkce: Pkce, state: string, nonce: string) {
|
||||
return `${issuer}/authorize?${new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: clientID,
|
||||
redirect_uri: redirectURI,
|
||||
scope,
|
||||
code_challenge: pkce.challenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
nonce,
|
||||
plan: "generic",
|
||||
referrer: "opencode",
|
||||
})}`
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { EventV2 } from "../event"
|
||||
import type { PluginV2 } from "../plugin"
|
||||
|
||||
export const Updated = EventV2.ephemeral({ type: "sdk.plugin.updated", schema: {} })
|
||||
|
||||
@@ -21,7 +20,7 @@ export const Updated = EventV2.ephemeral({ type: "sdk.plugin.updated", schema: {
|
||||
*/
|
||||
export interface Interface {
|
||||
readonly register: (plugin: Plugin) => Effect.Effect<void>
|
||||
readonly all: () => readonly PluginV2.Versioned[]
|
||||
readonly all: () => readonly Plugin[]
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SdkPlugins") {}
|
||||
@@ -30,12 +29,11 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const plugins = new Map<string, PluginV2.Versioned>()
|
||||
let revision = 0
|
||||
const plugins = new Map<string, Plugin>()
|
||||
return Service.of({
|
||||
register: (plugin) =>
|
||||
Effect.sync(() => {
|
||||
plugins.set(plugin.id, { ...plugin, version: String(++revision) })
|
||||
plugins.set(plugin.id, plugin)
|
||||
}).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid),
|
||||
all: () => [...plugins.values()],
|
||||
})
|
||||
|
||||
@@ -116,15 +116,15 @@ const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Con
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
pre: readonly PluginV2.Versioned[],
|
||||
post: readonly PluginV2.Versioned[],
|
||||
pre: readonly Plugin[],
|
||||
post: readonly Plugin[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
const matches = (selector: string, target: string) =>
|
||||
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
|
||||
const definitions = [...pre, ...post]
|
||||
const enabled = new Set(definitions.map((plugin) => plugin.id))
|
||||
const packages = new Map<string, PluginV2.Versioned>()
|
||||
const packages = new Map<string, Plugin>()
|
||||
const plugins = () => [...definitions, ...packages.values()]
|
||||
|
||||
for (const operation of operations) {
|
||||
@@ -178,9 +178,8 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Op
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
return {
|
||||
id: plugin.id,
|
||||
version: JSON.stringify(operation),
|
||||
effect: (host) => plugin.effect({ ...host, options: operation.options }),
|
||||
} satisfies PluginV2.Versioned
|
||||
} satisfies Plugin
|
||||
})
|
||||
|
||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
@@ -254,11 +253,10 @@ const layer = Layer.effect(
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
||||
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
|
||||
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
|
||||
const pre = [...internal.pre, ...sdk.all()]
|
||||
const operations = yield* scan(yield* config.entries())
|
||||
// Apply config operations and load enabled package plugins into one ordered generation.
|
||||
const plugins = yield* resolve(pre, post, operations)
|
||||
const plugins = yield* resolve(pre, internal.post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(plugins)
|
||||
applied = target
|
||||
|
||||
@@ -64,7 +64,7 @@ const layer = Layer.effect(
|
||||
if (source.type === "local") {
|
||||
materialized.set(
|
||||
name,
|
||||
Info.make({
|
||||
new Info({
|
||||
name,
|
||||
path: source.path,
|
||||
...(source.description === undefined ? {} : { description: source.description }),
|
||||
@@ -88,7 +88,7 @@ const layer = Layer.effect(
|
||||
seen.set(target, source.branch)
|
||||
materialized.set(
|
||||
name,
|
||||
Info.make({
|
||||
new Info({
|
||||
name,
|
||||
path: AbsolutePath.make(target),
|
||||
...(source.description === undefined ? {} : { description: source.description }),
|
||||
|
||||
@@ -220,31 +220,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
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) {
|
||||
yield* flush()
|
||||
yield* failTools(error, "uncalled")
|
||||
yield* startAssistant()
|
||||
if (replace || stepFailure === undefined) stepFailure = error
|
||||
})
|
||||
@@ -268,7 +245,20 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
error: SessionError.Error,
|
||||
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) => {
|
||||
|
||||
@@ -92,7 +92,6 @@ const layer = Layer.effect(
|
||||
},
|
||||
list: () => draft.sources as Source[],
|
||||
}),
|
||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const load = Effect.fn("SkillV2.load")(function* (source: Source) {
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
export * as SearchFixtureTool from "./search-fixture"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export const catalog = [
|
||||
{
|
||||
namespace: "slack",
|
||||
label: "Slack",
|
||||
operations: [
|
||||
"send_message",
|
||||
"schedule_message",
|
||||
"create_channel",
|
||||
"archive_channel",
|
||||
"invite_user",
|
||||
"add_reaction",
|
||||
"pin_message",
|
||||
"set_topic",
|
||||
"start_huddle",
|
||||
"export_thread",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "github",
|
||||
label: "GitHub",
|
||||
operations: [
|
||||
"create_issue",
|
||||
"close_issue",
|
||||
"assign_issue",
|
||||
"label_issue",
|
||||
"create_pull_request",
|
||||
"request_review",
|
||||
"merge_pull_request",
|
||||
"create_release",
|
||||
"dispatch_workflow",
|
||||
"protect_branch",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "linear",
|
||||
label: "Linear",
|
||||
operations: [
|
||||
"create_issue",
|
||||
"update_issue",
|
||||
"assign_issue",
|
||||
"move_issue",
|
||||
"add_comment",
|
||||
"create_project",
|
||||
"update_project",
|
||||
"create_cycle",
|
||||
"add_label",
|
||||
"archive_issue",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "notion",
|
||||
label: "Notion",
|
||||
operations: [
|
||||
"create_page",
|
||||
"update_page",
|
||||
"archive_page",
|
||||
"search_workspace",
|
||||
"append_blocks",
|
||||
"create_database",
|
||||
"add_database_row",
|
||||
"update_database_row",
|
||||
"duplicate_page",
|
||||
"share_page",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "jira",
|
||||
label: "Jira",
|
||||
operations: [
|
||||
"create_ticket",
|
||||
"transition_ticket",
|
||||
"assign_ticket",
|
||||
"add_comment",
|
||||
"link_ticket",
|
||||
"create_epic",
|
||||
"add_to_sprint",
|
||||
"log_work",
|
||||
"set_priority",
|
||||
"close_ticket",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "google_drive",
|
||||
label: "Google Drive",
|
||||
operations: [
|
||||
"create_folder",
|
||||
"upload_file",
|
||||
"move_file",
|
||||
"copy_file",
|
||||
"share_file",
|
||||
"revoke_access",
|
||||
"rename_file",
|
||||
"trash_file",
|
||||
"restore_file",
|
||||
"search_files",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "gmail",
|
||||
label: "Gmail",
|
||||
operations: [
|
||||
"send_email",
|
||||
"create_draft",
|
||||
"reply_thread",
|
||||
"forward_email",
|
||||
"add_label",
|
||||
"remove_label",
|
||||
"archive_thread",
|
||||
"mark_read",
|
||||
"mark_unread",
|
||||
"schedule_email",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "calendar",
|
||||
label: "Google Calendar",
|
||||
operations: [
|
||||
"create_event",
|
||||
"update_event",
|
||||
"cancel_event",
|
||||
"invite_attendee",
|
||||
"remove_attendee",
|
||||
"find_availability",
|
||||
"reserve_room",
|
||||
"add_conference",
|
||||
"set_reminder",
|
||||
"list_events",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "stripe",
|
||||
label: "Stripe",
|
||||
operations: [
|
||||
"create_customer",
|
||||
"update_customer",
|
||||
"create_invoice",
|
||||
"finalize_invoice",
|
||||
"refund_payment",
|
||||
"cancel_subscription",
|
||||
"pause_subscription",
|
||||
"resume_subscription",
|
||||
"create_coupon",
|
||||
"send_receipt",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "salesforce",
|
||||
label: "Salesforce",
|
||||
operations: [
|
||||
"create_lead",
|
||||
"convert_lead",
|
||||
"update_contact",
|
||||
"create_account",
|
||||
"update_opportunity",
|
||||
"advance_opportunity",
|
||||
"add_activity",
|
||||
"create_case",
|
||||
"close_case",
|
||||
"assign_owner",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "hubspot",
|
||||
label: "HubSpot",
|
||||
operations: [
|
||||
"create_contact",
|
||||
"update_contact",
|
||||
"create_company",
|
||||
"create_deal",
|
||||
"move_deal_stage",
|
||||
"enroll_workflow",
|
||||
"add_note",
|
||||
"create_task",
|
||||
"merge_contacts",
|
||||
"assign_owner",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "zendesk",
|
||||
label: "Zendesk",
|
||||
operations: [
|
||||
"create_ticket",
|
||||
"update_ticket",
|
||||
"assign_ticket",
|
||||
"add_internal_note",
|
||||
"reply_ticket",
|
||||
"set_priority",
|
||||
"add_tag",
|
||||
"remove_tag",
|
||||
"merge_ticket",
|
||||
"solve_ticket",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "datadog",
|
||||
label: "Datadog",
|
||||
operations: [
|
||||
"create_monitor",
|
||||
"mute_monitor",
|
||||
"unmute_monitor",
|
||||
"create_dashboard",
|
||||
"add_dashboard_widget",
|
||||
"annotate_event",
|
||||
"create_incident",
|
||||
"update_incident",
|
||||
"resolve_incident",
|
||||
"schedule_downtime",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "pagerduty",
|
||||
label: "PagerDuty",
|
||||
operations: [
|
||||
"trigger_incident",
|
||||
"acknowledge_incident",
|
||||
"resolve_incident",
|
||||
"reassign_incident",
|
||||
"add_responder",
|
||||
"add_note",
|
||||
"create_escalation_policy",
|
||||
"override_schedule",
|
||||
"create_maintenance_window",
|
||||
"snooze_incident",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "aws",
|
||||
label: "AWS",
|
||||
operations: [
|
||||
"launch_instance",
|
||||
"stop_instance",
|
||||
"restart_instance",
|
||||
"scale_service",
|
||||
"deploy_lambda",
|
||||
"invalidate_cache",
|
||||
"rotate_secret",
|
||||
"create_queue",
|
||||
"publish_topic",
|
||||
"snapshot_database",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "vercel",
|
||||
label: "Vercel",
|
||||
operations: [
|
||||
"create_project",
|
||||
"deploy_project",
|
||||
"promote_deployment",
|
||||
"rollback_deployment",
|
||||
"add_domain",
|
||||
"remove_domain",
|
||||
"set_env_variable",
|
||||
"remove_env_variable",
|
||||
"purge_cache",
|
||||
"inspect_deployment",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "figma",
|
||||
label: "Figma",
|
||||
operations: [
|
||||
"create_file",
|
||||
"duplicate_file",
|
||||
"create_page",
|
||||
"rename_layer",
|
||||
"add_comment",
|
||||
"resolve_comment",
|
||||
"publish_library",
|
||||
"create_branch",
|
||||
"merge_branch",
|
||||
"export_frame",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "airtable",
|
||||
label: "Airtable",
|
||||
operations: [
|
||||
"create_base",
|
||||
"create_table",
|
||||
"add_field",
|
||||
"create_record",
|
||||
"update_record",
|
||||
"delete_record",
|
||||
"find_records",
|
||||
"link_records",
|
||||
"create_view",
|
||||
"sort_view",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "dropbox",
|
||||
label: "Dropbox",
|
||||
operations: [
|
||||
"upload_file",
|
||||
"download_file",
|
||||
"move_file",
|
||||
"copy_file",
|
||||
"delete_file",
|
||||
"restore_file",
|
||||
"share_link",
|
||||
"revoke_link",
|
||||
"create_folder",
|
||||
"search_files",
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "snowflake",
|
||||
label: "Snowflake",
|
||||
operations: [
|
||||
"create_warehouse",
|
||||
"resize_warehouse",
|
||||
"suspend_warehouse",
|
||||
"resume_warehouse",
|
||||
"create_database",
|
||||
"create_schema",
|
||||
"create_table",
|
||||
"load_stage",
|
||||
"run_query",
|
||||
"grant_role",
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
const Input = Schema.Struct({})
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.search-fixture",
|
||||
effect: Effect.fn("SearchFixtureTool.Plugin")(function* (ctx: PluginContext) {
|
||||
yield* ctx.tool
|
||||
.transform((draft) => {
|
||||
catalog.forEach((group) =>
|
||||
group.operations.forEach((operation) => {
|
||||
const action = operation.replaceAll("_", " ")
|
||||
draft.add(
|
||||
operation,
|
||||
Tool.make({
|
||||
description: `${action[0]?.toUpperCase()}${action.slice(1)} in ${group.label}.`,
|
||||
input: Input,
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed(`Completed ${action}.`),
|
||||
}),
|
||||
{ group: group.namespace, deferred: true },
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import { Tool } from "./tool"
|
||||
export const name = "subagent"
|
||||
|
||||
const NO_TEXT = "Subagent completed without a text response."
|
||||
const backgroundStarted = (sessionID: SessionSchema.ID) =>
|
||||
`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.`
|
||||
const BACKGROUND_STARTED =
|
||||
"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({
|
||||
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* (
|
||||
parentID: SessionSchema.ID,
|
||||
childID: SessionSchema.ID,
|
||||
agent: string,
|
||||
description: string,
|
||||
state: "completed" | "error" | "cancelled",
|
||||
text: string,
|
||||
@@ -73,32 +72,22 @@ export const Plugin = {
|
||||
yield* runtime.session.synthetic({
|
||||
sessionID: parentID,
|
||||
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* (
|
||||
parentID: SessionSchema.ID,
|
||||
childID: SessionSchema.ID,
|
||||
agent: string,
|
||||
description: string,
|
||||
) {
|
||||
yield* runtime.job.wait({ id: childID }).pipe(
|
||||
Effect.flatMap((result) => {
|
||||
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")
|
||||
return injectCompletion(
|
||||
parentID,
|
||||
childID,
|
||||
agent,
|
||||
description,
|
||||
"error",
|
||||
result.info.error ?? "Subagent failed",
|
||||
)
|
||||
return injectCompletion(parentID, childID, description, "error", result.info.error ?? "Subagent failed")
|
||||
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
|
||||
}),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
@@ -178,12 +167,8 @@ export const Plugin = {
|
||||
|
||||
if (background) {
|
||||
yield* runtime.job.background(info.id)
|
||||
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
|
||||
return {
|
||||
sessionID: child.id,
|
||||
status: "running" as const,
|
||||
output: backgroundStarted(child.id),
|
||||
}
|
||||
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
||||
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
||||
}
|
||||
|
||||
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
|
||||
@@ -194,12 +179,8 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
|
||||
return {
|
||||
sessionID: child.id,
|
||||
status: "running" as const,
|
||||
output: backgroundStarted(child.id),
|
||||
}
|
||||
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
||||
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
||||
}
|
||||
if (result?.info.status === "error")
|
||||
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
|
||||
|
||||
@@ -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 { EventV2 } from "@opencode-ai/core/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 { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
||||
@@ -13,26 +13,6 @@ import { testEffect } from "./lib/effect"
|
||||
const it = testEffect(LayerNode.compile(Git.node))
|
||||
|
||||
describe("Git", () => {
|
||||
it.live("discovers repository metadata without a work tree", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(async () => {
|
||||
await initRepo(root.path)
|
||||
await $`git config core.bare true`.cwd(root.path).quiet()
|
||||
})
|
||||
const directory = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||
const git = yield* Git.Service
|
||||
const repository = yield* git.repo.discover(directory)
|
||||
|
||||
expect(repository?.worktree).toBe(directory)
|
||||
expect(repository?.gitDirectory).toBe(AbsolutePath.make(path.join(directory, ".git")))
|
||||
expect(repository?.commonDirectory).toBe(repository?.gitDirectory)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("clones a remote and reads checkout metadata", () =>
|
||||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { CopilotModels } from "@opencode-ai/core/github-copilot/models"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
|
||||
test("defensively syncs advertised Copilot models", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () =>
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
model_picker_enabled: true,
|
||||
id: "gpt-5",
|
||||
name: "GPT-5 remote",
|
||||
version: "gpt-5-2026-06-01",
|
||||
supported_endpoints: ["/responses"],
|
||||
billing: {
|
||||
token_prices: {
|
||||
batch_size: 0,
|
||||
default: { input_price: 10, output_price: 20, cache_price: 5 },
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
family: "gpt",
|
||||
limits: {
|
||||
max_context_window_tokens: 200000,
|
||||
max_output_tokens: 16384,
|
||||
max_prompt_tokens: 180000,
|
||||
},
|
||||
supports: { tool_calls: true, reasoning_effort: ["low", "high"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
model_picker_enabled: false,
|
||||
id: "utility",
|
||||
name: "Utility",
|
||||
version: "utility-2026-06-01",
|
||||
capabilities: {
|
||||
family: "utility",
|
||||
limits: { max_output_tokens: 1000, max_prompt_tokens: 8000 },
|
||||
supports: { tool_calls: false },
|
||||
},
|
||||
},
|
||||
{ model_picker_enabled: true, id: "incomplete" },
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
try {
|
||||
const existing = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, ModelV2.ID.make("gpt-5")),
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
name: "GPT-5 local",
|
||||
})
|
||||
const stale = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, ModelV2.ID.make("stale")),
|
||||
modelID: ModelV2.ID.make("stale"),
|
||||
})
|
||||
const models = await CopilotModels.get(server.url.origin, {}, [existing, stale])
|
||||
const model = models.get(ModelV2.ID.make("gpt-5"))
|
||||
|
||||
expect(model?.name).toBe("GPT-5 local")
|
||||
expect(model?.settings).toMatchObject({ baseURL: server.url.origin, endpoint: "responses" })
|
||||
expect(model?.cost[0]).toMatchObject({ input: 0, output: 0, cache: { read: 0, write: 0 } })
|
||||
expect(model?.variants.map((variant) => variant.id)).toEqual([
|
||||
ModelV2.VariantID.make("low"),
|
||||
ModelV2.VariantID.make("high"),
|
||||
])
|
||||
expect(models.get(ModelV2.ID.make("utility"))?.enabled).toBe(false)
|
||||
expect(models.has(ModelV2.ID.make("stale"))).toBe(false)
|
||||
expect(models.has(ModelV2.ID.make("incomplete"))).toBe(false)
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
@@ -40,7 +40,7 @@ describe("Integration", () => {
|
||||
.transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI")))
|
||||
.pipe(Scope.provide(scope))
|
||||
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)
|
||||
|
||||
@@ -249,36 +249,6 @@ describe("LocationServiceMap", () => {
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("does not reload plugins when config updates leave plugin operations unchanged", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const activations = { count: 0 }
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(
|
||||
EffectPlugin.define({
|
||||
id: "unchanged-config-plugin",
|
||||
effect: () => Effect.sync(() => ++activations.count).pipe(Effect.asVoid),
|
||||
}),
|
||||
)
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
|
||||
expect(activations.count).toBe(1)
|
||||
|
||||
yield* EventV2.Service.use((events) => events.publish(Config.Event.Updated, {})).pipe(Effect.provide(context))
|
||||
yield* Effect.sleep("200 millis")
|
||||
|
||||
expect(activations.count).toBe(1)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("keeps flush open while later hot reload runs", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -580,6 +550,7 @@ describe("LocationServiceMap", () => {
|
||||
expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false)
|
||||
expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
"edit",
|
||||
"execute",
|
||||
"glob",
|
||||
"grep",
|
||||
"patch",
|
||||
@@ -597,6 +568,7 @@ describe("LocationServiceMap", () => {
|
||||
expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false)
|
||||
expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
"edit",
|
||||
"execute",
|
||||
"glob",
|
||||
"grep",
|
||||
"patch",
|
||||
@@ -736,7 +708,7 @@ describe("LocationServiceMap", () => {
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
})
|
||||
yield* plugins.activate([{ ...reviewer, version: "1" }])
|
||||
yield* plugins.activate([reviewer])
|
||||
|
||||
expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({
|
||||
description: "Reviews code",
|
||||
|
||||
@@ -18,8 +18,6 @@ const it = testEffect(PluginTestLayer)
|
||||
|
||||
class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSecret") {}
|
||||
|
||||
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
|
||||
|
||||
describe("PluginV2", () => {
|
||||
it.live("exposes public events through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -39,18 +37,15 @@ describe("PluginV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces plugins by ID and version", () =>
|
||||
it.effect("replaces plugins by ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
let description = "first"
|
||||
let updates = 0
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type === Plugin.Event.Updated.type) updates++
|
||||
}),
|
||||
)
|
||||
const updated = yield* events
|
||||
.subscribe(Plugin.Event.Updated)
|
||||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
|
||||
|
||||
const managed = () =>
|
||||
EffectPlugin.define({
|
||||
@@ -65,23 +60,17 @@ describe("PluginV2", () => {
|
||||
.pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
yield* plugins.activate([versioned(managed(), "1")])
|
||||
yield* plugins.activate([managed()])
|
||||
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
|
||||
|
||||
description = "second"
|
||||
yield* plugins.activate([versioned(managed(), "2")])
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second")
|
||||
|
||||
description = "third"
|
||||
yield* plugins.activate([versioned(managed(), "2")])
|
||||
expect(updates).toBe(2)
|
||||
yield* plugins.activate([managed()])
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second")
|
||||
expect(yield* Fiber.join(updated)).toHaveLength(2)
|
||||
|
||||
yield* plugins.activate([])
|
||||
expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
|
||||
expect(updates).toBe(3)
|
||||
yield* unsubscribe
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -90,12 +79,12 @@ describe("PluginV2", () => {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const active = Plugin.ID.make("active")
|
||||
const duplicate = "duplicate"
|
||||
yield* plugins.activate([{ id: active, version: "1", effect: () => Effect.void }])
|
||||
yield* plugins.activate([{ id: active, effect: () => Effect.void }])
|
||||
|
||||
const result = yield* plugins
|
||||
.activate([
|
||||
{ id: duplicate, version: "1", effect: () => Effect.void },
|
||||
{ id: duplicate, version: "1", effect: () => Effect.void },
|
||||
{ id: duplicate, effect: () => Effect.void },
|
||||
{ id: duplicate, effect: () => Effect.void },
|
||||
])
|
||||
.pipe(Effect.exit)
|
||||
|
||||
@@ -128,12 +117,12 @@ describe("PluginV2", () => {
|
||||
},
|
||||
})
|
||||
|
||||
yield* plugins.activate([versioned(good), versioned(bad)])
|
||||
yield* plugins.activate([good, bad])
|
||||
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("loaded")
|
||||
|
||||
fail = false
|
||||
yield* plugins.activate([versioned(good), versioned(bad, "2")])
|
||||
yield* plugins.activate([good, bad])
|
||||
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }, { id: Plugin.ID.make("bad") }])
|
||||
}),
|
||||
)
|
||||
@@ -166,8 +155,8 @@ describe("PluginV2", () => {
|
||||
}),
|
||||
})
|
||||
|
||||
yield* plugins.activate([versioned(previous)])
|
||||
yield* plugins.activate([versioned(replacement, "2")])
|
||||
yield* plugins.activate([previous])
|
||||
yield* plugins.activate([replacement])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("managed") }])
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("previous")
|
||||
@@ -198,8 +187,8 @@ describe("PluginV2", () => {
|
||||
effect: () => Effect.die(new Error("replacement failed")),
|
||||
})
|
||||
|
||||
yield* plugins.activate([versioned(previous)])
|
||||
yield* plugins.activate([versioned(replacement, "2")])
|
||||
yield* plugins.activate([previous])
|
||||
yield* plugins.activate([replacement])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([])
|
||||
expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
|
||||
@@ -213,7 +202,6 @@ describe("PluginV2", () => {
|
||||
yield* plugins.activate(
|
||||
["first", "second"].map((id) => ({
|
||||
id,
|
||||
version: "1",
|
||||
effect: () => Effect.addFinalizer(() => Effect.sync(() => closed.push(id))),
|
||||
})),
|
||||
)
|
||||
@@ -237,7 +225,7 @@ describe("PluginV2", () => {
|
||||
),
|
||||
})
|
||||
|
||||
yield* plugins.activate([versioned(plugin)]).pipe(Effect.provideService(Secret, "secret"))
|
||||
yield* plugins.activate([plugin]).pipe(Effect.provideService(Secret, "secret"))
|
||||
|
||||
expect(visible).toBe(false)
|
||||
}),
|
||||
@@ -265,7 +253,7 @@ describe("PluginV2", () => {
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
|
||||
yield* plugins.activate([versioned(plugin)])
|
||||
yield* plugins.activate([plugin])
|
||||
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain("plugin_tool")
|
||||
|
||||
yield* plugins.activate([])
|
||||
@@ -296,7 +284,7 @@ describe("PluginV2", () => {
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
|
||||
yield* plugins.activate([versioned(plugin)])
|
||||
yield* plugins.activate([plugin])
|
||||
|
||||
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toEqual([
|
||||
"plain",
|
||||
@@ -355,7 +343,7 @@ describe("PluginV2", () => {
|
||||
}),
|
||||
})
|
||||
|
||||
yield* plugins.activate([versioned(plugin)])
|
||||
yield* plugins.activate([plugin])
|
||||
|
||||
const materialized = yield* registry.materialize()
|
||||
const settlement = yield* materialized.settle({
|
||||
|
||||
@@ -218,16 +218,14 @@ export function integrationHost(integration: Integration.Interface): PluginConte
|
||||
method: {
|
||||
list: (id) => draft.method.list(Integration.ID.make(id)).map(method),
|
||||
update: (input) => {
|
||||
if (input.method.type === "oauth") {
|
||||
const oauth =
|
||||
input as import("@opencode-ai/plugin/v2/effect/integration").IntegrationOAuthMethodRegistration
|
||||
const methodID = Integration.MethodID.make(oauth.method.id)
|
||||
const refresh = oauth.refresh
|
||||
if ("authorize" in input) {
|
||||
const methodID = Integration.MethodID.make(input.method.id)
|
||||
const refresh = input.refresh
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(oauth.integrationID),
|
||||
method: { ...oauth.method, id: methodID },
|
||||
authorize: (inputs: import("@opencode-ai/plugin/v2/effect/integration").IntegrationInputs) =>
|
||||
oauth.authorize(inputs).pipe(
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { ...input.method, id: methodID },
|
||||
authorize: (inputs) =>
|
||||
input.authorize(inputs).pipe(
|
||||
Effect.map((authorization) => {
|
||||
if (authorization.mode === "auto") {
|
||||
return {
|
||||
@@ -269,7 +267,7 @@ export function integrationHost(integration: Integration.Interface): PluginConte
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(oauth.label ? { label: oauth.label } : {}),
|
||||
...(input.label ? { label: input.label } : {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -280,19 +278,9 @@ export function integrationHost(integration: Integration.Interface): PluginConte
|
||||
})
|
||||
return
|
||||
}
|
||||
const registration =
|
||||
input as import("@opencode-ai/plugin/v2/effect/integration").IntegrationKeyMethodRegistration
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(registration.integrationID),
|
||||
method: registration.method,
|
||||
...(registration.authorize
|
||||
? {
|
||||
authorize: (key, inputs) =>
|
||||
registration.authorize!(key, inputs).pipe(
|
||||
Effect.map((credential) => Credential.Key.make(credential)),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: input.method,
|
||||
})
|
||||
},
|
||||
remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)),
|
||||
|
||||
@@ -214,7 +214,7 @@ describe("ModelsDevPlugin", () => {
|
||||
}),
|
||||
)
|
||||
expect(yield* integrations.list()).toEqual([
|
||||
Integration.Info.make({
|
||||
new Integration.Info({
|
||||
id: Integration.ID.make("acme"),
|
||||
name: "Acme",
|
||||
methods: [
|
||||
|
||||
@@ -3,7 +3,6 @@ import { describe, expect } from "bun:test"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
@@ -61,55 +60,6 @@ function fakeSelectorSdk(calls: string[]) {
|
||||
}
|
||||
|
||||
describe("AzurePlugin", () => {
|
||||
it.effect("registers an API key method and stores prompted resource metadata", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* addPlugin()
|
||||
expect((yield* integrations.get(Integration.ID.make("azure")))?.methods).toEqual([
|
||||
{
|
||||
type: "key",
|
||||
label: "API key",
|
||||
prompts: [
|
||||
{
|
||||
type: "text",
|
||||
key: "resourceName",
|
||||
message: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
yield* integrations.connection.key({
|
||||
integrationID: Integration.ID.make("azure"),
|
||||
key: "secret",
|
||||
inputs: { resourceName: "my-models" },
|
||||
})
|
||||
const connection = required(yield* integrations.connection.active(Integration.ID.make("azure")))
|
||||
expect(yield* integrations.connection.resolve(connection)).toMatchObject({
|
||||
type: "key",
|
||||
key: "secret",
|
||||
metadata: { resourceName: "my-models" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("omits the resource prompt when Azure resource env is configured", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* addPlugin()
|
||||
expect((yield* integrations.get(Integration.ID.make("azure")))?.methods).toContainEqual({
|
||||
type: "key",
|
||||
label: "API key",
|
||||
prompts: [],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("resolves resourceName from env", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { describe, expect, mock } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
@@ -103,69 +102,6 @@ mock.module("ai-gateway-provider/providers/unified", () => ({
|
||||
}))
|
||||
|
||||
describe("CloudflareAIGatewayPlugin", () => {
|
||||
it.effect("registers a gateway token method and stores prompted gateway metadata", () =>
|
||||
withEnv(
|
||||
cloudflareEnv({
|
||||
CLOUDFLARE_ACCOUNT_ID: undefined,
|
||||
CLOUDFLARE_GATEWAY_ID: undefined,
|
||||
CLOUDFLARE_API_TOKEN: undefined,
|
||||
}),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* addPlugin()
|
||||
const integrationID = Integration.ID.make("cloudflare-ai-gateway")
|
||||
expect((yield* integrations.get(integrationID))?.methods).toEqual([
|
||||
{
|
||||
type: "key",
|
||||
label: "Gateway API token",
|
||||
prompts: [
|
||||
{
|
||||
type: "text",
|
||||
key: "accountId",
|
||||
message: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
key: "gatewayId",
|
||||
message: "Enter your Cloudflare AI Gateway ID",
|
||||
placeholder: "e.g. my-gateway",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
yield* integrations.connection.key({
|
||||
integrationID,
|
||||
key: "secret",
|
||||
inputs: { accountId: "acct", gatewayId: "gateway" },
|
||||
})
|
||||
const connection = yield* integrations.connection.active(integrationID)
|
||||
if (!connection) throw new Error("Expected connection")
|
||||
expect(yield* integrations.connection.resolve(connection)).toMatchObject({
|
||||
type: "key",
|
||||
key: "secret",
|
||||
metadata: { accountId: "acct", gatewayId: "gateway" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("omits gateway prompts backed by Cloudflare env", () =>
|
||||
withEnv(cloudflareEnv(), () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* addPlugin()
|
||||
expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({
|
||||
type: "key",
|
||||
label: "Gateway API token",
|
||||
prompts: [],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("requires account, gateway, and token before creating the unified SDK", () =>
|
||||
withEnv(
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
@@ -80,56 +79,6 @@ function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") {
|
||||
}
|
||||
|
||||
describe("CloudflareWorkersAIPlugin", () => {
|
||||
it.effect("registers an API key method and stores prompted account metadata", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* addPlugin()
|
||||
const integrationID = Integration.ID.make("cloudflare-workers-ai")
|
||||
expect((yield* integrations.get(integrationID))?.methods).toEqual([
|
||||
{
|
||||
type: "key",
|
||||
label: "API key",
|
||||
prompts: [
|
||||
{
|
||||
type: "text",
|
||||
key: "accountId",
|
||||
message: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
yield* integrations.connection.key({
|
||||
integrationID,
|
||||
key: "secret",
|
||||
inputs: { accountId: "acct" },
|
||||
})
|
||||
const connection = required(yield* integrations.connection.active(integrationID))
|
||||
expect(yield* integrations.connection.resolve(connection)).toMatchObject({
|
||||
type: "key",
|
||||
key: "secret",
|
||||
metadata: { accountId: "acct" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("omits the account prompt when Cloudflare account env is configured", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* addPlugin()
|
||||
expect((yield* integrations.get(Integration.ID.make("cloudflare-workers-ai")))?.methods).toContainEqual({
|
||||
type: "key",
|
||||
label: "API key",
|
||||
prompts: [],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { DigitalOceanPlugin } from "@opencode-ai/core/plugin/provider/digitalocean"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const integrationID = Integration.ID.make("digitalocean")
|
||||
const providerID = ProviderV2.ID.make("digitalocean")
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* DigitalOceanPlugin.effect(host)
|
||||
})
|
||||
|
||||
describe("DigitalOceanPlugin", () => {
|
||||
it.effect("registers implicit OAuth and manual model access keys", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
expect((yield* (yield* Integration.Service).get(integrationID))?.methods).toEqual([
|
||||
{
|
||||
id: Integration.MethodID.make("implicit"),
|
||||
type: "oauth",
|
||||
label: "Login with DigitalOcean",
|
||||
},
|
||||
{ type: "key", label: "Paste Model Access Key" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds cached inference routers to the DigitalOcean catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.name = "DigitalOcean"
|
||||
})
|
||||
})
|
||||
yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("implicit"),
|
||||
refresh: "token",
|
||||
access: "token",
|
||||
expires: Date.now() + 60_000,
|
||||
metadata: {
|
||||
routers: [
|
||||
{ name: "production", uuid: "router-1" },
|
||||
{ name: "support", description: "Support router" },
|
||||
],
|
||||
routersFetchedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
yield* addPlugin()
|
||||
|
||||
const model = yield* catalog.model.get(providerID, ModelV2.ID.make("router:production"))
|
||||
expect(model).toMatchObject({
|
||||
id: "router:production",
|
||||
modelID: "router:production",
|
||||
providerID: "digitalocean",
|
||||
name: "production",
|
||||
family: "digitalocean-inference-routers",
|
||||
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: "https://inference.do-ai.run/v1" },
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
limit: { context: 128_000, output: 8_192 },
|
||||
})
|
||||
expect(yield* catalog.model.get(providerID, ModelV2.ID.make("router:support"))).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stores a manually registered model access key", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.connection.key({ integrationID, key: "model-access-key" })
|
||||
expect((yield* (yield* Credential.Service).list(integrationID))[0]?.value).toEqual({
|
||||
type: "key",
|
||||
key: "model-access-key",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -5,9 +5,8 @@ import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { copilotFetch, GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
|
||||
import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
@@ -40,46 +39,6 @@ function fakeSelectorSdk(calls: string[]) {
|
||||
}
|
||||
|
||||
describe("GithubCopilotPlugin", () => {
|
||||
it.effect("registers GitHub Copilot device OAuth", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
expect((yield* (yield* Integration.Service).get(Integration.ID.make("github-copilot")))?.methods).toContainEqual({
|
||||
id: Integration.MethodID.make("device"),
|
||||
type: "oauth",
|
||||
label: "Login with GitHub Copilot",
|
||||
prompts: expect.any(Array),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("adds Copilot authentication and request metadata headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const requests: Headers[] = []
|
||||
const send = copilotFetch(
|
||||
"token",
|
||||
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
requests.push(new Headers(init?.headers))
|
||||
return Response.json({ ok: true })
|
||||
},
|
||||
false,
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
send("https://api.githubcopilot.com/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "x-api-key": "old" },
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png" } }] }],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(requests[0]?.get("authorization")).toBe("Bearer token")
|
||||
expect(requests[0]?.has("x-api-key")).toBe(false)
|
||||
expect(requests[0]?.get("x-initiator")).toBe("user")
|
||||
expect(requests[0]?.get("copilot-vision-request")).toBe("true")
|
||||
expect(requests[0]?.get("x-github-api-version")).toBe("2026-06-01")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { describe, expect, mock } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
@@ -43,20 +41,6 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
||||
)
|
||||
}
|
||||
|
||||
function eventually<A>(
|
||||
effect: Effect.Effect<A>,
|
||||
predicate: (value: A) => boolean,
|
||||
remaining = 1000,
|
||||
): Effect.Effect<A, Error> {
|
||||
return Effect.gen(function* () {
|
||||
const value = yield* effect
|
||||
if (predicate(value)) return value
|
||||
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* eventually(effect, predicate, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
void mock.module("gitlab-ai-provider", () => ({
|
||||
VERSION: "test-version",
|
||||
createGitLab: (options: Record<string, unknown>) => {
|
||||
@@ -71,155 +55,6 @@ void mock.module("gitlab-ai-provider", () => ({
|
||||
}))
|
||||
|
||||
describe("GitLabPlugin", () => {
|
||||
it.effect("registers OAuth, PAT, and environment methods", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
expect((yield* (yield* Integration.Service).get(Integration.ID.make("gitlab")))?.methods).toEqual([
|
||||
{
|
||||
id: Integration.MethodID.make("oauth"),
|
||||
type: "oauth",
|
||||
label: "GitLab OAuth",
|
||||
prompts: [
|
||||
{
|
||||
type: "text",
|
||||
key: "instanceUrl",
|
||||
message: "GitLab instance URL",
|
||||
placeholder: "https://gitlab.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "key",
|
||||
label: "GitLab Personal Access Token",
|
||||
prompts: [
|
||||
{
|
||||
type: "text",
|
||||
key: "instanceUrl",
|
||||
message: "GitLab instance URL",
|
||||
placeholder: "https://gitlab.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "env", names: ["GITLAB_TOKEN"] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("validates PATs and stores normalized instance URL metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const original = globalThis.fetch
|
||||
const calls: [string, RequestInit | undefined][] = []
|
||||
globalThis.fetch = Object.assign(
|
||||
async (input: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push([String(input), init])
|
||||
return new Response(JSON.stringify({ id: 1 }), { status: 200 })
|
||||
},
|
||||
{ preconnect: original.preconnect },
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => (globalThis.fetch = original)))
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.connection.key({
|
||||
integrationID: Integration.ID.make("gitlab"),
|
||||
key: "glpat-test",
|
||||
inputs: { instanceUrl: "https://gitlab.example/path/" },
|
||||
})
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.[0]).toBe("https://gitlab.example/api/v4/user")
|
||||
expect(calls[0]?.[1]?.headers).toEqual({ Authorization: "Bearer glpat-test" })
|
||||
expect((yield* (yield* Credential.Service).list(Integration.ID.make("gitlab")))[0]?.value).toEqual({
|
||||
type: "key",
|
||||
key: "glpat-test",
|
||||
metadata: { instanceUrl: "https://gitlab.example" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects invalid PAT instance URLs before validation", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
const exit = yield* integrations.connection
|
||||
.key({
|
||||
integrationID: Integration.ID.make("gitlab"),
|
||||
key: "glpat-test",
|
||||
inputs: { instanceUrl: "file:///tmp/gitlab" },
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
expect(yield* (yield* Credential.Service).list(Integration.ID.make("gitlab"))).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("completes OAuth PKCE and refreshes with instance metadata", () =>
|
||||
withEnv({ GITLAB_OAUTH_CLIENT_ID: "test-client" }, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const original = globalThis.fetch
|
||||
const tokenBodies: URLSearchParams[] = []
|
||||
globalThis.fetch = Object.assign(
|
||||
async (input: string | URL | Request, init?: RequestInit) => {
|
||||
if (String(input).startsWith("http://127.0.0.1:8080/")) return original(input, init)
|
||||
tokenBodies.push(new URLSearchParams(String(init?.body)))
|
||||
return Response.json({
|
||||
access_token: tokenBodies.length === 1 ? "access" : "refreshed-access",
|
||||
refresh_token: tokenBodies.length === 1 ? "refresh" : "rotated-refresh",
|
||||
expires_in: 1,
|
||||
})
|
||||
},
|
||||
{ preconnect: original.preconnect },
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => (globalThis.fetch = original)))
|
||||
|
||||
const integrations = yield* Integration.Service
|
||||
const attempt = yield* integrations.connection.oauth({
|
||||
integrationID: Integration.ID.make("gitlab"),
|
||||
methodID: Integration.MethodID.make("oauth"),
|
||||
inputs: { instanceUrl: "http://gitlab.example/path" },
|
||||
})
|
||||
const authorize = new URL(attempt.url)
|
||||
expect(authorize.origin).toBe("http://gitlab.example")
|
||||
expect(authorize.pathname).toBe("/oauth/authorize")
|
||||
expect(authorize.searchParams.get("client_id")).toBe("test-client")
|
||||
expect(authorize.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:8080/callback")
|
||||
expect(authorize.searchParams.get("code_challenge_method")).toBe("S256")
|
||||
expect(authorize.searchParams.get("code_challenge")).toBeTruthy()
|
||||
yield* Effect.promise(() =>
|
||||
fetch(`http://127.0.0.1:8080/callback?code=test-code&state=${authorize.searchParams.get("state")}`),
|
||||
)
|
||||
yield* eventually(integrations.attempt.status(attempt.attemptID), (status) => status.status === "complete")
|
||||
|
||||
const saved = (yield* (yield* Credential.Service).list(Integration.ID.make("gitlab")))[0]
|
||||
expect(saved?.value).toEqual({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("oauth"),
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: expect.any(Number),
|
||||
metadata: { instanceUrl: "http://gitlab.example" },
|
||||
})
|
||||
expect(tokenBodies[0]?.get("grant_type")).toBe("authorization_code")
|
||||
expect(tokenBodies[0]?.get("code_verifier")).toBeTruthy()
|
||||
|
||||
if (saved?.value.type !== "oauth") throw new Error("Expected OAuth credential")
|
||||
yield* (yield* Credential.Service).update(saved.id, { value: { ...saved.value, expires: 0 } })
|
||||
const resolved = yield* integrations.connection.resolve({
|
||||
type: "credential",
|
||||
id: saved!.id,
|
||||
label: saved!.label,
|
||||
})
|
||||
expect(resolved).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "refreshed-access",
|
||||
refresh: "rotated-refresh",
|
||||
metadata: { instanceUrl: "http://gitlab.example" },
|
||||
})
|
||||
expect(tokenBodies[1]?.get("grant_type")).toBe("refresh_token")
|
||||
expect(tokenBodies[1]?.get("refresh_token")).toBe("refresh")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () =>
|
||||
withEnv(
|
||||
{
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PoePlugin } from "@opencode-ai/core/plugin/provider/poe"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const integrationID = Integration.ID.make("poe")
|
||||
const methodID = Integration.MethodID.make("browser")
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* PoePlugin.effect(host)
|
||||
})
|
||||
|
||||
describe("PoePlugin", () => {
|
||||
it.effect("registers browser OAuth and manual API key methods", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
const integration = yield* integrations.get(integrationID)
|
||||
expect(integration?.name).toBe("Poe")
|
||||
expect(integration?.methods).toEqual([
|
||||
{ id: methodID, type: "oauth", label: "Login with Poe (browser)" },
|
||||
{ type: "key", label: "Manually enter API Key" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stores manually entered Poe API keys", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
yield* integrations.connection.key({ integrationID, key: "poe-test" })
|
||||
expect((yield* credentials.list(integrationID))[0]?.value).toEqual({ type: "key", key: "poe-test" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("starts a PKCE browser authorization on an ephemeral loopback server", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||
const url = new URL(attempt.url)
|
||||
const redirect = new URL(url.searchParams.get("redirect_uri") ?? "")
|
||||
|
||||
expect(url.origin + url.pathname).toBe("https://poe.com/oauth/authorize")
|
||||
expect(url.searchParams.get("response_type")).toBe("code")
|
||||
expect(url.searchParams.get("client_id")).toBe("client_728290227fc048cc9262091a1ea197ea")
|
||||
expect(url.searchParams.get("scope")).toBe("apikey:create")
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256")
|
||||
expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||
expect(redirect.hostname).toBe("127.0.0.1")
|
||||
expect(redirect.pathname).toBe("/callback")
|
||||
expect(Number(redirect.port)).toBeGreaterThan(0)
|
||||
|
||||
yield* integrations.attempt.cancel(attempt.attemptID)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,11 +1,10 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { describe, expect, it as bun_it } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { SnowflakeCortexPlugin, cortexFetch, oauthScope } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
|
||||
import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -42,61 +41,6 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
||||
}
|
||||
|
||||
describe("SnowflakeCortexPlugin", () => {
|
||||
it.effect("registers browser OAuth, key, and environment methods", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
expect((yield* (yield* Integration.Service).get(Integration.ID.make("snowflake-cortex")))?.methods).toEqual([
|
||||
{
|
||||
id: Integration.MethodID.make("snowflake-browser"),
|
||||
type: "oauth",
|
||||
label: "Login with Snowflake (External Browser)",
|
||||
prompts: [
|
||||
{
|
||||
type: "text",
|
||||
key: "account",
|
||||
message: "Snowflake Account Identifier",
|
||||
placeholder: "myorg-myaccount",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
key: "role",
|
||||
message: "Snowflake Role (optional)",
|
||||
placeholder: "PUBLIC",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "key", label: "Paste PAT or bearer token manually" },
|
||||
{ type: "env", names: ["SNOWFLAKE_CORTEX_TOKEN", "SNOWFLAKE_CORTEX_PAT"] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses account metadata to derive the Cortex endpoint", () =>
|
||||
withEnv({ SNOWFLAKE_ACCOUNT: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { account: "https://myorg-myaccount.snowflakecomputing.com/", apiKey: "test-pat" },
|
||||
})
|
||||
expect(result.options.baseURL).toBe("https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/v1")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses Snowflake-compatible OAuth scopes", () =>
|
||||
Effect.sync(() => {
|
||||
expect(oauthScope(undefined)).toBe("refresh_token")
|
||||
expect(oauthScope("PUBLIC")).toBe("refresh_token session:role:PUBLIC")
|
||||
expect(oauthScope("AUTH SNOWFLAKE")).toBe("refresh_token session:role-encoded:AUTH%20SNOWFLAKE")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
|
||||
Effect.sync(() => {
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake-cortex")
|
||||
@@ -250,7 +194,6 @@ describe("cortexFetch", () => {
|
||||
const body = JSON.parse(captured[0].body as string)
|
||||
expect(body.max_completion_tokens).toBe(1024)
|
||||
expect(body.max_tokens).toBeUndefined()
|
||||
expect(new Headers(captured[0].headers).get("User-Agent")).toMatch(/^opencode\//)
|
||||
})
|
||||
|
||||
bun_it("preserves body when max_tokens is absent", async () => {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
@@ -18,8 +16,7 @@ const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const integration = yield* Integration.Service
|
||||
yield* XAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration))
|
||||
yield* XAIPlugin.effect(host)
|
||||
})
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
@@ -36,39 +33,6 @@ function fakeSelectorSdk(calls: string[]) {
|
||||
}
|
||||
|
||||
describe("XAIPlugin", () => {
|
||||
it.effect("registers browser OAuth, device OAuth, and API key methods", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integration = yield* (yield* Integration.Service).get(Integration.ID.make("xai"))
|
||||
expect(integration?.name).toBe("xAI")
|
||||
expect(integration?.methods).toEqual([
|
||||
{
|
||||
id: Integration.MethodID.make("browser"),
|
||||
type: "oauth",
|
||||
label: "xAI Grok OAuth (SuperGrok Subscription)",
|
||||
},
|
||||
{
|
||||
id: Integration.MethodID.make("device"),
|
||||
type: "oauth",
|
||||
label: "xAI Grok OAuth (Headless / Remote / VPS)",
|
||||
},
|
||||
{ type: "key", label: "Manually enter API Key" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stores API keys through the registered key method", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.connection.key({ integrationID: Integration.ID.make("xai"), key: "xai-test" })
|
||||
expect((yield* (yield* Credential.Service).list(Integration.ID.make("xai")))[0]?.value).toEqual({
|
||||
type: "key",
|
||||
key: "xai-test",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates an xAI SDK only for @ai-sdk/xai", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("ReferenceGuidance", () => {
|
||||
Layer.mock(Reference.Service, {
|
||||
list: () =>
|
||||
Effect.succeed([
|
||||
Reference.Info.make({
|
||||
new Reference.Info({
|
||||
name: "docs",
|
||||
path: AbsolutePath.make("/docs"),
|
||||
description: "Use for product documentation",
|
||||
@@ -62,7 +62,7 @@ describe("ReferenceGuidance", () => {
|
||||
Layer.mock(Reference.Service, {
|
||||
list: () =>
|
||||
Effect.succeed([
|
||||
Reference.Info.make({
|
||||
new Reference.Info({
|
||||
name: "docs",
|
||||
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", () => {
|
||||
const reference = (name: string, description: string) =>
|
||||
Reference.Info.make({
|
||||
new Reference.Info({
|
||||
name,
|
||||
path: AbsolutePath.make(`/${name}`),
|
||||
description,
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("Reference", () => {
|
||||
yield* references.transform((editor) => editor.add("docs", source)).pipe(Scope.provide(scope))
|
||||
|
||||
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)
|
||||
@@ -45,7 +45,7 @@ describe("Reference", () => {
|
||||
yield* references.transform((editor) => editor.add("sdk", source))
|
||||
|
||||
expect(yield* references.list()).toEqual([
|
||||
Reference.Info.make({
|
||||
new Reference.Info({
|
||||
name: "sdk",
|
||||
path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)),
|
||||
source,
|
||||
@@ -66,7 +66,7 @@ describe("Reference", () => {
|
||||
yield* references.transform((editor) => editor.add("sdk", source))
|
||||
|
||||
expect(yield* references.list()).toEqual([
|
||||
Reference.Info.make({
|
||||
new Reference.Info({
|
||||
name: "sdk",
|
||||
path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)),
|
||||
description: "Use for SDK implementation details",
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Model,
|
||||
ToolFailure,
|
||||
TransportReason,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
RateLimitReason,
|
||||
type LLMClientShape,
|
||||
@@ -717,18 +716,7 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
error: { type: "provider.transport", message: "Provider unavailable" },
|
||||
content: [
|
||||
kind === "tool input"
|
||||
? {
|
||||
type: "tool",
|
||||
id: fragmentID(kind, "partial"),
|
||||
state: {
|
||||
status: "error",
|
||||
error: { type: "provider.transport", message: "Provider unavailable" },
|
||||
},
|
||||
}
|
||||
: fixture.expectedContent,
|
||||
],
|
||||
content: [fixture.expectedContent],
|
||||
},
|
||||
])
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -54,23 +54,6 @@ function waitForSkillUpdate() {
|
||||
}
|
||||
|
||||
describe("SkillV2", () => {
|
||||
it.live("publishes updates when skill sources change", () =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* SkillV2.Service
|
||||
|
||||
yield* Effect.acquireUseRelease(
|
||||
waitForSkillUpdate(),
|
||||
({ deferred }) =>
|
||||
skill
|
||||
.transform((editor) =>
|
||||
editor.source({ type: "directory", path: AbsolutePath.make("/tmp/opencode-skills") }),
|
||||
)
|
||||
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
|
||||
({ fiber }) => Fiber.interrupt(fiber),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("registers sources and resolves later source precedence", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect } from "bun:test"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { SearchFixtureTool } from "@opencode-ai/core/tool/search-fixture"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { registerToolPlugin } from "./lib/tool"
|
||||
|
||||
const outputStore = Layer.mock(ToolOutputStore.Service, {
|
||||
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
|
||||
})
|
||||
const it = testEffect(AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]]))
|
||||
|
||||
it.effect("registers 200 searchable no-op tools across namespaces", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(SearchFixtureTool.catalog).toHaveLength(20)
|
||||
expect(SearchFixtureTool.catalog.flatMap((group) => group.operations)).toHaveLength(200)
|
||||
|
||||
yield* registerToolPlugin(SearchFixtureTool.Plugin)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const tools = yield* registry.materialize()
|
||||
expect(tools.definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
|
||||
const invoke = (code: string, id: string) =>
|
||||
tools.settle({
|
||||
sessionID: SessionV2.ID.make("ses_search_fixture"),
|
||||
agent: AgentV2.ID.make("build"),
|
||||
assistantMessageID: SessionMessage.ID.make("msg_search_fixture"),
|
||||
call: { type: "tool-call", id, name: "execute", input: { code } },
|
||||
})
|
||||
|
||||
const searched = yield* invoke(
|
||||
'return await tools.$codemode.search({ query: "refund payment", namespace: "stripe" })',
|
||||
"call_search_fixture",
|
||||
)
|
||||
expect(searched.result.type).toBe("text")
|
||||
if (searched.result.type !== "text") return
|
||||
expect(JSON.parse(String(searched.result.value)).items[0]).toMatchObject({
|
||||
path: "tools.stripe.refund_payment",
|
||||
description: "Refund payment in Stripe.",
|
||||
})
|
||||
|
||||
const completed = yield* invoke("return await tools.stripe.refund_payment({})", "call_run_fixture")
|
||||
expect(completed.result).toEqual({ type: "text", value: "Completed refund payment." })
|
||||
}),
|
||||
)
|
||||
@@ -281,22 +281,10 @@ describe("SubagentTool", () => {
|
||||
},
|
||||
})
|
||||
const childID = outputSessionID(settled.output?.structured)
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
status: "running",
|
||||
output: expect.stringContaining(`id: ${childID}`),
|
||||
})
|
||||
expect(settled.output?.structured).toMatchObject({ status: "running" })
|
||||
|
||||
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).toMatchObject({
|
||||
description: "background review",
|
||||
metadata: {
|
||||
source: "subagent",
|
||||
childID,
|
||||
agent: "reviewer",
|
||||
state: "completed",
|
||||
},
|
||||
})
|
||||
const database = yield* Database.Service
|
||||
yield* SessionPending.promoteSteers(database.db, events, parent.id)
|
||||
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")
|
||||
})
|
||||
|
||||
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", () => {
|
||||
const output = compile(
|
||||
api(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Config } from "@/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"
|
||||
|
||||
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 { Filesystem } from "@/util/filesystem"
|
||||
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 { unique } from "remeda"
|
||||
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 { Global } from "@opencode-ai/core/global"
|
||||
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 { CurrentWorkingDirectory } from "./tui-cwd"
|
||||
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 { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { ConfigVariable } from "@/config/variable"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
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 type Info = TuiConfig.Info
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AudioPlayOptions, AudioSound } from "@opentui/core"
|
||||
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"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
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 { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { spyOn } from "bun:test"
|
||||
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 { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind"
|
||||
import { TuiKeybind } from "@opencode-ai/tui/config/keybind"
|
||||
|
||||
type PluginSpec = string | [string, Record<string, unknown>]
|
||||
type PluginOrigin = {
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"./tui": "./src/tui.ts",
|
||||
"./v2/effect": "./src/v2/effect/index.ts",
|
||||
"./v2/effect/*": "./src/v2/effect/*.ts",
|
||||
"./v2/tui/*": "./src/v2/tui/*.ts",
|
||||
"./v2": "./src/v2/promise/index.ts",
|
||||
"./v2/*": "./src/v2/promise/*.ts"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
ConnectionInfo,
|
||||
CredentialKey,
|
||||
CredentialOAuth,
|
||||
CredentialValue,
|
||||
IntegrationEnvMethod,
|
||||
@@ -14,8 +13,6 @@ import type { IntegrationApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Effect, Scope } from "effect"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export type { IntegrationInputs }
|
||||
|
||||
export type IntegrationOAuthAuthorization = {
|
||||
readonly url: string
|
||||
readonly instructions: string
|
||||
@@ -36,19 +33,16 @@ export type IntegrationOAuthMethodRegistration = {
|
||||
readonly refresh?: (credential: CredentialOAuth) => Effect.Effect<CredentialOAuth, unknown>
|
||||
readonly label?: (credential: CredentialOAuth) => string | undefined
|
||||
}
|
||||
export type IntegrationKeyMethodRegistration = {
|
||||
readonly integrationID: string
|
||||
readonly method: IntegrationKeyMethod
|
||||
readonly authorize?: (key: string, inputs: IntegrationInputs) => Effect.Effect<CredentialKey, unknown>
|
||||
}
|
||||
export type IntegrationEnvMethodRegistration = {
|
||||
readonly integrationID: string
|
||||
readonly method: IntegrationEnvMethod
|
||||
}
|
||||
export type IntegrationMethodRegistration =
|
||||
| IntegrationOAuthMethodRegistration
|
||||
| IntegrationKeyMethodRegistration
|
||||
| IntegrationEnvMethodRegistration
|
||||
| {
|
||||
readonly integrationID: string
|
||||
readonly method: IntegrationKeyMethod
|
||||
}
|
||||
| {
|
||||
readonly integrationID: string
|
||||
readonly method: IntegrationEnvMethod
|
||||
}
|
||||
|
||||
export interface IntegrationDraft {
|
||||
list(): readonly IntegrationRef[]
|
||||
|
||||
@@ -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.pty": "pty",
|
||||
"server.shell": "shell",
|
||||
"server.mcp": "mcp",
|
||||
"server.question": "question",
|
||||
"server.reference": "reference",
|
||||
"server.project": "project",
|
||||
|
||||
@@ -43,7 +43,6 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
|
||||
query: LocationQuery,
|
||||
payload: Schema.Struct({
|
||||
key: Schema.String,
|
||||
inputs: Schema.optional(Inputs),
|
||||
label: Schema.optional(Schema.String),
|
||||
}),
|
||||
success: HttpApiSchema.NoContent,
|
||||
|
||||
@@ -60,7 +60,6 @@ export interface KeyMethod extends Schema.Schema.Type<typeof KeyMethod> {}
|
||||
export const KeyMethod = Schema.Struct({
|
||||
type: Schema.Literal("key"),
|
||||
label: optional(Schema.String),
|
||||
prompts: optional(Schema.Array(Prompt)),
|
||||
}).annotate({ identifier: "Integration.KeyMethod" })
|
||||
|
||||
export interface EnvMethod extends Schema.Schema.Type<typeof EnvMethod> {}
|
||||
@@ -93,13 +92,12 @@ export const Ref = Schema.Struct({
|
||||
name: Schema.String,
|
||||
}).annotate({ identifier: "Integration.Ref" })
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
export class Info extends Schema.Class<Info>("Integration.Info")({
|
||||
id: ID,
|
||||
name: Schema.String,
|
||||
methods: Schema.Array(Method),
|
||||
connections: Schema.Array(Connection.Info),
|
||||
}).annotate({ identifier: "Integration.Info" })
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
}) {}
|
||||
|
||||
export const AttemptID = Schema.String.pipe(
|
||||
Schema.brand("Integration.AttemptID"),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user