mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 18:06:25 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eb2042acd | ||
|
|
54504ab3a5 | ||
|
|
cc5086d127 | ||
|
|
b20482461c | ||
|
|
5b5368fe98 |
@@ -168,7 +168,6 @@
|
||||
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2",
|
||||
"solid-refresh": "0.6.3",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-solid": "catalog:",
|
||||
},
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-/5VErB3NjnKi0/LHqqJgcDadD9woNLMZZYxUjriRvJI=",
|
||||
"aarch64-linux": "sha256-CTXqFEvQIiKDe0OmtdkdY8KLQtQOxdsJNCom/Clzc1c=",
|
||||
"aarch64-darwin": "sha256-vF2+/jgWhF1Smef9U3nSpTS3RI5ZcriV0mjg1q9s8YM=",
|
||||
"x86_64-darwin": "sha256-suCQ+yDT048D3EbjFAzplyZedYZYAihseLkqg6c+wHc="
|
||||
"x86_64-linux": "sha256-EKhY3iZDrbNrBhntWpSdtLcmNLte6yVBxpIrCxr1uNM=",
|
||||
"aarch64-linux": "sha256-0OjDGZHgcnnk6IxkfK6ogeeqsGTqY/dcaZ/XzT23sgA=",
|
||||
"aarch64-darwin": "sha256-Zk51gnOicaLtPuqCYfgARhm2TjL222w1Y0Em288o0YY=",
|
||||
"x86_64-darwin": "sha256-hvDZ9zCV6zOSx6i7JZ1kVUMht+JI/jc8/y+aYrNHQ1E="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli src/index.ts",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
"dev:vite": "bun run --cwd packages/cli --conditions=browser dev/vite.ts",
|
||||
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# Vite TUI entrypoint
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
bun run dev:vite:live /path/to/project
|
||||
```
|
||||
|
||||
This uses the normal CLI and its real TUI through Vite + `solid-refresh`. For an explicit server or private backend, use `dev:vite` with `--server URL` or `--standalone` respectively. Plain `dev:vite` uses normal CLI service discovery; `dev:vite:live` explicitly connects to the installed server without replacing it.
|
||||
|
||||
- Component edits hot-update through the existing Solid refresh runtime.
|
||||
- Full reloads await TUI cleanup and restore the current route: the selected session, Home workspace, or plugin page. They do not preserve composer drafts or other component-local state, and do not replay launch prompts, route prompts, `--continue`, or `--fork`.
|
||||
- Correcting syntax errors retries a failed reload. The backend stays alive.
|
||||
- Refreshable components get local error boundaries. A render failure during a hot update triggers one full UI reload. If the fresh render also fails, the error appears in the shared themed Dialog rather than causing a reload loop. Only the latest error is shown. Escape dismisses it; saving retries failed components. State within remounted components can still reset, especially when several components share an edited file.
|
||||
- Launcher/config/dependency changes require restarting the development client.
|
||||
|
||||
`vite.ts` registers a Bun runtime module that supplies the Vite runner for the CLI's existing static `@opencode/tui` import. This registration runs only in the dev launcher; production handlers and their import graph are unchanged. `tui.ts` owns Vite and the TUI lifecycle. `entry.ts` loads the real application source through Vite. `host.js` keeps lifecycle ownership outside Vite's reloadable module cache. No production CLI handler, TUI component, or route changes are needed.
|
||||
|
||||
`refresh.ts` delegates component replacement to stock `solid-refresh`, wrapping each returned component proxy in Solid's standard ErrorBoundary. It preserves registered context identities during module evaluation: Vite's native runner can re-evaluate cyclic dependencies without invoking their HMR accept callbacks, which is too late for stock context patching. `refresh-runtime.d.ts` supplies types for the package's existing deep runtime export.
|
||||
|
||||
Vite redirects imports of the TUI route context through `route.tsx`, a dev-only wrapper around the real provider. It saves plain route snapshots in the external `host.js`, including nested Home location and plugin page data. Production route code is unchanged. Recovery is armed only for a hot update, consumed before requesting a full reload, and disarmed when the update settles or the full reload starts.
|
||||
|
||||
The entry initializes the error overlay after loading the app graph because the shared dialog and theme modules themselves use the refresh runtime.
|
||||
|
||||
Tested on Linux/Bun with full-app rendering, message/palette HMR, draft preservation, and native-terminal full reload/error recovery. External native-loaded plugins remain experimental across full reloads because their process-lifetime runtime mappings can retain an older Solid generation.
|
||||
@@ -1,17 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { host } from "@opencode/cli/vite-host"
|
||||
import { configureErrorOverlay } from "./refresh"
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.on("vite:afterUpdate", () => queueMicrotask(() => host.settle?.()))
|
||||
import.meta.hot.on("vite:beforeFullReload", async () => {
|
||||
await host.stop?.()
|
||||
host.reset?.()
|
||||
})
|
||||
}
|
||||
|
||||
const { run } = await import("../../tui/src/index")
|
||||
// Theme/dialog modules use refresh themselves; initialize their overlay after the app graph loads.
|
||||
const { ErrorOverlay } = await import("./error-overlay")
|
||||
configureErrorOverlay(ErrorOverlay)
|
||||
await host.mount?.(run)
|
||||
@@ -1,56 +0,0 @@
|
||||
/* @refresh skip */
|
||||
import { BoxRenderable, TextAttributes } from "@opentui/core"
|
||||
import { Portal, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { useTheme } from "../../tui/src/context/theme"
|
||||
import { Dialog } from "../../tui/src/ui/dialog"
|
||||
import { Keymap } from "../../tui/src/context/keymap"
|
||||
|
||||
export function ErrorOverlay(props: { component: string; error: unknown; onClose: () => void }) {
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const focus = renderer.currentFocusedRenderable
|
||||
onCleanup(Keymap.use().mode.push("modal"))
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
priority: 1000,
|
||||
commands: [{ bind: "escape", title: "Close hot reload error", group: "Development", run: props.onClose }],
|
||||
}))
|
||||
onMount(() => focus?.blur())
|
||||
onCleanup(() => {
|
||||
if (focus && !focus.isDestroyed) focus.focus()
|
||||
})
|
||||
|
||||
return (
|
||||
<Portal
|
||||
ref={(container) => {
|
||||
if (!(container instanceof BoxRenderable)) return
|
||||
// Anchor Portal's wrapper above the app rather than after it in root layout.
|
||||
container.position = "absolute"
|
||||
container.left = 0
|
||||
container.top = 0
|
||||
container.zIndex = 5000
|
||||
}}
|
||||
>
|
||||
<Dialog centered onClose={props.onClose}>
|
||||
<box maxHeight={Math.max(1, dimensions().height - 3)} paddingX={2} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
||||
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Error while hot reloading
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={props.onClose}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.default}>
|
||||
{props.error instanceof Error ? props.error.message : String(props.error)}
|
||||
</text>
|
||||
<text flexShrink={0} fg={theme.text.subdued}>
|
||||
{props.component} · Fix the component and save to retry.
|
||||
</text>
|
||||
</box>
|
||||
</Dialog>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
Vendored
-16
@@ -1,16 +0,0 @@
|
||||
import type { Effect, Fiber, FileSystem } from "effect"
|
||||
import type { TuiInput } from "@opencode/tui"
|
||||
import type { Global } from "@opencode/util/global"
|
||||
import type { Route } from "../../tui/src/context/route"
|
||||
|
||||
export type Run = (input: TuiInput) => Effect.Effect<void, unknown, Global.Service | FileSystem.FileSystem>
|
||||
|
||||
export declare const host: {
|
||||
active?: Fiber.Fiber<void, unknown>
|
||||
mount?: (app: Run) => Promise<void>
|
||||
stop?: () => Promise<void>
|
||||
reset?: () => void
|
||||
recover?: () => boolean
|
||||
settle?: () => void
|
||||
route?: Route
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// External to Vite's module cache: keep lifecycle and route state across reloads.
|
||||
export const host = {}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
declare module "solid-refresh/dist/solid-refresh.mjs" {
|
||||
export * from "solid-refresh"
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { createComponent, createSignal, ErrorBoundary, onCleanup, Show, type JSX } from "solid-js"
|
||||
import { $$component, $$refresh, type Registry } from "solid-refresh/dist/solid-refresh.mjs"
|
||||
import type { ErrorOverlay } from "./error-overlay"
|
||||
import { host } from "@opencode/cli/vite-host"
|
||||
|
||||
let overlay: typeof ErrorOverlay
|
||||
const [activeError, setActiveError] = createSignal<symbol>()
|
||||
export function configureErrorOverlay(component: typeof ErrorOverlay) {
|
||||
overlay = component
|
||||
}
|
||||
|
||||
export { $$context, $$decline, $$registry } from "solid-refresh/dist/solid-refresh.mjs"
|
||||
export { refresh as $$refresh }
|
||||
export { component as $$component }
|
||||
|
||||
function refresh(...args: Parameters<typeof $$refresh>) {
|
||||
// The native runner can re-evaluate a dependency in a cycle without accepting
|
||||
// an update for that module. Preserve context identity now, before an updated
|
||||
// consumer renders, rather than waiting for solid-refresh's accept callback.
|
||||
const previous = args[1].data?.["solid-refresh"]
|
||||
args[2].contexts.forEach((entry, id) => {
|
||||
const old = previous?.contexts.get(id)
|
||||
if (!old) return
|
||||
old.context.defaultValue = entry.context.defaultValue
|
||||
entry.context.id = old.context.id
|
||||
entry.context.Provider = old.context.Provider
|
||||
})
|
||||
$$refresh(...args)
|
||||
}
|
||||
|
||||
function component<P extends Record<string, unknown>>(
|
||||
registry: Registry,
|
||||
id: string,
|
||||
render: (props: P) => JSX.Element,
|
||||
options?: Parameters<typeof $$component>[3],
|
||||
) {
|
||||
const proxy = $$component(registry, id, render, options)
|
||||
return (props: P) =>
|
||||
createComponent(ErrorBoundary, {
|
||||
fallback(error: unknown, reset: () => void) {
|
||||
if (host.recover?.()) return null
|
||||
const token = Symbol(id)
|
||||
// Several instances can fail in one update. Stack neither dialogs nor translucent backdrops.
|
||||
setActiveError(token)
|
||||
onCleanup(() => {
|
||||
if (activeError() === token) setActiveError(undefined)
|
||||
})
|
||||
// Retry only this failed subtree. Resetting the app's boundary destroys its providers and route.
|
||||
import.meta.hot?.on("vite:afterUpdate", reset)
|
||||
onCleanup(() => import.meta.hot?.off("vite:afterUpdate", reset))
|
||||
return createComponent(Show, {
|
||||
keyed: true,
|
||||
get when() {
|
||||
return activeError() === token
|
||||
},
|
||||
get children() {
|
||||
return createComponent(overlay, { component: id, error, onClose: () => setActiveError(undefined) })
|
||||
},
|
||||
})
|
||||
},
|
||||
get children() {
|
||||
return createComponent(proxy, props)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createEffect, on, type ComponentProps } from "solid-js"
|
||||
import { unwrap } from "solid-js/store"
|
||||
import { host } from "@opencode/cli/vite-host"
|
||||
import { RouteProvider, useRoute } from "../../tui/src/context/route"
|
||||
|
||||
export {
|
||||
useRoute,
|
||||
useRouteData,
|
||||
type Route,
|
||||
type HomeRoute,
|
||||
type SessionRoute,
|
||||
type PluginRoute,
|
||||
} from "../../tui/src/context/route"
|
||||
export { ReloadableRouteProvider as RouteProvider }
|
||||
|
||||
function ReloadableRouteProvider(props: ComponentProps<typeof RouteProvider>) {
|
||||
return (
|
||||
<RouteProvider {...props} initialRoute={host.route ?? props.initialRoute}>
|
||||
<RememberRoute />
|
||||
{props.children}
|
||||
</RouteProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function RememberRoute() {
|
||||
const route = useRoute()
|
||||
createEffect(
|
||||
on(
|
||||
() => JSON.stringify(route.data),
|
||||
() => {
|
||||
// A route's prompt is a one-shot handoff, not a composer draft.
|
||||
const value = structuredClone(unwrap({ ...route.data }))
|
||||
host.route =
|
||||
value.type === "home"
|
||||
? { type: "home", location: value.location }
|
||||
: value.type === "session"
|
||||
? { type: "session", sessionID: value.sessionID }
|
||||
: value
|
||||
},
|
||||
),
|
||||
)
|
||||
return null
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { createRequire } from "node:module"
|
||||
import path from "node:path"
|
||||
import { Effect, Exit, Fiber } from "effect"
|
||||
import { createRunnableDevEnvironment, createServer, isRunnableDevEnvironment } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import refresh from "solid-refresh/babel"
|
||||
import { host, type Run } from "./host.js"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
export const run: Run = Effect.fn("Tui.vite")(function* (input: Parameters<Run>[0]) {
|
||||
const fork = Effect.runForkWith(yield* Effect.context<Effect.Services<ReturnType<Run>>>())
|
||||
const finished = Promise.withResolvers<Exit.Exit<void, unknown>>()
|
||||
let initial = true
|
||||
let recoverable = false
|
||||
host.route = undefined
|
||||
host.settle = () => {
|
||||
recoverable = false
|
||||
}
|
||||
host.stop = async () => {
|
||||
const fiber = host.active
|
||||
host.active = undefined
|
||||
if (fiber) await Effect.runPromise(Fiber.interrupt(fiber))
|
||||
}
|
||||
host.mount = async (app) => {
|
||||
await host.stop?.()
|
||||
const fiber = fork(
|
||||
app({
|
||||
...input,
|
||||
args: initial
|
||||
? input.args
|
||||
: { ...input.args, prompt: undefined, sessionID: undefined, continue: false, fork: false },
|
||||
terminalHandoff: initial ? input.terminalHandoff : undefined,
|
||||
}),
|
||||
)
|
||||
initial = false
|
||||
host.active = fiber
|
||||
fiber.addObserver((exit) => {
|
||||
if (host.active !== fiber) return
|
||||
host.active = undefined
|
||||
finished.resolve(exit)
|
||||
})
|
||||
}
|
||||
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.tryPromise(() =>
|
||||
createServer({
|
||||
root: path.resolve(import.meta.dirname, "../../tui"),
|
||||
configFile: false,
|
||||
appType: "custom",
|
||||
clearScreen: false,
|
||||
logLevel: "error",
|
||||
server: { middlewareMode: true, ws: false },
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: /^solid-js(?:\/dist\/solid.js)?$/, replacement: require.resolve("solid-js/dist/dev.js") },
|
||||
{
|
||||
find: /^solid-js\/store(?:\/dist\/store.js)?$/,
|
||||
replacement: require.resolve("solid-js/store/dist/dev.js"),
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: "tui-refresh-boundaries",
|
||||
enforce: "pre",
|
||||
async resolveId(source, importer) {
|
||||
if (importer === path.join(import.meta.dirname, "route.tsx")) return
|
||||
if (!source.endsWith("/route") && !source.endsWith("/route.tsx")) return
|
||||
const resolved = await this.resolve(source, importer, { skipSelf: true })
|
||||
if (resolved?.id === path.resolve(import.meta.dirname, "../../tui/src/context/route.tsx"))
|
||||
return path.join(import.meta.dirname, "route.tsx")
|
||||
},
|
||||
load(id) {
|
||||
if (id === "/@solid-refresh")
|
||||
return `export * from ${JSON.stringify(path.join(import.meta.dirname, "refresh.ts"))}`
|
||||
},
|
||||
},
|
||||
solid({
|
||||
hot: false,
|
||||
dev: true,
|
||||
solid: { generate: "universal", moduleName: "@opentui/solid" },
|
||||
// Enable the existing refresh plugin in Vite's non-browser environment.
|
||||
babel: { plugins: [[refresh, { bundler: "vite" }]] },
|
||||
}),
|
||||
{
|
||||
name: "tui-recovery",
|
||||
hotUpdate() {
|
||||
if (this.environment.name !== "native") return
|
||||
recoverable = Boolean(host.active)
|
||||
if (host.active) return
|
||||
this.environment.moduleGraph.invalidateAll()
|
||||
this.environment.hot.send({ type: "full-reload" })
|
||||
return []
|
||||
},
|
||||
},
|
||||
],
|
||||
environments: {
|
||||
native: {
|
||||
consumer: "server",
|
||||
resolve: {
|
||||
conditions: ["bun", "development", "module"],
|
||||
externalConditions: ["bun", "node"],
|
||||
noExternal: [
|
||||
"solid-js",
|
||||
"solid-refresh",
|
||||
"@opentui/solid",
|
||||
"@opentui/keymap",
|
||||
"opentui-spinner",
|
||||
/^@solid-primitives\//,
|
||||
"@opencode/plugin",
|
||||
"@opencode/client",
|
||||
"@opencode/latex",
|
||||
"@opencode/merman",
|
||||
],
|
||||
// Exact subpaths are needed for workspace TypeScript exports.
|
||||
external: [
|
||||
"@opentui/core",
|
||||
"@opentui/core/testing",
|
||||
"effect",
|
||||
"@opencode/cli/vite-host",
|
||||
"@opencode/client",
|
||||
"@opencode/client/effect/service",
|
||||
"@opencode/client/promise",
|
||||
"@opencode/core/util/slug",
|
||||
"@opencode/schema",
|
||||
"@opencode/schema/event",
|
||||
"@opencode/schema/project",
|
||||
"@opencode/schema/session-id",
|
||||
"@opencode/schema/session-inbox",
|
||||
"@opencode/schema/session-message",
|
||||
"@opencode/schema/skill",
|
||||
"@opencode/schema/token-usage",
|
||||
"@opencode/schema/vcs",
|
||||
"@opencode/schema/worktree",
|
||||
"@opencode/simulation/frontend",
|
||||
"@opencode/simulation/protocol",
|
||||
"@opencode/theme/tui",
|
||||
"@opencode/theme/tui/v1",
|
||||
"@opencode/util/activity-calendar",
|
||||
"@opencode/util/flock",
|
||||
"@opencode/util/global",
|
||||
"@opencode/util/hash",
|
||||
"@opencode/util/session-title-fallback",
|
||||
],
|
||||
},
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
dev: {
|
||||
createEnvironment: (name, config) =>
|
||||
createRunnableDevEnvironment(name, config, {
|
||||
runnerOptions: {
|
||||
sourcemapInterceptor: false,
|
||||
hmr: { logger: { debug() {}, error: (error) => console.error(error) } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.promise(async () => {
|
||||
await host.stop?.()
|
||||
await server.close()
|
||||
}),
|
||||
)
|
||||
const environment = server.environments.native
|
||||
if (!isRunnableDevEnvironment(environment)) return yield* Effect.die(new Error("Expected a runnable environment"))
|
||||
host.reset = () => {
|
||||
recoverable = false
|
||||
environment.runner.clearCache()
|
||||
}
|
||||
host.recover = () => {
|
||||
if (!recoverable) return false
|
||||
recoverable = false
|
||||
queueMicrotask(() => {
|
||||
input.log?.("warn", "TUI hot update failed; reloading", {})
|
||||
environment.moduleGraph.invalidateAll()
|
||||
environment.hot.send({ type: "full-reload" })
|
||||
})
|
||||
return true
|
||||
}
|
||||
yield* Effect.promise(() =>
|
||||
environment.runner.import(path.join(import.meta.dirname, "entry.ts")).catch(console.error),
|
||||
)
|
||||
const exit = yield* Effect.promise(() => finished.promise)
|
||||
if (Exit.isFailure(exit)) return yield* Effect.failCause(exit.cause)
|
||||
}, Effect.scoped)
|
||||
@@ -1,15 +0,0 @@
|
||||
import { plugin } from "bun"
|
||||
import { ensureSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
|
||||
ensureSolidTransformPlugin()
|
||||
if (process.argv[2] !== "serve") {
|
||||
// Vite must initialize before the CLI installs its process/error handling on Bun.
|
||||
const { run } = await import("./tui")
|
||||
plugin({
|
||||
name: "vite-tui-entry",
|
||||
setup(build) {
|
||||
build.module("@opencode/tui", () => ({ loader: "object", exports: { run } }))
|
||||
},
|
||||
})
|
||||
}
|
||||
await import("../src/index")
|
||||
@@ -11,7 +11,6 @@
|
||||
"bin"
|
||||
],
|
||||
"exports": {
|
||||
"./vite-host": "./dev/host.js",
|
||||
"./run": "./src/run/index.ts",
|
||||
"./server-process": "./src/server-process.ts"
|
||||
},
|
||||
@@ -75,7 +74,6 @@
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||
"@parcel/watcher-win32-arm64": "2.5.1",
|
||||
"@parcel/watcher-win32-x64": "2.5.1",
|
||||
"solid-refresh": "0.6.3",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-solid": "catalog:"
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createComponent, createContext, createRoot, useContext } from "solid-js"
|
||||
import { $$context, $$registry } from "solid-refresh/dist/solid-refresh.mjs"
|
||||
import { $$refresh } from "../dev/refresh"
|
||||
|
||||
test("re-evaluated dependencies retain their mounted context before any HMR accept callback", () => {
|
||||
const previous = $$registry()
|
||||
const mounted = $$context(previous, "Context", createContext("old default"))
|
||||
const next = $$registry()
|
||||
const updated = $$context(next, "Context", createContext("new default"))
|
||||
const unrelated = $$context($$registry(), "Context", createContext("unrelated"))
|
||||
let accepted = false
|
||||
$$refresh(
|
||||
"vite",
|
||||
{
|
||||
data: { "solid-refresh": previous, "solid-refresh-prev": previous },
|
||||
accept() {
|
||||
accepted = true
|
||||
},
|
||||
invalidate() {
|
||||
throw new Error("Unexpected invalidation")
|
||||
},
|
||||
decline() {
|
||||
throw new Error("Unexpected decline")
|
||||
},
|
||||
},
|
||||
next,
|
||||
)
|
||||
|
||||
// Only register acceptance: Vite re-evaluates cyclic dependencies without
|
||||
// necessarily sending those modules their own accepted update.
|
||||
expect(accepted).toBe(true)
|
||||
createRoot((dispose) => {
|
||||
createComponent(mounted.Provider, {
|
||||
value: "mounted provider",
|
||||
get children() {
|
||||
expect(useContext(updated)).toBe("mounted provider")
|
||||
expect(useContext(unrelated)).toBe("unrelated")
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
expect(useContext(mounted)).toBe("new default")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { RouteProvider, useRoute, type Route } from "../dev/route"
|
||||
import { host } from "../dev/host.js"
|
||||
import { TuiStartupProvider } from "../../tui/src/context/runtime"
|
||||
|
||||
test("the dev route wrapper restores the current route without replaying its prompt", async () => {
|
||||
const saved = () => host.route
|
||||
let route!: ReturnType<typeof useRoute>
|
||||
function Probe() {
|
||||
route = useRoute()
|
||||
return null
|
||||
}
|
||||
async function render() {
|
||||
return testRender(
|
||||
() => (
|
||||
<TuiStartupProvider value={{ skipInitialLoading: true }}>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_launch" }}>
|
||||
<Probe />
|
||||
</RouteProvider>
|
||||
</TuiStartupProvider>
|
||||
),
|
||||
{ width: 80, height: 24 },
|
||||
)
|
||||
}
|
||||
const routes: Route[] = [
|
||||
{ type: "home", location: { directory: "/selected/worktree", workspaceID: "wrk_test" } },
|
||||
{ type: "home", location: { directory: "/another/worktree", workspaceID: "wrk_other" } },
|
||||
{ type: "session", sessionID: "ses_selected" },
|
||||
{ type: "plugin", id: "test", name: "page", data: { nested: { selected: 1 } } },
|
||||
{ type: "plugin", id: "test", name: "page", data: { nested: { selected: 2 } } },
|
||||
]
|
||||
host.route = undefined
|
||||
const app = await render()
|
||||
try {
|
||||
await app.waitFor(() => host.route !== undefined)
|
||||
for (const value of routes) {
|
||||
route.navigate(
|
||||
value.type === "plugin"
|
||||
? value
|
||||
: {
|
||||
...value,
|
||||
prompt: { text: "one-shot handoff", files: [], agents: [], pasted: [] },
|
||||
},
|
||||
)
|
||||
await app.waitFor(() => JSON.stringify(host.route) === JSON.stringify(value))
|
||||
expect(saved()).toEqual(value)
|
||||
// Saved routes contain plain data, not a proxy tied to the old Solid tree.
|
||||
expect(structuredClone(saved())).toEqual(value)
|
||||
}
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
for (const value of routes) {
|
||||
host.route = value
|
||||
const restored = await render()
|
||||
try {
|
||||
expect(route.data).toEqual(value)
|
||||
} finally {
|
||||
restored.renderer.destroy()
|
||||
}
|
||||
}
|
||||
host.route = undefined
|
||||
})
|
||||
@@ -17,6 +17,7 @@ import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import type { AgentAttachment } from "@opencode/schema/prompt"
|
||||
import type { Skill } from "@opencode/schema/skill"
|
||||
import type { Event } from "@opencode/schema/event"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
|
||||
import type { Schema } from "effect"
|
||||
import type { EventLog } from "@opencode/schema/event-log"
|
||||
@@ -36,7 +37,6 @@ import type { PtyTicket } from "@opencode/schema/pty-ticket"
|
||||
import type { Reference } from "@opencode/schema/reference"
|
||||
import type { Worktree } from "@opencode/schema/worktree"
|
||||
import type { Vcs } from "@opencode/schema/vcs"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { WebSearch } from "@opencode/schema/websearch"
|
||||
import type { Config } from "@opencode/schema/config"
|
||||
|
||||
@@ -360,6 +360,15 @@ export type SessionContextInput = { readonly sessionID: Session.ID }
|
||||
export type SessionContextOutput = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: SessionContextInput) => Effect.Effect<SessionContextOutput, E>
|
||||
|
||||
export type SessionDiffInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly messageID?: SessionMessage.ID | undefined
|
||||
readonly to?: SessionMessage.ID | undefined
|
||||
readonly context?: number | undefined
|
||||
}
|
||||
export type SessionDiffOutput = ReadonlyArray<FileDiff.Info>
|
||||
export type SessionDiffOperation<E = never> = (input: SessionDiffInput) => Effect.Effect<SessionDiffOutput, E>
|
||||
|
||||
export type SessionInboxListInput = { readonly sessionID: Session.ID }
|
||||
export type SessionInboxListOutput = ReadonlyArray<SessionInbox.Info>
|
||||
export type SessionInboxListOperation<E = never> = (
|
||||
@@ -1133,6 +1142,7 @@ export interface SessionApi<E = never> {
|
||||
readonly commit: SessionRevertCommitOperation<E>
|
||||
}
|
||||
readonly context: SessionContextOperation<E>
|
||||
readonly diff: SessionDiffOperation<E>
|
||||
readonly inbox: {
|
||||
readonly list: SessionInboxListOperation<E>
|
||||
readonly cancel: SessionInboxCancelOperation<E>
|
||||
|
||||
@@ -68,6 +68,8 @@ import type {
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionDiffInput,
|
||||
SessionDiffOutput,
|
||||
SessionInboxListInput,
|
||||
SessionInboxListOutput,
|
||||
SessionInboxCancelInput,
|
||||
@@ -594,6 +596,17 @@ const EndpointSessionContext = (raw: RawClient["server.session"]) => (input: Ses
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionDiff = (raw: RawClient["server.session"]) => (input: SessionDiffInput) =>
|
||||
preserveEffect<SessionDiffOutput>()(
|
||||
raw["session.diff"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: SessionInboxListInput) =>
|
||||
preserveEffect<SessionInboxListOutput>()(
|
||||
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
@@ -744,6 +757,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
|
||||
commit: EndpointSessionRevertCommit(raw),
|
||||
},
|
||||
context: EndpointSessionContext(raw),
|
||||
diff: EndpointSessionDiff(raw),
|
||||
inbox: {
|
||||
list: EndpointSessionInboxList(raw),
|
||||
cancel: EndpointSessionInboxCancel(raw),
|
||||
|
||||
@@ -62,6 +62,8 @@ import type {
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionDiffInput,
|
||||
SessionDiffOutput,
|
||||
SessionInboxListInput,
|
||||
SessionInboxListOutput,
|
||||
SessionInboxCancelInput,
|
||||
@@ -844,6 +846,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
diff: (input: SessionDiffInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionDiffOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`,
|
||||
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404, 500],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
inbox: {
|
||||
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionInboxListOutput }>(
|
||||
|
||||
@@ -147,6 +147,14 @@ export type SessionProviderContextProvenance = {
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
export type SessionMessageIdle = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
type: "idle"
|
||||
outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
|
||||
export type SessionActive = { type: "running" }
|
||||
|
||||
export type SessionInboxDelivery = "steer" | "queue"
|
||||
@@ -2177,6 +2185,7 @@ export type SessionMessageInfo =
|
||||
| SessionMessageShell
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
| SessionMessageIdle
|
||||
|
||||
export type SessionMessageContentUpdated = {
|
||||
id: string
|
||||
@@ -3122,6 +3131,13 @@ export type SessionImportInput = {
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["info"]
|
||||
@@ -3413,6 +3429,13 @@ export type SessionImportInput = {
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["messages"]
|
||||
@@ -3704,6 +3727,13 @@ export type SessionImportInput = {
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["location"]
|
||||
@@ -4193,6 +4223,27 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
|
||||
|
||||
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
|
||||
|
||||
export type SessionDiffInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly messageID?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["messageID"]
|
||||
readonly to?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["to"]
|
||||
readonly context?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["context"]
|
||||
}
|
||||
|
||||
export type SessionDiffOutput = { data: Array<FileDiffInfo> }["data"]
|
||||
|
||||
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
|
||||
|
||||
@@ -1032,6 +1032,18 @@ export function createData(config: CreateDataInput) {
|
||||
if (currentAssistant) currentAssistant.retry = undefined
|
||||
})
|
||||
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
|
||||
// Mirror the projected idle marker so turn boundaries match before the next message read.
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "idle",
|
||||
outcome:
|
||||
event.type === "session.execution.succeeded"
|
||||
? "succeeded"
|
||||
: event.type === "session.execution.failed"
|
||||
? "failed"
|
||||
: "interrupted",
|
||||
time: { created: event.created },
|
||||
})
|
||||
// An event can overtake the first read; queue a revalidation when that read is still active.
|
||||
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
|
||||
@@ -91,12 +91,6 @@ runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
|
||||
The Effect environment is inferred from the supplied tools. `onToolCallStart` observes admitted calls with decoded
|
||||
input; `onToolCallEnd` observes settled outcomes and duration. Both hooks return Effects and must not fail.
|
||||
|
||||
### `Values`
|
||||
|
||||
`Values` exports the runtime's non-JSON value classes: `Values.URL`, `Values.URLSearchParams`, `Values.Date`,
|
||||
`Values.RegExp`, `Values.Map`, `Values.Set`, and `Values.Promise`. The interpreter recognizes these by class; a
|
||||
program's `new URL(...)` is a `Values.URL` wrapping the host `URL`. `Values.isValue` narrows to the data-like kinds.
|
||||
|
||||
### OpenAPI tools
|
||||
|
||||
`OpenAPI.fromSpec` converts an OpenAPI 3.x document into one tool per supported operation. Dotted `operationId` values
|
||||
|
||||
@@ -2,6 +2,5 @@ export * as CodeMode from "./codemode.js"
|
||||
export * as Namespace from "./namespace.js"
|
||||
export * as Tool from "./tool.js"
|
||||
export * as OpenAPI from "./openapi/index.js"
|
||||
export { Values } from "./values.js"
|
||||
export { searchSignature, toolExpression } from "./codemode.js"
|
||||
export { ToolError, toolError } from "./tool-error.js"
|
||||
|
||||
@@ -15,8 +15,17 @@ import {
|
||||
UriFunction,
|
||||
} from "./model.js"
|
||||
import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js"
|
||||
import { compareText, isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
||||
import { Values } from "../values.js"
|
||||
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
||||
import {
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModePromise,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
isCodeModeValue,
|
||||
} from "../values.js"
|
||||
import { dateSetterArgumentCount, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
|
||||
import { invokeMathMethod } from "../stdlib/math.js"
|
||||
import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js"
|
||||
@@ -34,7 +43,7 @@ export type CallbackRunner<R> = {
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
) => Effect.Effect<unknown, unknown, R>
|
||||
readonly settlePromise: (promise: Values.Promise) => Effect.Effect<unknown, unknown, never>
|
||||
readonly settlePromise: (promise: CodeModePromise) => Effect.Effect<unknown, unknown, never>
|
||||
}
|
||||
|
||||
// The single acceptance list for callbacks: collections, sort, string replacers,
|
||||
@@ -91,7 +100,7 @@ export const invokeIntrinsic = <R>(
|
||||
if (Array.isArray(ref.receiver)) {
|
||||
return invokeArrayMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof Values.Date) {
|
||||
if (ref.receiver instanceof CodeModeDate) {
|
||||
const target = ref.receiver
|
||||
const argumentCount = dateSetterArgumentCount(ref.name)
|
||||
if (argumentCount === undefined) return Effect.succeed(invokeDateMethod(target, ref.name, [], node))
|
||||
@@ -104,61 +113,53 @@ export const invokeIntrinsic = <R>(
|
||||
(values) => invokeDateMethod(target, ref.name, values, node, initialTime),
|
||||
)
|
||||
}
|
||||
if (ref.receiver instanceof Values.RegExp) {
|
||||
if (ref.receiver instanceof CodeModeRegExp) {
|
||||
return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node))
|
||||
}
|
||||
if (ref.receiver instanceof Values.Map) {
|
||||
if (ref.receiver instanceof CodeModeMap) {
|
||||
return invokeMapMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof Values.Set) {
|
||||
if (ref.receiver instanceof CodeModeSet) {
|
||||
return invokeSetMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof Values.URL) {
|
||||
if (ref.receiver instanceof CodeModeURL) {
|
||||
return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node))
|
||||
}
|
||||
if (ref.receiver instanceof Values.URLSearchParams) {
|
||||
if (ref.receiver instanceof CodeModeURLSearchParams) {
|
||||
return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available.`, node)
|
||||
}
|
||||
|
||||
/**
|
||||
* ToPrimitive: tries an object's own `valueOf`/`toString` in hint order and returns the first
|
||||
* primitive result. Runtime values behave like their JS counterparts (Date yields its time under a
|
||||
* number hint; the rest yield their string form). An inherited `toString` yields the default
|
||||
* string form, so plain objects become "[object Object]" and arrays join.
|
||||
*/
|
||||
export const toPrimitive = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
value: unknown,
|
||||
hint: "number" | "string",
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
if (value === null || typeof value !== "object") return Effect.succeed(value)
|
||||
if (Values.isValue(value)) {
|
||||
return Effect.succeed(value instanceof Values.Date && hint === "number" ? value.time : coerceToString(value))
|
||||
}
|
||||
const object = value as Record<string, unknown>
|
||||
const order = hint === "number" ? ["valueOf", "toString"] : ["toString", "valueOf"]
|
||||
return Effect.gen(function* () {
|
||||
for (const method of order) {
|
||||
if (method === "toString" && !Object.hasOwn(object, "toString")) return coerceToString(value)
|
||||
if (!Object.hasOwn(object, method) || typeofValue(object[method]) !== "function") continue
|
||||
const result = yield* runner.invokeCallable(object[method], [], node)
|
||||
if (result === null || (typeof result !== "object" && typeof result !== "function")) return result
|
||||
}
|
||||
throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node).as("TypeError")
|
||||
})
|
||||
}
|
||||
|
||||
const coerceNumericArgument = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
value: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<number, unknown, R> => Effect.map(toPrimitive(runner, value, "number", node), coerceToNumber)
|
||||
): Effect.Effect<number, unknown, R> => {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value) || isCodeModeValue(value)) {
|
||||
return Effect.succeed(coerceToNumber(value))
|
||||
}
|
||||
const object = value as Record<string, unknown>
|
||||
return Effect.gen(function* () {
|
||||
if (Object.hasOwn(object, "valueOf") && typeofValue(object.valueOf) === "function") {
|
||||
const result = yield* runner.invokeCallable(object.valueOf, [], node)
|
||||
if (result === null || (typeof result !== "object" && typeof result !== "function")) {
|
||||
return coerceToNumber(result)
|
||||
}
|
||||
}
|
||||
if (!Object.hasOwn(object, "toString")) return coerceToNumber(value)
|
||||
if (typeofValue(object.toString) === "function") {
|
||||
const result = yield* runner.invokeCallable(object.toString, [], node)
|
||||
if (result === null || (typeof result !== "object" && typeof result !== "function")) {
|
||||
return coerceToNumber(result)
|
||||
}
|
||||
}
|
||||
throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node).as("TypeError")
|
||||
})
|
||||
}
|
||||
|
||||
// console is intercepted by the interpreter before reaching here.
|
||||
export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
|
||||
if (ref.namespace === "console") throw new InterpreterRuntimeError(`console.${ref.name} is not available.`, node)
|
||||
if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node)
|
||||
if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node)
|
||||
if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node)
|
||||
@@ -167,6 +168,9 @@ export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unkno
|
||||
if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node)
|
||||
if (ref.namespace === "Date") return invokeDateStatic(ref.name, args, node)
|
||||
if (ref.namespace === "RegExp") return invokeRegExpStatic(ref.name, args, node)
|
||||
if (ref.namespace === "Map" || ref.namespace === "Set" || ref.namespace === "URLSearchParams") {
|
||||
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available.`, node)
|
||||
}
|
||||
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available.`, node)
|
||||
}
|
||||
|
||||
@@ -188,7 +192,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
|
||||
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
|
||||
const rejectRegex = (): void => {
|
||||
if (args[0] instanceof Values.RegExp) {
|
||||
if (args[0] instanceof CodeModeRegExp) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
|
||||
node,
|
||||
@@ -237,7 +241,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
result = requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value]
|
||||
break
|
||||
}
|
||||
if (args[0] instanceof Values.RegExp) {
|
||||
if (args[0] instanceof CodeModeRegExp) {
|
||||
result = value.split(args[0].regex, optNum(1))
|
||||
break
|
||||
}
|
||||
@@ -268,7 +272,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
break
|
||||
case "replace":
|
||||
case "replaceAll": {
|
||||
if (args[0] instanceof Values.RegExp) {
|
||||
if (args[0] instanceof CodeModeRegExp) {
|
||||
const pattern = args[0].regex
|
||||
const replacement = str(1)
|
||||
if (name === "replaceAll" && !pattern.global) {
|
||||
@@ -364,7 +368,7 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
|
||||
}
|
||||
|
||||
const arrayLikeSource = (source: unknown, node: AstNode): { readonly length: number; readonly source: object } => {
|
||||
if (source instanceof Values.Promise) {
|
||||
if (source instanceof CodeModePromise) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array.from received an un-awaited Promise; await it before creating the array.",
|
||||
node,
|
||||
@@ -441,7 +445,7 @@ export const invokeGroupBy = <R>(
|
||||
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
|
||||
}
|
||||
if (namespace === "Map") {
|
||||
const result = new Values.Map()
|
||||
const result = new CodeModeMap()
|
||||
let index = 0
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
@@ -484,11 +488,30 @@ const coerceGroupByPropertyKey = <R>(
|
||||
value: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<string, unknown, R> => {
|
||||
if (value instanceof Values.Promise) return Effect.succeed("[object Promise]")
|
||||
if (!Values.isValue(value) && isRuntimeReference(value)) {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value) || isCodeModeValue(value)) {
|
||||
return Effect.succeed(coerceToString(value))
|
||||
}
|
||||
if (value instanceof CodeModePromise) return Effect.succeed("[object Promise]")
|
||||
if (isRuntimeReference(value)) {
|
||||
throw new InterpreterRuntimeError("Object.groupBy callback must return a data value.", node, "InvalidDataValue")
|
||||
}
|
||||
return Effect.map(toPrimitive(runner, value, "string", node), coerceToString)
|
||||
const object = value as Record<string, unknown>
|
||||
if (!Object.hasOwn(object, "toString")) return Effect.succeed(coerceToString(value))
|
||||
return Effect.gen(function* () {
|
||||
if (typeofValue(object.toString) === "function") {
|
||||
const result = yield* runner.invokeCallable(object.toString, [], node)
|
||||
if (result === null || (typeof result !== "object" && typeof result !== "function")) {
|
||||
return coerceToString(result)
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(object, "valueOf") && typeofValue(object.valueOf) === "function") {
|
||||
const result = yield* runner.invokeCallable(object.valueOf, [], node)
|
||||
if (result === null || (typeof result !== "object" && typeof result !== "function")) {
|
||||
return coerceToString(result)
|
||||
}
|
||||
}
|
||||
throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node).as("TypeError")
|
||||
})
|
||||
}
|
||||
|
||||
const invokeStringReplacer = <R>(
|
||||
@@ -520,7 +543,7 @@ const invokeStringReplacer = <R>(
|
||||
}
|
||||
|
||||
const pattern = args[0]
|
||||
if (pattern instanceof Values.RegExp) {
|
||||
if (pattern instanceof CodeModeRegExp) {
|
||||
if (name === "replaceAll" && !pattern.regex.global) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`,
|
||||
@@ -543,7 +566,7 @@ const invokeStringReplacer = <R>(
|
||||
// Error values are branded plain objects; boundedData would strip the brand before coercion.
|
||||
output.push(
|
||||
value.slice(end, match.offset),
|
||||
replacement instanceof Values.Promise
|
||||
replacement instanceof CodeModePromise
|
||||
? "[object Promise]"
|
||||
: errorBrandName(replacement)
|
||||
? coerceToString(replacement)
|
||||
@@ -576,7 +599,7 @@ export const applyCollectionCallback = <R>(
|
||||
|
||||
const invokeMapMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: Values.Map,
|
||||
target: CodeModeMap,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
@@ -618,7 +641,7 @@ const invokeMapMethod = <R>(
|
||||
|
||||
const invokeSetMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: Values.Set,
|
||||
target: CodeModeSet,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
@@ -665,7 +688,7 @@ const invokeSetMethod = <R>(
|
||||
|
||||
const invokeSetOperation = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: Values.Set,
|
||||
target: CodeModeSet,
|
||||
name: string,
|
||||
source: unknown,
|
||||
node: AstNode,
|
||||
@@ -678,7 +701,7 @@ const invokeSetOperation = <R>(
|
||||
return result
|
||||
}
|
||||
if (name === "intersection") {
|
||||
const result = new Values.Set()
|
||||
const result = new CodeModeSet()
|
||||
if (target.set.size <= other.size) {
|
||||
for (const item of target.set.values()) {
|
||||
if (yield* other.has(item)) result.set.add(item)
|
||||
@@ -735,28 +758,28 @@ const invokeSetOperation = <R>(
|
||||
return true
|
||||
})
|
||||
|
||||
const copySet = (source: Values.Set): Values.Set => {
|
||||
const result = new Values.Set()
|
||||
const copySet = (source: CodeModeSet): CodeModeSet => {
|
||||
const result = new CodeModeSet()
|
||||
for (const item of source.set.values()) result.set.add(item)
|
||||
return result
|
||||
}
|
||||
|
||||
const loadSetRecord = <R>(runner: CallbackRunner<R>, source: unknown, name: string, node: AstNode) => {
|
||||
if (source instanceof Values.Set) {
|
||||
if (source instanceof CodeModeSet) {
|
||||
return Effect.succeed({
|
||||
size: source.set.size,
|
||||
has: (item: unknown) => Effect.succeed(source.set.has(item)),
|
||||
keys: () => Effect.succeed(source.set.values()),
|
||||
})
|
||||
}
|
||||
if (source instanceof Values.Map) {
|
||||
if (source instanceof CodeModeMap) {
|
||||
return Effect.succeed({
|
||||
size: source.map.size,
|
||||
has: (item: unknown) => Effect.succeed(source.map.has(item)),
|
||||
keys: () => Effect.succeed(source.map.keys()),
|
||||
})
|
||||
}
|
||||
if (source === null || typeof source !== "object" || Values.isValue(source)) {
|
||||
if (source === null || typeof source !== "object" || isCodeModeValue(source)) {
|
||||
throw new InterpreterRuntimeError(`Set.${name} expects a Set-like object.`, node).as("TypeError")
|
||||
}
|
||||
const object = source as Record<string, unknown>
|
||||
@@ -786,7 +809,7 @@ const loadSetRecord = <R>(runner: CallbackRunner<R>, source: unknown, name: stri
|
||||
|
||||
const invokeURLSearchParamsMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: Values.URLSearchParams,
|
||||
target: CodeModeURLSearchParams,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
@@ -1110,7 +1133,11 @@ const sortArray = <R>(
|
||||
): Effect.Effect<Array<unknown>, unknown, R> => {
|
||||
if (comparator === undefined) {
|
||||
return Effect.sync(() =>
|
||||
[...target].sort((a, b) => compareText(coerceToString(a), coerceToString(b))),
|
||||
[...target].sort((a, b) => {
|
||||
const left = coerceToString(a)
|
||||
const right = coerceToString(b)
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}),
|
||||
)
|
||||
}
|
||||
const apply = applyCollectionCallback(runner, comparator, name, node)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Effect } from "effect"
|
||||
import type { DiagnosticKind } from "../codemode.js"
|
||||
import type { SafeObject } from "../tool-runtime.js"
|
||||
import type { Values } from "../values.js"
|
||||
import type { CodeModePromise, CodeModeRegExp, CodeModeURL } from "../values.js"
|
||||
|
||||
export type SourcePosition = {
|
||||
line: number
|
||||
@@ -37,7 +36,7 @@ export type StatementResult =
|
||||
| { kind: "continue"; label?: string }
|
||||
|
||||
export type MemberReference = {
|
||||
target: SafeObject | Array<unknown> | Values.RegExp | Values.URL
|
||||
target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL
|
||||
key: PropertyKey
|
||||
}
|
||||
|
||||
@@ -100,7 +99,7 @@ export type PromiseInstanceMethodName = "then" | "catch" | "finally"
|
||||
|
||||
export class PromiseInstanceMethodReference {
|
||||
constructor(
|
||||
readonly promise: Values.Promise,
|
||||
readonly promise: CodeModePromise,
|
||||
readonly name: PromiseInstanceMethodName,
|
||||
) {}
|
||||
}
|
||||
@@ -159,6 +158,18 @@ export class ErrorConstructorReference {
|
||||
constructor(readonly name: string) {}
|
||||
}
|
||||
|
||||
export type DiagnosticKind =
|
||||
| "ParseError"
|
||||
| "UnsupportedSyntax"
|
||||
| "UnknownTool"
|
||||
| "InvalidToolInput"
|
||||
| "InvalidToolOutput"
|
||||
| "InvalidDataValue"
|
||||
| "ToolCallLimitExceeded"
|
||||
| "TimeoutExceeded"
|
||||
| "ToolFailure"
|
||||
| "ExecutionFailure"
|
||||
|
||||
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
|
||||
|
||||
export const supportedSyntaxMessage =
|
||||
|
||||
@@ -14,25 +14,25 @@ import { caughtErrorValue, normalizeError } from "./errors.js"
|
||||
import { applyCollectionCallback, isSupportedCallback, type CallbackRunner, type SupportedCallback } from "./methods.js"
|
||||
import { typeofValue } from "./references.js"
|
||||
import { createAggregateErrorValue } from "../stdlib/value.js"
|
||||
import { Values } from "../values.js"
|
||||
import { CodeModePromise } from "../values.js"
|
||||
import type { SyncIteratorRunner } from "./iterator.js"
|
||||
|
||||
// Observation only controls rejection reporting; program completion interrupts all promise work.
|
||||
export class PromiseRuntime<R> {
|
||||
private readonly active = new Set<Values.Promise>()
|
||||
private readonly ids = new WeakMap<Values.Promise, number>()
|
||||
private readonly observed = new WeakSet<Values.Promise>()
|
||||
private readonly active = new Set<CodeModePromise>()
|
||||
private readonly ids = new WeakMap<CodeModePromise, number>()
|
||||
private readonly observed = new WeakSet<CodeModePromise>()
|
||||
private readonly failures = new Map<number, Diagnostic>()
|
||||
private nextID = 0
|
||||
|
||||
constructor(private readonly scope: Scope.Scope) {}
|
||||
|
||||
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<Values.Promise, never, R> {
|
||||
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
|
||||
return Effect.suspend(() => {
|
||||
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
|
||||
const id = this.nextID++
|
||||
return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => {
|
||||
const promise = new Values.Promise(fiber)
|
||||
const promise = new CodeModePromise(fiber)
|
||||
this.active.add(promise)
|
||||
this.ids.set(promise, id)
|
||||
fiber.addObserver((exit) => {
|
||||
@@ -53,14 +53,14 @@ export class PromiseRuntime<R> {
|
||||
}
|
||||
|
||||
// Observation must be recorded when responsibility transfers, before the consumer fiber runs.
|
||||
markObserved(promise: Values.Promise): void {
|
||||
markObserved(promise: CodeModePromise): void {
|
||||
this.observed.add(promise)
|
||||
const id = this.ids.get(promise)
|
||||
this.ids.delete(promise)
|
||||
if (id !== undefined) this.failures.delete(id)
|
||||
}
|
||||
|
||||
await(promise: Values.Promise): Effect.Effect<Exit.Exit<unknown, unknown>> {
|
||||
await(promise: CodeModePromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
|
||||
return Fiber.await(promise.fiber)
|
||||
}
|
||||
|
||||
@@ -91,10 +91,10 @@ export const resolvePromiseValue = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
value: unknown,
|
||||
node: AstNode,
|
||||
own?: { promise?: Values.Promise },
|
||||
own?: { promise?: CodeModePromise },
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
if (own?.promise !== undefined && value === own.promise) return Effect.fail(selfResolutionError(node))
|
||||
if (value instanceof Values.Promise) return runner.settlePromise(value)
|
||||
if (value instanceof CodeModePromise) return runner.settlePromise(value)
|
||||
if (value === null || typeof value !== "object" || !Object.hasOwn(value, "then")) return Effect.succeed(value)
|
||||
const then = (value as SafeObject).then
|
||||
if (typeofValue(then) !== "function") return Effect.succeed(value)
|
||||
@@ -123,9 +123,9 @@ export const resolvePromise = <R>(
|
||||
promises: PromiseRuntime<R>,
|
||||
value: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<Values.Promise, never, R> => {
|
||||
if (value instanceof Values.Promise) return Effect.succeed(value)
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
): Effect.Effect<CodeModePromise, never, R> => {
|
||||
if (value instanceof CodeModePromise) return Effect.succeed(value)
|
||||
const box: { promise?: CodeModePromise } = {}
|
||||
return Effect.map(promises.create(resolvePromiseValue(runner, value, node, box)), (promise) => {
|
||||
box.promise = promise
|
||||
return promise
|
||||
@@ -155,7 +155,7 @@ export const invokePromiseMethod = <R>(
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
const items: Array<Values.Promise> = []
|
||||
const items: Array<CodeModePromise> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) break
|
||||
@@ -227,7 +227,7 @@ export const invokePromiseInstanceMethod = <R>(
|
||||
ref: PromiseInstanceMethodReference,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<Values.Promise, never, R> => {
|
||||
): Effect.Effect<CodeModePromise, never, R> => {
|
||||
const method = `Promise.prototype.${ref.name}`
|
||||
promises.markObserved(ref.promise)
|
||||
if (ref.name === "finally") {
|
||||
@@ -243,7 +243,7 @@ export const constructPromise = <R>(
|
||||
promises: PromiseRuntime<R>,
|
||||
executor: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<Values.Promise, unknown, R> => {
|
||||
): Effect.Effect<CodeModePromise, unknown, R> => {
|
||||
if (!(executor instanceof CodeModeFunction)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
|
||||
@@ -252,7 +252,7 @@ export const constructPromise = <R>(
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const deferred = Deferred.makeUnsafe<unknown, unknown>()
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
const box: { promise?: CodeModePromise } = {}
|
||||
const promise = yield* promises.create(
|
||||
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, box)),
|
||||
)
|
||||
@@ -294,7 +294,7 @@ const reactionHandler = (value: unknown, method: string, node: AstNode): Support
|
||||
// Teardown bypasses handlers; settled reactions yield once so handlers never run inline.
|
||||
const reactionExit = <R>(
|
||||
promises: PromiseRuntime<R>,
|
||||
source: Values.Promise,
|
||||
source: CodeModePromise,
|
||||
): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* promises.await(source)
|
||||
@@ -306,13 +306,13 @@ const reactionExit = <R>(
|
||||
const chainReaction = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
source: Values.Promise,
|
||||
source: CodeModePromise,
|
||||
onFulfilled: SupportedCallback | undefined,
|
||||
onRejected: SupportedCallback | undefined,
|
||||
method: string,
|
||||
node: AstNode,
|
||||
): Effect.Effect<Values.Promise, never, R> => {
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
): Effect.Effect<CodeModePromise, never, R> => {
|
||||
const box: { promise?: CodeModePromise } = {}
|
||||
const body = Effect.gen(function* () {
|
||||
const exit = yield* reactionExit(promises, source)
|
||||
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
|
||||
@@ -330,11 +330,11 @@ const chainReaction = <R>(
|
||||
const chainFinally = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
source: Values.Promise,
|
||||
source: CodeModePromise,
|
||||
cleanup: SupportedCallback | undefined,
|
||||
method: string,
|
||||
node: AstNode,
|
||||
): Effect.Effect<Values.Promise, never, R> =>
|
||||
): Effect.Effect<CodeModePromise, never, R> =>
|
||||
promises.create(
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* reactionExit(promises, source)
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
UriFunction,
|
||||
} from "./model.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import { Values } from "../values.js"
|
||||
import { isCodeModeValue, CodeModePromise } from "../values.js"
|
||||
|
||||
export const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof CodeModeFunction ||
|
||||
@@ -35,14 +35,14 @@ export const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof PromiseNamespace ||
|
||||
value instanceof PromiseMethodReference ||
|
||||
value instanceof PromiseInstanceMethodReference ||
|
||||
value instanceof Values.Promise ||
|
||||
value instanceof CodeModePromise ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof UriFunction ||
|
||||
value instanceof SearchFunction ||
|
||||
value instanceof PromiseCapabilityFunction ||
|
||||
value instanceof ErrorConstructorReference ||
|
||||
value instanceof SymbolNamespace ||
|
||||
Values.isValue(value)
|
||||
isCodeModeValue(value)
|
||||
|
||||
function* childValues(value: object): Generator {
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
@@ -52,14 +52,9 @@ function* childValues(value: object): Generator {
|
||||
}
|
||||
}
|
||||
|
||||
// Depth-first search over a value tree. `match` stops the walk; `skip` prunes a subtree without matching it.
|
||||
const find = (
|
||||
value: unknown,
|
||||
match: (current: unknown) => boolean,
|
||||
skip: (current: unknown) => boolean,
|
||||
seen: Set<object>,
|
||||
): boolean => {
|
||||
export const containsRuntimeReference = (value: unknown): boolean => {
|
||||
const pending: Array<Iterator<unknown>> = [[value].values()]
|
||||
const seen = new Set<object>()
|
||||
while (pending.length > 0) {
|
||||
const next = pending.at(-1)!.next()
|
||||
if (next.done) {
|
||||
@@ -67,22 +62,33 @@ const find = (
|
||||
continue
|
||||
}
|
||||
const current = next.value
|
||||
if (match(current)) return true
|
||||
if (current === null || typeof current !== "object" || skip(current) || seen.has(current)) continue
|
||||
if (isRuntimeReference(current)) return true
|
||||
if (current === null || typeof current !== "object" || seen.has(current)) continue
|
||||
seen.add(current)
|
||||
pending.push(childValues(current))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const never = () => false
|
||||
|
||||
export const containsRuntimeReference = (value: unknown): boolean =>
|
||||
find(value, isRuntimeReference, never, new Set())
|
||||
|
||||
// CodeMode values are data here, not opaque interpreter references.
|
||||
export const containsOpaqueReference = (value: unknown): boolean =>
|
||||
find(value, (current) => !Values.isValue(current) && isRuntimeReference(current), Values.isValue, new Set())
|
||||
export const containsOpaqueReference = (value: unknown): boolean => {
|
||||
const pending: Array<Iterator<unknown>> = [[value].values()]
|
||||
const seen = new Set<object>()
|
||||
while (pending.length > 0) {
|
||||
const next = pending.at(-1)!.next()
|
||||
if (next.done) {
|
||||
pending.pop()
|
||||
continue
|
||||
}
|
||||
const current = next.value
|
||||
if (isCodeModeValue(current)) continue
|
||||
if (isRuntimeReference(current)) return true
|
||||
if (current === null || typeof current !== "object" || seen.has(current)) continue
|
||||
seen.add(current)
|
||||
pending.push(childValues(current))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Reject cycles before mutation so later boundary walks remain safe.
|
||||
export const rejectCircularInsertion = (
|
||||
@@ -92,8 +98,19 @@ export const rejectCircularInsertion = (
|
||||
node: AstNode,
|
||||
seen = new Set<object>(),
|
||||
): void => {
|
||||
if (find(value, (current) => current === container, isRuntimeReference, seen)) {
|
||||
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
|
||||
const pending: Array<Iterator<unknown>> = [[value].values()]
|
||||
while (pending.length > 0) {
|
||||
const next = pending.at(-1)!.next()
|
||||
if (next.done) {
|
||||
pending.pop()
|
||||
continue
|
||||
}
|
||||
const current = next.value
|
||||
if (current === container)
|
||||
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
|
||||
if (current === null || typeof current !== "object" || isRuntimeReference(current) || seen.has(current)) continue
|
||||
seen.add(current)
|
||||
pending.push(childValues(current))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
invokeGlobalMethod,
|
||||
invokeGroupBy,
|
||||
invokeIntrinsic,
|
||||
toPrimitive,
|
||||
} from "./methods.js"
|
||||
import { preserveConsumerError, type SyncIteratorRunner } from "./iterator.js"
|
||||
import {
|
||||
@@ -99,7 +98,16 @@ import {
|
||||
invokeCoercion,
|
||||
valueConstructors,
|
||||
} from "../stdlib/value.js"
|
||||
import { Values } from "../values.js"
|
||||
import {
|
||||
isCodeModeValue,
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModePromise,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
} from "../values.js"
|
||||
|
||||
const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
|
||||
Object: objectStatics,
|
||||
@@ -145,24 +153,24 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean =>
|
||||
if (rhs instanceof GlobalNamespace) {
|
||||
switch (rhs.name) {
|
||||
case "Date":
|
||||
return lhs instanceof Values.Date
|
||||
return lhs instanceof CodeModeDate
|
||||
case "RegExp":
|
||||
return lhs instanceof Values.RegExp
|
||||
return lhs instanceof CodeModeRegExp
|
||||
case "Map":
|
||||
return lhs instanceof Values.Map
|
||||
return lhs instanceof CodeModeMap
|
||||
case "Set":
|
||||
return lhs instanceof Values.Set
|
||||
return lhs instanceof CodeModeSet
|
||||
case "URL":
|
||||
return lhs instanceof Values.URL
|
||||
return lhs instanceof CodeModeURL
|
||||
case "URLSearchParams":
|
||||
return lhs instanceof Values.URLSearchParams
|
||||
return lhs instanceof CodeModeURLSearchParams
|
||||
case "Array":
|
||||
return Array.isArray(lhs)
|
||||
case "Object":
|
||||
return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function")
|
||||
}
|
||||
}
|
||||
if (rhs instanceof PromiseNamespace) return lhs instanceof Values.Promise
|
||||
if (rhs instanceof PromiseNamespace) return lhs instanceof CodeModePromise
|
||||
if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) {
|
||||
return false
|
||||
}
|
||||
@@ -363,16 +371,16 @@ export class Interpreter<R> {
|
||||
private createToolCallPromise(
|
||||
path: ReadonlyArray<string>,
|
||||
args: Array<unknown>,
|
||||
): Effect.Effect<Values.Promise, never, R> {
|
||||
): Effect.Effect<CodeModePromise, never, R> {
|
||||
return this.createPromise(Effect.suspend(() => this.executeTool(path, args)))
|
||||
}
|
||||
|
||||
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<Values.Promise, never, R> {
|
||||
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
|
||||
return this.promises.create(effect)
|
||||
}
|
||||
|
||||
// Fiber exits make settlement idempotent; yielding prevents inline continuation.
|
||||
private settlePromise(promise: Values.Promise): Effect.Effect<unknown, unknown, never> {
|
||||
private settlePromise(promise: CodeModePromise): Effect.Effect<unknown, unknown, never> {
|
||||
const promises = this.promises
|
||||
return Effect.suspend(() => {
|
||||
promises.markObserved(promise)
|
||||
@@ -804,11 +812,11 @@ export class Interpreter<R> {
|
||||
? value[Symbol.iterator]()
|
||||
: typeof value === "string"
|
||||
? value[Symbol.iterator]()
|
||||
: value instanceof Values.Map
|
||||
: value instanceof CodeModeMap
|
||||
? value.map.entries()
|
||||
: value instanceof Values.Set
|
||||
: value instanceof CodeModeSet
|
||||
? value.set.values()
|
||||
: value instanceof Values.URLSearchParams
|
||||
: value instanceof CodeModeURLSearchParams
|
||||
? value.params.entries()
|
||||
: undefined
|
||||
if (iterator !== undefined) {
|
||||
@@ -1470,25 +1478,43 @@ export class Interpreter<R> {
|
||||
)
|
||||
}
|
||||
|
||||
private constructDate(args: Array<unknown>, node: AstNode): Effect.Effect<Values.Date, unknown, R> {
|
||||
if (args.length === 0) return Effect.succeed(new Values.Date(Date.now()))
|
||||
private constructDate(args: Array<unknown>, node: AstNode): Effect.Effect<CodeModeDate, unknown, R> {
|
||||
if (args.length === 0) return Effect.succeed(new CodeModeDate(Date.now()))
|
||||
if (args.length === 1) {
|
||||
const arg = args[0]
|
||||
if (arg instanceof Values.Date) return Effect.succeed(new Values.Date(arg.time))
|
||||
return Effect.map(toPrimitive(this.runner, arg, "number", node), (value) =>
|
||||
if (arg instanceof CodeModeDate) return Effect.succeed(new CodeModeDate(arg.time))
|
||||
return Effect.map(this.toDatePrimitive(arg, node), (value) =>
|
||||
typeof value === "string"
|
||||
? new Values.Date(Date.parse(value))
|
||||
: new Values.Date(new Date(coerceToNumber(value)).getTime()),
|
||||
? new CodeModeDate(Date.parse(value))
|
||||
: new CodeModeDate(new Date(coerceToNumber(value)).getTime()),
|
||||
)
|
||||
}
|
||||
const parts = args.map((arg) => coerceToNumber(arg))
|
||||
return Effect.succeed(new Values.Date(new Date(...(parts as [number, number])).getTime()))
|
||||
return Effect.succeed(new CodeModeDate(new Date(...(parts as [number, number])).getTime()))
|
||||
}
|
||||
|
||||
private constructRegExp(args: Array<unknown>, node: AstNode): Values.RegExp {
|
||||
private toDatePrimitive(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
if (value === null || (typeof value !== "object" && typeof value !== "function")) return Effect.succeed(value)
|
||||
const object = value as Record<string, unknown>
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
if (Object.hasOwn(object, "valueOf") && typeofValue(object.valueOf) === "function") {
|
||||
const result = yield* self.runner.invokeCallable(object.valueOf, [], node)
|
||||
if (result === null || (typeof result !== "object" && typeof result !== "function")) return result
|
||||
}
|
||||
if (!Object.hasOwn(object, "toString")) return coerceToString(value)
|
||||
if (typeofValue(object.toString) === "function") {
|
||||
const result = yield* self.runner.invokeCallable(object.toString, [], node)
|
||||
if (result === null || (typeof result !== "object" && typeof result !== "function")) return result
|
||||
}
|
||||
throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node).as("TypeError")
|
||||
})
|
||||
}
|
||||
|
||||
private constructRegExp(args: Array<unknown>, node: AstNode): CodeModeRegExp {
|
||||
const first = args[0]
|
||||
const pattern =
|
||||
first instanceof Values.RegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
|
||||
first instanceof CodeModeRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
|
||||
const flagsArg = args[1]
|
||||
if (flagsArg !== undefined && typeof flagsArg !== "string") {
|
||||
throw new InterpreterRuntimeError(
|
||||
@@ -1496,9 +1522,9 @@ export class Interpreter<R> {
|
||||
node,
|
||||
).as("SyntaxError")
|
||||
}
|
||||
const flags = flagsArg ?? (first instanceof Values.RegExp ? first.regex.flags : "")
|
||||
const flags = flagsArg ?? (first instanceof CodeModeRegExp ? first.regex.flags : "")
|
||||
try {
|
||||
return new Values.RegExp(pattern, flags)
|
||||
return new CodeModeRegExp(pattern, flags)
|
||||
} catch (error) {
|
||||
const reason = regexFailureReason(error)
|
||||
throw new InterpreterRuntimeError(
|
||||
@@ -1510,8 +1536,8 @@ export class Interpreter<R> {
|
||||
}
|
||||
}
|
||||
|
||||
private constructMap(init: unknown, node: AstNode): Effect.Effect<Values.Map, unknown, R> {
|
||||
const target = new Values.Map()
|
||||
private constructMap(init: unknown, node: AstNode): Effect.Effect<CodeModeMap, unknown, R> {
|
||||
const target = new CodeModeMap()
|
||||
if (init === undefined || init === null) return Effect.succeed(target)
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
@@ -1540,8 +1566,8 @@ export class Interpreter<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private constructSet(init: unknown, node: AstNode): Effect.Effect<Values.Set, unknown, R> {
|
||||
const target = new Values.Set()
|
||||
private constructSet(init: unknown, node: AstNode): Effect.Effect<CodeModeSet, unknown, R> {
|
||||
const target = new CodeModeSet()
|
||||
if (init === undefined || init === null) return Effect.succeed(target)
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
@@ -1559,7 +1585,7 @@ export class Interpreter<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private constructURL(args: Array<unknown>, node: AstNode): Values.URL {
|
||||
private constructURL(args: Array<unknown>, node: AstNode): CodeModeURL {
|
||||
if (args.length === 0) {
|
||||
throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node).as(
|
||||
"TypeError",
|
||||
@@ -1568,7 +1594,7 @@ export class Interpreter<R> {
|
||||
const input = urlArgument(args[0], "new URL input")
|
||||
const base = args[1] === undefined ? undefined : urlArgument(args[1], "new URL base")
|
||||
try {
|
||||
return new Values.URL(new URL(input, base))
|
||||
return new CodeModeURL(new URL(input, base))
|
||||
} catch {
|
||||
throw new InterpreterRuntimeError(
|
||||
`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`,
|
||||
@@ -1577,14 +1603,14 @@ export class Interpreter<R> {
|
||||
}
|
||||
}
|
||||
|
||||
private constructURLSearchParams(init: unknown, node: AstNode): Effect.Effect<Values.URLSearchParams, unknown, R> {
|
||||
if (init === undefined) return Effect.succeed(new Values.URLSearchParams(new URLSearchParams()))
|
||||
if (init instanceof Values.URLSearchParams) {
|
||||
return Effect.succeed(new Values.URLSearchParams(new URLSearchParams(init.params)))
|
||||
private constructURLSearchParams(init: unknown, node: AstNode): Effect.Effect<CodeModeURLSearchParams, unknown, R> {
|
||||
if (init === undefined) return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams()))
|
||||
if (init instanceof CodeModeURLSearchParams) {
|
||||
return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(init.params)))
|
||||
}
|
||||
if (typeof init === "string") return Effect.succeed(new Values.URLSearchParams(new URLSearchParams(init)))
|
||||
if (typeof init === "string") return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(init)))
|
||||
if (init === null || typeof init === "number" || typeof init === "boolean") {
|
||||
return Effect.succeed(new Values.URLSearchParams(new URLSearchParams(coerceToString(init))))
|
||||
return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(coerceToString(init))))
|
||||
}
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
@@ -1600,7 +1626,7 @@ export class Interpreter<R> {
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
return new Values.URLSearchParams(
|
||||
return new CodeModeURLSearchParams(
|
||||
new URLSearchParams(entries.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])),
|
||||
)
|
||||
}
|
||||
@@ -1613,7 +1639,7 @@ export class Interpreter<R> {
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
if (Values.isValue(init)) return new Values.URLSearchParams(new URLSearchParams())
|
||||
if (isCodeModeValue(init)) return new CodeModeURLSearchParams(new URLSearchParams())
|
||||
const data = boundedData(init, "new URLSearchParams input")
|
||||
if (data === null || typeof data !== "object") {
|
||||
throw new InterpreterRuntimeError(
|
||||
@@ -1621,7 +1647,7 @@ export class Interpreter<R> {
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
return new Values.URLSearchParams(
|
||||
return new CodeModeURLSearchParams(
|
||||
new URLSearchParams(
|
||||
Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)])),
|
||||
),
|
||||
@@ -1672,7 +1698,7 @@ export class Interpreter<R> {
|
||||
// Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects.
|
||||
// Dates use their default string hint for addition and loose equality, and epoch time elsewhere.
|
||||
const coerceOperand = (operand: unknown): unknown => {
|
||||
if (operand instanceof Values.Date) {
|
||||
if (operand instanceof CodeModeDate) {
|
||||
return operator === "+" || operator === "==" || operator === "!=" ? coerceToString(operand) : operand.time
|
||||
}
|
||||
return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand
|
||||
@@ -1757,7 +1783,7 @@ export class Interpreter<R> {
|
||||
throw new InterpreterRuntimeError("Unary operators require data values.", node, "InvalidDataValue")
|
||||
}
|
||||
const operand =
|
||||
value instanceof Values.Date
|
||||
value instanceof CodeModeDate
|
||||
? value.time
|
||||
: value !== null && typeof value === "object"
|
||||
? coerceToString(value)
|
||||
@@ -2084,7 +2110,7 @@ export class Interpreter<R> {
|
||||
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
|
||||
if (!fn.async) return run
|
||||
// The initial yield assigns the promise before the body can self-resolve.
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
const box: { promise?: CodeModePromise } = {}
|
||||
return Effect.map(
|
||||
this.createPromise(Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runner, value, fn.body, box))),
|
||||
(promise) => {
|
||||
@@ -2255,9 +2281,9 @@ export class Interpreter<R> {
|
||||
if (
|
||||
Array.isArray(value) ||
|
||||
typeof value === "string" ||
|
||||
value instanceof Values.Map ||
|
||||
value instanceof Values.Set ||
|
||||
value instanceof Values.URLSearchParams
|
||||
value instanceof CodeModeMap ||
|
||||
value instanceof CodeModeSet ||
|
||||
value instanceof CodeModeURLSearchParams
|
||||
) {
|
||||
const cursor = yield* self.syncIterator(value, node)
|
||||
if (!cursor) throw new InterpreterRuntimeError("Built-in iterator is unavailable.", node)
|
||||
@@ -2348,7 +2374,7 @@ export class Interpreter<R> {
|
||||
|
||||
if (property.type === "SpreadElement") {
|
||||
const spread = yield* self.evaluateExpression(getNode(property, "argument"))
|
||||
if (spread === null || spread === undefined || Values.isValue(spread)) continue
|
||||
if (spread === null || spread === undefined || isCodeModeValue(spread)) continue
|
||||
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
|
||||
throw new InterpreterRuntimeError("Object spread requires a data object.", property, "InvalidDataValue")
|
||||
}
|
||||
@@ -2572,11 +2598,11 @@ export class Interpreter<R> {
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
|
||||
if (objectValue instanceof Values.Date) {
|
||||
if (objectValue instanceof CodeModeDate) {
|
||||
if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof Values.RegExp) {
|
||||
if (objectValue instanceof CodeModeRegExp) {
|
||||
if (key === "lastIndex") return { target: objectValue, key }
|
||||
if (typeof key === "string" && regexpProperties.has(key)) {
|
||||
return new ComputedValue((objectValue.regex as unknown as Record<string, unknown>)[key])
|
||||
@@ -2584,17 +2610,17 @@ export class Interpreter<R> {
|
||||
if (typeof key === "string" && regexpMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof Values.Map) {
|
||||
if (objectValue instanceof CodeModeMap) {
|
||||
if (key === "size") return new ComputedValue(objectValue.map.size)
|
||||
if (typeof key === "string" && mapMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof Values.Set) {
|
||||
if (objectValue instanceof CodeModeSet) {
|
||||
if (key === "size") return new ComputedValue(objectValue.set.size)
|
||||
if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof Values.URL) {
|
||||
if (objectValue instanceof CodeModeURL) {
|
||||
if (key === "searchParams") {
|
||||
return new ComputedValue(objectValue.searchParams)
|
||||
}
|
||||
@@ -2602,7 +2628,7 @@ export class Interpreter<R> {
|
||||
if (typeof key === "string" && urlProperties.has(key)) return { target: objectValue, key }
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof Values.URLSearchParams) {
|
||||
if (objectValue instanceof CodeModeURLSearchParams) {
|
||||
if (key === "size") return new ComputedValue(objectValue.params.size)
|
||||
if (typeof key === "string" && urlSearchParamsMethods.has(key)) {
|
||||
return new IntrinsicReference(objectValue, key)
|
||||
@@ -2611,7 +2637,7 @@ export class Interpreter<R> {
|
||||
}
|
||||
|
||||
// Reject unknown promise properties so a missing await cannot hide.
|
||||
if (objectValue instanceof Values.Promise) {
|
||||
if (objectValue instanceof CodeModePromise) {
|
||||
if (key === "then" || key === "catch" || key === "finally") {
|
||||
return new PromiseInstanceMethodReference(objectValue, key)
|
||||
}
|
||||
@@ -2677,8 +2703,8 @@ export class Interpreter<R> {
|
||||
if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key)
|
||||
return Reflect.get(reference.target, reference.key)
|
||||
}
|
||||
if (reference.target instanceof Values.RegExp) return reference.target.lastIndex
|
||||
if (reference.target instanceof Values.URL) {
|
||||
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
|
||||
if (reference.target instanceof CodeModeURL) {
|
||||
return Reflect.get(reference.target.url, reference.key)
|
||||
}
|
||||
return Reflect.get(reference.target, reference.key)
|
||||
@@ -2700,11 +2726,11 @@ export class Interpreter<R> {
|
||||
reference instanceof ComputedValue ||
|
||||
reference === undefined ||
|
||||
isOpaqueMemberReference(reference) ||
|
||||
reference.target instanceof Values.URL
|
||||
reference.target instanceof CodeModeURL
|
||||
) {
|
||||
throw new InterpreterRuntimeError("Only data fields may be deleted.", target, "InvalidDataValue")
|
||||
}
|
||||
if (reference.target instanceof Values.RegExp) {
|
||||
if (reference.target instanceof CodeModeRegExp) {
|
||||
return Reflect.deleteProperty(reference.target.regex, reference.key)
|
||||
}
|
||||
return Reflect.deleteProperty(reference.target, reference.key)
|
||||
@@ -2741,10 +2767,10 @@ export class Interpreter<R> {
|
||||
}
|
||||
|
||||
private readReferenceValue(reference: MemberReference, key: PropertyKey): unknown {
|
||||
if (reference.target instanceof Values.URL) {
|
||||
if (reference.target instanceof CodeModeURL) {
|
||||
return Reflect.get(reference.target.url, key)
|
||||
}
|
||||
if (reference.target instanceof Values.RegExp) return reference.target.lastIndex
|
||||
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
|
||||
return Reflect.get(reference.target, key)
|
||||
}
|
||||
|
||||
@@ -2762,7 +2788,7 @@ export class Interpreter<R> {
|
||||
target[key] = next
|
||||
return
|
||||
}
|
||||
if (reference.target instanceof Values.URL) {
|
||||
if (reference.target instanceof CodeModeURL) {
|
||||
const property = key as string
|
||||
if (!urlWritableProperties.has(property)) {
|
||||
throw new InterpreterRuntimeError(`URL.${property} is read-only.`, node).as("TypeError")
|
||||
@@ -2776,7 +2802,7 @@ export class Interpreter<R> {
|
||||
throw new InterpreterRuntimeError(`URL.${property} received an invalid value.`, node).as("TypeError")
|
||||
}
|
||||
}
|
||||
if (reference.target instanceof Values.RegExp) {
|
||||
if (reference.target instanceof CodeModeRegExp) {
|
||||
reference.target.lastIndex = next
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js"
|
||||
import { copyIn, copyOut } from "../tool-runtime.js"
|
||||
import { Values } from "../values.js"
|
||||
import {
|
||||
isCodeModeValue,
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModePromise,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
} from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
|
||||
export const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"])
|
||||
@@ -25,14 +34,14 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
|
||||
if (typeof value === "string") return JSON.stringify(value)
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
if (typeof value !== "object") return String(value)
|
||||
if (value instanceof Values.Promise) return "[Promise (await it to get its value)]"
|
||||
if (value instanceof Values.Date) return coerceToString(value)
|
||||
if (value instanceof Values.RegExp) return coerceToString(value)
|
||||
if (value instanceof Values.URL) return coerceToString(value)
|
||||
if (value instanceof Values.URLSearchParams) return coerceToString(value)
|
||||
if (value instanceof CodeModePromise) return "[Promise (await it to get its value)]"
|
||||
if (value instanceof CodeModeDate) return coerceToString(value)
|
||||
if (value instanceof CodeModeRegExp) return coerceToString(value)
|
||||
if (value instanceof CodeModeURL) return coerceToString(value)
|
||||
if (value instanceof CodeModeURLSearchParams) return coerceToString(value)
|
||||
if (depth > MAX_CONSOLE_DEPTH) return "..."
|
||||
if (seen.has(value)) return "[Circular]"
|
||||
if (value instanceof Values.Map) {
|
||||
if (value instanceof CodeModeMap) {
|
||||
seen.add(value)
|
||||
try {
|
||||
const entries = Array.from(value.map.entries(), ([key, item]): Array<unknown> => [key, item])
|
||||
@@ -41,7 +50,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
if (value instanceof Values.Set) {
|
||||
if (value instanceof CodeModeSet) {
|
||||
seen.add(value)
|
||||
try {
|
||||
return `Set(${value.set.size}) ${formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}`
|
||||
@@ -91,14 +100,14 @@ const consoleTableRows = (
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) }))
|
||||
}
|
||||
if (data !== null && typeof data === "object" && !Values.isValue(data)) {
|
||||
if (data !== null && typeof data === "object" && !isCodeModeValue(data)) {
|
||||
return Object.entries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) }))
|
||||
}
|
||||
return [{ index: "0", values: { Value: data } }]
|
||||
}
|
||||
|
||||
const consoleTableValues = (value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> => {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value) && !Values.isValue(value)) {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value) && !isCodeModeValue(value)) {
|
||||
const source = value as Record<string, unknown>
|
||||
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]]))
|
||||
return Object.fromEntries(Object.entries(source))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { Values } from "../values.js"
|
||||
import { CodeModeDate } from "../values.js"
|
||||
import { coerceToNumber, coerceToString } from "./value.js"
|
||||
|
||||
const dateSetterArguments = new Map<string, number>([
|
||||
@@ -66,7 +66,7 @@ export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNo
|
||||
export const dateSetterArgumentCount = (name: string): number | undefined => dateSetterArguments.get(name)
|
||||
|
||||
export const invokeDateMethod = (
|
||||
value: Values.Date,
|
||||
value: CodeModeDate,
|
||||
name: string,
|
||||
args: Array<number>,
|
||||
node: AstNode,
|
||||
@@ -174,7 +174,7 @@ export const invokeDateMethod = (
|
||||
}
|
||||
}
|
||||
|
||||
const updateDate = (value: Values.Date, time: number): number => {
|
||||
const updateDate = (value: CodeModeDate, time: number): number => {
|
||||
value.time = time
|
||||
return time
|
||||
}
|
||||
|
||||
@@ -4,7 +4,14 @@ import { applyCollectionCallback } from "../interpreter/methods.js"
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { typeofValue } from "../interpreter/references.js"
|
||||
import { copyIn, copyOut, type SafeObject } from "../tool-runtime.js"
|
||||
import { Values } from "../values.js"
|
||||
import {
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
} from "../values.js"
|
||||
|
||||
export const jsonStatics = new Set(["parse", "stringify"])
|
||||
export type JsonMethodName = "parse" | "stringify"
|
||||
@@ -124,12 +131,19 @@ const stringify = <R>(
|
||||
}
|
||||
|
||||
const toJSONValue = (value: unknown): unknown => {
|
||||
if (value instanceof Values.Date) {
|
||||
if (value instanceof CodeModeDate) {
|
||||
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
|
||||
}
|
||||
if (value instanceof Values.URL) return value.url.href
|
||||
if (value instanceof CodeModeURL) return value.url.href
|
||||
return value
|
||||
}
|
||||
|
||||
const isPlainObject = (value: unknown): value is SafeObject =>
|
||||
value !== null && typeof value === "object" && !Values.isValue(value)
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
!(value instanceof CodeModeDate) &&
|
||||
!(value instanceof CodeModeRegExp) &&
|
||||
!(value instanceof CodeModeMap) &&
|
||||
!(value instanceof CodeModeSet) &&
|
||||
!(value instanceof CodeModeURL) &&
|
||||
!(value instanceof CodeModeURLSearchParams)
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
|
||||
export const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString", "valueOf"])
|
||||
|
||||
export const numberConstants = new Set([
|
||||
@@ -77,3 +74,5 @@ export const invokeNumberStatic = (name: string, args: Array<unknown>, node: Ast
|
||||
throw new InterpreterRuntimeError(`Number.${name} is not available.`, node)
|
||||
}
|
||||
}
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Effect } from "effect"
|
||||
import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "../interpreter/model.js"
|
||||
import { containsOpaqueReference, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
import { isBlockedMember } from "../tool-runtime.js"
|
||||
import { Values } from "../values.js"
|
||||
import { isCodeModeValue, CodeModePromise } from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
import { preserveConsumerError, type SyncIteratorRunner } from "../interpreter/iterator.js"
|
||||
|
||||
@@ -14,8 +14,8 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||
const requireObject = (): Record<string, unknown> => {
|
||||
const input = args[0]
|
||||
if (Array.isArray(input)) return input as unknown as Record<string, unknown>
|
||||
if (Values.isValue(input)) return {}
|
||||
if (input instanceof Values.Promise) {
|
||||
if (isCodeModeValue(input)) return {}
|
||||
if (input instanceof CodeModePromise) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Object.${name} received an un-awaited Promise; await it before inspecting the result.`,
|
||||
node,
|
||||
@@ -50,7 +50,7 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||
return Object.is(args[0], args[1])
|
||||
case "assign": {
|
||||
const target = args[0]
|
||||
if (target === null || typeof target !== "object" || Array.isArray(target) || Values.isValue(target)) {
|
||||
if (target === null || typeof target !== "object" || Array.isArray(target) || isCodeModeValue(target)) {
|
||||
throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
|
||||
}
|
||||
const out = target as Record<string, unknown>
|
||||
@@ -65,7 +65,7 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||
)
|
||||
}
|
||||
for (const source of args.slice(1)) {
|
||||
if (source === null || source === undefined || Values.isValue(source)) continue
|
||||
if (source === null || source === undefined || isCodeModeValue(source)) continue
|
||||
if (typeof source !== "object" || Array.isArray(source)) {
|
||||
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export const invokeObjectFromEntries = <R>(
|
||||
if (
|
||||
step.value === null ||
|
||||
typeof step.value !== "object" ||
|
||||
Values.isValue(step.value) ||
|
||||
isCodeModeValue(step.value) ||
|
||||
containsOpaqueReference(step.value)
|
||||
) {
|
||||
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node).as(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
||||
import { Values } from "../values.js"
|
||||
import { CodeModeRegExp } from "../values.js"
|
||||
import { coerceToNumber, coerceToString } from "./value.js"
|
||||
|
||||
type MatchValue = Array<unknown> & {
|
||||
@@ -40,7 +40,7 @@ export const escapeRegexHint =
|
||||
export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
|
||||
// Native parity: an undefined pattern behaves as an empty pattern.
|
||||
if (arg === undefined) return new RegExp("", extraFlags)
|
||||
if (arg instanceof Values.RegExp) return arg.regex
|
||||
if (arg instanceof CodeModeRegExp) return arg.regex
|
||||
if (typeof arg === "string") {
|
||||
try {
|
||||
return new RegExp(arg, extraFlags)
|
||||
@@ -80,7 +80,7 @@ export const invokeRegExpStatic = (name: string, args: Array<unknown>, node: Ast
|
||||
}
|
||||
|
||||
export const invokeRegExpMethod = (
|
||||
value: Values.RegExp,
|
||||
value: CodeModeRegExp,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
|
||||
export const stringMethods = new Set([
|
||||
"toLowerCase",
|
||||
"toUpperCase",
|
||||
@@ -48,3 +46,4 @@ export const invokeStringStatic = (name: string, args: Array<unknown>, node: Ast
|
||||
throw new InterpreterRuntimeError(`String.${name} is not available.`, node)
|
||||
}
|
||||
}
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { type AstNode, InterpreterRuntimeError, UriFunction } from "../interpreter/model.js"
|
||||
import { Values } from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
|
||||
export const urlProperties = new Set([
|
||||
"href",
|
||||
"origin",
|
||||
@@ -70,7 +66,7 @@ export const invokeUriFunction = (ref: UriFunction, args: Array<unknown>, node:
|
||||
}
|
||||
|
||||
export const urlArgument = (value: unknown, label: string): string =>
|
||||
value instanceof Values.URL ? value.url.href : uriArgument(value, label)
|
||||
value instanceof CodeModeURL ? value.url.href : uriArgument(value, label)
|
||||
|
||||
export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
if (!urlStatics.has(name)) throw new InterpreterRuntimeError(`URL.${name} is not available.`, node)
|
||||
@@ -79,13 +75,16 @@ export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNod
|
||||
const base = args[1] === undefined ? undefined : urlArgument(args[1], `URL.${name} base`)
|
||||
try {
|
||||
const url = new URL(input, base)
|
||||
return name === "canParse" ? true : new Values.URL(url)
|
||||
return name === "canParse" ? true : new CodeModeURL(url)
|
||||
} catch {
|
||||
return name === "canParse" ? false : null
|
||||
}
|
||||
}
|
||||
|
||||
export const invokeURLMethod = (value: Values.URL, name: string, node: AstNode): string => {
|
||||
export const invokeURLMethod = (value: CodeModeURL, name: string, node: AstNode): string => {
|
||||
if (name === "toString" || name === "toJSON") return value.url.href
|
||||
throw new InterpreterRuntimeError(`URL method '${name}' is not available.`, node)
|
||||
}
|
||||
import { type AstNode, InterpreterRuntimeError, UriFunction } from "../interpreter/model.js"
|
||||
import { CodeModeURL } from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { copyIn, type SafeObject } from "../tool-runtime.js"
|
||||
import { Values } from "../values.js"
|
||||
|
||||
export const errorConstructors = new Set([
|
||||
"Error",
|
||||
"TypeError",
|
||||
@@ -38,13 +34,13 @@ export const boundedData = (value: unknown, label: string): unknown => copyIn(va
|
||||
export const coerceToString = (value: unknown): string => {
|
||||
if (value === null) return "null"
|
||||
if (value === undefined) return "undefined"
|
||||
if (value instanceof Values.Date)
|
||||
if (value instanceof CodeModeDate)
|
||||
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date"
|
||||
if (value instanceof Values.RegExp) return `/${value.regex.source}/${value.regex.flags}`
|
||||
if (value instanceof Values.Map) return "[object Map]"
|
||||
if (value instanceof Values.Set) return "[object Set]"
|
||||
if (value instanceof Values.URL) return value.url.href
|
||||
if (value instanceof Values.URLSearchParams) return value.params.toString()
|
||||
if (value instanceof CodeModeRegExp) return `/${value.regex.source}/${value.regex.flags}`
|
||||
if (value instanceof CodeModeMap) return "[object Map]"
|
||||
if (value instanceof CodeModeSet) return "[object Set]"
|
||||
if (value instanceof CodeModeURL) return value.url.href
|
||||
if (value instanceof CodeModeURLSearchParams) return value.params.toString()
|
||||
if (errorBrandName(value) !== undefined) {
|
||||
// Match Error.prototype.toString: "name: message", or just one when the other is empty.
|
||||
const error = value as { name?: unknown; message?: unknown }
|
||||
@@ -63,8 +59,8 @@ export const coerceToString = (value: unknown): string => {
|
||||
}
|
||||
|
||||
export const coerceToNumber = (value: unknown): number => {
|
||||
if (value instanceof Values.Date) return value.time
|
||||
if (Values.isValue(value)) return Number.NaN
|
||||
if (value instanceof CodeModeDate) return value.time
|
||||
if (isCodeModeValue(value)) return Number.NaN
|
||||
// Arrays coerce through our own string coercion: host Number(array) joins with host
|
||||
// ToPrimitive, which throws on the null-prototype objects the interpreter produces.
|
||||
if (Array.isArray(value)) return Number(coerceToString(value))
|
||||
@@ -81,7 +77,7 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
|
||||
const raw = args[0]
|
||||
// Error values are plain SafeObjects; the boundedData path below would strip their brand.
|
||||
if (ref.name === "String" && errorBrandName(raw) !== undefined) return coerceToString(raw)
|
||||
if (Values.isValue(raw)) {
|
||||
if (isCodeModeValue(raw)) {
|
||||
if (ref.name === "Boolean") return true
|
||||
if (ref.name === "Number") return coerceToNumber(raw)
|
||||
if (ref.name === "String") return coerceToString(raw)
|
||||
@@ -105,3 +101,14 @@ 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 { copyIn, type SafeObject } from "../tool-runtime.js"
|
||||
import {
|
||||
isCodeModeValue,
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
} from "../values.js"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Cause, Effect, Exit, Formatter, Schema } from "effect"
|
||||
import type { DiagnosticKind } from "./codemode.js"
|
||||
import { toolError } from "./tool-error.js"
|
||||
import {
|
||||
decodeInput as decodeToolInput,
|
||||
@@ -13,9 +12,17 @@ import {
|
||||
import { isNamespace, type Namespace } from "./namespace.js"
|
||||
import { isTool, type Tool } from "./tool.js"
|
||||
import type { Tools } from "./tools.js"
|
||||
import { Values } from "./values.js"
|
||||
import {
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModePromise,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
} from "./values.js"
|
||||
|
||||
export const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0)
|
||||
const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0)
|
||||
|
||||
export type Services<T> = ServicesOf<T, []>
|
||||
|
||||
@@ -98,10 +105,12 @@ const MAX_VALUE_DEPTH = 32
|
||||
|
||||
export class ToolRuntimeError extends Error {
|
||||
constructor(
|
||||
readonly kind: Extract<
|
||||
DiagnosticKind,
|
||||
"UnknownTool" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded"
|
||||
>,
|
||||
readonly kind:
|
||||
| "UnknownTool"
|
||||
| "InvalidToolInput"
|
||||
| "InvalidToolOutput"
|
||||
| "InvalidDataValue"
|
||||
| "ToolCallLimitExceeded",
|
||||
message: string,
|
||||
readonly suggestions: ReadonlyArray<string> = [],
|
||||
) {
|
||||
@@ -142,7 +151,7 @@ const copyBounded = (
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
|
||||
}
|
||||
|
||||
if (value instanceof Values.Promise) {
|
||||
if (value instanceof CodeModePromise) {
|
||||
throw new ToolRuntimeError(
|
||||
"InvalidDataValue",
|
||||
`${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`,
|
||||
@@ -150,36 +159,47 @@ const copyBounded = (
|
||||
}
|
||||
|
||||
if (preserveCodeModeValues) {
|
||||
if (Values.isValue(value)) return value
|
||||
if (value instanceof Date) return new Values.Date(value.getTime())
|
||||
if (value instanceof RegExp) return new Values.RegExp(value.source, value.flags)
|
||||
if (
|
||||
value instanceof CodeModeDate ||
|
||||
value instanceof CodeModeRegExp ||
|
||||
value instanceof CodeModeMap ||
|
||||
value instanceof CodeModeSet ||
|
||||
value instanceof CodeModeURL ||
|
||||
value instanceof CodeModeURLSearchParams
|
||||
) {
|
||||
return value
|
||||
}
|
||||
if (value instanceof Date) return new CodeModeDate(value.getTime())
|
||||
if (value instanceof RegExp) return new CodeModeRegExp(value.source, value.flags)
|
||||
if (value instanceof Map) {
|
||||
const wrapped = new Values.Map()
|
||||
const wrapped = new CodeModeMap()
|
||||
for (const [key, item] of value.entries()) {
|
||||
wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true))
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
if (value instanceof Set) {
|
||||
const wrapped = new Values.Set()
|
||||
const wrapped = new CodeModeSet()
|
||||
for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true))
|
||||
return wrapped
|
||||
}
|
||||
if (value instanceof URL) return new Values.URL(new URL(value.href))
|
||||
if (value instanceof URLSearchParams) return new Values.URLSearchParams(new URLSearchParams(value))
|
||||
if (value instanceof URL) return new CodeModeURL(new URL(value.href))
|
||||
if (value instanceof URLSearchParams) return new CodeModeURLSearchParams(new URLSearchParams(value))
|
||||
}
|
||||
|
||||
if (value instanceof Values.Date) {
|
||||
if (value instanceof CodeModeDate) {
|
||||
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return Number.isFinite(value.getTime()) ? value.toISOString() : null
|
||||
}
|
||||
if (value instanceof Values.URL) return value.url.href
|
||||
if (value instanceof CodeModeURL) return value.url.href
|
||||
if (value instanceof URL) return value.href
|
||||
// Remaining runtime values and their host counterparts serialize as empty objects, like JSON.stringify.
|
||||
if (
|
||||
Values.isValue(value) ||
|
||||
value instanceof CodeModeRegExp ||
|
||||
value instanceof CodeModeMap ||
|
||||
value instanceof CodeModeSet ||
|
||||
value instanceof CodeModeURLSearchParams ||
|
||||
value instanceof RegExp ||
|
||||
value instanceof Map ||
|
||||
value instanceof Set ||
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
export * as Values from "./values.js"
|
||||
|
||||
import type { Fiber } from "effect"
|
||||
|
||||
/**
|
||||
* Runtime values the interpreter recognizes by class. Each wraps the host value it stands for,
|
||||
* so hosts construct these to hand a value to a program and receive them back unchanged.
|
||||
*/
|
||||
|
||||
export class Promise {
|
||||
export class CodeModePromise {
|
||||
constructor(readonly fiber: Fiber.Fiber<unknown, unknown>) {}
|
||||
}
|
||||
|
||||
export class Date {
|
||||
export class CodeModeDate {
|
||||
constructor(public time: number) {}
|
||||
}
|
||||
|
||||
export class RegExp {
|
||||
readonly regex: globalThis.RegExp
|
||||
export class CodeModeRegExp {
|
||||
readonly regex: RegExp
|
||||
constructor(pattern: string, flags: string) {
|
||||
this.regex = new globalThis.RegExp(pattern, flags)
|
||||
this.regex = new RegExp(pattern, flags)
|
||||
}
|
||||
|
||||
get lastIndex(): unknown {
|
||||
@@ -30,30 +23,31 @@ export class RegExp {
|
||||
}
|
||||
}
|
||||
|
||||
export class Map {
|
||||
readonly map = new globalThis.Map<unknown, unknown>()
|
||||
export class CodeModeMap {
|
||||
readonly map = new Map<unknown, unknown>()
|
||||
}
|
||||
|
||||
export class Set {
|
||||
readonly set = new globalThis.Set<unknown>()
|
||||
export class CodeModeSet {
|
||||
readonly set = new Set<unknown>()
|
||||
}
|
||||
|
||||
export class URLSearchParams {
|
||||
constructor(readonly params: globalThis.URLSearchParams) {}
|
||||
export class CodeModeURLSearchParams {
|
||||
constructor(readonly params: URLSearchParams) {}
|
||||
}
|
||||
|
||||
export class URL {
|
||||
readonly searchParams: URLSearchParams
|
||||
constructor(readonly url: globalThis.URL) {
|
||||
this.searchParams = new URLSearchParams(url.searchParams)
|
||||
export class CodeModeURL {
|
||||
readonly searchParams: CodeModeURLSearchParams
|
||||
constructor(readonly url: URL) {
|
||||
this.searchParams = new CodeModeURLSearchParams(url.searchParams)
|
||||
}
|
||||
}
|
||||
|
||||
/** Data-like runtime values; excludes Promise, which never crosses a boundary. */
|
||||
export const isValue = (value: unknown): value is Date | RegExp | Map | Set | URL | URLSearchParams =>
|
||||
value instanceof Date ||
|
||||
value instanceof RegExp ||
|
||||
value instanceof Map ||
|
||||
value instanceof Set ||
|
||||
value instanceof URL ||
|
||||
value instanceof URLSearchParams
|
||||
export const isCodeModeValue = (
|
||||
value: unknown,
|
||||
): value is CodeModeDate | CodeModeRegExp | CodeModeMap | CodeModeSet | CodeModeURL | CodeModeURLSearchParams =>
|
||||
value instanceof CodeModeDate ||
|
||||
value instanceof CodeModeRegExp ||
|
||||
value instanceof CodeModeMap ||
|
||||
value instanceof CodeModeSet ||
|
||||
value instanceof CodeModeURL ||
|
||||
value instanceof CodeModeURLSearchParams
|
||||
|
||||
@@ -26,7 +26,7 @@ export const Plugin = define({
|
||||
directory: AbsolutePath.make(
|
||||
directory.startsWith("~/")
|
||||
? path.join(global.home, directory.slice(2))
|
||||
: path.resolve(location.project.canonical, directory),
|
||||
: path.resolve(entry.path ? path.dirname(entry.path) : location.directory, directory),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
+75
-64
@@ -9,6 +9,7 @@ import { AppProcess } from "@opencode/util/process"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { File } from "./file.js"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex.js"
|
||||
import { VcsPatch } from "./vcs/patch.js"
|
||||
|
||||
export class Repository extends Schema.Class<Repository>("Git.Repository")({
|
||||
worktree: AbsolutePath,
|
||||
@@ -308,7 +309,7 @@ const layer = Layer.effect(
|
||||
operationName: OperationError["operation"],
|
||||
repository: Repository,
|
||||
args: string[],
|
||||
options?: { stdin?: string; env?: Record<string, string> },
|
||||
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
|
||||
) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
@@ -317,7 +318,7 @@ const layer = Layer.effect(
|
||||
env: options?.env,
|
||||
extendEnv: true,
|
||||
}),
|
||||
{ stdin: options?.stdin },
|
||||
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
@@ -331,7 +332,8 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
const text = result.stdout.toString("utf8")
|
||||
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
|
||||
if (result.exitCode === 0)
|
||||
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
|
||||
return yield* new OperationError({
|
||||
operation: operationName,
|
||||
directory: repository.worktree,
|
||||
@@ -385,9 +387,7 @@ const layer = Layer.effect(
|
||||
maximumUntrackedFileBytes?: number
|
||||
}) {
|
||||
const list = (args: string[]) =>
|
||||
repositoryOperation("refresh", input.repository, args).pipe(
|
||||
Effect.map((result) => result.text.split("\0").filter(Boolean)),
|
||||
)
|
||||
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
|
||||
const [tracked, untracked] = yield* Effect.all(
|
||||
[
|
||||
list(["diff-files", "--name-only", "-z", "--", input.scope]),
|
||||
@@ -464,13 +464,7 @@ const layer = Layer.effect(
|
||||
directory: input.repository.worktree,
|
||||
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
|
||||
})
|
||||
return new Set(
|
||||
result.stdout
|
||||
.toString("utf8")
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((file) => RelativePath.make(file)),
|
||||
)
|
||||
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
|
||||
})
|
||||
|
||||
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
|
||||
@@ -499,19 +493,23 @@ const layer = Layer.effect(
|
||||
to: TreeID
|
||||
}) {
|
||||
// Undo needs both paths of a rename, not only its destination.
|
||||
return (yield* repositoryOperation("list_files", input.repository, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-z",
|
||||
input.from,
|
||||
input.to,
|
||||
])).text
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((file) => RelativePath.make(file))
|
||||
return nuls(
|
||||
(yield* repositoryOperation("list_files", input.repository, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-z",
|
||||
input.from,
|
||||
input.to,
|
||||
])).text,
|
||||
).map((file) => RelativePath.make(file))
|
||||
})
|
||||
|
||||
/**
|
||||
* Three batched invocations over the tree pair instead of three per file. An
|
||||
* explicit empty selection diffs nothing; an absent one diffs every changed path.
|
||||
* Patch output is capped like VCS diffs: files past the cap get an empty patch.
|
||||
*/
|
||||
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
|
||||
repository: Repository
|
||||
from: TreeID
|
||||
@@ -519,49 +517,57 @@ const layer = Layer.effect(
|
||||
context?: number
|
||||
paths?: readonly RelativePath[]
|
||||
}) {
|
||||
const paths = input.paths ?? (yield* treeFiles(input))
|
||||
return yield* Effect.forEach(paths, (file) =>
|
||||
Effect.gen(function* () {
|
||||
const statusText = (yield* repositoryOperation("diff", input.repository, [
|
||||
if (input.paths?.length === 0) return []
|
||||
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? [])]
|
||||
// Patch headers have no -z form: unquoted paths keep chunksByFile matching non-ASCII names.
|
||||
const [names, numbers, patch] = yield* Effect.all(
|
||||
[
|
||||
repositoryOperation("diff", input.repository, ["diff", "--name-status", "-z", ...args]),
|
||||
repositoryOperation("diff", input.repository, ["diff", "--numstat", "-z", ...args]),
|
||||
repositoryOperation(
|
||||
"diff",
|
||||
"--name-status",
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text.trim()
|
||||
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
|
||||
const stats = (yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
"--numstat",
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text.split("\t")
|
||||
const binary = stats[0] === "-" || stats[1] === "-"
|
||||
const patch = binary
|
||||
? ""
|
||||
: (yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
`--unified=${input.context ?? 3}`,
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text
|
||||
return {
|
||||
file,
|
||||
status,
|
||||
additions: binary ? 0 : Number(stats[0] ?? 0),
|
||||
deletions: binary ? 0 : Number(stats[1] ?? 0),
|
||||
patch,
|
||||
} satisfies File.Diff
|
||||
input.repository,
|
||||
["-c", "core.quotepath=false", "diff", "--no-ext-diff", `--unified=${input.context ?? 3}`, ...args],
|
||||
{ maxOutputBytes: VcsPatch.MAX_TOTAL_PATCH_BYTES },
|
||||
),
|
||||
],
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
const statuses = nuls(names.text)
|
||||
const files = statuses.flatMap((code, index) => {
|
||||
const file = statuses[index + 1]
|
||||
if (index % 2 !== 0 || !file) return []
|
||||
return [
|
||||
{
|
||||
file: RelativePath.make(file),
|
||||
status: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified",
|
||||
} as const,
|
||||
]
|
||||
})
|
||||
const stats = new Map(
|
||||
nuls(numbers.text).flatMap((line) => {
|
||||
const [additions, deletions, ...file] = line.split("\t")
|
||||
if (!additions || !deletions || file.length === 0) return []
|
||||
return [
|
||||
[
|
||||
file.join("\t"),
|
||||
additions === "-" || deletions === "-"
|
||||
? { binary: true, additions: 0, deletions: 0 }
|
||||
: { binary: false, additions: Number(additions), deletions: Number(deletions) },
|
||||
] as const,
|
||||
]
|
||||
}),
|
||||
)
|
||||
const patches = VcsPatch.chunksByFile(patch, (index) => files[index]?.file)
|
||||
return files.map((entry) => {
|
||||
const stat = stats.get(entry.file)
|
||||
return {
|
||||
...entry,
|
||||
additions: stat?.additions ?? 0,
|
||||
deletions: stat?.deletions ?? 0,
|
||||
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
|
||||
} satisfies File.Diff
|
||||
})
|
||||
})
|
||||
|
||||
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
|
||||
@@ -733,6 +739,11 @@ function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Split NUL-terminated git output into its records. */
|
||||
function nuls(text: string) {
|
||||
return text.split("\0").filter(Boolean)
|
||||
}
|
||||
|
||||
function resolvePath(cwd: string, value: string) {
|
||||
const trimmed = value.replace(/[\r\n]+$/, "")
|
||||
if (!trimmed) return cwd
|
||||
|
||||
@@ -25,7 +25,6 @@ import { OpenAICompatiblePlugin } from "./provider/openai-compatible.js"
|
||||
import { OpencodePlugin } from "./provider/opencode.js"
|
||||
import { OpenRouterPlugin } from "./provider/openrouter.js"
|
||||
import { PerplexityPlugin } from "./provider/perplexity.js"
|
||||
import { PoePlugin } from "./provider/poe.js"
|
||||
import { SapAICorePlugin } from "./provider/sap-ai-core.js"
|
||||
import { VercelPlugin } from "./provider/vercel.js"
|
||||
import { VenicePlugin } from "./provider/venice.js"
|
||||
@@ -61,7 +60,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
OpenAIPlugin,
|
||||
OpenRouterPlugin,
|
||||
PerplexityPlugin,
|
||||
PoePlugin,
|
||||
SapAICorePlugin,
|
||||
VercelPlugin,
|
||||
VenicePlugin,
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import { Duration, Effect, Equal, Schema, Semaphore, Stream } from "effect"
|
||||
import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
|
||||
import type { Scope } from "effect"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { WebSearch } from "../../websearch.js"
|
||||
import { ConfigProvider } from "@opencode/schema/config/provider"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
|
||||
const defaultServer = "https://opencode.ai/console"
|
||||
const clientID = "opencode-cli"
|
||||
const methodID = Integration.MethodID.make("device")
|
||||
const RemoteResponse = Schema.Struct({
|
||||
providers: Schema.Record(Schema.String, ConfigProvider.Info),
|
||||
websearch: Schema.Struct({
|
||||
providerID: WebSearch.ID,
|
||||
}).pipe(Schema.optional),
|
||||
})
|
||||
const RemoteResponse = Schema.Struct({ providers: Schema.Record(Schema.String, ConfigProvider.Info) })
|
||||
const Device = Schema.Struct({
|
||||
device_code: Schema.String,
|
||||
user_code: Schema.String,
|
||||
@@ -67,9 +61,10 @@ function oauth(http: HttpClient.HttpClient) {
|
||||
}),
|
||||
refresh: (credential) =>
|
||||
Effect.gen(function* () {
|
||||
const server = typeof credential.metadata?.server === "string" ? credential.metadata.server : defaultServer
|
||||
const token = yield* post(
|
||||
http,
|
||||
`${serverUrl(credential)}/auth/device/token`,
|
||||
`${server}/auth/device/token`,
|
||||
{ grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID },
|
||||
Token,
|
||||
)
|
||||
@@ -90,25 +85,22 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
const bus = yield* Bus.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
type ActiveConnection = Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
|
||||
let snapshot: {
|
||||
config: typeof RemoteResponse.Type | undefined
|
||||
connection: ActiveConnection
|
||||
} = { config: undefined, connection: undefined }
|
||||
let connected = false
|
||||
let providers: typeof RemoteResponse.Type.providers | undefined
|
||||
|
||||
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("opencode")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
const config = credential
|
||||
? yield* fetchConfig(http, credential).pipe(
|
||||
connected = connection !== undefined
|
||||
providers = credential
|
||||
? yield* fetchProviders(http, credential).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
return { config, connection }
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
@@ -119,9 +111,9 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
editor.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
|
||||
})
|
||||
|
||||
snapshot = yield* load()
|
||||
yield* load()
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
for (const [providerID, item] of Object.entries(snapshot.config?.providers ?? {})) {
|
||||
for (const [providerID, item] of Object.entries(providers ?? {})) {
|
||||
const source = catalog.provider.get(item.canonical ?? providerID)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (source && source.provider !== provider)
|
||||
@@ -191,7 +183,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
|
||||
const item = catalog.provider.get(Provider.ID.opencode)
|
||||
if (!item) return
|
||||
const hasKey = Boolean(process.env.OPENCODE_API_KEY || snapshot.connection || item.provider.settings?.apiKey)
|
||||
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
|
||||
catalog.provider.update(item.provider.id, (provider) => {
|
||||
if (!hasKey) {
|
||||
provider.activation = "enabled"
|
||||
@@ -207,95 +199,23 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
}
|
||||
})
|
||||
|
||||
yield* ctx.websearch.transform((editor) => {
|
||||
const descriptor = snapshot.config?.websearch
|
||||
const connection = snapshot.connection
|
||||
if (!descriptor || !connection) return
|
||||
editor.add({
|
||||
id: descriptor.providerID,
|
||||
name: "OpenCode Web Search",
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const active = yield* ctx.integration.connection.active("opencode")
|
||||
if (
|
||||
!active ||
|
||||
(connection.type === "credential"
|
||||
? active.type !== "credential" || active.id !== connection.id
|
||||
: active.type !== "env" || active.name !== connection.name)
|
||||
) {
|
||||
return yield* Effect.fail(new Error("OpenCode Console connection changed"))
|
||||
}
|
||||
const credential = yield* ctx.integration.connection.resolve(active)
|
||||
if (!credential) return yield* Effect.fail(new Error("OpenCode Console is not connected"))
|
||||
const metadata = credential.metadata
|
||||
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
|
||||
const token = credential.type === "oauth" ? credential.access : credential.key
|
||||
const server = yield* normalizeServer(serverUrl(credential))
|
||||
const request = yield* HttpClientRequest.post(`${server}/api/websearch`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(token),
|
||||
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
|
||||
HttpClientRequest.schemaBodyJson(WebSearch.Input)({
|
||||
query: input.query,
|
||||
providerID: descriptor.providerID,
|
||||
}),
|
||||
)
|
||||
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http))
|
||||
.execute(request)
|
||||
.pipe(
|
||||
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(WebSearch.Response)),
|
||||
Effect.scoped,
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
orElse: () => Effect.fail(new Error("OpenCode web search request timed out")),
|
||||
}),
|
||||
)
|
||||
if (response.providerID !== descriptor.providerID) {
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
`OpenCode web search returned provider ${response.providerID} instead of ${descriptor.providerID}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
return response.results
|
||||
}),
|
||||
})
|
||||
editor.default.set(descriptor.providerID)
|
||||
})
|
||||
|
||||
const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) {
|
||||
snapshot = next
|
||||
yield* Effect.all([ctx.catalog.reload(), ctx.websearch.reload()], { concurrency: 2, discard: true })
|
||||
})
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(apply)))
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
// Console config can change independently of local credential activity, so re-fetch
|
||||
// periodically and only rebuild the catalog and search providers when the snapshot differs.
|
||||
yield* Effect.sleep(Duration.minutes(10)).pipe(
|
||||
Effect.andThen(
|
||||
loading.withPermit(
|
||||
load().pipe(Effect.flatMap((next) => (Equal.equals(snapshot, next) ? Effect.void : apply(next)))),
|
||||
),
|
||||
),
|
||||
Effect.forever,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
const metadata = value.metadata
|
||||
const server = typeof metadata?.server === "string" ? metadata.server : defaultServer
|
||||
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
|
||||
const token = value.type === "oauth" ? value.access : value.key
|
||||
return http
|
||||
.execute(
|
||||
HttpClientRequest.get(`${serverUrl(value)}/api/v2/config`).pipe(
|
||||
HttpClientRequest.get(`${server}/api/v2/config`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(token),
|
||||
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
|
||||
@@ -306,15 +226,12 @@ function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
if (response.status === 404) return Effect.undefined
|
||||
return HttpClientResponse.filterStatusOk(response).pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
|
||||
Effect.map((remote) => remote.providers),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function serverUrl(value: Credential.Value) {
|
||||
return typeof value.metadata?.server === "string" ? value.metadata.server : defaultServer
|
||||
}
|
||||
|
||||
function withoutCredentials<Value>(body: Readonly<Record<string, Value>> | undefined) {
|
||||
return (
|
||||
body &&
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Clock, Deferred, Effect, Option, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import type { ServerResponse } from "node:http"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { OauthCallbackPage } from "../../oauth/page.js"
|
||||
|
||||
const integrationID = Integration.ID.make("poe")
|
||||
const methodID = Integration.MethodID.make("browser")
|
||||
const clientID = "client_728290227fc048cc9262091a1ea197ea"
|
||||
const issuer = "https://poe.com"
|
||||
const maxExpiry = 8_640_000_000_000_000
|
||||
const Token = Schema.Struct({
|
||||
api_key: Schema.Trim.check(Schema.isNonEmpty(), Schema.isPattern(/^\S+$/)),
|
||||
api_key_expires_in: Schema.optional(Schema.NullOr(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)))),
|
||||
})
|
||||
const decodeError = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(
|
||||
Schema.Struct({ error: Schema.optional(Schema.String), error_description: Schema.optional(Schema.String) }),
|
||||
),
|
||||
)
|
||||
|
||||
export const PoePlugin = define({
|
||||
id: "opencode.provider.poe",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: { id: methodID, type: "oauth", label: "Login with Poe (browser)" },
|
||||
// Poe-issued API keys remain usable until expiry, then require another login.
|
||||
refresh: (value) =>
|
||||
Clock.currentTimeMillis.pipe(
|
||||
Effect.flatMap((now) =>
|
||||
value.expires > now
|
||||
? Effect.succeed(value)
|
||||
: Effect.fail(new Error("Poe API key expired. Log in with Poe again.")),
|
||||
),
|
||||
),
|
||||
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 state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
|
||||
const callback = yield* Deferred.make<{ code: string; response: ServerResponse }, Error>()
|
||||
const { createServer } = yield* Effect.promise(() => import("node:http"))
|
||||
const { EventEmitter } = yield* Effect.promise(() => import("node:events"))
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1")
|
||||
if (request.method !== "GET" || url.pathname !== "/callback") {
|
||||
response.writeHead(404).end()
|
||||
return
|
||||
}
|
||||
const error = callbackError(url.searchParams, state)
|
||||
if (error) {
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(error, { provider: "Poe" }))
|
||||
Effect.runSync(Deferred.fail(callback, new Error(error)))
|
||||
return
|
||||
}
|
||||
if (!Effect.runSync(Deferred.succeed(callback, { code: url.searchParams.get("code") ?? "", response })))
|
||||
response.writeHead(409).end("OAuth callback already received")
|
||||
})
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
server.close()
|
||||
server.closeAllConnections()
|
||||
}),
|
||||
)
|
||||
yield* Effect.tryPromise(() => EventEmitter.once(server.listen(0, "127.0.0.1"), "listening"))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string")
|
||||
return yield* Effect.fail(new Error("Missing OAuth callback port"))
|
||||
const redirect = `http://127.0.0.1:${address.port}/callback`
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: `${issuer}/oauth/authorize?${new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: clientID,
|
||||
redirect_uri: redirect,
|
||||
scope: "apikey:create",
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
}).toString()}`,
|
||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||
callback: Effect.gen(function* () {
|
||||
const request = yield* Deferred.await(callback)
|
||||
const respond = (error?: string) =>
|
||||
Effect.sync(() =>
|
||||
request.response
|
||||
.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
|
||||
.end(
|
||||
error
|
||||
? OauthCallbackPage.error(error, { provider: "Poe" })
|
||||
: OauthCallbackPage.success({ provider: "Poe" }),
|
||||
),
|
||||
)
|
||||
return yield* exchangeCode(http, { code: request.code, redirect, verifier }).pipe(
|
||||
Effect.tap(() => respond()),
|
||||
Effect.tapError((error) => respond(error.message)),
|
||||
// Bun's server.closeAllConnections() leaves an unanswered callback response pending.
|
||||
Effect.onInterrupt(() => Effect.sync(() => request.response.destroy())),
|
||||
)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
function callbackError(params: URLSearchParams, state: string) {
|
||||
if (params.get("state") !== state) return "Invalid OAuth state"
|
||||
// Poe's client pins this issuer but does not require iss; its documented callbacks may omit it.
|
||||
if (params.has("iss") && params.get("iss") !== issuer) return "Invalid OAuth issuer"
|
||||
if (params.has("error")) {
|
||||
const detail = params.get("error_description") || params.get("error") || "Authorization denied"
|
||||
return detail.includes(state) ? "Poe authorization failed" : detail
|
||||
}
|
||||
return params.get("code")?.trim() ? undefined : "Missing authorization code"
|
||||
}
|
||||
|
||||
function exchangeCode(http: HttpClient.HttpClient, input: { code: string; redirect: string; verifier: string }) {
|
||||
return Effect.gen(function* () {
|
||||
const response = yield* http
|
||||
.execute(
|
||||
HttpClientRequest.post("https://api.poe.com/token").pipe(
|
||||
HttpClientRequest.bodyUrlParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: clientID,
|
||||
code: input.code,
|
||||
redirect_uri: input.redirect,
|
||||
code_verifier: input.verifier,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.pipe(Effect.mapError(() => new Error("Poe token exchange request failed")))
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const error = Option.getOrUndefined(decodeError(yield* response.text.pipe(Effect.orElseSucceed(() => ""))))
|
||||
const detail = error?.error_description || error?.error
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
detail && ![input.code, input.verifier].some((secret) => detail.includes(secret))
|
||||
? `Poe token exchange failed: ${detail}`
|
||||
: `Poe token exchange failed (${response.status})`,
|
||||
),
|
||||
)
|
||||
}
|
||||
const token = yield* HttpClientResponse.schemaBodyJson(Token)(response).pipe(
|
||||
Effect.mapError(() => new Error("Invalid Poe token response")),
|
||||
)
|
||||
const expires =
|
||||
token.api_key_expires_in == null ? maxExpiry : (yield* Clock.currentTimeMillis) + token.api_key_expires_in * 1000
|
||||
if (!Number.isSafeInteger(expires) || expires > maxExpiry)
|
||||
return yield* Effect.fail(new Error("Invalid Poe API key expiry"))
|
||||
return Credential.OAuth.make({ type: "oauth", methodID, access: token.api_key, refresh: "", expires })
|
||||
})
|
||||
}
|
||||
@@ -57,8 +57,11 @@ import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { llmClient } from "./effect/app-node-platform.js"
|
||||
import { Snapshot } from "./snapshot.js"
|
||||
import { Session } from "./session/session.js"
|
||||
import { SessionDiff, TurnRangeError } from "./session/diff.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import type { EventLog } from "@opencode/schema/event-log"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import { Job } from "./job.js"
|
||||
import type { Command } from "./command.js"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
@@ -113,6 +116,7 @@ export {
|
||||
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
|
||||
|
||||
export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnavailableError }
|
||||
export { TurnRangeError }
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<{
|
||||
@@ -142,6 +146,13 @@ export interface Interface {
|
||||
readonly context: (
|
||||
sessionID: SessionSchema.ID,
|
||||
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
/** Structured diffs of the files changed by a turn or range of turns; see `SessionDiff.turn`. */
|
||||
readonly diff: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly messageID?: SessionMessage.ID
|
||||
readonly to?: SessionMessage.ID
|
||||
readonly context?: number
|
||||
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
|
||||
/**
|
||||
* Durable admitted session work not yet visible in projected history,
|
||||
* ordered by admission. Includes unpromoted user and synthetic inputs and
|
||||
@@ -230,6 +241,7 @@ const layer = Layer.effect(
|
||||
const moves = yield* SessionMove.Service
|
||||
const jobs = yield* Job.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const sessions = yield* Session.make()
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
|
||||
@@ -362,6 +374,17 @@ const layer = Layer.effect(
|
||||
yield* result.get(sessionID)
|
||||
return yield* store.context(sessionID)
|
||||
}),
|
||||
diff: Effect.fn("Session.diff")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const active = yield* execution.isActive(input.sessionID)
|
||||
return yield* SessionDiff.turn(db, locations, {
|
||||
session,
|
||||
active,
|
||||
messageID: input.messageID,
|
||||
to: input.to,
|
||||
context: input.context,
|
||||
})
|
||||
}),
|
||||
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
|
||||
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
|
||||
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
|
||||
@@ -450,6 +473,7 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
|
||||
SessionInbox.node,
|
||||
SessionMove.node,
|
||||
SessionProjector.node,
|
||||
LocationServiceMap.node,
|
||||
FSUtil.node,
|
||||
App.node,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
export * as SessionDiff from "./diff.js"
|
||||
|
||||
import { and, asc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"
|
||||
import { Context, Effect, Schema } from "effect"
|
||||
import { Location } from "@opencode/schema/location"
|
||||
import { Database } from "../database/database.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { Snapshot } from "../snapshot.js"
|
||||
import { PATCH_CONTEXT_LINES } from "../vcs/patch.js"
|
||||
import { MessageNotFoundError } from "./error.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionMessageTable } from "./sql.js"
|
||||
|
||||
export class TurnRangeError extends Schema.TaggedError<TurnRangeError>()("Session.TurnRangeError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
field: Schema.Literals(["messageID", "to"]),
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
const decodeLocation = Schema.decodeUnknownSync(Schema.fromJsonString(Location.Ref))
|
||||
|
||||
/**
|
||||
* Diff the files changed by the turn containing a user message. A turn runs from
|
||||
* the first prompt after the Session was last idle until the next idle marker, so
|
||||
* prompts steered in while it was busy belong to the same turn; `to` extends the
|
||||
* range through the turn containing a later user message. Compares the range's
|
||||
* first recorded start snapshot with its last recorded end snapshot; only a step
|
||||
* still running in the active Session compares against the working copy. Like VCS
|
||||
* diffs, an omitted `context` yields full-file patches.
|
||||
*
|
||||
* A Session without any idle marker predates them, so its prompts span until the
|
||||
* next user message instead.
|
||||
*
|
||||
* Snapshot trees live in the repository of the Location that captured them, so a
|
||||
* range spanning a location switch is rejected rather than diffed wrongly.
|
||||
*/
|
||||
export const turn = Effect.fn("SessionDiff.turn")(function* (
|
||||
db: Database.Interface["db"],
|
||||
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
|
||||
input: {
|
||||
readonly session: SessionSchema.Info
|
||||
/** The process is currently executing this Session. */
|
||||
readonly active: boolean
|
||||
readonly messageID?: SessionMessage.ID
|
||||
readonly to?: SessionMessage.ID
|
||||
readonly context?: number
|
||||
},
|
||||
) {
|
||||
const sessionID = input.session.id
|
||||
const rows = yield* db
|
||||
.select({ id: SessionMessageTable.id, type: SessionMessageTable.type, seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
or(
|
||||
inArray(SessionMessageTable.type, ["user", "idle"]),
|
||||
input.messageID ? eq(SessionMessageTable.id, input.messageID) : undefined,
|
||||
input.to ? eq(SessionMessageTable.id, input.to) : undefined,
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const users = rows.filter((row) => row.type === "user")
|
||||
const markers = rows.filter((row) => row.type === "idle")
|
||||
const resolve = Effect.fn(function* (field: "messageID" | "to", id: SessionMessage.ID) {
|
||||
const row = rows.find((row) => row.id === id)
|
||||
if (!row) return yield* new MessageNotFoundError({ sessionID, messageID: id })
|
||||
if (row.type !== "user")
|
||||
return yield* new TurnRangeError({ sessionID, field, message: `Message ${id} is not a user message` })
|
||||
return row
|
||||
})
|
||||
const anchor = input.messageID ? yield* resolve("messageID", input.messageID) : users[users.length - 1]
|
||||
if (!anchor) return []
|
||||
const last = input.to ? yield* resolve("to", input.to) : anchor
|
||||
if (last.seq < anchor.seq)
|
||||
return yield* new TurnRangeError({ sessionID, field: "to", message: `Message ${last.id} precedes ${anchor.id}` })
|
||||
// Without any marker, history predates idle markers and a prompt's turn ends at the next prompt.
|
||||
const legacy = markers.length === 0
|
||||
// The turn opens with the first prompt after the previous idle marker; the anchor itself is the latest candidate.
|
||||
const opened = markers.findLast((row) => row.seq < anchor.seq)?.seq ?? -1
|
||||
const start = legacy ? anchor.seq : (users.find((row) => row.seq > opened)?.seq ?? anchor.seq)
|
||||
const end = legacy ? users.find((row) => row.seq > last.seq)?.seq : markers.find((row) => row.seq > last.seq)?.seq
|
||||
const steps = yield* db
|
||||
.select({
|
||||
seq: SessionMessageTable.seq,
|
||||
start: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.start')`,
|
||||
end: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.end')`,
|
||||
completed: sql<number | null>`json_extract(${SessionMessageTable.data}, '$.time.completed')`,
|
||||
})
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, "assistant"),
|
||||
gt(SessionMessageTable.seq, start),
|
||||
end === undefined ? undefined : lt(SessionMessageTable.seq, end),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const first = steps[0]
|
||||
const final = steps[steps.length - 1]
|
||||
const from = steps.find((step) => step.start)?.start
|
||||
if (!first || !final || !from) return []
|
||||
const switches = yield* db
|
||||
.select({
|
||||
seq: SessionMessageTable.seq,
|
||||
location: sql<string>`json_extract(${SessionMessageTable.data}, '$.location')`,
|
||||
previous: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.previous.location')`,
|
||||
})
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "location-switched")))
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
if (switches.some((row) => row.seq > first.seq && row.seq < final.seq))
|
||||
return yield* new TurnRangeError({ sessionID, field: "to", message: "Turn range spans a location change" })
|
||||
const before = switches.findLast((row) => row.seq < first.seq)?.location
|
||||
const after = switches.find((row) => row.seq > first.seq)?.previous
|
||||
const location = before ? decodeLocation(before) : after ? decodeLocation(after) : input.session.location
|
||||
const recorded = steps.findLast((step) => step.end)?.end
|
||||
return yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const running = input.active && final.completed === null
|
||||
const to = running ? ((yield* snapshot.capture()) ?? recorded) : recorded
|
||||
if (!to) return []
|
||||
return yield* snapshot.diff({
|
||||
from: Snapshot.ID.make(from),
|
||||
to: Snapshot.ID.make(to),
|
||||
context: input.context ?? PATCH_CONTEXT_LINES,
|
||||
})
|
||||
}).pipe(Effect.provide(locations.get(location)))
|
||||
})
|
||||
@@ -60,6 +60,21 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
)
|
||||
})
|
||||
|
||||
const idle = (outcome: SessionMessage.Idle["outcome"]) =>
|
||||
clearCurrentRetry.pipe(
|
||||
Effect.andThen(
|
||||
adapter.appendMessage(
|
||||
SessionMessage.Idle.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "idle",
|
||||
outcome,
|
||||
metadata: event.metadata,
|
||||
time: { created },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const project = pipe(
|
||||
Match.type<SessionEvent.DurableEvent>(),
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
@@ -123,9 +138,11 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
"session.inbox.cancelled": () => Effect.void,
|
||||
"session.inbox.delivery.changed": () => Effect.void,
|
||||
"session.execution.started": () => Effect.void,
|
||||
"session.execution.succeeded": () => clearCurrentRetry,
|
||||
"session.execution.failed": () => clearCurrentRetry,
|
||||
"session.execution.interrupted": () => clearCurrentRetry,
|
||||
"session.execution.succeeded": () => idle("succeeded"),
|
||||
"session.execution.failed": () => idle("failed"),
|
||||
// Shutdown keeps the execution claim and the resumed drain continues the turn.
|
||||
"session.execution.interrupted": (event) =>
|
||||
event.data.reason === "shutdown" ? clearCurrentRetry : idle("interrupted"),
|
||||
"session.instructions.updated": (event) => {
|
||||
if (event.data.text === undefined) return Effect.void
|
||||
return adapter.appendMessage(
|
||||
|
||||
@@ -226,6 +226,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
switch (message.type) {
|
||||
case "agent-switched":
|
||||
case "model-switched":
|
||||
case "idle":
|
||||
return []
|
||||
case "location-switched":
|
||||
return [
|
||||
|
||||
@@ -131,38 +131,55 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const comparison = {
|
||||
return {
|
||||
source: repo.source,
|
||||
repository: repo.snapshotRepository,
|
||||
from: Git.TreeID.make(input.from),
|
||||
to: Git.TreeID.make(input.to),
|
||||
}
|
||||
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const ignored = yield* git.index
|
||||
.ignored({ repository: repo.source, paths: files })
|
||||
})
|
||||
|
||||
// Snapshots track every scoped file; the source repository's ignore rules decide what callers see.
|
||||
const ignored = Effect.fnUntraced(function* (
|
||||
operation: "files" | "diff",
|
||||
source: Git.Repository,
|
||||
paths: readonly RelativePath[],
|
||||
) {
|
||||
return yield* git.index
|
||||
.ignored({ repository: source, paths })
|
||||
.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
return {
|
||||
input: comparison,
|
||||
files,
|
||||
ignored,
|
||||
}
|
||||
})
|
||||
|
||||
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
|
||||
const comparison = yield* compare("files", input)
|
||||
return comparison.files.filter((file) => !comparison.ignored.has(file))
|
||||
const compared = yield* comparison("files", input)
|
||||
const changed = yield* git.tree
|
||||
.files({ repository: compared.repository, from: compared.from, to: compared.to })
|
||||
.pipe(Effect.mapError((cause) => failure("files", cause)))
|
||||
const skipped = yield* ignored("files", compared.source, changed)
|
||||
return changed.filter((file) => !skipped.has(file))
|
||||
})
|
||||
|
||||
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
|
||||
const comparison = yield* compare("diff", input)
|
||||
return yield* git.tree
|
||||
if (input.paths?.length === 0) return []
|
||||
const compared = yield* comparison("diff", input)
|
||||
// Only an explicit selection becomes a pathspec; ignored paths are dropped from the result instead.
|
||||
const diffs = yield* git.tree
|
||||
.diff({
|
||||
...comparison.input,
|
||||
repository: compared.repository,
|
||||
from: compared.from,
|
||||
to: compared.to,
|
||||
context: input.context,
|
||||
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
|
||||
paths: input.paths,
|
||||
})
|
||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
const skipped = yield* ignored(
|
||||
"diff",
|
||||
compared.source,
|
||||
diffs.map((file) => RelativePath.make(file.file)),
|
||||
)
|
||||
return diffs.filter((file) => !skipped.has(RelativePath.make(file.file)))
|
||||
})
|
||||
|
||||
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
|
||||
|
||||
@@ -254,6 +254,8 @@ const layer = Layer.effect(
|
||||
})
|
||||
.pipe(Effect.mapError((error) => operationError(selected.id, "create", error)))
|
||||
const result = { directory: yield* canonical(fs, created.directory) }
|
||||
if (result.directory !== (yield* canonical(fs, worktreeDirectory)))
|
||||
return yield* new InvalidDirectoryError({ directory: result.directory })
|
||||
yield* changed(
|
||||
yield* ops.create({
|
||||
directory: result.directory,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Effect } from "effect"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Git } from "@opencode/core/git"
|
||||
import { AbsolutePath, RelativePath } from "@opencode/core/schema"
|
||||
import { VcsPatch } from "@opencode/core/vcs/patch"
|
||||
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -196,6 +197,42 @@ describe("Git trees", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("caps batched tree patches, keeps per-file stats past the cap, and matches non-ASCII names", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const git = yield* Git.Service
|
||||
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
|
||||
if (!repository) throw new Error("Repository not found")
|
||||
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
const lines = Math.ceil(VcsPatch.MAX_TOTAL_PATCH_BYTES / 80) + 1
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(root.path, "a-small.txt"), "small\n")
|
||||
await Bun.write(path.join(root.path, "b-large.txt"), `${"x".repeat(79)}\n`.repeat(lines))
|
||||
await Bun.write(path.join(root.path, "c-binary.bin"), new Uint8Array([0, 1, 2, 3]))
|
||||
await Bun.write(path.join(root.path, "a-caf\u00e9.txt"), "caf\u00e9\n")
|
||||
})
|
||||
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
|
||||
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 0 })
|
||||
expect(diffs.map((item) => [item.file, item.status, item.additions, item.deletions])).toEqual([
|
||||
["a-caf\u00e9.txt", "added", 1, 0],
|
||||
["a-small.txt", "added", 1, 0],
|
||||
["b-large.txt", "added", lines, 0],
|
||||
["c-binary.bin", "added", 0, 0],
|
||||
])
|
||||
// Patch headers are not NUL-delimited; a quoted (octal-escaped) header would orphan this chunk.
|
||||
expect(diffs[0]?.patch).toContain("+caf\u00e9\n")
|
||||
expect(diffs[1]?.patch).toContain("+small\n")
|
||||
expect(diffs[2]?.patch).toBe(VcsPatch.emptyPatch("b-large.txt"))
|
||||
expect(diffs[3]?.patch).toBe("")
|
||||
expect(yield* git.tree.diff({ repository, from: before, to: after, paths: [] })).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("captures, compares, previews, and restores scoped trees", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
|
||||
@@ -3,8 +3,7 @@ import { LLM } from "@opencode/ai"
|
||||
import { LLMClient, RequestExecutor } from "@opencode/ai/route"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Catalog } from "@opencode/core/catalog"
|
||||
import { Credential } from "@opencode/core/credential"
|
||||
import { Integration } from "@opencode/core/integration"
|
||||
@@ -14,9 +13,7 @@ import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { OpencodePlugin } from "@opencode/core/plugin/provider/opencode"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { WebSearch } from "@opencode/core/websearch"
|
||||
import { withEnv } from "../fixture/env"
|
||||
import { drain } from "../lib/clock"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -383,409 +380,6 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("refreshes hosted search with Console config and skips unchanged snapshots", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state = { advertised: false, requests: 0 }
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => {
|
||||
state.requests++
|
||||
return Response.json({
|
||||
providers: {},
|
||||
...(state.advertised ? { websearch: { providerID: "opencode" } } : {}),
|
||||
})
|
||||
},
|
||||
})
|
||||
return { server, state }
|
||||
}),
|
||||
({ server, state }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const rebuilds = { catalog: 0, websearch: 0 }
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({ type: "key", key: "secret", metadata: { server: server.url.origin } }),
|
||||
})
|
||||
yield* catalog.transform(() => {
|
||||
rebuilds.catalog++
|
||||
})
|
||||
yield* websearch.transform(() => {
|
||||
rebuilds.websearch++
|
||||
})
|
||||
yield* addPlugin()
|
||||
yield* drain
|
||||
const initial = { ...rebuilds }
|
||||
expect(state.requests).toBe(1)
|
||||
expect(yield* websearch.default()).toBeUndefined()
|
||||
|
||||
state.advertised = true
|
||||
yield* TestClock.adjust("9 minutes")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(1)
|
||||
expect(rebuilds).toEqual(initial)
|
||||
expect(yield* websearch.default()).toBeUndefined()
|
||||
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(2)
|
||||
expect(rebuilds).toEqual({ catalog: initial.catalog + 1, websearch: initial.websearch + 1 })
|
||||
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
|
||||
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(3)
|
||||
expect(rebuilds).toEqual({ catalog: initial.catalog + 1, websearch: initial.websearch + 1 })
|
||||
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
|
||||
|
||||
state.advertised = false
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(4)
|
||||
expect(rebuilds).toEqual({ catalog: initial.catalog + 2, websearch: initial.websearch + 2 })
|
||||
expect(yield* websearch.providers()).toEqual([])
|
||||
expect(yield* websearch.default()).toBeUndefined()
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads and executes hosted web search from the connected OpenCode server", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const requests: Array<{
|
||||
method: string
|
||||
path: string
|
||||
authorization: string | null
|
||||
orgID: string | null
|
||||
body?: unknown
|
||||
}> = []
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const state = { advertised: true, providerID: "opencode", waitForConfig: false }
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
const body = request.method === "POST" ? await request.json() : undefined
|
||||
requests.push({
|
||||
method: request.method,
|
||||
path,
|
||||
authorization: request.headers.get("authorization"),
|
||||
orgID: request.headers.get("x-org-id"),
|
||||
...(body === undefined ? {} : { body }),
|
||||
})
|
||||
if (path === "/api/v2/config") {
|
||||
if (state.waitForConfig) await gate.promise
|
||||
return Response.json({
|
||||
providers: {},
|
||||
...(state.advertised
|
||||
? {
|
||||
websearch: {
|
||||
providerID: "opencode",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
if (path === "/api/websearch" || path === "/other/api/websearch") {
|
||||
return Response.json({
|
||||
providerID: state.providerID,
|
||||
results: [
|
||||
{
|
||||
url: "https://github.com/anomalyco/opencode",
|
||||
title: "OpenCode",
|
||||
content: "Open source AI coding agent.",
|
||||
time: { published: 1_700_000_000_000 },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
return { gate, requests, server, state }
|
||||
}),
|
||||
({ gate, requests, server, state }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const account = (access: string, serverURL = server.url.origin, orgID = "org_test") =>
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access,
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 600_000,
|
||||
metadata: { server: serverURL, orgID },
|
||||
})
|
||||
const initial = yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: account("secret"),
|
||||
})
|
||||
|
||||
yield* addPlugin()
|
||||
expect(yield* websearch.providers()).toContainEqual({
|
||||
id: WebSearch.ID.make("opencode"),
|
||||
name: "OpenCode Web Search",
|
||||
})
|
||||
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
|
||||
expect(yield* websearch.query({ query: "effect web search" })).toEqual(
|
||||
new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("opencode"),
|
||||
results: [
|
||||
{
|
||||
url: "https://github.com/anomalyco/opencode",
|
||||
title: "OpenCode",
|
||||
content: "Open source AI coding agent.",
|
||||
time: { published: 1_700_000_000_000 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v2/config",
|
||||
authorization: "Bearer secret",
|
||||
orgID: "org_test",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/websearch",
|
||||
authorization: "Bearer secret",
|
||||
orgID: "org_test",
|
||||
body: { query: "effect web search", providerID: "opencode" },
|
||||
},
|
||||
])
|
||||
|
||||
yield* credentials.update(initial.id, {
|
||||
value: account("replacement"),
|
||||
})
|
||||
yield* websearch.query({ query: "fresh credential" })
|
||||
expect(requests.at(-1)).toMatchObject({
|
||||
method: "POST",
|
||||
authorization: "Bearer replacement",
|
||||
body: { query: "fresh credential", providerID: "opencode" },
|
||||
})
|
||||
|
||||
yield* credentials.update(initial.id, {
|
||||
value: account("moved", `${server.url.origin}/other///?ignored=true#ignored`),
|
||||
})
|
||||
yield* websearch.query({ query: "updated server" })
|
||||
expect(requests.at(-1)).toMatchObject({
|
||||
method: "POST",
|
||||
path: "/other/api/websearch",
|
||||
authorization: "Bearer moved",
|
||||
orgID: "org_test",
|
||||
body: { query: "updated server", providerID: "opencode" },
|
||||
})
|
||||
yield* credentials.update(initial.id, {
|
||||
value: account("replacement"),
|
||||
})
|
||||
|
||||
state.providerID = "unexpected"
|
||||
expect((yield* websearch.query({ query: "wrong provider" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request")
|
||||
|
||||
state.advertised = false
|
||||
state.waitForConfig = true
|
||||
const searchCount = requests.filter((request) => request.path === "/api/websearch").length
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: account("switched", server.url.origin, "org_switched"),
|
||||
})
|
||||
yield* eventually(
|
||||
Effect.sync(() => requests),
|
||||
(requests) => requests.some((request) => request.authorization === "Bearer switched"),
|
||||
)
|
||||
expect((yield* websearch.query({ query: "switch race" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request")
|
||||
expect(requests.filter((request) => request.path === "/api/websearch")).toHaveLength(searchCount)
|
||||
gate.resolve()
|
||||
yield* eventually(websearch.providers(), (providers) =>
|
||||
providers.every((provider) => provider.id !== WebSearch.ID.make("opencode")),
|
||||
)
|
||||
expect(yield* websearch.default()).toBeUndefined()
|
||||
expect(requests.at(-1)).toMatchObject({
|
||||
method: "GET",
|
||||
path: "/api/v2/config",
|
||||
authorization: "Bearer switched",
|
||||
orgID: "org_switched",
|
||||
})
|
||||
}),
|
||||
({ gate, server }) =>
|
||||
Effect.sync(() => gate.resolve()).pipe(Effect.andThen(Effect.promise(() => server.stop(true)))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("derives hosted search identity and the default Console endpoint locally", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
if (new URL(request.url).pathname === "/console/api/v2/config") {
|
||||
return Response.json({
|
||||
providers: {},
|
||||
websearch: {
|
||||
providerID: "managed-search",
|
||||
name: "Remote name",
|
||||
url: "https://example.invalid/search",
|
||||
},
|
||||
})
|
||||
}
|
||||
return Response.json({ providerID: "managed-search", results: [] })
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const requests: string[] = []
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({ type: "key", key: "secret" }),
|
||||
})
|
||||
yield* addPlugin().pipe(
|
||||
Effect.provideService(
|
||||
HttpClient.HttpClient,
|
||||
http.pipe(
|
||||
HttpClient.mapRequest((request) => {
|
||||
requests.push(request.url)
|
||||
return HttpClientRequest.setUrl(request, `${server.url.origin}${new URL(request.url).pathname}`)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* websearch.default()).toEqual({
|
||||
id: WebSearch.ID.make("managed-search"),
|
||||
name: "OpenCode Web Search",
|
||||
})
|
||||
expect(yield* websearch.query({ query: "default Console" })).toEqual(
|
||||
new WebSearch.Response({ providerID: WebSearch.ID.make("managed-search"), results: [] }),
|
||||
)
|
||||
expect(requests).toEqual([
|
||||
"https://opencode.ai/console/api/v2/config",
|
||||
"https://opencode.ai/console/api/websearch",
|
||||
])
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not forward hosted search credentials through redirects", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const requests: string[] = []
|
||||
const state = { crossOrigin: false }
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
const url = new URL(request.url)
|
||||
requests.push(url.pathname)
|
||||
if (url.pathname === "/console/api/v2/config") {
|
||||
return Response.json({
|
||||
providers: {},
|
||||
websearch: {
|
||||
providerID: "opencode",
|
||||
},
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/console/api/websearch") {
|
||||
if (state.crossOrigin) url.hostname = "127.0.0.1"
|
||||
return Response.redirect(`${url.origin}/outside-console`, 307)
|
||||
}
|
||||
return Response.json({ providerID: "opencode", results: [] })
|
||||
},
|
||||
})
|
||||
return { requests, server, state }
|
||||
}),
|
||||
({ requests, server, state }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: "secret",
|
||||
metadata: { server: `${server.url.origin}/console`, orgID: "org_test" },
|
||||
}),
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect((yield* websearch.query({ query: "private search" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request")
|
||||
expect(requests).toEqual(["/console/api/v2/config", "/console/api/websearch"])
|
||||
|
||||
state.crossOrigin = true
|
||||
expect((yield* websearch.query({ query: "private search" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request")
|
||||
expect(requests).toEqual(["/console/api/v2/config", "/console/api/websearch", "/console/api/websearch"])
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("closes a rejected hosted search response without waiting for its body", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state = { cancelled: false }
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/v2/config") {
|
||||
return Response.json({
|
||||
providers: {},
|
||||
websearch: {
|
||||
providerID: "opencode",
|
||||
},
|
||||
})
|
||||
}
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("temporarily unavailable"))
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true
|
||||
},
|
||||
}),
|
||||
{ status: 503 },
|
||||
)
|
||||
},
|
||||
})
|
||||
return { server, state }
|
||||
}),
|
||||
({ server, state }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: "secret",
|
||||
metadata: { server: server.url.origin, orgID: "org_test" },
|
||||
}),
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
const error = yield* websearch.query({ query: "rejected search" }).pipe(Effect.flip)
|
||||
expect(error._tag).toBe("WebSearch.Request")
|
||||
yield* eventually(
|
||||
Effect.sync(() => state.cancelled),
|
||||
(cancelled) => cancelled,
|
||||
)
|
||||
// Callers can retain errors, so response cleanup must not depend on garbage collection.
|
||||
expect(error).toBeInstanceOf(WebSearch.RequestError)
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves native Console OpenAI variant bodies in inference requests", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
import { LLM } from "@opencode/ai"
|
||||
import { LLMClient, RequestExecutor } from "@opencode/ai/route"
|
||||
import { Catalog } from "@opencode/core/catalog"
|
||||
import { Credential } from "@opencode/core/credential"
|
||||
import { Integration } from "@opencode/core/integration"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { ModelResolver } from "@opencode/core/model-resolver"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { ProviderPlugins } from "@opencode/core/plugin/provider"
|
||||
import { PoePlugin } from "@opencode/core/plugin/provider/poe"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { expect } from "bun:test"
|
||||
import { Clock, Deferred, Effect, Fiber, Layer, Schedule, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const integrationID = Integration.ID.make("poe")
|
||||
const providerID = Provider.ID.make("poe")
|
||||
const methodID = Integration.MethodID.make("browser")
|
||||
const modelID = Model.ID.make("test-model")
|
||||
|
||||
const fixture = Effect.gen(function* () {
|
||||
const requests: Request[] = []
|
||||
const replies: (Response | Effect.Effect<Response>)[] = []
|
||||
const http = HttpClient.make((request) =>
|
||||
Effect.gen(function* () {
|
||||
requests.push(yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie))
|
||||
const response = replies.shift()
|
||||
if (!response) throw new Error(`Unexpected request: ${request.url}`)
|
||||
return HttpClientResponse.fromWeb(request, yield* Effect.isEffect(response) ? response : Effect.succeed(response))
|
||||
}),
|
||||
)
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* integrations.transform((editor) => {
|
||||
editor.method.update({ integrationID, method: { type: "key" } })
|
||||
editor.method.update({ integrationID, method: { type: "env", names: ["POE_API_KEY"] } })
|
||||
})
|
||||
yield* catalog.transform((editor) => {
|
||||
editor.provider.update(providerID, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { baseURL: "https://api.poe.com/v1" }
|
||||
})
|
||||
editor.model.update(providerID, modelID, () => {})
|
||||
})
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* PoePlugin.effect(host).pipe(Effect.provideService(HttpClient.HttpClient, http))
|
||||
const status = (attemptID: Integration.AttemptID) =>
|
||||
integrations.oauth.status({ integrationID, attemptID }).pipe(
|
||||
Effect.repeat({
|
||||
until: (value) => value.status !== "pending",
|
||||
schedule: Schedule.spaced("1 millis"),
|
||||
times: 100,
|
||||
}),
|
||||
)
|
||||
const connect = Effect.gen(function* () {
|
||||
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, label: "Poe browser" })
|
||||
const url = new URL(attempt.url)
|
||||
const callback = new URL(url.searchParams.get("redirect_uri") ?? "")
|
||||
callback.searchParams.set("state", url.searchParams.get("state") ?? "")
|
||||
callback.searchParams.set("code", "auth-code")
|
||||
return { attempt, url, callback }
|
||||
})
|
||||
const send = Effect.gen(function* () {
|
||||
const resolver = yield* ModelResolver.Service
|
||||
const resolved = yield* resolver.resolve(Model.Ref.make({ providerID, id: modelID }))
|
||||
if (!resolved) throw new Error("Expected Poe model")
|
||||
expect(resolved.model.route.id).toBe("openai-compatible-chat")
|
||||
return yield* LLMClient.stream(LLM.request({ model: resolved.model, prompt: "Hello" })).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer), Layer.fresh)),
|
||||
Effect.provideService(HttpClient.HttpClient, http),
|
||||
)
|
||||
}).pipe(Effect.provide(ModelResolver.layer))
|
||||
return { requests, replies, integrations, credentials, status, connect, send }
|
||||
})
|
||||
|
||||
it.effect("registers Poe browser OAuth alongside generic key and environment methods without fetching", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture
|
||||
expect(ProviderPlugins).toContain(PoePlugin)
|
||||
expect((yield* test.integrations.get(integrationID))?.methods).toEqual([
|
||||
{ type: "key" },
|
||||
{ type: "env", names: ["POE_API_KEY"] },
|
||||
{ id: methodID, type: "oauth", label: "Login with Poe (browser)" },
|
||||
])
|
||||
expect(test.requests).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const expiry of [3600, null, undefined]) {
|
||||
it.live(`exchanges a PKCE code for a native Poe credential (expiry: ${expiry})`, () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture
|
||||
yield* test.integrations.connection.key({ integrationID, key: "previous-key", label: "Previous account" })
|
||||
const previous = yield* test.integrations.connection.active(integrationID)
|
||||
const login = yield* test.connect
|
||||
expect(yield* test.integrations.connection.active(integrationID)).toEqual(previous)
|
||||
const url = login.url
|
||||
expect(url.origin + url.pathname).toBe("https://poe.com/oauth/authorize")
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({
|
||||
response_type: "code",
|
||||
client_id: "client_728290227fc048cc9262091a1ea197ea",
|
||||
scope: "apikey:create",
|
||||
code_challenge_method: "S256",
|
||||
})
|
||||
const callback = login.callback
|
||||
expect(callback.hostname).toBe("127.0.0.1")
|
||||
expect(callback.pathname).toBe("/callback")
|
||||
expect(url.searchParams.get("state")).toBeTruthy()
|
||||
// Both issuer-bearing and documented issuer-less callbacks must work.
|
||||
if (expiry != null) callback.searchParams.set("iss", "https://poe.com")
|
||||
test.replies.push(Response.json({ api_key: " poe-key ", api_key_expires_in: expiry }))
|
||||
const now = Date.now()
|
||||
expect((yield* Effect.promise(() => fetch(callback, { headers: { Connection: "close" } }))).status).toBe(200)
|
||||
expect((yield* test.status(login.attempt.attemptID)).status).toBe("complete")
|
||||
const exchange = test.requests[0]
|
||||
expect(exchange.url).toBe("https://api.poe.com/token")
|
||||
expect(exchange.headers.get("content-type")).toContain("application/x-www-form-urlencoded")
|
||||
const form = new URLSearchParams(yield* Effect.promise(() => exchange.text()))
|
||||
expect(Object.fromEntries(form)).toMatchObject({
|
||||
grant_type: "authorization_code",
|
||||
client_id: "client_728290227fc048cc9262091a1ea197ea",
|
||||
code: "auth-code",
|
||||
redirect_uri: url.searchParams.get("redirect_uri"),
|
||||
})
|
||||
expect(url.searchParams.get("code_challenge")).toBe(
|
||||
Buffer.from(
|
||||
yield* Effect.promise(() =>
|
||||
crypto.subtle.digest("SHA-256", new TextEncoder().encode(form.get("code_verifier") ?? "")),
|
||||
),
|
||||
).toString("base64url"),
|
||||
)
|
||||
const records = yield* test.credentials.list(integrationID)
|
||||
const active = records.find((credential) => credential.label === "Poe browser")
|
||||
expect(records).toHaveLength(2)
|
||||
expect(yield* test.integrations.connection.active(integrationID)).toMatchObject({
|
||||
type: "credential",
|
||||
id: active?.id,
|
||||
label: "Poe browser",
|
||||
})
|
||||
const saved = active?.value
|
||||
if (saved?.type !== "oauth") throw new Error("Expected OAuth credential")
|
||||
expect(saved.access).toBe("poe-key")
|
||||
expect(saved.refresh).toBe("")
|
||||
if (expiry == null) expect(saved.expires).toBe(8_640_000_000_000_000)
|
||||
if (expiry != null) {
|
||||
expect(saved.expires).toBeGreaterThanOrEqual(now + expiry * 1000)
|
||||
expect(saved.expires).toBeLessThanOrEqual(Date.now() + expiry * 1000)
|
||||
}
|
||||
test.replies.push(
|
||||
new Response(
|
||||
'data: {"choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
|
||||
{
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
},
|
||||
),
|
||||
)
|
||||
expect(yield* test.send).toContainEqual(expect.objectContaining({ type: "text-delta", text: "Hello" }))
|
||||
expect(test.requests[1].url).toBe("https://api.poe.com/v1/chat/completions")
|
||||
expect(test.requests[1].headers.get("authorization")).toBe("Bearer poe-key")
|
||||
yield* test.integrations.oauth.complete({ integrationID, attemptID: login.attempt.attemptID })
|
||||
expect(yield* test.credentials.list(integrationID)).toEqual(records)
|
||||
expect(test.requests).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("isolates overlapping login attempts and closes cancelled listeners", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture
|
||||
const first = yield* test.connect
|
||||
const next = yield* test.connect
|
||||
expect(first.callback.origin).not.toBe(next.callback.origin)
|
||||
expect(first.url.searchParams.get("code_challenge")).not.toBe(next.url.searchParams.get("code_challenge"))
|
||||
expect(first.url.searchParams.get("state")).not.toBe(next.url.searchParams.get("state"))
|
||||
first.callback.searchParams.set("state", next.url.searchParams.get("state") ?? "")
|
||||
expect((yield* Effect.promise(() => fetch(first.callback, { headers: { Connection: "close" } }))).status).toBe(400)
|
||||
expect(yield* test.status(first.attempt.attemptID)).toMatchObject({
|
||||
status: "failed",
|
||||
message: "Invalid OAuth state",
|
||||
})
|
||||
expect((yield* test.integrations.oauth.status({ integrationID, attemptID: next.attempt.attemptID })).status).toBe(
|
||||
"pending",
|
||||
)
|
||||
yield* test.integrations.oauth.cancel({ integrationID, attemptID: next.attempt.attemptID })
|
||||
expect((yield* Effect.tryPromise(() => fetch(next.callback)).pipe(Effect.exit))._tag).toBe("Failure")
|
||||
expect(test.requests).toHaveLength(0)
|
||||
expect(yield* test.credentials.list(integrationID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
for (const invalid of [
|
||||
{ params: { state: "" }, message: "Invalid OAuth state" },
|
||||
{ params: { iss: "https://other.example", error: "access_denied" }, message: "Invalid OAuth issuer" },
|
||||
{ params: { iss: "https://poe.com/" }, message: "Invalid OAuth issuer" },
|
||||
{ params: { iss: "" }, message: "Invalid OAuth issuer" },
|
||||
{ params: { code: "" }, message: "Missing authorization code" },
|
||||
{ params: { error: "access_denied", error_description: "User declined access" }, message: "User declined access" },
|
||||
]) {
|
||||
it.live(`rejects invalid or denied callbacks (${JSON.stringify(invalid.params)})`, () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture
|
||||
const login = yield* test.connect
|
||||
Object.entries(invalid.params).forEach(([key, value]) => login.callback.searchParams.set(key, value))
|
||||
const response = yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } }))
|
||||
expect(response.status).toBe(400)
|
||||
expect(yield* Effect.promise(() => response.text())).toContain("Authorization failed")
|
||||
expect(yield* test.status(login.attempt.attemptID)).toMatchObject({ status: "failed", message: invalid.message })
|
||||
expect(test.requests).toHaveLength(0)
|
||||
expect(yield* test.credentials.list(integrationID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const response of [
|
||||
{
|
||||
status: 400,
|
||||
body: JSON.stringify({ error: "invalid_grant", error_description: "Code expired" }),
|
||||
message: "Poe token exchange failed: Code expired",
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
body: JSON.stringify({ error: "invalid_grant" }),
|
||||
message: "Poe token exchange failed: invalid_grant",
|
||||
},
|
||||
{ status: 502, body: "Bad gateway", message: "Poe token exchange failed (502)" },
|
||||
{
|
||||
status: 400,
|
||||
body: JSON.stringify({ error_description: "Rejected auth-code" }),
|
||||
message: "Poe token exchange failed (400)",
|
||||
},
|
||||
...[
|
||||
"{}",
|
||||
"{",
|
||||
'{"api_key":" "}',
|
||||
'{"api_key":"poe-key","api_key_expires_in":-1}',
|
||||
'{"api_key":"poe-key","api_key_expires_in":1.5}',
|
||||
'{"api_key":"poe-key","api_key_expires_in":1e309}',
|
||||
].map((body) => ({ status: 200, body, message: "Invalid Poe token response" })),
|
||||
{
|
||||
status: 200,
|
||||
body: '{"api_key":"poe-key","api_key_expires_in":8640000000000}',
|
||||
message: "Invalid Poe API key expiry",
|
||||
},
|
||||
]) {
|
||||
it.live(`preserves the active connection after a failed token exchange (${response.status}: ${response.body})`, () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture
|
||||
yield* test.integrations.connection.key({ integrationID, key: "previous-key" })
|
||||
const previous = yield* test.integrations.connection.active(integrationID)
|
||||
const saved = yield* test.credentials.list(integrationID)
|
||||
const login = yield* test.connect
|
||||
test.replies.push(new Response(response.body, { status: response.status }))
|
||||
const page = yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } }))
|
||||
expect(page.status).toBe(400)
|
||||
expect(yield* Effect.promise(() => page.text())).toContain(response.message)
|
||||
expect(yield* test.status(login.attempt.attemptID)).toMatchObject({ status: "failed", message: response.message })
|
||||
expect(yield* test.credentials.list(integrationID)).toEqual(saved)
|
||||
expect(yield* test.integrations.connection.active(integrationID)).toEqual(previous)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const cancel of [false, true]) {
|
||||
it.live(`waits for the token exchange and consumes the callback once (cancel: ${cancel})`, () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture
|
||||
const login = yield* test.connect
|
||||
const started = yield* Deferred.make<void>()
|
||||
const token = yield* Deferred.make<Response>()
|
||||
test.replies.push(Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(token))))
|
||||
const page = yield* Effect.tryPromise(() => fetch(login.callback, { headers: { Connection: "close" } })).pipe(
|
||||
Effect.exit,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Deferred.await(started)
|
||||
expect(
|
||||
(yield* test.integrations.oauth.status({ integrationID, attemptID: login.attempt.attemptID })).status,
|
||||
).toBe("pending")
|
||||
expect(yield* test.credentials.list(integrationID)).toEqual([])
|
||||
expect((yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } }))).status).toBe(
|
||||
409,
|
||||
)
|
||||
expect(test.requests).toHaveLength(1)
|
||||
if (cancel) {
|
||||
yield* test.integrations.oauth.cancel({ integrationID, attemptID: login.attempt.attemptID })
|
||||
yield* Deferred.succeed(token, Response.json({ api_key: "cancelled-key" }))
|
||||
expect((yield* Fiber.join(page))._tag).toBe("Failure")
|
||||
expect(yield* test.credentials.list(integrationID)).toEqual([])
|
||||
return
|
||||
}
|
||||
yield* Deferred.succeed(token, Response.json({ api_key: "poe-key" }))
|
||||
const result = yield* Fiber.join(page)
|
||||
const response = yield* result
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => response.text())).toContain("Authorization successful")
|
||||
expect((yield* test.status(login.attempt.attemptID)).status).toBe("complete")
|
||||
expect(yield* test.credentials.list(integrationID)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("keeps near-expiry keys usable and requires a new login after expiry", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture
|
||||
const saved = yield* test.credentials.create({
|
||||
integrationID,
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: "poe-key",
|
||||
refresh: "",
|
||||
expires: (yield* Clock.currentTimeMillis) + 120_000,
|
||||
}),
|
||||
})
|
||||
const connection = { type: "credential" as const, id: saved.id, label: saved.label }
|
||||
expect(yield* test.integrations.connection.resolve(connection)).toEqual(saved.value)
|
||||
yield* TestClock.adjust("2 minutes")
|
||||
const error = yield* test.integrations.connection.resolve(connection).pipe(Effect.flip)
|
||||
expect(error.cause).toEqual(new Error("Poe API key expired. Log in with Poe again."))
|
||||
yield* test.integrations.connection.key({ integrationID, key: "manual-key" })
|
||||
const active = yield* test.integrations.connection.active(integrationID)
|
||||
if (!active) throw new Error("Expected key connection")
|
||||
expect(yield* test.integrations.connection.resolve(active)).toEqual({ type: "key", key: "manual-key" })
|
||||
expect(test.requests).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,198 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { LocationServiceMap } from "@opencode/core/location-service-map"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionDiff } from "@opencode/core/session/diff"
|
||||
import { SessionEvent } from "@opencode/core/session/event"
|
||||
import { SessionExecution } from "@opencode/core/session/execution"
|
||||
import { SessionInbox } from "@opencode/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import { SessionProjector } from "@opencode/core/session/projector"
|
||||
import { Snapshot } from "@opencode/core/snapshot"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
|
||||
[Global.node.replace(tempGlobalLayer), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
|
||||
),
|
||||
)
|
||||
|
||||
const summarize = (file: { file: string; status: string; additions: number; deletions: number }) => [
|
||||
file.file,
|
||||
file.status,
|
||||
file.additions,
|
||||
file.deletions,
|
||||
]
|
||||
|
||||
describe("Session.diff", () => {
|
||||
it.live(
|
||||
"diffs the busy period containing a user message and ranges across later turns",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const directory = path.join(tmp.path, "project")
|
||||
const write = (name: string, content: string) => () => Bun.write(path.join(directory, name), content)
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(directory)
|
||||
await write("first.txt", "first\n")()
|
||||
await write("second.txt", "second\n")()
|
||||
await write("manual.txt", "manual\n")()
|
||||
await $`git init -q`.cwd(directory).quiet()
|
||||
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
|
||||
})
|
||||
const sessions = yield* Session.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const created = yield* sessions.create({ location: { directory: AbsolutePath.make(directory) } })
|
||||
const diff = (input?: { messageID?: SessionMessage.ID; to?: SessionMessage.ID }) =>
|
||||
sessions
|
||||
.diff({ sessionID: created.id, context: 0, ...input })
|
||||
.pipe(Effect.map((files) => files.map(summarize)))
|
||||
expect(yield* diff()).toEqual([])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const usage = {
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
}
|
||||
const prompt = Effect.fn(function* (text: string) {
|
||||
const admitted = yield* sessions.prompt({ sessionID: created.id, text, resume: false })
|
||||
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
|
||||
return admitted.id
|
||||
})
|
||||
const step = Effect.fn(function* (edit: () => Promise<unknown>, end: "recorded" | "unrecorded" | "running") {
|
||||
const before = yield* snapshot.capture()
|
||||
if (!before) throw new Error("Start snapshot missing")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
agent: Agent.defaultID,
|
||||
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
|
||||
snapshot: before,
|
||||
})
|
||||
yield* Effect.promise(edit)
|
||||
if (end === "running") return assistantMessageID
|
||||
const after = end === "recorded" ? yield* snapshot.capture() : undefined
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
finish: "stop",
|
||||
...usage,
|
||||
snapshot: after,
|
||||
files: after && before ? yield* snapshot.files({ from: before, to: after }) : undefined,
|
||||
})
|
||||
return assistantMessageID
|
||||
})
|
||||
|
||||
const idle = (outcome: "succeeded" | "failed") =>
|
||||
outcome === "succeeded"
|
||||
? bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
|
||||
: bus.publish(SessionEvent.Execution.Failed, {
|
||||
sessionID: created.id,
|
||||
error: { type: "unknown", message: "failed" },
|
||||
})
|
||||
|
||||
// Before any idle marker exists, a prompt's turn ends at the next prompt.
|
||||
const first = yield* prompt("Edit the first file")
|
||||
const firstStep = yield* step(write("first.txt", "first edited\n"), "recorded")
|
||||
// Edits made while idle are not a turn's work, but a range spanning them still sees them.
|
||||
yield* Effect.promise(write("manual.txt", "manual edited\n"))
|
||||
const second = yield* prompt("Edit the second file")
|
||||
yield* step(write("second.txt", "second edited\n"), "recorded")
|
||||
expect(yield* diff()).toEqual([["second.txt", "modified", 1, 1]])
|
||||
expect(yield* diff({ messageID: first })).toEqual([["first.txt", "modified", 1, 1]])
|
||||
|
||||
// Once markers exist, a turn spans a whole busy period, steers included; earlier history merges into the first one.
|
||||
yield* idle("succeeded")
|
||||
const third = yield* prompt("Add a third file")
|
||||
yield* step(write("third.txt", "third\n"), "recorded")
|
||||
const steer = yield* prompt("Also add a fourth file")
|
||||
yield* step(write("fourth.txt", "fourth\n"), "recorded")
|
||||
yield* idle("failed")
|
||||
const busy = [
|
||||
["fourth.txt", "added", 1, 0],
|
||||
["third.txt", "added", 1, 0],
|
||||
]
|
||||
expect(yield* diff()).toEqual(busy)
|
||||
expect(yield* diff({ messageID: steer })).toEqual(busy)
|
||||
expect(yield* diff({ messageID: second })).toEqual([
|
||||
["first.txt", "modified", 1, 1],
|
||||
["manual.txt", "modified", 1, 1],
|
||||
["second.txt", "modified", 1, 1],
|
||||
])
|
||||
expect(yield* diff({ messageID: first, to: third })).toEqual([
|
||||
["first.txt", "modified", 1, 1],
|
||||
["fourth.txt", "added", 1, 0],
|
||||
["manual.txt", "modified", 1, 1],
|
||||
["second.txt", "modified", 1, 1],
|
||||
["third.txt", "added", 1, 0],
|
||||
])
|
||||
const full = yield* sessions.diff({ sessionID: created.id, messageID: first })
|
||||
expect(full[0]?.patch).toContain("-first\n+first edited\n")
|
||||
expect(yield* diff({ messageID: steer, to: second }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.TurnRangeError",
|
||||
field: "to",
|
||||
})
|
||||
expect(yield* diff({ messageID: firstStep }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.TurnRangeError",
|
||||
field: "messageID",
|
||||
})
|
||||
expect(yield* diff({ messageID: SessionMessage.ID.create() }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.MessageNotFoundError",
|
||||
})
|
||||
|
||||
// A completed step without an end snapshot falls back to the last recorded end.
|
||||
yield* prompt("Edit both files again")
|
||||
yield* step(write("first.txt", "first edited twice\n"), "recorded")
|
||||
yield* step(write("second.txt", "second edited twice\n"), "unrecorded")
|
||||
yield* idle("succeeded")
|
||||
expect(yield* diff()).toEqual([["first.txt", "modified", 1, 1]])
|
||||
|
||||
// Only a step still running in the active session compares against the working copy.
|
||||
yield* prompt("Delete the manual file")
|
||||
yield* step(() => fs.rm(path.join(directory, "manual.txt")), "running")
|
||||
expect(yield* diff()).toEqual([])
|
||||
const session = yield* sessions.get(created.id)
|
||||
const live = yield* SessionDiff.turn(database.db, locations, { session, active: true, context: 0 })
|
||||
expect(live.map(summarize)).toEqual([["manual.txt", "deleted", 0, 1]])
|
||||
|
||||
// Reverting removes later history, markers included; a fork keeps the copied turns.
|
||||
yield* sessions.revert.stage({ sessionID: created.id, messageID: steer, files: false })
|
||||
yield* sessions.revert.commit(created.id)
|
||||
expect(yield* diff()).toEqual([["third.txt", "added", 1, 0]])
|
||||
expect(yield* diff({ messageID: steer }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.MessageNotFoundError",
|
||||
})
|
||||
const forked = yield* sessions.fork({ sessionID: created.id, boundary: { type: "through" } })
|
||||
expect((yield* sessions.diff({ sessionID: forked.id, context: 0 })).map(summarize)).toEqual([
|
||||
["third.txt", "added", 1, 0],
|
||||
])
|
||||
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
|
||||
}),
|
||||
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
})
|
||||
@@ -561,7 +561,9 @@ describe("SessionRestart background recovery", () => {
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
|
||||
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
|
||||
expect(yield* sessions.messages({ sessionID })).toMatchObject([
|
||||
// Recovery ends a busy period, so an idle marker follows the notification.
|
||||
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
|
||||
expect(messages).toMatchObject([
|
||||
{
|
||||
id: background.notificationID,
|
||||
type: "synthetic",
|
||||
@@ -569,7 +571,6 @@ describe("SessionRestart background recovery", () => {
|
||||
metadata: { state: "completed" },
|
||||
},
|
||||
])
|
||||
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -315,15 +315,6 @@ describe("Worktree", () => {
|
||||
const bus = yield* Bus.Service
|
||||
const context = yield* Layer.build(worktreeLayer(selected.directory, selected.id, database, bus, root.path))
|
||||
const worktrees = Context.get(context, Worktree.Service)
|
||||
const config = yield* Config.Test
|
||||
yield* config.setEntries([
|
||||
new Document({
|
||||
type: "document",
|
||||
path: abs(path.join(root.path, "global/opencode.json")),
|
||||
info: new Info({ worktree: { directory: ".lane/trees" } }),
|
||||
}),
|
||||
])
|
||||
yield* ConfigWorktreePlugin.Plugin.effect(host()).pipe(Effect.provide(context))
|
||||
yield* projects.update({
|
||||
projectID: initial.id,
|
||||
commands: {
|
||||
@@ -335,11 +326,11 @@ describe("Worktree", () => {
|
||||
const created = yield* worktrees.create({
|
||||
strategy: gitWorktree,
|
||||
from: selected.canonical,
|
||||
directory: abs(path.join(root.path, "worktrees")),
|
||||
name: "selected-clone",
|
||||
})
|
||||
|
||||
expect(selected.id).toBe(initial.id)
|
||||
expect(created.directory).toBe(abs(path.join(clone, ".lane/trees/selected-clone")))
|
||||
expect((yield* projects.list()).find((project) => project.id === initial.id)?.canonical).toBe(main)
|
||||
expect(yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(created.directory).text())).toBe(
|
||||
yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(clone).text()),
|
||||
@@ -920,7 +911,7 @@ describe("Worktree", () => {
|
||||
}),
|
||||
)
|
||||
const first = yield* worktrees.create({ name: "one" })
|
||||
expect(first.directory).toBe(abs(path.join(input.root.path, "copies/one")))
|
||||
expect(first.directory).toBe(abs(path.join(input.root.path, "nested/copies/one")))
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: first.directory, strategy: "custom" })
|
||||
yield* config.setEntries(documents.slice(0, 1))
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
@@ -934,50 +925,6 @@ describe("Worktree", () => {
|
||||
expect(third.directory).toBe(abs(path.join(input.root.path, "worktree", input.projectID.slice(0, 6), "three")))
|
||||
}),
|
||||
)
|
||||
;["relative", "absolute", "home"].forEach((mode) => {
|
||||
it.live(`resolves ${mode} global directory config from a linked checkout's subdirectory`, () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const config = yield* Config.Test
|
||||
const projects = yield* Project.Service
|
||||
const global = yield* Global.Service
|
||||
const worktrees = yield* Worktree.Service
|
||||
const linked = abs(path.join(input.root.path, "linked"))
|
||||
const nested = abs(path.join(linked, "src"))
|
||||
const home = abs(path.join(input.root.path, "home"))
|
||||
yield* Effect.promise(async () => {
|
||||
await $`git worktree add ${linked} -b linked`.cwd(input.sourceDirectory).quiet()
|
||||
await fs.mkdir(nested)
|
||||
})
|
||||
const project = yield* projects.resolve(nested)
|
||||
const directory =
|
||||
mode === "relative" ? ".lane/trees" : mode === "home" ? "~/copies" : path.join(home, "absolute")
|
||||
yield* config.setEntries([
|
||||
new Document({
|
||||
type: "document",
|
||||
path: abs(path.join(home, ".config/opencode/opencode.json")),
|
||||
info: new Info({ worktree: { directory } }),
|
||||
}),
|
||||
])
|
||||
yield* ConfigWorktreePlugin.Plugin.effect(host()).pipe(
|
||||
Effect.provideService(Location.Service, { directory: nested, project }),
|
||||
Effect.provideService(Global.Service, { ...global, home }),
|
||||
)
|
||||
|
||||
const created = yield* worktrees.create({ name: "task" })
|
||||
|
||||
expect(project.directory).toBe(linked)
|
||||
expect(project.canonical).toBe(input.sourceDirectory)
|
||||
expect(created.directory).toBe(
|
||||
abs(
|
||||
mode === "relative"
|
||||
? path.join(input.sourceDirectory, ".lane/trees/task")
|
||||
: path.join(home, mode === "home" ? "copies/task" : "absolute/task"),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("normalization retains worktree directory and rejects invalid configuration", () =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export interface WorktreeCreateInput {
|
||||
readonly sourceDirectory: string
|
||||
/** Suggested destination after naming and collision handling. Strategies may return a different directory. */
|
||||
readonly directory: string
|
||||
/** Starting ref, not the name of a new branch. Reject unsupported refs rather than ignoring them. */
|
||||
readonly branch?: string
|
||||
@@ -12,7 +11,6 @@ export interface WorktreeRemoveInput {
|
||||
}
|
||||
|
||||
export interface WorktreeResult {
|
||||
/** Actual directory created by the strategy, used for inventory and startup commands. */
|
||||
readonly directory: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1621,14 +1621,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3249,6 +3242,152 @@
|
||||
"summary": "Get session context"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/diff": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.diff",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "messageID",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "to",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "context",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Unchanged lines around each hunk. Omit for full-file patches."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileDiff.Info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "MessageNotFoundError | SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "UnknownError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnknownErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
|
||||
"summary": "Diff session turns"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/inbox": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -18486,6 +18625,38 @@
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Idle": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["created"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["idle"]
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": ["succeeded", "failed", "interrupted"]
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "outcome"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Info": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -18517,6 +18688,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Compaction"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Idle"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Model } from "@opencode/schema/model"
|
||||
import { Location } from "@opencode/schema/location"
|
||||
import { SessionEvent } from "@opencode/schema/session-event"
|
||||
import { EventLog } from "@opencode/schema/event-log"
|
||||
import { FileDiff } from "@opencode/schema/file-diff"
|
||||
|
||||
const ParentIDFilter = Schema.Union([
|
||||
Session.ID,
|
||||
@@ -521,6 +522,31 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.diff", "/api/session/:sessionID/diff", {
|
||||
params: { sessionID: Session.ID },
|
||||
query: Schema.Struct({
|
||||
messageID: Schema.optional(SessionMessage.ID).annotate({
|
||||
description: "User message whose turn to diff. Defaults to the turn of the newest user message.",
|
||||
}),
|
||||
to: Schema.optional(SessionMessage.ID).annotate({
|
||||
description: "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone.",
|
||||
}),
|
||||
context: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional).annotate({
|
||||
description: "Unchanged lines around each hunk. Omit for full-file patches.",
|
||||
}),
|
||||
}),
|
||||
success: Schema.Struct({ data: Schema.Array(FileDiff.Info) }),
|
||||
error: [InvalidRequestError, MessageNotFoundError, SessionNotFoundError, UnknownError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.diff",
|
||||
summary: "Diff session turns",
|
||||
description:
|
||||
"Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.inbox.list", "/api/session/:sessionID/inbox", {
|
||||
params: { sessionID: Session.ID },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
directory: Schema.Trim.pipe(Schema.check(Schema.isNonEmpty())).annotate({
|
||||
description: "Parent directory for new worktrees, relative to the project's primary checkout when not absolute",
|
||||
description: "Parent directory for new worktrees, relative to the declaring config file when not absolute",
|
||||
}),
|
||||
}).annotate({ identifier: "Config.Worktree" })
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
@@ -272,6 +272,18 @@ export const Compaction = Schema.Union([CompactionRunning, CompactionCompleted,
|
||||
)
|
||||
export type Compaction = CompactionRunning | CompactionCompleted | CompactionFailed
|
||||
|
||||
/**
|
||||
* Marks the Session going idle: every step since the previous marker belongs to
|
||||
* one turn, including prompts steered in while it was busy. A shutdown does not
|
||||
* record one, since the resumed execution continues the same turn.
|
||||
*/
|
||||
export interface Idle extends Schema.Schema.Type<typeof Idle> {}
|
||||
export const Idle = Schema.Struct({
|
||||
...Base,
|
||||
type: Schema.tag("idle"),
|
||||
outcome: Schema.Literals(["succeeded", "failed", "interrupted"]),
|
||||
}).annotate({ identifier: "Session.Message.Idle" })
|
||||
|
||||
export const Info = Schema.Union([
|
||||
AgentSelected,
|
||||
ModelSelected,
|
||||
@@ -283,6 +295,7 @@ export const Info = Schema.Union([
|
||||
Shell,
|
||||
Assistant,
|
||||
Compaction,
|
||||
Idle,
|
||||
]).annotate({ identifier: "Session.Message.Info" })
|
||||
export type Info =
|
||||
| AgentSelected
|
||||
@@ -295,4 +308,5 @@ export type Info =
|
||||
| Shell
|
||||
| Assistant
|
||||
| Compaction
|
||||
| Idle
|
||||
export type Type = Info["type"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
|
||||
import type { Snapshot } from "@opencode/core/snapshot"
|
||||
import { MessageNotFoundError, SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export function missingSession(error: Session.NotFoundError) {
|
||||
@@ -9,6 +10,14 @@ export function missingSession(error: Session.NotFoundError) {
|
||||
})
|
||||
}
|
||||
|
||||
export function missingMessage(error: Session.MessageNotFoundError) {
|
||||
return new MessageNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
messageID: error.messageID,
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
})
|
||||
}
|
||||
|
||||
export function failedMessageDecode(error: Session.MessageDecodeError) {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to decode session message").pipe(
|
||||
@@ -18,3 +27,16 @@ export function failedMessageDecode(error: Session.MessageDecodeError) {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Snapshot repositories are host state clients cannot repair, so surface only a log reference. */
|
||||
export function failedSnapshot(operation: string, sessionID: Session.ID) {
|
||||
return (error: Snapshot.Error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError(`failed to ${operation}`, { cause: error }).pipe(
|
||||
Effect.annotateLogs({ ref, sessionID }),
|
||||
Effect.andThen(
|
||||
Effect.fail(new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref })),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,9 @@ import {
|
||||
ServiceUnavailableError,
|
||||
SessionBusyError,
|
||||
SkillNotFoundError,
|
||||
UnknownError,
|
||||
} from "@opencode/protocol/errors"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { failedMessageDecode, missingSession } from "./session-error"
|
||||
import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"
|
||||
|
||||
const DefaultSessionsLimit = 50
|
||||
|
||||
@@ -212,15 +211,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
return {
|
||||
data: yield* session.fork({ sessionID: ctx.params.sessionID, boundary: ctx.payload.boundary }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag(
|
||||
"Session.MessageNotFoundError",
|
||||
(error) =>
|
||||
new MessageNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
messageID: error.messageID,
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
|
||||
Effect.catchTag(
|
||||
"Session.ForkEmptyError",
|
||||
(error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }),
|
||||
@@ -448,32 +439,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
files: ctx.payload.files,
|
||||
})
|
||||
return {
|
||||
data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag(
|
||||
"Session.MessageNotFoundError",
|
||||
(error) =>
|
||||
new MessageNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
messageID: error.messageID,
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
}),
|
||||
data: yield* session.revert
|
||||
.stage({ ...ctx.params, ...ctx.payload })
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
|
||||
Effect.catchTag("Session.BusyError", busySession),
|
||||
Effect.catchTag("Snapshot.Error", failedSnapshot("stage session revert", ctx.params.sessionID)),
|
||||
),
|
||||
Effect.catchTag("Session.BusyError", busySession),
|
||||
Effect.catchTag("Snapshot.Error", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to stage session revert", { cause: error }).pipe(
|
||||
Effect.andThen(
|
||||
Effect.fail(
|
||||
new UnknownError({
|
||||
message: "Unexpected server error. Check server logs for details.",
|
||||
ref,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -481,23 +454,13 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
"session.revert.clear",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* Effect.log("session.revert.clear", { sessionID: ctx.params.sessionID })
|
||||
yield* session.revert.clear(ctx.params.sessionID).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.BusyError", busySession),
|
||||
Effect.catchTag("Snapshot.Error", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to clear session revert", { cause: error }).pipe(
|
||||
Effect.andThen(
|
||||
Effect.fail(
|
||||
new UnknownError({
|
||||
message: "Unexpected server error. Check server logs for details.",
|
||||
ref,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* session.revert
|
||||
.clear(ctx.params.sessionID)
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.BusyError", busySession),
|
||||
Effect.catchTag("Snapshot.Error", failedSnapshot("clear session revert", ctx.params.sessionID)),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
@@ -527,6 +490,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.diff",
|
||||
Effect.fn(function* (ctx) {
|
||||
return {
|
||||
data: yield* session.diff({ sessionID: ctx.params.sessionID, ...ctx.query }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
|
||||
Effect.catchTag(
|
||||
"Session.TurnRangeError",
|
||||
(error) => new InvalidRequestError({ message: error.message, field: error.field }),
|
||||
),
|
||||
Effect.catchTag("Snapshot.Error", failedSnapshot("diff session turn", ctx.params.sessionID)),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.inbox.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
@@ -642,15 +621,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
Effect.fn(function* (ctx) {
|
||||
const message = yield* session.updateMessage({ ...ctx.params, content: ctx.payload.content }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag(
|
||||
"Session.MessageNotFoundError",
|
||||
(error) =>
|
||||
new MessageNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
messageID: error.messageID,
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
|
||||
Effect.catchTag("Session.BusyError", busySession),
|
||||
Effect.catchTag(
|
||||
"Session.MessageNotAssistantError",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { expect, setDefaultTimeout } from "bun:test"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionEvent } from "@opencode/core/session/event"
|
||||
import { SessionExecution } from "@opencode/core/session/execution"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
setDefaultTimeout(30_000)
|
||||
|
||||
it.live("serves turn diffs by user message with range validation", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-session-diff-")))
|
||||
const ids = { user: SessionMessage.ID.create(), assistant: SessionMessage.ID.create() }
|
||||
// Deliver the prompt and one step the way the runner would, without a model.
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
return SessionExecution.Service.of({
|
||||
active: Effect.succeed(new Set()),
|
||||
isActive: () => Effect.succeed(false),
|
||||
resume: () => Effect.void,
|
||||
wake: (sessionID) =>
|
||||
Effect.gen(function* () {
|
||||
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: ids.user })
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: ids.assistant,
|
||||
agent: Agent.defaultID,
|
||||
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID: ids.assistant,
|
||||
finish: "stop",
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
}),
|
||||
interrupt: () => Effect.succeed(false),
|
||||
awaitIdle: () => Effect.void,
|
||||
})
|
||||
}),
|
||||
)
|
||||
const handler = yield* ServerFetch.make(
|
||||
{
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
},
|
||||
{
|
||||
overrides: [
|
||||
SessionExecution.node.replace(
|
||||
makeGlobalNode({ service: SessionExecution.Service, layer: execution, deps: [Bus.node] }),
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
const request = (path: string, body?: unknown) =>
|
||||
Effect.promise(async () => {
|
||||
const response = await handler(
|
||||
new Request(`http://opencode.local${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: body === undefined ? undefined : { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}),
|
||||
)
|
||||
return { status: response.status, body: (await response.json()) as Record<string, unknown> }
|
||||
})
|
||||
const created = yield* request("/api/session", { location: { directory: tmp.path } })
|
||||
const sessionID = Session.ID.make((created.body.data as { id: string }).id)
|
||||
const diff = (query = "") => request(`/api/session/${sessionID}/diff${query}`)
|
||||
|
||||
expect(yield* diff()).toEqual({ status: 200, body: { data: [] } })
|
||||
expect((yield* request(`/api/session/${sessionID}/prompt`, { id: ids.user, text: "prompt" })).status).toBe(200)
|
||||
// Not a git repository, so steps record no snapshots and the turn has no diff.
|
||||
expect(yield* diff(`?messageID=${ids.user}&context=3`)).toEqual({ status: 200, body: { data: [] } })
|
||||
expect(yield* diff(`?messageID=${ids.assistant}`)).toMatchObject({
|
||||
status: 400,
|
||||
body: { _tag: "InvalidRequestError", field: "messageID" },
|
||||
})
|
||||
expect(yield* diff(`?messageID=${SessionMessage.ID.create()}`)).toMatchObject({
|
||||
status: 404,
|
||||
body: { _tag: "MessageNotFoundError" },
|
||||
})
|
||||
expect((yield* request(`/api/session/${Session.ID.create()}/diff`)).status).toBe(404)
|
||||
}),
|
||||
)
|
||||
@@ -16,7 +16,7 @@ export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
|
||||
|
||||
export type ReasoningMode = "hidden" | "compact" | "full"
|
||||
|
||||
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
|
||||
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" | "idle" }>
|
||||
type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice }
|
||||
type Content = SessionMessageAssistant["content"][number]
|
||||
type GroupRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
|
||||
@@ -765,7 +765,8 @@ function record(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function isNotice(message: SessionMessageInfo): message is Notice {
|
||||
if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false
|
||||
if (message.type === "user" || message.type === "assistant" || message.type === "shell" || message.type === "idle")
|
||||
return false
|
||||
if (message.type !== "synthetic") return true
|
||||
return !!message.description?.trim() || timelineNoticeRequired(message)
|
||||
}
|
||||
|
||||
@@ -821,6 +821,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
title: "Switch model",
|
||||
suggested: true,
|
||||
category: "Agent",
|
||||
// Bias /mo toward /models over /move without changing global fuzzy scoring.
|
||||
slash: { name: "models", aliases: ["mo"] },
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogModel />)
|
||||
|
||||
+12
-23
@@ -20,23 +20,23 @@ import type { WorktreeListOutput } from "@opencode/client"
|
||||
import { useRoute } from "../context/route"
|
||||
import { DialogWorktreeName } from "./dialog-worktree-name"
|
||||
|
||||
export type WorkspaceSelection =
|
||||
export type MoveSessionSelection =
|
||||
| { type: "directory"; directory: string; subdirectory: boolean }
|
||||
| { type: "new"; name: string }
|
||||
type ProjectDirectory = WorktreeListOutput[number]
|
||||
|
||||
type DialogWorkspacesProps = {
|
||||
type DialogMoveSessionProps = {
|
||||
projectID: string
|
||||
location?: { directory: string; workspaceID?: string }
|
||||
current?: WorkspaceSelection
|
||||
onSelect: (selection: WorkspaceSelection) => void
|
||||
onCurrentChange?: (selection: WorkspaceSelection) => void
|
||||
current?: MoveSessionSelection
|
||||
onSelect: (selection: MoveSessionSelection) => void
|
||||
onCurrentChange?: (selection: MoveSessionSelection) => void
|
||||
initialDirectories?: ReadonlyArray<ProjectDirectory>
|
||||
fixture?: boolean
|
||||
initialRemoving?: string
|
||||
}
|
||||
|
||||
export function DialogWorkspaces(props: DialogWorkspacesProps) {
|
||||
export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const dimensions = useTerminalDimensions()
|
||||
@@ -60,7 +60,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
|
||||
|
||||
function reopen(initialRemoving?: string) {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaces {...props} initialDirectories={directoryData()} initialRemoving={initialRemoving} />
|
||||
<DialogMoveSession {...props} initialDirectories={directoryData()} initialRemoving={initialRemoving} />
|
||||
))
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
|
||||
.toSorted((a, b) => b.directory.length - a.directory.length)[0]
|
||||
})
|
||||
|
||||
const options = createMemo<DialogSelectOption<WorkspaceSelection | undefined>[]>(() => {
|
||||
const options = createMemo<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
|
||||
if (showError()) return []
|
||||
const data = directoryData()
|
||||
const current = currentRoot()?.directory
|
||||
@@ -213,7 +213,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
|
||||
return true
|
||||
}
|
||||
|
||||
async function remove(option: DialogSelectOption<WorkspaceSelection | undefined>) {
|
||||
async function remove(option: DialogSelectOption<MoveSessionSelection | undefined>) {
|
||||
if (!option.value || option.value.type !== "directory" || option.value.subdirectory || removing()) return
|
||||
const data = directoryData()
|
||||
const selected = option.value
|
||||
@@ -299,14 +299,6 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
|
||||
props.onSelect({ type: "new", name })
|
||||
}
|
||||
|
||||
async function move(option: DialogSelectOption<WorkspaceSelection | undefined>) {
|
||||
if (route.data.type !== "session" || option.value?.type !== "directory") return
|
||||
const sessionID = route.data.sessionID
|
||||
const directory = option.value.directory
|
||||
dialog.clear()
|
||||
await client.api.session.move({ sessionID, directory }).catch(toast.error)
|
||||
}
|
||||
|
||||
const fullHeight = createMemo(() =>
|
||||
Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2)),
|
||||
)
|
||||
@@ -314,11 +306,11 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
|
||||
return (
|
||||
<box minHeight={showError() ? 5 : fullHeight()}>
|
||||
<DialogSelect
|
||||
title="Worktrees"
|
||||
title="Move session"
|
||||
titleView={
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
Worktrees
|
||||
Move session
|
||||
</text>
|
||||
<Show when={working() || directories.loading || loadedProject.loading}>
|
||||
<Spinner />
|
||||
@@ -335,7 +327,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
|
||||
Could not load worktrees
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
|
||||
<text fg={theme.text.subdued}>Close and reopen Worktrees to try again.</text>
|
||||
<text fg={theme.text.subdued}>Close and reopen Move session to try again.</text>
|
||||
</box>
|
||||
) : directories.loading || loadedProject.loading ? (
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
@@ -362,9 +354,6 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
|
||||
showError() || props.fixture
|
||||
? []
|
||||
: [
|
||||
...(route.data.type === "session"
|
||||
? [{ command: "dialog.move_session.move", title: "move", onTrigger: move }]
|
||||
: []),
|
||||
{
|
||||
command: "dialog.move_session.new",
|
||||
title: "new",
|
||||
@@ -458,6 +458,8 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
|
||||
directory: data.project.get(id)!.canonical,
|
||||
workspace: workspaceID(),
|
||||
},
|
||||
strategy: "git",
|
||||
directory: path.join(paths.worktree, id.slice(0, 6)),
|
||||
...(value.trim() ? { name: value.trim() } : {}),
|
||||
})
|
||||
.then((created) => {
|
||||
|
||||
@@ -615,11 +615,11 @@ export function Prompt(props: PromptProps) {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Manage workspaces",
|
||||
desc: "Manage workspaces",
|
||||
title: "Move session",
|
||||
desc: "Move to another project dir",
|
||||
name: "session.move",
|
||||
category: "Session",
|
||||
slash: { name: "worktrees", aliases: ["move", "mov"] },
|
||||
slash: { name: "move" },
|
||||
run: () => {
|
||||
move.open()
|
||||
},
|
||||
|
||||
@@ -4,10 +4,9 @@ import { errorMessage } from "../../util/error"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { useClient } from "../../context/client"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { DialogWorkspaces, type WorkspaceSelection } from "../dialog-workspaces"
|
||||
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
|
||||
import { useData } from "../../context/data"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { useRoute } from "../../context/route"
|
||||
|
||||
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
|
||||
const dialog = useDialog()
|
||||
@@ -15,12 +14,11 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
const toast = useToast()
|
||||
const data = useData()
|
||||
const currentLocation = useLocation()
|
||||
const route = useRoute()
|
||||
const paths = useTuiPaths()
|
||||
const [creating, setCreating] = createSignal(false)
|
||||
const [creatingDots, setCreatingDots] = createSignal(3)
|
||||
const [progress, setProgress] = createSignal<string>()
|
||||
const [destination, setDestination] = createSignal<WorkspaceSelection>()
|
||||
const [destination, setDestination] = createSignal<MoveSessionSelection>()
|
||||
|
||||
function homeLocation() {
|
||||
const location = currentLocation.ref ?? data.location.default()
|
||||
@@ -70,7 +68,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
const sessionID = input.sessionID()
|
||||
const session = sessionID ? await resolveSession(sessionID) : undefined
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaces
|
||||
<DialogMoveSession
|
||||
projectID={projectID}
|
||||
location={session?.location ?? homeLocation()}
|
||||
current={
|
||||
@@ -89,18 +87,19 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
}
|
||||
onCurrentChange={setDestination}
|
||||
onSelect={(selection) => {
|
||||
if (!input.sessionID() && selection.type === "new") {
|
||||
const sessionID = input.sessionID()
|
||||
if (!sessionID) {
|
||||
setDestination(selection)
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
void selectWorkspace(selection)
|
||||
void moveExistingSession(sessionID, selection)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
async function selectWorkspace(selection: WorkspaceSelection) {
|
||||
async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
|
||||
dialog.clear()
|
||||
const directory = selection.type === "new" ? await create(selection.name) : selection.directory
|
||||
if (!directory) {
|
||||
@@ -108,8 +107,17 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
finishSubmit()
|
||||
route.navigate({ type: "home", location: { directory } })
|
||||
setProgress("Moving session")
|
||||
try {
|
||||
await client.api.session.move({ sessionID, directory })
|
||||
dialog.clear()
|
||||
} catch (error) {
|
||||
toast.error(error)
|
||||
dialog.clear()
|
||||
} finally {
|
||||
setProgress(undefined)
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveProjectID() {
|
||||
|
||||
@@ -105,7 +105,7 @@ export const Definitions = {
|
||||
"session.export": keybind("<leader>x", "Export session to editor"),
|
||||
"session.copy": keybind("none", "Copy session transcript"),
|
||||
"session.copy.id": keybind("none", "Copy session ID"),
|
||||
"session.move": keybind("none", "Manage workspaces"),
|
||||
"session.move": keybind("none", "Move session"),
|
||||
"session.new": keybind("<leader>n", "Create a new session"),
|
||||
"session.list": keybind("<leader>l", "List all sessions"),
|
||||
"open.menu": keybind("ctrl+o", "Open recent sessions and projects"),
|
||||
@@ -262,8 +262,7 @@ export const Definitions = {
|
||||
"dialog.integration.rename": keybind("ctrl+r", "Rename integration account"),
|
||||
"dialog.integration.delete": keybind("ctrl+d", "Delete integration account"),
|
||||
"dialog.worktree.generate": keybind("tab", "Generate worktree name"),
|
||||
"dialog.move_session.new": keybind("ctrl+a", "New worktree"),
|
||||
"dialog.move_session.move": keybind("ctrl+m", "Move session to worktree"),
|
||||
"dialog.move_session.new": keybind("ctrl+m", "New worktree"),
|
||||
"dialog.move_session.delete": keybind("ctrl+d", "Delete worktree"),
|
||||
"dialog.move_session.refresh": keybind("ctrl+r", "Refresh worktrees"),
|
||||
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Plugin } from "@opencode/plugin/tui"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { createSignal } from "solid-js"
|
||||
import { DialogWorkspaces } from "../../../component/dialog-workspaces"
|
||||
import { DialogMoveSession } from "../../../component/dialog-move-session"
|
||||
import { SessionLocationUnavailable } from "../../../routes/session/location-missing"
|
||||
import type { Story } from "./index"
|
||||
import { StoryFooter } from "./footer"
|
||||
@@ -15,7 +15,7 @@ function SessionLocationMissingStory(props: { context: Plugin.Context }) {
|
||||
const [message, setMessage] = createSignal("Choose another directory to continue")
|
||||
const open = () =>
|
||||
props.context.ui.dialog.show(() => (
|
||||
<DialogWorkspaces
|
||||
<DialogMoveSession
|
||||
projectID="fixture-project"
|
||||
initialDirectories={[
|
||||
{ directory: "/Users/kit/code/open-source/opencode" },
|
||||
|
||||
@@ -305,6 +305,7 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
|
||||
...messages.filter(isInput),
|
||||
].reduce<SessionRow[]>((rows, message) => {
|
||||
if (message.type !== "assistant") {
|
||||
if (message.type === "idle") return rows
|
||||
if (message.type === "synthetic" && !message.description?.trim()) return rows
|
||||
if (message.type === "compaction" && message.status === "completed" && usage) usage.previousTurnCache = undefined
|
||||
if (!pending.has(message.id)) completePrevious(rows)
|
||||
|
||||
@@ -45,7 +45,7 @@ export function useWorkingDirectoryActions(input: { directory: () => string | un
|
||||
...(input.onMove
|
||||
? [
|
||||
{
|
||||
title: "Workspaces",
|
||||
title: "Move session",
|
||||
value: "session.move",
|
||||
description: "to another working directory",
|
||||
onSelect: () => void input.onMove?.(),
|
||||
|
||||
@@ -509,7 +509,11 @@ test.each(["", "search-ui"])("creates a worktree named '%s' and opens it in the
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
|
||||
expect(payload).toEqual(name ? { name } : {})
|
||||
expect(payload).toEqual({
|
||||
strategy: "git",
|
||||
directory: path.join("/tmp/opencode", projectID.slice(0, 6)),
|
||||
...(name ? { name } : {}),
|
||||
})
|
||||
expect(fixture.route.data).toEqual({ type: "home", location: { directory: created, workspaceID } })
|
||||
expect(fixture.location.ref).toEqual({ directory: created, workspaceID })
|
||||
} finally {
|
||||
|
||||
@@ -362,7 +362,7 @@ test("dialog actions run without options while row actions still require a selec
|
||||
)
|
||||
|
||||
try {
|
||||
app.mockInput.pressKey("a", { ctrl: true })
|
||||
app.mockInput.pressKey("m", { ctrl: true })
|
||||
app.mockInput.pressKey("d", { ctrl: true })
|
||||
|
||||
expect(global).toBe(1)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ClientProvider } from "../../../src/context/client"
|
||||
import { DataProvider, useData } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
import { RouteProvider, useRoute } from "../../../src/context/route"
|
||||
import { RouteProvider } from "../../../src/context/route"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { DialogProvider } from "../../../src/ui/dialog"
|
||||
import { ToastProvider, useToast } from "../../../src/ui/toast"
|
||||
@@ -49,8 +49,7 @@ test.each([
|
||||
expect(fixture.data.location.info({ directory: created })?.project.canonical).toBe(clone)
|
||||
expect(fixture.reads.locations.filter((directory) => directory === input.directory)).toHaveLength(1)
|
||||
expect(fixture.reads.session).toBe(input.home ? 0 : 1)
|
||||
expect(fixture.moves).toEqual([])
|
||||
if (!input.home) expect(fixture.route.data).toEqual({ type: "home", location: { directory: created } })
|
||||
expect(fixture.moves).toEqual(input.home ? [] : [{ directory: created }])
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
@@ -84,30 +83,11 @@ test.each([
|
||||
}
|
||||
})
|
||||
|
||||
test.each([false, true])("selecting a workspace opens Home without moving a session (home=%s)", async (home) => {
|
||||
const fixture = await renderMove({ directory: clone, home })
|
||||
try {
|
||||
await fixture.move.open()
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Worktrees") && frame.includes(linked))
|
||||
await fixture.app.mockInput.typeText("linked")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes(linked) && !frame.includes(clone))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home" && fixture.route.data.location?.directory === linked)
|
||||
expect(fixture.route.data).toEqual({ type: "home", location: { directory: linked } })
|
||||
expect(fixture.moves).toEqual([])
|
||||
expect(fixture.requests).toEqual([])
|
||||
expect(fixture.move.pending()).toBe(false)
|
||||
if (!home) expect(fixture.data.session.get("ses_clone")?.location.directory).toBe(clone)
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("removal uses the current configuration location, not the destination directory", async () => {
|
||||
const fixture = await renderMove({ directory: clone, home: true })
|
||||
try {
|
||||
await fixture.move.open()
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Worktrees") && frame.includes(linked))
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Move session") && frame.includes(linked))
|
||||
await fixture.app.mockInput.typeText("linked")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes(linked) && !frame.includes(clone))
|
||||
fixture.app.mockInput.pressKey("d", { ctrl: true })
|
||||
@@ -120,32 +100,6 @@ test("removal uses the current configuration location, not the destination direc
|
||||
}
|
||||
})
|
||||
|
||||
test.each([false, true])("Ctrl+M moves only an existing session (home=%s)", async (home) => {
|
||||
const fixture = await renderMove({ directory: clone, home })
|
||||
try {
|
||||
await fixture.move.open()
|
||||
const frame = await fixture.app.waitForFrame((frame) => frame.includes("Worktrees") && frame.includes(linked))
|
||||
expect(frame).toContain("new ctrl+a")
|
||||
expect(frame.includes("move ctrl+m")).toBe(!home)
|
||||
await fixture.app.mockInput.typeText("linked")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes(linked) && !frame.includes(clone))
|
||||
fixture.app.mockInput.pressKey("m", { ctrl: true })
|
||||
if (home) {
|
||||
await fixture.app.renderOnce()
|
||||
expect(fixture.moves).toEqual([])
|
||||
expect(fixture.route.data).toEqual({ type: "home" })
|
||||
expect(fixture.requests).toEqual([])
|
||||
return
|
||||
}
|
||||
await fixture.app.waitFor(() => fixture.moves.length === 1)
|
||||
expect(fixture.moves).toEqual([{ directory: linked }])
|
||||
expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_clone" })
|
||||
expect(fixture.requests).toEqual([])
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ name: "session", unavailable: "session" as const },
|
||||
{ name: "location", unavailable: "location" as const },
|
||||
@@ -251,13 +205,11 @@ async function renderMove(input: {
|
||||
let move!: ReturnType<typeof usePromptMove>
|
||||
let toast!: ReturnType<typeof useToast>
|
||||
let location!: ReturnType<typeof useLocation>
|
||||
let route!: ReturnType<typeof useRoute>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
toast = useToast()
|
||||
location = useLocation()
|
||||
route = useRoute()
|
||||
move = usePromptMove({
|
||||
projectID: () => (input.home ? data.location.info()?.project.id : "proj_test"),
|
||||
sessionID: () => (input.home ? undefined : "ses_clone"),
|
||||
@@ -271,7 +223,7 @@ async function renderMove(input: {
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<RouteProvider initialRoute={input.home ? { type: "home" } : { type: "session", sessionID: "ses_clone" }}>
|
||||
<RouteProvider>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider directory={launch}>
|
||||
<LocationProvider>
|
||||
@@ -300,7 +252,6 @@ async function renderMove(input: {
|
||||
move,
|
||||
toast,
|
||||
location,
|
||||
route,
|
||||
requests,
|
||||
removals,
|
||||
moves,
|
||||
@@ -308,9 +259,9 @@ async function renderMove(input: {
|
||||
async create() {
|
||||
await move.open()
|
||||
const frame = await app.waitForFrame(
|
||||
(frame) => frame.includes("Worktrees") && (frame.includes(clone) || frame.includes(launch)),
|
||||
(frame) => frame.includes("Move session") && (frame.includes(clone) || frame.includes(launch)),
|
||||
)
|
||||
app.mockInput.pressKey("a", { ctrl: true })
|
||||
app.mockInput.pressKey("m", { ctrl: true })
|
||||
await app.waitForFrame((frame) => frame.includes("Name worktree"))
|
||||
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
|
||||
await app.mockInput.typeText("fresh")
|
||||
@@ -320,7 +271,7 @@ async function renderMove(input: {
|
||||
await move.getDirectory()
|
||||
return frame
|
||||
}
|
||||
await app.waitFor(() => route.data.type === "home" || toast.currentToast !== null)
|
||||
await app.waitFor(() => moves.length > 0 || toast.currentToast !== null)
|
||||
return frame
|
||||
},
|
||||
}
|
||||
|
||||
@@ -25,31 +25,6 @@ URLs (`opencode` or `github`). Each manifest includes the selected artifact's ve
|
||||
file URLs, SHA-512 checksums, sizes, and release date. Existing minimum-version selection
|
||||
and `current`/User-Agent handling also apply to these feeds.
|
||||
|
||||
## Channel rollouts
|
||||
|
||||
Set each channel's rollout duration in hours on the admin page. All channels default
|
||||
to `0` (immediate); fractional hours are supported. For example, `6` makes a release
|
||||
available to roughly half of IPs after three hours and all IPs after six hours.
|
||||
|
||||
Eligibility uses the original publication time (`time_created`) and a SHA-256 hash
|
||||
of the channel and Cloudflare's `CF-Connecting-IP`. Each IP keeps the same rollout
|
||||
position across releases in that channel. Requests without this header wait for
|
||||
the full duration. The retired `next` channel is not available or configurable.
|
||||
|
||||
Until the active release is eligible, callers receive the newest eligible artifact
|
||||
published before it, for the same name and distribution. This also handles overlapping
|
||||
rollouts. Earlier inactive releases can be fallbacks, including manually deactivated
|
||||
releases; releases newer than the active release cannot. If no eligible artifact
|
||||
exists, it is omitted from listings and individual artifact requests return 404.
|
||||
|
||||
Minimum releases bypass rollout for clients that need them, and identified clients
|
||||
are not sent a fallback below their configured minimum. Rollout applies to all JSON
|
||||
endpoints and desktop manifests. Responses, including unavailable artifacts, are not cached.
|
||||
|
||||
Duration changes apply immediately to existing releases. Manual activation uses the
|
||||
original publication time too; set the duration to `0` to make it immediate.
|
||||
Apply the `0004_channel_rollout.sql` migration before deploying.
|
||||
|
||||
## Minimum releases
|
||||
|
||||
Each channel/name/distribution can mark one retained artifact as `minimum`, independently
|
||||
@@ -70,8 +45,8 @@ artifact. All three public API paths apply the same selection and use `Cache-Con
|
||||
no-store` because responses can depend on the User-Agent.
|
||||
|
||||
Version comparison uses semver, normalizing preview run numbers to numeric prerelease
|
||||
identifiers and historical `next` versions to `beta`. The retired `/api/next`
|
||||
channel returns 404; use `/api/beta` instead.
|
||||
identifiers and historical `next` versions to `beta`. The `/api/next` channel also
|
||||
resolves to `beta`.
|
||||
|
||||
Choose a minimum that older clients can install and that can itself consume the active
|
||||
release. For the CLI package migration, retain a package-aware release published as
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
CREATE TABLE channel_rollout (
|
||||
channel TEXT PRIMARY KEY,
|
||||
duration_hours REAL NOT NULL CHECK (duration_hours >= 0)
|
||||
);
|
||||
+24
-106
@@ -68,7 +68,6 @@ export default {
|
||||
if (pathname === "/admin" && request.method === "GET") return admin(request, env, prefix)
|
||||
if (pathname === "/admin/activate" && request.method === "POST") return markArtifact(request, env, "active", prefix)
|
||||
if (pathname === "/admin/minimum" && request.method === "POST") return markArtifact(request, env, "minimum", prefix)
|
||||
if (pathname === "/admin/rollout" && request.method === "POST") return configureRollout(request, env, prefix)
|
||||
if (pathname === "/api/publish" && request.method === "POST") return publishArtifact(request, env)
|
||||
if (request.method !== "GET") return new Response("Method not allowed", { status: 405 })
|
||||
|
||||
@@ -77,59 +76,39 @@ export default {
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
if (path[0] === "next") return json({ error: "Channel not found" }, 404)
|
||||
const resolved = path[0]
|
||||
const resolved = resolveChannel(path[0])
|
||||
const agent = request.headers.get("User-Agent")?.match(/^opencode\/(?:([^/]+)\/([^/]+)\/cli|(.*))$/)
|
||||
const current = url.searchParams.get("current") ?? agent?.[2] ?? agent?.[3]
|
||||
const source = agent?.[1] ?? current?.match(/^v?0\.0\.0-(.+)-\d+(?:\.\d+)?(?:\+.*)?$/)?.[1]
|
||||
const caller = source === undefined || resolveChannel(source) === resolved ? current : undefined
|
||||
const rollout = await env.DB.prepare("SELECT duration_hours FROM channel_rollout WHERE channel = ?")
|
||||
.bind(resolved)
|
||||
.first<{ duration_hours: number }>()
|
||||
const ip = request.headers.get("CF-Connecting-IP")
|
||||
const hash =
|
||||
rollout?.duration_hours && ip
|
||||
? new DataView(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${resolved}:${ip}`))).getUint32(
|
||||
0,
|
||||
)
|
||||
: undefined
|
||||
// Missing IPs wait for full rollout. The same IP keeps its position across releases.
|
||||
const cutoff =
|
||||
Date.now() - (rollout?.duration_hours ?? 0) * 3_600_000 * (hash === undefined ? 1 : (hash + 1) / 2 ** 32)
|
||||
if (path.length === 4) {
|
||||
if (path[1] !== "desktop" || !/^latest(?:-mac|-linux(?:-arm64)?)?\.yml$/.test(path[3])) {
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
return artifactDistribution(env.DB, resolved, path[1], path[2], caller, cutoff, path[3])
|
||||
return artifactDistribution(env.DB, resolved, path[1], path[2], caller, path[3])
|
||||
}
|
||||
if (path.length === 1) return channel(env.DB, resolved, caller, cutoff)
|
||||
if (path.length === 2) return artifactName(env.DB, resolved, path[1], caller, cutoff)
|
||||
return artifactDistribution(env.DB, resolved, path[1], path[2], caller, cutoff)
|
||||
if (path.length === 1) return channel(env.DB, resolved, caller)
|
||||
if (path.length === 2) return artifactName(env.DB, resolved, path[1], caller)
|
||||
return artifactDistribution(env.DB, resolved, path[1], path[2], caller)
|
||||
},
|
||||
} satisfies ExportedHandler<Env>
|
||||
|
||||
async function channel(db: D1Database, channel: string, current: string | undefined, cutoff: number) {
|
||||
async function channel(db: D1Database, channel: string, current: string | undefined) {
|
||||
const result = await db
|
||||
.prepare(`${select} WHERE channel = ? AND (active = 1 OR minimum = 1) ORDER BY name, distribution`)
|
||||
.bind(channel)
|
||||
.all<ArtifactRow>()
|
||||
const artifacts = await selectArtifacts(db, result.results, current, cutoff)
|
||||
const artifacts = selectArtifacts(result.results, current)
|
||||
if (!artifacts.length) return json({ error: "Channel not found" }, 404)
|
||||
return updateResponse({ channel, artifacts: artifacts.map(decodeArtifact) })
|
||||
}
|
||||
|
||||
async function artifactName(
|
||||
db: D1Database,
|
||||
channel: string,
|
||||
name: string,
|
||||
current: string | undefined,
|
||||
cutoff: number,
|
||||
) {
|
||||
async function artifactName(db: D1Database, channel: string, name: string, current: string | undefined) {
|
||||
const result = await db
|
||||
.prepare(`${select} WHERE channel = ? AND name = ? AND (active = 1 OR minimum = 1) ORDER BY distribution`)
|
||||
.bind(channel, name)
|
||||
.all<ArtifactRow>()
|
||||
const artifacts = await selectArtifacts(db, result.results, current, cutoff)
|
||||
const artifacts = selectArtifacts(result.results, current)
|
||||
if (!artifacts.length) return json({ error: "Artifact not found" }, 404)
|
||||
return updateResponse({ channel, name, artifacts: artifacts.map(decodeArtifact) })
|
||||
}
|
||||
@@ -140,14 +119,13 @@ async function artifactDistribution(
|
||||
name: string,
|
||||
distribution: string,
|
||||
current: string | undefined,
|
||||
cutoff: number,
|
||||
manifest?: string,
|
||||
) {
|
||||
const result = await db
|
||||
.prepare(`${select} WHERE channel = ? AND name = ? AND distribution = ? AND (active = 1 OR minimum = 1)`)
|
||||
.bind(channel, name, distribution)
|
||||
.all<ArtifactRow>()
|
||||
const artifact = (await selectArtifacts(db, result.results, current, cutoff))[0]
|
||||
const artifact = selectArtifacts(result.results, current)[0]
|
||||
if (!artifact) return json({ error: "Artifact not found" }, 404)
|
||||
if (manifest) {
|
||||
const metadata = decodeMetadata(artifact.metadata)
|
||||
@@ -163,57 +141,20 @@ async function artifactDistribution(
|
||||
return updateResponse(decodeArtifact(artifact))
|
||||
}
|
||||
|
||||
async function selectArtifacts(db: D1Database, rows: ArtifactRow[], current: string | undefined, cutoff: number) {
|
||||
function selectArtifacts(rows: ArtifactRow[], current: string | undefined) {
|
||||
const caller = current === undefined ? undefined : releaseVersion(current)
|
||||
const artifacts = await Promise.all(
|
||||
rows
|
||||
.filter((row) => row.active === 1)
|
||||
.map(async (active) => {
|
||||
const minimum = rows.find(
|
||||
(row) => row.minimum === 1 && row.name === active.name && row.distribution === active.distribution,
|
||||
)
|
||||
const floor = minimum && releaseVersion(minimum.version)
|
||||
if (current !== undefined && minimum && (!floor || !caller || semver.lt(caller, floor))) return minimum
|
||||
if (active.time_created <= cutoff) return active
|
||||
const previous = await db
|
||||
.prepare(
|
||||
`${select} WHERE channel = ? AND name = ? AND distribution = ? AND time_created < ? AND time_created <= ? ORDER BY time_created DESC, version DESC LIMIT 1`,
|
||||
)
|
||||
.bind(active.channel, active.name, active.distribution, active.time_created, cutoff)
|
||||
.first<ArtifactRow>()
|
||||
// A rollout must not send an identified client back below its compatibility floor.
|
||||
if (current !== undefined && minimum) {
|
||||
const version = previous && releaseVersion(previous.version)
|
||||
if (!version || !floor || semver.lt(version, floor)) return minimum
|
||||
}
|
||||
return previous
|
||||
}),
|
||||
)
|
||||
return artifacts.filter((artifact) => artifact !== null)
|
||||
}
|
||||
|
||||
async function configureRollout(request: Request, env: Env, prefix: string) {
|
||||
const invalid = validMutation(request)
|
||||
if (invalid) return invalid
|
||||
const form = await request.formData()
|
||||
const channel = form.get("channel")
|
||||
const input = form.get("duration_hours")
|
||||
const duration = typeof input === "string" && input.trim() ? Number(input) : NaN
|
||||
if (
|
||||
!validIdentifier(channel) ||
|
||||
channel === "next" ||
|
||||
!Number.isFinite(duration) ||
|
||||
duration < 0 ||
|
||||
!Number.isFinite(duration * 3_600_000)
|
||||
) {
|
||||
return json({ error: "Channel and a non-negative rollout duration in hours are required" }, 400)
|
||||
}
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO channel_rollout (channel, duration_hours) VALUES (?, ?) ON CONFLICT (channel) DO UPDATE SET duration_hours = excluded.duration_hours",
|
||||
)
|
||||
.bind(channel, duration)
|
||||
.run()
|
||||
return Response.redirect(new URL(`${prefix}/admin`, request.url), 303)
|
||||
return rows
|
||||
.filter((row) => row.active === 1)
|
||||
.map((active) => {
|
||||
if (current === undefined) return active
|
||||
const minimum = rows.find(
|
||||
(row) => row.minimum === 1 && row.name === active.name && row.distribution === active.distribution,
|
||||
)
|
||||
if (!minimum) return active
|
||||
const floor = releaseVersion(minimum.version)
|
||||
if (!floor) return minimum
|
||||
return !caller || semver.lt(caller, floor) ? minimum : active
|
||||
})
|
||||
}
|
||||
|
||||
function releaseVersion(input: string) {
|
||||
@@ -229,12 +170,6 @@ function releaseVersion(input: string) {
|
||||
|
||||
async function admin(request: Request, env: Env, prefix: string) {
|
||||
const url = new URL(request.url)
|
||||
const rollouts = await env.DB.prepare(
|
||||
`SELECT channels.channel, COALESCE(channel_rollout.duration_hours, 0) AS duration_hours
|
||||
FROM (SELECT channel FROM artifact UNION SELECT channel FROM channel_rollout) AS channels
|
||||
LEFT JOIN channel_rollout ON channel_rollout.channel = channels.channel
|
||||
WHERE channels.channel != 'next' ORDER BY channels.channel`,
|
||||
).all<{ channel: string; duration_hours: number }>()
|
||||
const requestedPage = Number.parseInt(url.searchParams.get("page") ?? "1", 10)
|
||||
const page = Number.isSafeInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1
|
||||
const pageSize = 100
|
||||
@@ -311,22 +246,6 @@ async function admin(request: Request, env: Env, prefix: string) {
|
||||
<div><p>Release control</p><h1>Artifacts</h1></div>
|
||||
<span class="badge" data-variant="outline">${escape(request.headers.get("Cf-Access-Authenticated-User-Email") ?? "Cloudflare Access pending")}</span>
|
||||
</header>
|
||||
<article class="card" style="margin-bottom: 2rem">
|
||||
<header><h2>Channel rollouts</h2><p>Hours from publication to full availability. Zero is immediate. Changes apply immediately to existing releases.</p></header>
|
||||
<section>
|
||||
${
|
||||
rollouts.results
|
||||
.map(
|
||||
(rollout) => `<form action="${prefix}/admin/rollout" method="post">
|
||||
<input type="hidden" name="channel" value="${escape(rollout.channel)}">
|
||||
<label>${escape(rollout.channel)} — hours <input class="input" type="number" name="duration_hours" min="0" step="any" required value="${rollout.duration_hours}"></label>
|
||||
<button class="btn" type="submit">Save</button>
|
||||
</form>`,
|
||||
)
|
||||
.join("") || "<p>Publish a release to configure its channel.</p>"
|
||||
}
|
||||
</section>
|
||||
</article>
|
||||
<article class="card">
|
||||
<header><h2>Published builds</h2><p>Every build received from the trusted publishing workflow, newest first.</p></header>
|
||||
<section class="table-wrap">
|
||||
@@ -456,7 +375,6 @@ function parseArtifact(input: Record<string, unknown>): ArtifactInput | Response
|
||||
function parseKey(input: Record<string, unknown>): Omit<ArtifactInput, "metadata"> | Response {
|
||||
if (
|
||||
!validIdentifier(input.channel) ||
|
||||
input.channel === "next" ||
|
||||
!validIdentifier(input.name) ||
|
||||
!validIdentifier(input.distribution) ||
|
||||
!validVersion(input.version)
|
||||
@@ -555,7 +473,7 @@ function updateResponse(value: unknown) {
|
||||
}
|
||||
|
||||
function json(value: unknown, status = 200, headers?: HeadersInit) {
|
||||
return Response.json(value, { status, headers: { "Cache-Control": "no-store", ...headers } })
|
||||
return Response.json(value, { status, headers })
|
||||
}
|
||||
|
||||
function escape(value: string) {
|
||||
|
||||
+182
-8
@@ -1621,14 +1621,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3249,6 +3242,152 @@
|
||||
"summary": "Get session context"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/diff": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.diff",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "messageID",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "to",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "context",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Unchanged lines around each hunk. Omit for full-file patches."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileDiff.Info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "MessageNotFoundError | SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "UnknownError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnknownErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
|
||||
"summary": "Diff session turns"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/inbox": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -18486,6 +18625,38 @@
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Idle": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["created"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["idle"]
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": ["succeeded", "failed", "interrupted"]
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "outcome"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Info": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -18517,6 +18688,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Compaction"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Idle"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1621,14 +1621,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3249,6 +3242,152 @@
|
||||
"summary": "Get session context"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/diff": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.diff",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "messageID",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "to",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "context",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Unchanged lines around each hunk. Omit for full-file patches."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileDiff.Info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "MessageNotFoundError | SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "UnknownError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnknownErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
|
||||
"summary": "Diff session turns"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/inbox": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -18486,6 +18625,38 @@
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Idle": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["created"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["idle"]
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": ["succeeded", "failed", "interrupted"]
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "outcome"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Info": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -18517,6 +18688,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Compaction"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Idle"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1025,12 +1025,8 @@ force confirmation without depending on Core or Git errors.
|
||||
|
||||
#### Reference
|
||||
|
||||
Implementations receive a suggested destination after naming and collision handling. Return the actual directory from
|
||||
`create`; it may differ when a backend requires its own layout. OpenCode resolves the returned path and uses it for
|
||||
inventory, startup commands, and the API result. The returned directory must exist.
|
||||
|
||||
Strategies choosing another destination handle naming collisions there. OpenCode still creates the suggested parent
|
||||
directory before calling the strategy. `list` must report only directories the strategy owns, plus any repository roots.
|
||||
Implementations receive the final destination after naming and collision handling. Return that directory from `create`;
|
||||
`list` must report only directories the strategy owns, plus any repository roots. Core owns inventory and startup commands.
|
||||
|
||||
```ts
|
||||
interface WorktreeDefinition {
|
||||
|
||||
@@ -473,20 +473,7 @@ Set the parent directory for new local worktrees. OpenCode appends the requested
|
||||
}
|
||||
```
|
||||
|
||||
Relative paths resolve against the project's primary checkout, including when called from a subdirectory or linked
|
||||
worktree. This applies to global and project configuration alike; absolute paths are used as-is, and `~/` resolves
|
||||
against the user's home directory.
|
||||
|
||||
For example, this global configuration places new worktrees under each project's own `.lane/trees/` directory:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"worktree": {
|
||||
"directory": ".lane/trees",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Relative paths resolve against the config file that declares them; `~/` resolves against the user's home directory.
|
||||
Without this setting, creation uses the server's data directory under `worktree/<first-six-project-ID-characters>`.
|
||||
Configuration applies to the caller's location, not every clone sharing a project ID. Changing it does not move existing worktrees.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user