Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1faad5d62 | |||
| 8deb0e5780 | |||
| f488089179 | |||
| e3a39b214f | |||
| b6a2912bb1 |
@@ -890,8 +890,14 @@ export interface VcsApi<E = never> {
|
||||
export type Endpoint25_0Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location"]>>
|
||||
export type DebugLocationOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
|
||||
|
||||
type Endpoint25_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0]
|
||||
export type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] }
|
||||
export type Endpoint25_1Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location.evict"]>>
|
||||
export type DebugEvictLocationOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
||||
|
||||
export interface DebugApi<E = never> {
|
||||
readonly location: DebugLocationOperation<E>
|
||||
readonly evictLocation: DebugEvictLocationOperation<E>
|
||||
}
|
||||
|
||||
export interface AppApi<E = never> {
|
||||
|
||||
@@ -1069,7 +1069,15 @@ const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint24_0(r
|
||||
const Endpoint25_0 = (raw: RawClient["server.debug"]) => () =>
|
||||
raw["debug.location"]({}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ location: Endpoint25_0(raw) })
|
||||
type Endpoint25_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0]
|
||||
type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] }
|
||||
const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1Input) =>
|
||||
raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({
|
||||
location: Endpoint25_0(raw),
|
||||
evictLocation: Endpoint25_1(raw),
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
health: adaptGroup0(raw["server.health"]),
|
||||
|
||||
@@ -178,6 +178,8 @@ import type {
|
||||
VcsDiffInput,
|
||||
VcsDiffOutput,
|
||||
DebugLocationOutput,
|
||||
DebugEvictLocationInput,
|
||||
DebugEvictLocationOutput,
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
@@ -1494,6 +1496,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
evictLocation: (input?: DebugEvictLocationInput, requestOptions?: RequestOptions) =>
|
||||
request<DebugEvictLocationOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/debug/location`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6130,3 +6130,11 @@ export type VcsDiffOutput = {
|
||||
}
|
||||
|
||||
export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }>
|
||||
|
||||
export type DebugEvictLocationInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type DebugEvictLocationOutput = void
|
||||
|
||||
@@ -153,8 +153,7 @@ current omissions to implement, not intentional product boundaries.
|
||||
shims. Consider `Promise.any` in the same pass.
|
||||
- [ ] Support async iteration and `for await...of`. Define behavior first for the runtime's supported promise and
|
||||
collection values, then extend it to bounded host streams when a stream boundary exists.
|
||||
- [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to
|
||||
`Array.from(...)` and replacers for `JSON.stringify(...)`, including Effect-aware callbacks where needed.
|
||||
- [ ] Support the mapper argument to `Array.from(...)`, including Effect-aware callbacks.
|
||||
- [ ] Add `Object.is` after runtime method and tool references have stable identity semantics.
|
||||
- [ ] Add deterministic modern collection conveniences where they improve orchestration: `Object.groupBy`, Set
|
||||
composition methods, and `Array.prototype.toSpliced`.
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
copyIn,
|
||||
copyOut,
|
||||
isBlockedMember,
|
||||
MAX_VALUE_DEPTH,
|
||||
ToolReference,
|
||||
ToolRuntime,
|
||||
ToolRuntimeError,
|
||||
@@ -2027,6 +2028,7 @@ class Interpreter<R> {
|
||||
}
|
||||
if (callable instanceof GlobalMethodReference) {
|
||||
if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node)
|
||||
if (callable.namespace === "JSON") return yield* self.invokeJsonMethod(callable.name, args, node)
|
||||
if (callable.namespace === "Object" && args[0] instanceof ToolReference) {
|
||||
return self.invokeObjectMethodOnTools(callable.name, args[0], node)
|
||||
}
|
||||
@@ -2492,6 +2494,129 @@ class Interpreter<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private invokeJsonMethod(
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const callback = args[1]
|
||||
const callable =
|
||||
callback instanceof CodeModeFunction ||
|
||||
callback instanceof CoercionFunction ||
|
||||
callback instanceof GlobalMethodReference ||
|
||||
callback instanceof UriFunction
|
||||
if (name === "parse" && callable) return this.parseJsonWithReviver(args, node)
|
||||
if (name === "stringify" && (callable || Array.isArray(callback))) {
|
||||
return this.stringifyJsonWithReplacer(args, node)
|
||||
}
|
||||
return Effect.sync(() => invokeJsonMethod(name, args, node))
|
||||
}
|
||||
|
||||
private parseJsonWithReviver(args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const apply = this.applySettledCollectionCallback(args[1], "JSON.parse", node)
|
||||
const visit = (key: string, item: unknown): Effect.Effect<unknown, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
if (Array.isArray(item)) {
|
||||
for (let index = 0; index < item.length; index += 1) {
|
||||
if (!(index in item)) continue
|
||||
const revived = yield* visit(String(index), item[index])
|
||||
if (revived === undefined) {
|
||||
delete item[index]
|
||||
continue
|
||||
}
|
||||
item[index] = revived
|
||||
}
|
||||
return yield* apply([key, item])
|
||||
}
|
||||
if (item !== null && typeof item === "object" && !isSandboxValue(item)) {
|
||||
const object = item as SafeObject
|
||||
for (const childKey of Object.keys(object)) {
|
||||
const revived = yield* visit(childKey, object[childKey])
|
||||
if (revived === undefined) {
|
||||
delete object[childKey]
|
||||
continue
|
||||
}
|
||||
object[childKey] = revived
|
||||
}
|
||||
}
|
||||
return yield* apply([key, item])
|
||||
})
|
||||
return visit("", invokeJsonMethod("parse", [args[0]], node))
|
||||
}
|
||||
|
||||
private stringifyJsonWithReplacer(args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const callback = args[1]
|
||||
const apply = Array.isArray(callback)
|
||||
? undefined
|
||||
: this.applySettledCollectionCallback(callback, "JSON.stringify", node)
|
||||
const ancestors = new Set<object>()
|
||||
const propertyList = Array.isArray(callback)
|
||||
? Array.from(
|
||||
new Set(
|
||||
callback
|
||||
.filter((item): item is string | number => typeof item === "string" || typeof item === "number")
|
||||
.map(String),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
const visit = (key: string, item: unknown, depth: number): Effect.Effect<unknown, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* (() => {
|
||||
if (apply === undefined) return Effect.succeed(item)
|
||||
return apply([
|
||||
key,
|
||||
item instanceof SandboxDate || item instanceof SandboxURL
|
||||
? copyIn(item, "JSON.stringify value")
|
||||
: item,
|
||||
])
|
||||
})()
|
||||
if (typeofValue(resolved) === "function") return undefined
|
||||
if (resolved === null || typeof resolved !== "object") return resolved
|
||||
if (isSandboxValue(resolved)) {
|
||||
return apply === undefined
|
||||
? copyIn(resolved, "JSON.stringify replacer result")
|
||||
: (Object.create(null) as SafeObject)
|
||||
}
|
||||
if (!Array.isArray(resolved)) {
|
||||
const prototype = Object.getPrototypeOf(resolved)
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
return copyIn(resolved, "JSON.stringify replacer result")
|
||||
}
|
||||
}
|
||||
if (depth > MAX_VALUE_DEPTH) {
|
||||
throw new ToolRuntimeError(
|
||||
"InvalidDataValue",
|
||||
`JSON.stringify replacer result exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`,
|
||||
)
|
||||
}
|
||||
if (ancestors.has(resolved)) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", "JSON.stringify replacer result contains a circular value.")
|
||||
}
|
||||
ancestors.add(resolved)
|
||||
return yield* Effect.gen(function* () {
|
||||
if (Array.isArray(resolved)) {
|
||||
const output: Array<unknown> = []
|
||||
const length = resolved.length
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
output[index] = yield* visit(String(index), resolved[index], depth + 1)
|
||||
}
|
||||
return output
|
||||
}
|
||||
const output: SafeObject = Object.create(null) as SafeObject
|
||||
for (const childKey of propertyList ?? Object.keys(resolved)) {
|
||||
if (isBlockedMember(childKey)) continue
|
||||
if (apply === undefined && !Object.hasOwn(resolved, childKey)) continue
|
||||
const child = yield* visit(childKey, (resolved as SafeObject)[childKey], depth + 1)
|
||||
if (child !== undefined) output[childKey] = child
|
||||
}
|
||||
return output
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => ancestors.delete(resolved))))
|
||||
})
|
||||
return Effect.map(visit("", args[0], 0), (value) =>
|
||||
invokeJsonMethod("stringify", [value, propertyList, args[2]], node),
|
||||
)
|
||||
}
|
||||
|
||||
// Runs a collection callback accepting a user function or supported builtin callable,
|
||||
// mirroring the array-method callback contract.
|
||||
private applyCollectionCallback(
|
||||
@@ -2502,6 +2627,7 @@ class Interpreter<R> {
|
||||
if (
|
||||
!(callback instanceof CodeModeFunction) &&
|
||||
!(callback instanceof CoercionFunction) &&
|
||||
!(callback instanceof GlobalMethodReference) &&
|
||||
!(callback instanceof UriFunction)
|
||||
) {
|
||||
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
|
||||
@@ -2509,9 +2635,23 @@ class Interpreter<R> {
|
||||
return (callbackArgs) =>
|
||||
callback instanceof CoercionFunction
|
||||
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
|
||||
: callback instanceof UriFunction
|
||||
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
|
||||
: this.invokeFunction(callback, callbackArgs)
|
||||
: callback instanceof GlobalMethodReference
|
||||
? Effect.succeed(invokeGlobalMethod(callback, callbackArgs, node))
|
||||
: callback instanceof UriFunction
|
||||
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
|
||||
: this.invokeFunction(callback, callbackArgs)
|
||||
}
|
||||
|
||||
private applySettledCollectionCallback(
|
||||
callback: unknown,
|
||||
name: string,
|
||||
node: AstNode,
|
||||
): (args: Array<unknown>) => Effect.Effect<unknown, unknown, R> {
|
||||
const apply = this.applyCollectionCallback(callback, name, node)
|
||||
return (args) =>
|
||||
Effect.flatMap(apply(args), (value) =>
|
||||
value instanceof SandboxPromise ? this.settlePromise(value, node) : Effect.succeed(value),
|
||||
)
|
||||
}
|
||||
|
||||
private invokeMapMethod(
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
type AstNode,
|
||||
CodeModeFunction,
|
||||
InterpreterRuntimeError,
|
||||
supportedSyntaxMessage,
|
||||
} from "../interpreter/model.js"
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { copyIn, copyOut } from "../tool-runtime.js"
|
||||
|
||||
export const jsonStatics = new Set(["stringify", "parse"])
|
||||
@@ -12,18 +7,13 @@ export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNo
|
||||
if (!jsonStatics.has(name)) throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node)
|
||||
switch (name) {
|
||||
case "stringify": {
|
||||
const replacer = args[1]
|
||||
if (Array.isArray(replacer) || replacer instanceof CodeModeFunction) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"JSON.stringify replacers are not supported in CodeMode.",
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
const space = args[2]
|
||||
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
|
||||
return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value")), null, indent)
|
||||
return JSON.stringify(
|
||||
copyOut(copyIn(args[0], "JSON.stringify value")),
|
||||
Array.isArray(args[1]) ? args[1] : null,
|
||||
indent,
|
||||
)
|
||||
}
|
||||
case "parse": {
|
||||
const text = args[0]
|
||||
|
||||
@@ -119,7 +119,7 @@ export class ToolReference {
|
||||
* limit) purely because it produces a clearer diagnostic than a native stack-overflow
|
||||
* RangeError would.
|
||||
*/
|
||||
const MAX_VALUE_DEPTH = 32
|
||||
export const MAX_VALUE_DEPTH = 32
|
||||
|
||||
export class ToolRuntimeError extends Error {
|
||||
constructor(
|
||||
|
||||
@@ -19,6 +19,161 @@ const error = async (code: string) => {
|
||||
return result.error
|
||||
}
|
||||
|
||||
describe("JSON", () => {
|
||||
test("parse revivers run post-order including the root", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const order = []
|
||||
const parsed = JSON.parse('{"item":{"count":2}}', (key, item) => {
|
||||
order.push(key)
|
||||
if (key === "count") return item + 1
|
||||
if (key === "") return { parsed: item }
|
||||
return item
|
||||
})
|
||||
return { parsed, order }
|
||||
`),
|
||||
).toEqual({ parsed: { parsed: { item: { count: 3 } } }, order: ["count", "item", ""] })
|
||||
expect(await value(`return JSON.parse("1", (key, value) => undefined) === undefined`)).toBe(true)
|
||||
})
|
||||
|
||||
test("parse reviver deletion removes properties and creates array holes", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const parsed = JSON.parse('{"keep":1,"drop":2,"items":[1,2,3]}', (key, item) =>
|
||||
key === "drop" || key === "1" ? undefined : item
|
||||
)
|
||||
return { json: JSON.stringify(parsed), hasSecond: 1 in parsed.items, length: parsed.items.length }
|
||||
`),
|
||||
).toEqual({ json: '{"keep":1,"items":[1,null,3]}', hasSecond: false, length: 3 })
|
||||
})
|
||||
|
||||
test("stringify function replacers run root-first and apply JSON deletion rules", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const order = []
|
||||
const json = JSON.stringify({ keep: 1, drop: 2, items: [1, 2] }, (key, item) => {
|
||||
order.push(key)
|
||||
if (key === "drop" || key === "1") return undefined
|
||||
return item
|
||||
})
|
||||
return { json, order }
|
||||
`),
|
||||
).toEqual({ json: '{"keep":1,"items":[1,null]}', order: ["", "keep", "drop", "items", "0", "1"] })
|
||||
expect(
|
||||
await value(`
|
||||
return [
|
||||
JSON.stringify(1, (key, item) => key === "" ? new Date(0) : item),
|
||||
JSON.stringify(1, (key, item) => key === "" ? new URL("https://example.test") : item),
|
||||
]
|
||||
`),
|
||||
).toEqual(["{}", "{}"])
|
||||
})
|
||||
|
||||
test("stringify function replacers observe live mutations and sandbox values", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const item = { a: 1, b: 2 }
|
||||
const visited = []
|
||||
const mutated = JSON.stringify(item, (key, value) => {
|
||||
visited.push(key)
|
||||
if (key === "a") item.b = 3
|
||||
return value
|
||||
})
|
||||
const mapped = JSON.stringify(new Map([["x", 1]]), (key, value) =>
|
||||
value instanceof Map ? Object.fromEntries(value) : value
|
||||
)
|
||||
const removed = { a: 1, b: 2 }
|
||||
const deleted = JSON.stringify(removed, (key, value) => {
|
||||
if (key === "a") removed.b = undefined
|
||||
if (key === "b") visited.push(value === undefined ? "missing" : "present")
|
||||
return value
|
||||
})
|
||||
return { mutated, mapped, deleted, visited }
|
||||
`),
|
||||
).toEqual({
|
||||
mutated: '{"a":1,"b":3}',
|
||||
mapped: '{"x":1}',
|
||||
deleted: '{"a":1}',
|
||||
visited: ["", "a", "b", "missing"],
|
||||
})
|
||||
})
|
||||
|
||||
test("stringify replacer arrays coerce, dedupe, and recursively filter object keys", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return JSON.stringify(
|
||||
{ 1: "one", keep: { keep: 2, drop: 3 }, drop: 4, list: [{ keep: 5, drop: 6 }] },
|
||||
["keep", 1, "keep", null, {}, "list"],
|
||||
)
|
||||
`),
|
||||
).toBe('{"keep":{"keep":2},"1":"one","list":[{"keep":5}]}')
|
||||
})
|
||||
|
||||
test("JSON callbacks settle async tool calls sequentially", async () => {
|
||||
const calls: Array<number> = []
|
||||
const transform = Tool.make({
|
||||
description: "Transform a number",
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
run: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(input)
|
||||
return input * 10
|
||||
}),
|
||||
})
|
||||
const parse = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
tools: { host: { transform } },
|
||||
code: `return JSON.parse("[1,2]", async (key, item) => key === "" ? item : await tools.host.transform(item))`,
|
||||
}),
|
||||
)
|
||||
expect(parse.ok && parse.value).toEqual([10, 20])
|
||||
const stringify = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
tools: { host: { transform } },
|
||||
code: `return JSON.stringify([1,2], async (key, item) => key === "" ? item : await tools.host.transform(item))`,
|
||||
}),
|
||||
)
|
||||
expect(stringify.ok && stringify.value).toBe("[10,20]")
|
||||
expect(calls).toEqual([1, 2, 1, 2])
|
||||
})
|
||||
|
||||
test("non-callable JSON callback arguments are ignored", async () => {
|
||||
expect(await value(`return [JSON.parse("{\\"a\\":1}", 42), JSON.stringify({ a: 1 }, { nope: true })]`)).toEqual([
|
||||
{ a: 1 },
|
||||
'{"a":1}',
|
||||
])
|
||||
expect(await value(`return [JSON.parse("1", Boolean), JSON.stringify({ a: 1 }, String)]`)).toEqual([false, '""'])
|
||||
})
|
||||
|
||||
test("supported global methods work as JSON callbacks", async () => {
|
||||
expect(
|
||||
await value(`return [JSON.parse("[1,2]", Number.isFinite), JSON.stringify({ a: 1 }, Number.isFinite)]`),
|
||||
).toEqual([false, "false"])
|
||||
})
|
||||
|
||||
test("stringify replacers can prune values before the depth limit", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let item = 1
|
||||
for (let index = 0; index < 33; index++) item = { x: item }
|
||||
let calls = 0
|
||||
return JSON.stringify(item, (key, value) => ++calls === 34 ? undefined : value)
|
||||
`),
|
||||
).toBe(`${'{"x":'.repeat(32)}{}${"}".repeat(32)}`)
|
||||
})
|
||||
|
||||
test("stringify omits callable replacer results", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return JSON.stringify({ object: 1, array: [1] }, (key, value) =>
|
||||
key === "object" || key === "0" ? Number.isFinite : value
|
||||
)
|
||||
`),
|
||||
).toBe('{"array":[null]}')
|
||||
})
|
||||
})
|
||||
|
||||
describe("Date", () => {
|
||||
test("Date.now() returns a number", async () => {
|
||||
expect(await value(`return typeof Date.now()`)).toBe("number")
|
||||
|
||||
@@ -7,14 +7,6 @@ import { EventV2 } from "../event"
|
||||
|
||||
export const Updated = EventV2.ephemeral({ type: "sdk.plugin.updated", schema: {} })
|
||||
|
||||
export interface Store {
|
||||
readonly plugins: Map<string, Plugin>
|
||||
}
|
||||
|
||||
export const makeStore = (): Store => ({ plugins: new Map() })
|
||||
|
||||
const defaultStore = makeStore()
|
||||
|
||||
/**
|
||||
* Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes,
|
||||
* so `PluginSupervisor` can add them on every Location boot through the ordinary
|
||||
@@ -22,10 +14,9 @@ const defaultStore = makeStore()
|
||||
* config. Registration publishes an unlocated update so every booted Location
|
||||
* reloads its plugin generation from the shared store.
|
||||
*
|
||||
* The store is shared explicitly between the SDK construction graph and the
|
||||
* embedded route graph because `LocationServiceMap` builds Location layers lazily
|
||||
* in a nested graph. Each embedded SDK creates its own store, so instances do not
|
||||
* see each other's contributions.
|
||||
* Each host-global layer owns one private store. Location graphs reuse that
|
||||
* layer through Effect's memoization, so separate hosts remain isolated while
|
||||
* every Location in one host sees the same registrations.
|
||||
*/
|
||||
export interface Interface {
|
||||
readonly register: (plugin: Plugin) => Effect.Effect<void>
|
||||
@@ -34,26 +25,19 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SdkPlugins") {}
|
||||
|
||||
export const layerWithStore = (store: Store) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
yield* Effect.addFinalizer(() =>
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const plugins = new Map<string, Plugin>()
|
||||
return Service.of({
|
||||
register: (plugin) =>
|
||||
Effect.sync(() => {
|
||||
store.plugins.clear()
|
||||
}),
|
||||
)
|
||||
return Service.of({
|
||||
register: (plugin) =>
|
||||
Effect.sync(() => {
|
||||
store.plugins.set(plugin.id, plugin)
|
||||
}).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid),
|
||||
all: () => [...store.plugins.values()],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = layerWithStore(defaultStore)
|
||||
plugins.set(plugin.id, plugin)
|
||||
}).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid),
|
||||
all: () => [...plugins.values()],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [EventV2.node] })
|
||||
|
||||
@@ -25,20 +25,27 @@ const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <te
|
||||
- [constraints/preferences, decisions and why, important facts/assumptions, exact context needed to continue, or "(none)"]
|
||||
|
||||
## Work State
|
||||
- Completed: [finished work, verified facts, or changes made; otherwise "(none)"]
|
||||
- Active: [current work, partial changes, or investigation state; otherwise "(none)"]
|
||||
- Blocked: [blockers, failing commands, or unknowns; otherwise "(none)"]
|
||||
### Completed
|
||||
- [finished work, verified facts, or changes made; otherwise "(none)"]
|
||||
|
||||
### Active
|
||||
- [current work, partial changes, or investigation state; otherwise "(none)"]
|
||||
|
||||
### Blocked
|
||||
- [blockers, failing commands, or unknowns; otherwise "(none)"]
|
||||
|
||||
## Next Move
|
||||
1. [immediate concrete action, or "(none)"]
|
||||
2. [next action if known, or "(none)"]
|
||||
|
||||
## Relevant Files
|
||||
- [file or directory path: why it matters, or "(none)"]
|
||||
</template>
|
||||
|
||||
Rules:
|
||||
- Keep every section, even when empty.
|
||||
- Use terse bullets, not prose paragraphs.
|
||||
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
|
||||
- Put relevant files and symbols inside the section where they matter; do not add extra sections.
|
||||
- Do not mention the summary process or that context was compacted.`
|
||||
|
||||
type Settings = {
|
||||
|
||||
@@ -120,12 +120,12 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
|
||||
const content = message.content.flatMap((item): ContentPart[] => {
|
||||
if (item.type === "text") return [{ type: "text", text: item.text }]
|
||||
if (item.type === "reasoning")
|
||||
return sameModel
|
||||
return reuseProviderMetadata
|
||||
? [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: item.text,
|
||||
providerMetadata: reuseProviderMetadata ? providerMetadata(model.providerID, item.state) : undefined,
|
||||
providerMetadata: providerMetadata(model.providerID, item.state),
|
||||
},
|
||||
]
|
||||
: item.text.length > 0
|
||||
|
||||
@@ -12,6 +12,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
@@ -28,8 +29,43 @@ import { Reference } from "../src/reference"
|
||||
import { ToolRegistry } from "../src/tool/registry"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])))
|
||||
const itWithSdk = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node])),
|
||||
)
|
||||
|
||||
describe("LocationServiceMap", () => {
|
||||
itWithSdk.live("preserves embedded SDK plugins after Location eviction", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const id = AgentV2.ID.make("persistent-sdk-agent")
|
||||
const plugin = define({
|
||||
id: "persistent-sdk-plugin",
|
||||
effect: (ctx) => ctx.agent.transform((agents) => agents.update(id, () => {})),
|
||||
})
|
||||
yield* sdk.register(plugin)
|
||||
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
const read = Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.ready
|
||||
const agents = yield* AgentV2.Service
|
||||
return yield* agents.get(id)
|
||||
})
|
||||
|
||||
expect(yield* read.pipe(Effect.scoped, Effect.provide(locations.get(ref)))).toBeDefined()
|
||||
yield* locations.invalidate(ref)
|
||||
expect(yield* read.pipe(Effect.scoped, Effect.provide(locations.get(ref)))).toBeDefined()
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("applies ordered plugin config operations during boot", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -52,6 +52,15 @@ const it = testEffect(
|
||||
),
|
||||
)
|
||||
|
||||
test("compaction prompt preserves detailed work state and relevant files", () => {
|
||||
const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] })
|
||||
|
||||
expect(prompt).toContain("## Work State\n### Completed")
|
||||
expect(prompt).toContain("### Active")
|
||||
expect(prompt).toContain("### Blocked")
|
||||
expect(prompt).toContain("## Relevant Files")
|
||||
})
|
||||
|
||||
test("compaction describes tool media without embedding base64", () => {
|
||||
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
|
||||
const serialized = SessionCompaction.serializeToolContent([
|
||||
@@ -74,13 +83,14 @@ test("compaction prompt requires the checkpoint headings in order", () => {
|
||||
"## Objective",
|
||||
"## Important Details",
|
||||
"## Work State",
|
||||
"### Completed",
|
||||
"### Active",
|
||||
"### Blocked",
|
||||
"## Next Move",
|
||||
"## Relevant Files",
|
||||
])
|
||||
expect(prompt).toContain("one or two brief sentences")
|
||||
expect(prompt).toContain("constraints/preferences, decisions and why")
|
||||
expect(prompt).toContain("Completed:")
|
||||
expect(prompt).toContain("Active:")
|
||||
expect(prompt).toContain("Blocked:")
|
||||
expect(prompt).toContain("immediate concrete action")
|
||||
expect(prompt).toContain("next action if known")
|
||||
expect(prompt).toContain("Keep every section, even when empty.")
|
||||
|
||||
@@ -461,7 +461,7 @@ Recent work
|
||||
])
|
||||
})
|
||||
|
||||
test("drops provider-native continuation metadata from failed assistant turns", () => {
|
||||
test("lowers failed assistant reasoning to text", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.Assistant.make({
|
||||
@@ -501,7 +501,7 @@ Recent work
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "reasoning", text: "Partial thought", providerMetadata: undefined },
|
||||
{ type: "text", text: "Partial thought" },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted-failed",
|
||||
|
||||
@@ -6,6 +6,7 @@ const patterns = [
|
||||
/input is too long for requested model/i,
|
||||
/exceeds the context window/i,
|
||||
/input token count.*exceeds the maximum/i,
|
||||
/tokens in request more than max tokens allowed/i,
|
||||
/maximum prompt length is \d+/i,
|
||||
/reduce the length of the messages/i,
|
||||
/maximum context length is \d+ tokens/i,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { isContextOverflow } from "../src"
|
||||
|
||||
describe("provider error classification", () => {
|
||||
test("classifies Z.AI GLM token limit messages as context overflow", () => {
|
||||
expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1448,6 +1448,7 @@ describe("session.message-v2.fromError", () => {
|
||||
"prompt is too long: 213462 tokens > 200000 maximum",
|
||||
"Your input exceeds the context window of this model",
|
||||
"The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)",
|
||||
"tokens in request more than max tokens allowed",
|
||||
"Please reduce the length of the messages or completion",
|
||||
"400 status code (no body)",
|
||||
"413 status code (no body)",
|
||||
|
||||
@@ -61,6 +61,7 @@ export const groupNames = {
|
||||
} as const
|
||||
|
||||
export const endpointNames = {
|
||||
"debug.location.evict": "evictLocation",
|
||||
"session.messages": "list",
|
||||
"integration.connect.key": "connectKey",
|
||||
"integration.connect.oauth": "connectOauth",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
export const DebugGroup = HttpApiGroup.make("server.debug")
|
||||
.add(
|
||||
@@ -14,4 +15,18 @@ export const DebugGroup = HttpApiGroup.make("server.debug")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("debug.location.evict", "/api/debug/location", {
|
||||
query: LocationQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.debug.location.evict",
|
||||
summary: "Evict a loaded location",
|
||||
description: "Dispose the requested location's cached services so its next use boots them fresh.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "debug" }))
|
||||
|
||||
@@ -1,47 +1,23 @@
|
||||
import { OpenCode } from "@opencode-ai/client/effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { Context, Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
|
||||
export const create = Effect.fn("OpenCode.create")(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
const memoMap = yield* Layer.makeMemoMap
|
||||
const sdkPlugins = SdkPlugins.makeStore()
|
||||
const context = yield* Layer.buildWithMemoMap(
|
||||
AppNodeBuilder.build(LayerNode.group([EventV2.node, PermissionSaved.node, Project.node, SdkPlugins.node]), [
|
||||
[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)],
|
||||
]),
|
||||
memoMap,
|
||||
scope,
|
||||
const runtime = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => ManagedRuntime.make(createEmbeddedRoutes().pipe(Layer.provide(HttpServer.layerServices)))),
|
||||
(runtime) => runtime.disposeEffect,
|
||||
)
|
||||
const context = yield* runtime.contextEffect
|
||||
const plugins = Context.get(context, SdkPlugins.Service)
|
||||
const permissions = Context.get(context, PermissionSaved.Service)
|
||||
const project = Context.get(context, Project.Service)
|
||||
const web = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
HttpRouter.toWebHandler(
|
||||
createEmbeddedRoutes(sdkPlugins).pipe(
|
||||
HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)),
|
||||
HttpRouter.provideRequest(Layer.succeed(Project.Service, project)),
|
||||
Layer.provide(HttpServer.layerServices),
|
||||
),
|
||||
{ disableLogger: true, memoMap },
|
||||
),
|
||||
),
|
||||
(web) => Effect.promise(web.dispose),
|
||||
)
|
||||
const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => web.handler(new Request(input, init)), {
|
||||
const router = Context.get(context, HttpRouter.HttpRouter)
|
||||
const handler = HttpEffect.toWebHandler(router.asHttpEffect())
|
||||
const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => handler(new Request(input, init)), {
|
||||
preconnect: () => undefined,
|
||||
}) satisfies typeof globalThis.fetch
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe(
|
||||
Effect.provide(FetchHttpClient.layer),
|
||||
Effect.provideService(FetchHttpClient.Fetch, fetch),
|
||||
Effect.provide(FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch)), Layer.fresh)),
|
||||
)
|
||||
return {
|
||||
...client,
|
||||
|
||||
@@ -105,6 +105,50 @@ it.live(
|
||||
25_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"preserves SDK plugins across Location eviction",
|
||||
() =>
|
||||
withEmbedded("opencode-embedded-plugin-eviction-", (fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = yield* fixture.sdk.OpenCode.create()
|
||||
const ref = location(fixture)
|
||||
const connected = yield* Latch.make(false)
|
||||
const booted = yield* Deferred.make<void>()
|
||||
// The rebooted Location commits its second plugin generation.
|
||||
const recommitted = yield* Deferred.make<void>()
|
||||
const generations = yield* Ref.make(0)
|
||||
const id = `evicted-sdk-${crypto.randomUUID()}`
|
||||
|
||||
yield* opencode.events.subscribe().pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (event.type === "server.connected") return connected.open
|
||||
if (event.type !== "plugin.updated" || event.location?.directory !== fixture.directory) return Effect.void
|
||||
return Ref.updateAndGet(generations, (total) => total + 1).pipe(
|
||||
Effect.flatMap((total) => {
|
||||
if (total === 1) return Deferred.succeed(booted, undefined)
|
||||
if (total === 2) return Deferred.succeed(recommitted, undefined)
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.asVoid,
|
||||
)
|
||||
}),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* connected.await
|
||||
yield* opencode.plugin({ id, effect: () => Effect.void })
|
||||
|
||||
yield* opencode.plugin.list({ location: ref })
|
||||
yield* Deferred.await(booted).pipe(Effect.timeout("5 seconds"))
|
||||
yield* opencode.debug.evictLocation({ location: ref })
|
||||
yield* opencode.plugin.list({ location: ref })
|
||||
yield* Deferred.await(recommitted).pipe(Effect.timeout("5 seconds"))
|
||||
|
||||
expect((yield* opencode.plugin.list({ location: ref })).data.map((plugin) => String(plugin.id))).toContain(id)
|
||||
}),
|
||||
),
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"keeps SDK plugin registration isolated between embedded hosts",
|
||||
() =>
|
||||
|
||||
@@ -2,13 +2,24 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Effect, Option, RcMap } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { requestRef } from "../location"
|
||||
|
||||
export const DebugHandler = HttpApiBuilder.group(Api, "server.debug", (handlers) =>
|
||||
handlers.handle(
|
||||
"debug.location",
|
||||
Effect.fn(function* () {
|
||||
const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service))
|
||||
return Array.from(yield* RcMap.keys(locations.rcMap))
|
||||
}),
|
||||
),
|
||||
handlers
|
||||
.handle(
|
||||
"debug.location",
|
||||
Effect.fn(function* () {
|
||||
const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service))
|
||||
return Array.from(yield* RcMap.keys(locations.rcMap))
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"debug.location.evict",
|
||||
Effect.fn(function* (ctx) {
|
||||
const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service))
|
||||
// Resolve through requestRef so the key matches the shape the location
|
||||
// middleware cached the services under.
|
||||
yield* locations.invalidate(requestRef(ctx.request))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
export * as ServerProcess from "./process"
|
||||
|
||||
import { NodeHttpClient, NodeHttpServer } from "@effect/platform-node"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { HealthGroup } from "@opencode-ai/protocol/groups/health"
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { HttpClient, HttpClientRequest, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { createServer } from "node:http"
|
||||
import { ServerAuth } from "./auth"
|
||||
@@ -53,9 +48,11 @@ function listen(options: Options) {
|
||||
function bind(hostname: string, port: number, password: string) {
|
||||
const server = createServer()
|
||||
return Layer.build(
|
||||
HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe(
|
||||
createRoutes(password).pipe(
|
||||
Layer.flatMap((context) =>
|
||||
HttpServer.serve(Context.get(context, HttpRouter.HttpRouter).asHttpEffect(), HttpMiddleware.logger),
|
||||
),
|
||||
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
|
||||
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))),
|
||||
),
|
||||
).pipe(
|
||||
Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),
|
||||
|
||||
@@ -19,7 +19,7 @@ import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
import { Api } from "./api"
|
||||
import { ServerAuth } from "./auth"
|
||||
import { handlers } from "./handlers"
|
||||
@@ -40,6 +40,7 @@ const applicationServices = LayerNode.group([
|
||||
Project.node,
|
||||
SessionV2.node,
|
||||
PluginRuntime.providerNode,
|
||||
SdkPlugins.node,
|
||||
PermissionSaved.node,
|
||||
PtyTicket.node,
|
||||
Credential.node,
|
||||
@@ -55,26 +56,22 @@ export function createRoutes(password?: string) {
|
||||
)
|
||||
}
|
||||
|
||||
export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store) {
|
||||
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), sdkPlugins)
|
||||
export function createEmbeddedRoutes() {
|
||||
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }))
|
||||
}
|
||||
|
||||
function makeRoutes<AuthError, AuthServices>(
|
||||
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
|
||||
sdkPlugins?: SdkPlugins.Store,
|
||||
) {
|
||||
function makeRoutes<AuthError, AuthServices>(auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>) {
|
||||
const pluginRuntimeCell = PluginRuntime.makeCell()
|
||||
const replacements: LayerNode.Replacements = [
|
||||
[SessionExecution.node, SessionExecutionLocal.node],
|
||||
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
|
||||
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
|
||||
...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []),
|
||||
]
|
||||
const serviceLayer = simulateEnabled()
|
||||
? Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const { simulationReplacements, startDriveServer } = yield* Effect.promise(() =>
|
||||
import("@opencode-ai/simulation/backend"),
|
||||
const { simulationReplacements, startDriveServer } = yield* Effect.promise(
|
||||
() => import("@opencode-ai/simulation/backend"),
|
||||
)
|
||||
if (driveEnabled()) startDriveServer()
|
||||
return AppNodeBuilder.build(applicationServices, [
|
||||
@@ -85,16 +82,24 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
)
|
||||
: AppNodeBuilder.build(applicationServices, replacements)
|
||||
|
||||
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
|
||||
Layer.provide(handlers.pipe(Layer.provide(serviceLayer))),
|
||||
Layer.provide(formLocationLayer),
|
||||
Layer.provide(sessionLocationLayer),
|
||||
Layer.provide(layer),
|
||||
Layer.provide(authorizationLayer),
|
||||
Layer.provide(schemaErrorLayer),
|
||||
Layer.provide(auth),
|
||||
Layer.provide(serviceLayer),
|
||||
Layer.provide(Observability.layer),
|
||||
return serviceLayer.pipe(
|
||||
Layer.flatMap((context) => {
|
||||
const services = Layer.succeedContext(context)
|
||||
const requestServices = Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service)(context))
|
||||
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
|
||||
Layer.provide(handlers.pipe(Layer.provide(services))),
|
||||
Layer.provide(formLocationLayer),
|
||||
Layer.provide(sessionLocationLayer),
|
||||
Layer.provide(layer),
|
||||
Layer.provide(authorizationLayer),
|
||||
Layer.provide(schemaErrorLayer),
|
||||
Layer.provide(auth),
|
||||
Layer.provide(Observability.layer),
|
||||
HttpRouter.provideRequest(requestServices),
|
||||
Layer.provideMerge(services),
|
||||
Layer.provideMerge(HttpRouter.layer),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,5 +113,4 @@ function driveEnabled() {
|
||||
|
||||
export const routes = createRoutes()
|
||||
|
||||
export const webHandler = () =>
|
||||
HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices)))
|
||||
export const webHandler = () => HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices)))
|
||||
|
||||
@@ -25,10 +25,10 @@ import { SimulationNetwork } from "./network"
|
||||
type ControlSocket = Bun.ServerWebSocket<{ unsubscribe?: () => void }>
|
||||
|
||||
function parseRequest(input: string | Buffer) {
|
||||
return SimulationProtocol.JsonRpc.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString()))
|
||||
return SimulationProtocol.Backend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString()))
|
||||
}
|
||||
|
||||
async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc.Request): Promise<unknown> {
|
||||
async function handle(socket: ControlSocket, request: SimulationProtocol.Backend.Request): Promise<unknown> {
|
||||
switch (request.method) {
|
||||
case "llm.attach": {
|
||||
socket.data.unsubscribe?.()
|
||||
@@ -38,23 +38,22 @@ async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc
|
||||
return { attached: true }
|
||||
}
|
||||
case "llm.chunk": {
|
||||
const params = await SimulationProtocol.Backend.decodeChunkParams(request.params)
|
||||
await Effect.runPromise(
|
||||
SimulationLLMExchange.push(
|
||||
params.id,
|
||||
params.items.map((item) => ({ type: "item", item }) as const),
|
||||
request.params.id,
|
||||
request.params.items.map((item) => ({ type: "item", item }) as const),
|
||||
),
|
||||
)
|
||||
return { ok: true }
|
||||
}
|
||||
case "llm.finish": {
|
||||
const params = await SimulationProtocol.Backend.decodeFinishParams(request.params)
|
||||
await Effect.runPromise(SimulationLLMExchange.push(params.id, [{ type: "finish", reason: params.reason }]))
|
||||
await Effect.runPromise(
|
||||
SimulationLLMExchange.push(request.params.id, [{ type: "finish", reason: request.params.reason }]),
|
||||
)
|
||||
return { ok: true }
|
||||
}
|
||||
case "llm.disconnect": {
|
||||
const params = await SimulationProtocol.Backend.decodeDisconnectParams(request.params)
|
||||
await Effect.runPromise(SimulationLLMExchange.disconnect(params.id))
|
||||
await Effect.runPromise(SimulationLLMExchange.disconnect(request.params.id))
|
||||
return { ok: true }
|
||||
}
|
||||
case "llm.pending":
|
||||
@@ -62,7 +61,6 @@ async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc
|
||||
case "network.log":
|
||||
return { entries: SimulationNetwork.log() }
|
||||
}
|
||||
throw new Error(`Unknown simulation control method: ${request.method}`)
|
||||
}
|
||||
|
||||
export function start(endpoint: string) {
|
||||
@@ -79,7 +77,7 @@ export function start(endpoint: string) {
|
||||
socket.data.unsubscribe?.()
|
||||
},
|
||||
async message(socket, message) {
|
||||
let request: SimulationProtocol.JsonRpc.Request | undefined
|
||||
let request: SimulationProtocol.Backend.Request | undefined
|
||||
try {
|
||||
request = parseRequest(message)
|
||||
const result = await handle(socket, request)
|
||||
|
||||
@@ -47,7 +47,7 @@ function hit(renderer: CliRenderer, renderable: Renderable) {
|
||||
/**
|
||||
* Builds the harness the simulation server drives.
|
||||
*
|
||||
* When the renderer is the fake simulation renderer, its TestRendererSetup
|
||||
* When the renderer is the headless simulation renderer, its TestRendererSetup
|
||||
* provides the supported testing APIs. For the visible terminal renderer the
|
||||
* harness falls back to `requestRender` + `idle` and reading the private
|
||||
* `currentRenderBuffer`.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testin
|
||||
const setups = new WeakMap<CliRenderer, TestRendererSetup>()
|
||||
|
||||
/**
|
||||
* Creates the fake simulation renderer: a real CliRenderer backed by an
|
||||
* Creates the headless simulation renderer: a real CliRenderer backed by an
|
||||
* in-memory screen buffer instead of a terminal. The TestRendererSetup is
|
||||
* kept module-side (keyed by renderer) so the harness can use the supported
|
||||
* testing APIs without app code carrying it around.
|
||||
|
||||
@@ -7,29 +7,20 @@ export interface Server {
|
||||
readonly stop: () => void
|
||||
}
|
||||
|
||||
function actionParam(params: unknown) {
|
||||
return SimulationProtocol.Frontend.decodeActionParams(params).action
|
||||
}
|
||||
|
||||
function parseRequest(input: string | Buffer) {
|
||||
return SimulationProtocol.JsonRpc.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString()))
|
||||
return SimulationProtocol.Frontend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString()))
|
||||
}
|
||||
|
||||
async function handle(harness: Harness, request: SimulationProtocol.JsonRpc.Request) {
|
||||
async function handle(harness: Harness, request: SimulationProtocol.Frontend.Request, headless: boolean) {
|
||||
switch (request.method) {
|
||||
case "ui.state": {
|
||||
if (headless) await harness.renderOnce()
|
||||
const result = SimulationActions.state(harness)
|
||||
SimulationTrace.add("ui.state", { elements: result.elements.length, actions: result.actions.length })
|
||||
return result
|
||||
}
|
||||
case "ui.action":
|
||||
return SimulationActions.execute(harness, actionParam(request.params))
|
||||
case "ui.render": {
|
||||
await harness.renderOnce()
|
||||
const result = SimulationActions.state(harness)
|
||||
SimulationTrace.add("ui.render", { elements: result.elements.length, actions: result.actions.length })
|
||||
return result
|
||||
}
|
||||
return SimulationActions.execute(harness, request.params.action)
|
||||
case "trace.list":
|
||||
return { records: SimulationTrace.list() }
|
||||
case "trace.clear":
|
||||
@@ -38,10 +29,9 @@ async function handle(harness: Harness, request: SimulationProtocol.JsonRpc.Requ
|
||||
case "trace.export":
|
||||
return SimulationTrace.exportTrace()
|
||||
}
|
||||
throw new Error(`Unknown simulation method: ${request.method}`)
|
||||
}
|
||||
|
||||
export function start(harness: Harness, endpoint: string): Server {
|
||||
export function start(harness: Harness, endpoint: string, headless: boolean): Server {
|
||||
const url = new URL(endpoint)
|
||||
const server = Bun.serve<{ readonly drive: true }>({
|
||||
hostname: url.hostname,
|
||||
@@ -58,10 +48,10 @@ export function start(harness: Harness, endpoint: string): Server {
|
||||
SimulationTrace.add("control.disconnect")
|
||||
},
|
||||
async message(socket, message) {
|
||||
let request: SimulationProtocol.JsonRpc.Request | undefined
|
||||
let request: SimulationProtocol.Frontend.Request | undefined
|
||||
try {
|
||||
request = parseRequest(message)
|
||||
const result = await handle(harness, request)
|
||||
const result = await handle(harness, request, headless)
|
||||
const next = SimulationProtocol.JsonRpc.success(request.id, result)
|
||||
if (next) socket.send(JSON.stringify(next))
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,19 +7,18 @@ import { SimulationServer } from "./server"
|
||||
/**
|
||||
* Drive-mode renderer entry point.
|
||||
*
|
||||
* Creates the renderer (fake when OPENCODE_DRIVE_RENDERER=fake, the normal
|
||||
* Creates the renderer (headless when OPENCODE_DRIVE_RENDERER=headless, the normal
|
||||
* visible renderer otherwise) and starts the UI control
|
||||
* server against it. The server stops when the renderer is destroyed, so the
|
||||
* caller only manages the renderer lifecycle.
|
||||
*/
|
||||
export async function create(options: CliRendererConfig): Promise<CliRenderer> {
|
||||
const renderer =
|
||||
process.env.OPENCODE_DRIVE_RENDERER === "fake"
|
||||
? await SimulationRenderer.create(options)
|
||||
: await createCliRenderer(options)
|
||||
const headless = process.env.OPENCODE_DRIVE_RENDERER === "headless"
|
||||
const renderer = headless ? await SimulationRenderer.create(options) : await createCliRenderer(options)
|
||||
const server = SimulationServer.start(
|
||||
SimulationActions.createHarness(renderer),
|
||||
DriveManifest.resolve().endpoints.ui,
|
||||
headless,
|
||||
)
|
||||
process.stderr.write(`opencode drive ui websocket: ${server.url}\n`)
|
||||
renderer.once("destroy", () => server.stop())
|
||||
|
||||
@@ -4,9 +4,12 @@ const JsonRpcID = Schema.Union([Schema.String, Schema.Number, Schema.Null])
|
||||
type Json = Schema.Schema.Type<typeof Schema.Json>
|
||||
|
||||
export namespace JsonRpc {
|
||||
export const Request = Schema.Struct({
|
||||
export const RequestFields = {
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
id: Schema.optional(JsonRpcID),
|
||||
}
|
||||
export const Request = Schema.Struct({
|
||||
...RequestFields,
|
||||
method: Schema.String,
|
||||
params: Schema.optional(Schema.Json),
|
||||
})
|
||||
@@ -92,7 +95,16 @@ export namespace Frontend {
|
||||
|
||||
export const ActionParams = Schema.Struct({ action: Action })
|
||||
export interface ActionParams extends Schema.Schema.Type<typeof ActionParams> {}
|
||||
export const decodeActionParams = Schema.decodeUnknownSync(ActionParams)
|
||||
|
||||
export const Request = Schema.Union([
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.action"), params: ActionParams }),
|
||||
Schema.Struct({
|
||||
...JsonRpc.RequestFields,
|
||||
method: Schema.Literals(["ui.state", "trace.list", "trace.clear", "trace.export"]),
|
||||
}),
|
||||
])
|
||||
export type Request = Schema.Schema.Type<typeof Request>
|
||||
export const decodeRequest = Schema.decodeUnknownSync(Request)
|
||||
|
||||
export const TraceRecord = Schema.Struct({
|
||||
id: Schema.Number,
|
||||
@@ -130,6 +142,18 @@ export namespace Backend {
|
||||
export const DisconnectParams = Schema.Struct({ id: Schema.String })
|
||||
export interface DisconnectParams extends Schema.Schema.Type<typeof DisconnectParams> {}
|
||||
|
||||
export const Request = Schema.Union([
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.chunk"), params: ChunkParams }),
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.finish"), params: FinishParams }),
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.disconnect"), params: DisconnectParams }),
|
||||
Schema.Struct({
|
||||
...JsonRpc.RequestFields,
|
||||
method: Schema.Literals(["llm.attach", "llm.pending", "network.log"]),
|
||||
}),
|
||||
])
|
||||
export type Request = Schema.Schema.Type<typeof Request>
|
||||
export const decodeRequest = Schema.decodeUnknownSync(Request)
|
||||
|
||||
export const OpenedExchange = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json })
|
||||
export interface OpenedExchange extends Schema.Schema.Type<typeof OpenedExchange> {}
|
||||
|
||||
@@ -141,9 +165,6 @@ export namespace Backend {
|
||||
})
|
||||
export interface NetworkLogEntry extends Schema.Schema.Type<typeof NetworkLogEntry> {}
|
||||
|
||||
export const decodeChunkParams = Schema.decodeUnknownPromise(ChunkParams)
|
||||
export const decodeFinishParams = Schema.decodeUnknownPromise(FinishParams)
|
||||
export const decodeDisconnectParams = Schema.decodeUnknownPromise(DisconnectParams)
|
||||
}
|
||||
|
||||
export * as SimulationProtocol from "./index"
|
||||
|
||||
Reference in New Issue
Block a user