mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-26 21:25:17 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcc924386d | |||
| 689e17738c | |||
| e2f203a56e |
@@ -8,7 +8,6 @@ import { HttpTransport } from "./transport"
|
||||
import type { Transport, TransportRuntime } from "./transport"
|
||||
import { WebSocketExecutor } from "./transport"
|
||||
import type { Protocol } from "./protocol"
|
||||
import type { CallOptions } from "./request-transform"
|
||||
import { applyCachePolicy } from "../cache-policy"
|
||||
import * as ProviderShared from "../protocols/shared"
|
||||
import type { LLMError, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
|
||||
@@ -48,11 +47,7 @@ export interface Route<Body, Prepared = unknown> {
|
||||
readonly body: RouteBody<Body>
|
||||
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
|
||||
readonly model: (input: RouteMappedModelInput) => Model
|
||||
readonly prepareTransport: (
|
||||
body: Body,
|
||||
request: LLMRequest,
|
||||
options?: CallOptions,
|
||||
) => Effect.Effect<Prepared, LLMError>
|
||||
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
|
||||
readonly streamPrepared: (
|
||||
prepared: Prepared,
|
||||
request: LLMRequest,
|
||||
@@ -163,11 +158,11 @@ export interface Interface {
|
||||
}
|
||||
|
||||
export interface StreamMethod {
|
||||
(request: LLMRequest, options?: CallOptions): Stream.Stream<LLMEvent, LLMError>
|
||||
(request: LLMRequest): Stream.Stream<LLMEvent, LLMError>
|
||||
}
|
||||
|
||||
export interface GenerateMethod {
|
||||
(request: LLMRequest, options?: CallOptions): Effect.Effect<LLMResponse, LLMError>
|
||||
(request: LLMRequest): Effect.Effect<LLMResponse, LLMError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||
@@ -302,7 +297,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
})
|
||||
},
|
||||
model: (input) => makeRouteModel(route, input),
|
||||
prepareTransport: (body, request, options) =>
|
||||
prepareTransport: (body, request) =>
|
||||
routeInput.transport.prepare({
|
||||
body,
|
||||
request,
|
||||
@@ -310,7 +305,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
auth: routeInput.auth ?? Auth.none,
|
||||
encodeBody,
|
||||
headers: routeInput.headers,
|
||||
transformRequest: options?.transformRequest,
|
||||
}),
|
||||
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
|
||||
const route = `${request.model.provider}/${request.model.route.id}`
|
||||
@@ -379,14 +373,14 @@ export function make<Body, Prepared, Frame, Event, State>(
|
||||
// `compile` is the important boundary: it turns a common `LLMRequest` into a
|
||||
// validated provider body plus transport-private prepared data, but does not
|
||||
// execute transport.
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: CallOptions) {
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
|
||||
const resolved = applyCachePolicy(resolveRequestOptions(request))
|
||||
const route = resolved.model.route
|
||||
|
||||
const body = yield* route.body
|
||||
.from(resolved)
|
||||
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
|
||||
const prepared = yield* route.prepareTransport(body, resolved, options)
|
||||
const prepared = yield* route.prepareTransport(body, resolved)
|
||||
|
||||
return {
|
||||
request: resolved,
|
||||
@@ -409,17 +403,17 @@ const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMReques
|
||||
})
|
||||
})
|
||||
|
||||
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, options?: CallOptions) =>
|
||||
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const compiled = yield* compile(request, options)
|
||||
const compiled = yield* compile(request)
|
||||
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
|
||||
}),
|
||||
)
|
||||
|
||||
const generateWith = (stream: Interface["stream"]) =>
|
||||
Effect.fn("LLM.generate")(function* (request: LLMRequest, options?: CallOptions) {
|
||||
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
||||
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
|
||||
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return response
|
||||
return yield* ProviderShared.eventError(
|
||||
@@ -431,17 +425,17 @@ const generateWith = (stream: Interface["stream"]) =>
|
||||
export const prepare = <Body = unknown>(request: LLMRequest) =>
|
||||
prepareWith(request) as Effect.Effect<PreparedRequestOf<Body>, LLMError>
|
||||
|
||||
export function stream(request: LLMRequest, options?: CallOptions): Stream.Stream<LLMEvent, LLMError> {
|
||||
export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
return (yield* Service).stream(request, options)
|
||||
return (yield* Service).stream(request)
|
||||
}),
|
||||
) as Stream.Stream<LLMEvent, LLMError>
|
||||
}
|
||||
|
||||
export function generate(request: LLMRequest, options?: CallOptions): Effect.Effect<LLMResponse, LLMError> {
|
||||
export function generate(request: LLMRequest): Effect.Effect<LLMResponse, LLMError> {
|
||||
return Effect.gen(function* () {
|
||||
return yield* (yield* Service).generate(request, options)
|
||||
return yield* (yield* Service).generate(request)
|
||||
}) as Effect.Effect<LLMResponse, LLMError>
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ export type {
|
||||
Interface as LLMClientShape,
|
||||
Service as LLMClientService,
|
||||
} from "./client"
|
||||
export type { CallOptions, RequestData, RequestTransform, RequestValue } from "./request-transform"
|
||||
export * from "./executor"
|
||||
export { Auth } from "./auth"
|
||||
export { AuthOptions } from "./auth-options"
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { Effect } from "effect"
|
||||
|
||||
export type RequestValue = null | boolean | number | string | RequestValue[] | { [key: string]: RequestValue }
|
||||
|
||||
export interface RequestData {
|
||||
readonly headers: Record<string, string>
|
||||
readonly body: Record<string, RequestValue>
|
||||
}
|
||||
|
||||
export type RequestTransform = (request: RequestData) => Effect.Effect<RequestData>
|
||||
|
||||
export interface CallOptions {
|
||||
readonly transformRequest?: RequestTransform
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Auth } from "../auth"
|
||||
import { render as renderEndpoint } from "../endpoint"
|
||||
@@ -6,7 +6,6 @@ import { Framing } from "../framing"
|
||||
import type { Transport, TransportPrepareInput } from "./index"
|
||||
import * as ProviderShared from "../../protocols/shared"
|
||||
import { mergeJsonRecords, type LLMRequest } from "../../schema"
|
||||
import type { RequestValue } from "../request-transform"
|
||||
|
||||
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
|
||||
|
||||
@@ -87,61 +86,24 @@ const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (bod
|
||||
return yield* ProviderShared.invalidRequest("http.body can only overlay JSON object request bodies")
|
||||
})
|
||||
|
||||
const isRequestValue = (value: unknown): value is RequestValue => {
|
||||
if (value === null || ["boolean", "number", "string"].includes(typeof value)) return true
|
||||
if (Array.isArray(value)) return value.every(isRequestValue)
|
||||
return ProviderShared.isRecord(value) && Object.values(value).every(isRequestValue)
|
||||
}
|
||||
|
||||
export const isRequestBody = (value: unknown): value is Record<string, RequestValue> =>
|
||||
ProviderShared.isRecord(value) && Object.values(value).every(isRequestValue)
|
||||
|
||||
const decodeJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)
|
||||
|
||||
export const decodeRequestBody = (text: string) =>
|
||||
decodeJson(text).pipe(
|
||||
Effect.mapError(() => ProviderShared.invalidRequest("Request hooks require a JSON object body")),
|
||||
Effect.flatMap((body) =>
|
||||
isRequestBody(body)
|
||||
? Effect.succeed(body)
|
||||
: Effect.fail(ProviderShared.invalidRequest("Request hooks require a JSON object body")),
|
||||
),
|
||||
)
|
||||
|
||||
export const jsonRequestBaseParts = <Body>(input: JsonRequestInput<Body>) =>
|
||||
export const jsonRequestParts = <Body>(input: JsonRequestInput<Body>) =>
|
||||
Effect.gen(function* () {
|
||||
const url = applyQuery(
|
||||
renderEndpoint(input.endpoint, { request: input.request, body: input.body }).toString(),
|
||||
input.request.http?.query,
|
||||
)
|
||||
const body = yield* bodyWithOverlay(input.body, input.request, input.encodeBody)
|
||||
return {
|
||||
url,
|
||||
jsonBody: body.jsonBody,
|
||||
bodyText: body.bodyText,
|
||||
headers: {
|
||||
...input.headers?.({ request: input.request }),
|
||||
...input.request.http?.headers,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export const jsonRequestParts = <Body>(input: JsonRequestInput<Body>) =>
|
||||
Effect.gen(function* () {
|
||||
const base = yield* jsonRequestBaseParts(input)
|
||||
const transformRequest = input.transformRequest
|
||||
const transformed = transformRequest
|
||||
? yield* transformRequest({ headers: base.headers, body: yield* decodeRequestBody(base.bodyText) })
|
||||
: { headers: base.headers, body: base.jsonBody }
|
||||
const bodyText = transformRequest ? ProviderShared.encodeJson(transformed.body) : base.bodyText
|
||||
const headers = yield* Auth.toEffect(input.auth)({
|
||||
request: input.request,
|
||||
method: "POST",
|
||||
url: base.url,
|
||||
body: bodyText,
|
||||
headers: Headers.fromInput(transformed.headers),
|
||||
url,
|
||||
body: body.bodyText,
|
||||
headers: Headers.fromInput({
|
||||
...input.headers?.({ request: input.request }),
|
||||
...input.request.http?.headers,
|
||||
}),
|
||||
})
|
||||
return { url: base.url, jsonBody: transformed.body, bodyText, headers }
|
||||
return { url, jsonBody: body.jsonBody, bodyText: body.bodyText, headers }
|
||||
})
|
||||
|
||||
export interface HttpJsonInput<_Body, Frame> {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Auth } from "../auth"
|
||||
import type { Interface as RequestExecutorInterface } from "../executor"
|
||||
import type { Interface as WebSocketExecutorInterface } from "./websocket"
|
||||
import type { LLMError, LLMRequest } from "../../schema"
|
||||
import type { RequestTransform } from "../request-transform"
|
||||
|
||||
export interface TransportRuntime {
|
||||
readonly http: RequestExecutorInterface
|
||||
@@ -28,7 +27,6 @@ export interface TransportPrepareInput<Body> {
|
||||
readonly auth: Auth.Definition
|
||||
readonly encodeBody: (body: Body) => string
|
||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||
readonly transformRequest?: RequestTransform
|
||||
}
|
||||
|
||||
export * as HttpTransport from "./http"
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { encodeJson } from "../../protocols/shared"
|
||||
import { LLMError, TransportReason } from "../../schema"
|
||||
import { Auth } from "../auth"
|
||||
import * as HttpTransport from "./http"
|
||||
import type { Transport } from "./index"
|
||||
|
||||
@@ -230,24 +228,13 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
||||
with: (patch) => json({ ...input, ...patch }),
|
||||
prepare: (prepareInput) =>
|
||||
Effect.gen(function* () {
|
||||
const parts = yield* HttpTransport.jsonRequestBaseParts(prepareInput)
|
||||
const message = input.encodeMessage(yield* input.toMessage(parts.jsonBody))
|
||||
const transformRequest = prepareInput.transformRequest
|
||||
const transformed = transformRequest
|
||||
? yield* transformRequest({ headers: parts.headers, body: yield* HttpTransport.decodeRequestBody(message) })
|
||||
: undefined
|
||||
const bodyText = transformed ? encodeJson(transformed.body) : message
|
||||
const headers = yield* Auth.toEffect(prepareInput.auth)({
|
||||
request: prepareInput.request,
|
||||
method: "POST",
|
||||
url: parts.url,
|
||||
body: bodyText,
|
||||
headers: Headers.fromInput(transformed?.headers ?? parts.headers),
|
||||
const parts = yield* HttpTransport.jsonRequestParts({
|
||||
...prepareInput,
|
||||
})
|
||||
return {
|
||||
url: yield* webSocketUrl(parts.url),
|
||||
headers,
|
||||
message: bodyText,
|
||||
headers: parts.headers,
|
||||
message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)),
|
||||
}
|
||||
}),
|
||||
frames: (prepared, _request, runtime) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, mergeProviderOptions } from "../src"
|
||||
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
|
||||
@@ -136,41 +136,6 @@ describe("request option precedence", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("transforms provider-native headers and JSON", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
yield* LLMClient.stream(LLM.request({ model, prompt: "Say hello." }), {
|
||||
transformRequest: (request) =>
|
||||
Effect.sync(() => {
|
||||
const body = { ...request.body, store: true }
|
||||
delete body.stream_options
|
||||
return {
|
||||
headers: { ...request.headers, "x-plugin": "enabled" },
|
||||
body,
|
||||
}
|
||||
}),
|
||||
}).pipe(
|
||||
Stream.runDrain,
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(web.headers.get("authorization")).toBe("Bearer test")
|
||||
expect(web.headers.get("x-plugin")).toBe("enabled")
|
||||
expect(decodeJson(input.text)).toMatchObject({ store: true })
|
||||
expect(decodeJson(input.text)).not.toHaveProperty("stream_options")
|
||||
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects raw body overlays for protocol-owned roots", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenAIChat.route
|
||||
|
||||
@@ -251,34 +251,16 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
const text: string[] = []
|
||||
yield* LLMClient.stream(
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
|
||||
"gpt-4.1-mini",
|
||||
),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
{
|
||||
transformRequest: (request) =>
|
||||
Effect.sync(() => {
|
||||
expect(request.body.type).toBe("response.create")
|
||||
expect(request.body.stream).toBeUndefined()
|
||||
const body = { ...request.body, plugin: true }
|
||||
delete body.store
|
||||
return { headers: { ...request.headers, "x-plugin": "enabled" }, body }
|
||||
}),
|
||||
},
|
||||
).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.sync(() => {
|
||||
if (LLMEvent.is.textDelta(event)) text.push(event.text)
|
||||
}),
|
||||
),
|
||||
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
|
||||
)
|
||||
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
|
||||
|
||||
expect(text.join("")).toBe("Hi")
|
||||
expect(response.text).toBe("Hi")
|
||||
expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }])
|
||||
expect(closed).toBe(true)
|
||||
expect(sent).toHaveLength(1)
|
||||
@@ -286,7 +268,7 @@ describe("OpenAI Responses route", () => {
|
||||
type: "response.create",
|
||||
model: "gpt-4.1-mini",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
|
||||
plugin: true,
|
||||
store: false,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -32,9 +32,6 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
replayLimit: Option.getOrUndefined(input.replayLimit),
|
||||
demo: input.demo,
|
||||
tuiConfig: resolved,
|
||||
config: {
|
||||
update: (update) => runServicePromise(config.update(update)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -22,7 +22,6 @@ export type MiniCommandInput = {
|
||||
replayLimit?: number
|
||||
demo?: boolean
|
||||
tuiConfig?: MiniFrontendInput["tuiConfig"]
|
||||
config?: MiniFrontendInput["config"]
|
||||
}
|
||||
|
||||
type Model = MiniFrontendInput["model"]
|
||||
@@ -120,7 +119,6 @@ export async function runMini(input: MiniCommandInput) {
|
||||
replayLimit: input.replayLimit,
|
||||
demo: input.demo,
|
||||
tuiConfig: input.tuiConfig,
|
||||
config: input.config,
|
||||
})
|
||||
})
|
||||
if (result.exitCode !== 0) process.exit(result.exitCode)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NodeServices } from "@effect/platform-node"
|
||||
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
@@ -75,13 +75,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
password,
|
||||
simulation: truthy(process.env.OPENCODE_SIMULATE),
|
||||
database: {
|
||||
path:
|
||||
process.env.OPENCODE_DB ??
|
||||
(["latest", "beta", "prod"].includes(InstallationChannel) ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
|
||||
? "opencode.db"
|
||||
: `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
|
||||
path: process.env.OPENCODE_DB,
|
||||
},
|
||||
models: {
|
||||
url: process.env.OPENCODE_MODELS_URL,
|
||||
|
||||
@@ -131,16 +131,11 @@ test("updates a config draft while preserving JSONC comments", async () => {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.update((draft) => {
|
||||
draft.prompt = { paste: "compact" }
|
||||
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide" }
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config).toEqual({
|
||||
animations: true,
|
||||
prompt: { paste: "compact" },
|
||||
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide" },
|
||||
})
|
||||
expect(config).toEqual({ animations: true, prompt: { paste: "compact" } })
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
|
||||
@@ -36,8 +36,7 @@ ultimate source of truth.
|
||||
- [x] Regular-expression literals.
|
||||
- [x] `NaN` and `Infinity` globals.
|
||||
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
|
||||
- [ ] Arbitrary Symbol primitive values and symbol-keyed properties. The confined `Symbol.iterator` and
|
||||
`Symbol.asyncIterator` keys are available only for custom iterator protocols.
|
||||
- [ ] Symbol primitive values and symbol-keyed properties.
|
||||
- [ ] Tagged-template calls.
|
||||
- [ ] Getter and setter definitions in object literals.
|
||||
|
||||
@@ -71,11 +70,9 @@ ultimate source of truth.
|
||||
- [x] `try`, `catch`, optional catch bindings, and `finally`.
|
||||
- [x] `throw` with arbitrary values.
|
||||
- [x] Labeled statements, labeled `break`, and labeled `continue`.
|
||||
- [x] `for await...of` over the supported synchronous collections and custom iterator objects using
|
||||
`Symbol.asyncIterator` or the `Symbol.iterator` fallback. Each iterator step is sequential, yielded promises and
|
||||
plain values from synchronous collections and sync iterators are awaited before binding, and abrupt loop
|
||||
completion invokes the iterator's optional `return()`. Custom async iterators control their yielded values, as in
|
||||
JavaScript; only their `next()` results are awaited. Async generators remain outside the supported subset.
|
||||
- [x] `for await...of` over the supported synchronous collections, awaiting each yielded CodeMode promise or plain
|
||||
value before binding it. Custom sync/async iterator objects, `Symbol.asyncIterator`, and async generators remain
|
||||
outside the supported subset.
|
||||
|
||||
## Functions and callbacks
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export type StatementResult =
|
||||
|
||||
export type MemberReference = {
|
||||
target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL
|
||||
key: PropertyKey
|
||||
key: string | number
|
||||
}
|
||||
|
||||
export class CodeModeFunction {
|
||||
@@ -61,12 +61,6 @@ export class ComputedValue {
|
||||
|
||||
export class PromiseNamespace {}
|
||||
|
||||
export class SymbolNamespace {}
|
||||
|
||||
export const AsyncIteratorSymbol: unique symbol = Symbol("codemode.async-iterator")
|
||||
export const IteratorSymbol: unique symbol = Symbol("codemode.iterator")
|
||||
export const IteratorSymbols = [AsyncIteratorSymbol, IteratorSymbol] as const
|
||||
|
||||
export type PromiseMethodName = "all" | "allSettled" | "race" | "any" | "resolve" | "reject"
|
||||
|
||||
export class PromiseMethodReference {
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
PromiseMethodReference,
|
||||
PromiseNamespace,
|
||||
SearchFunction,
|
||||
SymbolNamespace,
|
||||
UriFunction,
|
||||
} from "./model.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
@@ -35,7 +34,6 @@ export const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof SearchFunction ||
|
||||
value instanceof PromiseCapabilityFunction ||
|
||||
value instanceof ErrorConstructorReference ||
|
||||
value instanceof SymbolNamespace ||
|
||||
isCodeModeValue(value)
|
||||
|
||||
function* childValues(value: object): Generator<unknown> {
|
||||
@@ -115,8 +113,7 @@ export const typeofValue = (value: unknown): string => {
|
||||
value instanceof PromiseInstanceMethodReference ||
|
||||
value instanceof PromiseNamespace ||
|
||||
value instanceof PromiseCapabilityFunction ||
|
||||
value instanceof ErrorConstructorReference ||
|
||||
value instanceof SymbolNamespace
|
||||
value instanceof ErrorConstructorReference
|
||||
)
|
||||
return "function"
|
||||
if (value instanceof UriFunction || value instanceof SearchFunction) return "function"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { isBlockedMember, ToolReference, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
|
||||
import {
|
||||
type AstNode,
|
||||
AsyncIteratorSymbol,
|
||||
asNode,
|
||||
type Binding,
|
||||
CodeModeFunction,
|
||||
@@ -20,8 +19,6 @@ import {
|
||||
IntrinsicReference,
|
||||
InterpreterRuntimeError,
|
||||
isRecord,
|
||||
IteratorSymbol,
|
||||
IteratorSymbols,
|
||||
JsonMethodReference,
|
||||
type MemberReference,
|
||||
OptionalShortCircuit,
|
||||
@@ -33,7 +30,6 @@ import {
|
||||
ProgramThrow,
|
||||
type ProgramNode,
|
||||
SearchFunction,
|
||||
SymbolNamespace,
|
||||
type StatementResult,
|
||||
supportedSyntaxMessage,
|
||||
unsupportedSyntax,
|
||||
@@ -215,12 +211,6 @@ const loopDeclaration = (left: AstNode, statement: "for...of" | "for...in") => {
|
||||
}
|
||||
}
|
||||
|
||||
type CustomIterator = {
|
||||
iterator: SafeObject
|
||||
next: unknown
|
||||
asynchronous: boolean
|
||||
}
|
||||
|
||||
export class Interpreter<R> {
|
||||
private scopes: ScopeStack
|
||||
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
@@ -251,7 +241,6 @@ export class Interpreter<R> {
|
||||
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
|
||||
globalScope.set("search", { mutable: false, value: new SearchFunction() })
|
||||
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
|
||||
globalScope.set("Symbol", { mutable: false, value: new SymbolNamespace() })
|
||||
globalScope.set("undefined", { mutable: false, value: undefined })
|
||||
globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
|
||||
globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") })
|
||||
@@ -647,10 +636,9 @@ export class Interpreter<R> {
|
||||
const body = getNode(node, "body")
|
||||
|
||||
const iterable = spreadItems(right)
|
||||
const iterator = iterable === undefined && awaiting ? yield* self.customIterator(right, node) : undefined
|
||||
if (iterable === undefined && iterator === undefined) {
|
||||
if (iterable === undefined) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams${awaiting ? ", or custom iterator" : ""} value.`,
|
||||
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams value.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
@@ -669,14 +657,19 @@ export class Interpreter<R> {
|
||||
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
|
||||
}
|
||||
|
||||
const evaluateBody = (value: unknown) =>
|
||||
Effect.gen(function* () {
|
||||
for (const value of iterable) {
|
||||
const resolved = awaiting
|
||||
? value instanceof CodeModePromise
|
||||
? yield* self.settlePromise(value)
|
||||
: yield* Effect.as(Effect.yieldNow, value)
|
||||
: value
|
||||
const result = yield* Effect.gen(function* () {
|
||||
if (declared) {
|
||||
self.scopes.push()
|
||||
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
|
||||
yield* self.declarePattern(declared.pattern, value, declared.mutable, left, declared.lexical)
|
||||
yield* self.declarePattern(declared.pattern, resolved, declared.mutable, left, declared.lexical)
|
||||
} else if (assignment) {
|
||||
yield* self.assignPattern(assignment, value, left)
|
||||
yield* self.assignPattern(assignment, resolved, left)
|
||||
}
|
||||
return yield* self.evaluateStatement(body)
|
||||
}).pipe(
|
||||
@@ -687,48 +680,22 @@ export class Interpreter<R> {
|
||||
),
|
||||
)
|
||||
|
||||
if (iterable !== undefined) {
|
||||
for (const value of iterable) {
|
||||
const result = yield* evaluateBody(awaiting ? yield* self.awaitValue(value) : value)
|
||||
|
||||
if (result.kind === "return") return result
|
||||
if (result.kind === "break") {
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) return result
|
||||
}
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
if (iterator === undefined) throw new InterpreterRuntimeError("Custom iterator is unavailable.", node)
|
||||
|
||||
while (true) {
|
||||
const step = yield* self.nextIteratorResult(iterator, node)
|
||||
if (step.done) return { kind: "none" } satisfies StatementResult
|
||||
const bodyExit = yield* Effect.exit(evaluateBody(step.value))
|
||||
if (!Exit.isSuccess(bodyExit)) {
|
||||
// Process interruption must remain prompt; user cleanup cannot extend a timeout.
|
||||
if (!Cause.hasInterruptsOnly(bodyExit.cause)) yield* Effect.exit(self.closeIterator(iterator, node))
|
||||
return yield* Effect.failCause(bodyExit.cause)
|
||||
}
|
||||
const result = bodyExit.value
|
||||
|
||||
if (result.kind === "return") {
|
||||
yield* self.closeIterator(iterator, node)
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
yield* self.closeIterator(iterator, node)
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) {
|
||||
yield* self.closeIterator(iterator, node)
|
||||
return result
|
||||
if (result.kind === "continue") {
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
@@ -738,101 +705,6 @@ export class Interpreter<R> {
|
||||
)
|
||||
}
|
||||
|
||||
private awaitValue(value: unknown): Effect.Effect<unknown, unknown, R> {
|
||||
return value instanceof CodeModePromise ? this.settlePromise(value) : Effect.as(Effect.yieldNow, value)
|
||||
}
|
||||
|
||||
private customIterator(value: unknown, node: AstNode) {
|
||||
if (!isRecord(value) || isRuntimeReference(value)) return Effect.succeed(undefined)
|
||||
const asyncMethod = Reflect.get(value, AsyncIteratorSymbol)
|
||||
const method = asyncMethod ?? Reflect.get(value, IteratorSymbol)
|
||||
if (method === undefined || method === null) return Effect.succeed(undefined)
|
||||
const self = this
|
||||
return Effect.map(
|
||||
this.invokeCallable(this.requireIteratorMethod(method, "Iterator method", node), [], node),
|
||||
(iterator) => {
|
||||
const object = self.requireIteratorObject(iterator, "Iterator method result", node)
|
||||
return {
|
||||
iterator: object,
|
||||
next: self.requireIteratorMethod(object.next, "Iterator next", node),
|
||||
asynchronous: asyncMethod !== undefined && asyncMethod !== null,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private nextIteratorResult(iterator: CustomIterator, node: AstNode) {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
if (iterator.asynchronous) {
|
||||
const object = self.requireIteratorObject(
|
||||
yield* self.awaitValue(yield* self.invokeCallable(iterator.next, [], node)),
|
||||
"Iterator next() result",
|
||||
node,
|
||||
)
|
||||
return { done: Boolean(object.done), value: object.value }
|
||||
}
|
||||
|
||||
const called = yield* Effect.exit(self.invokeCallable(iterator.next, [], node))
|
||||
if (!Exit.isSuccess(called)) {
|
||||
yield* Effect.yieldNow
|
||||
return yield* Effect.failCause(called.cause)
|
||||
}
|
||||
const captured = yield* Effect.exit(
|
||||
Effect.sync(() => {
|
||||
const object = self.requireIteratorObject(called.value, "Iterator next() result", node)
|
||||
return { done: Boolean(object.done), value: object.value }
|
||||
}),
|
||||
)
|
||||
if (!Exit.isSuccess(captured)) {
|
||||
yield* Effect.yieldNow
|
||||
return yield* Effect.failCause(captured.cause)
|
||||
}
|
||||
return { done: captured.value.done, value: yield* self.awaitValue(captured.value.value) }
|
||||
})
|
||||
}
|
||||
|
||||
private closeIterator(iterator: CustomIterator, node: AstNode): Effect.Effect<void, unknown, R> {
|
||||
const close = iterator.iterator.return
|
||||
if (close === undefined || close === null) return iterator.asynchronous ? Effect.void : Effect.yieldNow
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const method = self.requireIteratorMethod(close, "Iterator return", node)
|
||||
if (iterator.asynchronous) {
|
||||
self.requireIteratorObject(
|
||||
yield* self.awaitValue(yield* self.invokeCallable(method, [], node)),
|
||||
"Iterator return() result",
|
||||
node,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const called = yield* Effect.exit(self.invokeCallable(method, [], node))
|
||||
if (!Exit.isSuccess(called)) {
|
||||
yield* Effect.yieldNow
|
||||
return yield* Effect.failCause(called.cause)
|
||||
}
|
||||
const captured = yield* Effect.exit(
|
||||
Effect.sync(() => self.requireIteratorObject(called.value, "Iterator return() result", node).value),
|
||||
)
|
||||
if (!Exit.isSuccess(captured)) {
|
||||
yield* Effect.yieldNow
|
||||
return yield* Effect.failCause(captured.cause)
|
||||
}
|
||||
yield* self.awaitValue(captured.value)
|
||||
})
|
||||
}
|
||||
|
||||
private requireIteratorObject(value: unknown, context: string, node: AstNode): SafeObject {
|
||||
if (isRecord(value) && !isRuntimeReference(value)) return value
|
||||
throw new InterpreterRuntimeError(`${context} must be an object.`, node).as("TypeError")
|
||||
}
|
||||
|
||||
private requireIteratorMethod(value: unknown, context: string, node: AstNode): unknown {
|
||||
if (typeofValue(value) === "function") return value
|
||||
throw new InterpreterRuntimeError(`${context} must be a function.`, node).as("TypeError")
|
||||
}
|
||||
|
||||
private enumerableKeys(value: unknown): Array<string> | undefined {
|
||||
if (value instanceof ToolReference) {
|
||||
return [...this.toolKeys(value.path)]
|
||||
@@ -1050,7 +922,7 @@ export class Interpreter<R> {
|
||||
)
|
||||
}
|
||||
|
||||
const consumed = new Set<PropertyKey>()
|
||||
const consumed = new Set<string>()
|
||||
for (const propertyValue of getArray(pattern, "properties")) {
|
||||
const property = asNode(propertyValue, "properties")
|
||||
|
||||
@@ -1059,10 +931,6 @@ export class Interpreter<R> {
|
||||
for (const [key, item] of Object.entries(value as SafeObject)) {
|
||||
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
|
||||
}
|
||||
for (const symbol of IteratorSymbols) {
|
||||
if (!consumed.has(symbol) && Object.hasOwn(value, symbol))
|
||||
Reflect.set(rest, symbol, Reflect.get(value, symbol))
|
||||
}
|
||||
yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property, initialize)
|
||||
continue
|
||||
}
|
||||
@@ -1071,7 +939,7 @@ export class Interpreter<R> {
|
||||
if (isBlockedMember(String(key))) {
|
||||
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
|
||||
}
|
||||
consumed.add(typeof key === "symbol" ? key : String(key))
|
||||
consumed.add(String(key))
|
||||
yield* self.declarePattern(
|
||||
getNode(property, "value"),
|
||||
self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
|
||||
@@ -1134,7 +1002,7 @@ export class Interpreter<R> {
|
||||
}
|
||||
|
||||
const source = value as SafeObject | Array<unknown>
|
||||
const consumed = new Set<PropertyKey>()
|
||||
const consumed = new Set<string>()
|
||||
for (const propertyValue of getArray(pattern, "properties")) {
|
||||
const property = asNode(propertyValue, "properties")
|
||||
if (property.type === "RestElement") {
|
||||
@@ -1142,10 +1010,6 @@ export class Interpreter<R> {
|
||||
for (const [key, item] of Object.entries(source)) {
|
||||
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
|
||||
}
|
||||
for (const symbol of IteratorSymbols) {
|
||||
if (!consumed.has(symbol) && Object.hasOwn(source, symbol))
|
||||
Reflect.set(rest, symbol, Reflect.get(source, symbol))
|
||||
}
|
||||
yield* self.assignPattern(getNode(property, "argument"), rest, property)
|
||||
continue
|
||||
}
|
||||
@@ -1153,7 +1017,7 @@ export class Interpreter<R> {
|
||||
if (isBlockedMember(String(key))) {
|
||||
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
|
||||
}
|
||||
consumed.add(typeof key === "symbol" ? key : String(key))
|
||||
consumed.add(String(key))
|
||||
yield* self.assignPattern(getNode(property, "value"), self.destructuringPropertyValue(source, key), property)
|
||||
}
|
||||
return
|
||||
@@ -1180,7 +1044,7 @@ export class Interpreter<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private destructuringPropertyKey(property: AstNode): Effect.Effect<PropertyKey, unknown, R> {
|
||||
private destructuringPropertyKey(property: AstNode): Effect.Effect<string | number, unknown, R> {
|
||||
if (property.type !== "Property" || getString(property, "kind") !== "init") {
|
||||
throw new InterpreterRuntimeError("Unsupported object destructuring property.", property)
|
||||
}
|
||||
@@ -1191,12 +1055,12 @@ export class Interpreter<R> {
|
||||
return Effect.succeed(keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value))
|
||||
}
|
||||
|
||||
private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: PropertyKey): unknown {
|
||||
if (!Array.isArray(source)) return Reflect.get(source, key)
|
||||
private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: string | number): unknown {
|
||||
if (!Array.isArray(source)) return source[String(key)]
|
||||
if (key === "length") return source.length
|
||||
if (typeof key === "number") return source[key]
|
||||
if (Object.hasOwn(source, key)) return Reflect.get(source, key)
|
||||
if (typeof key === "string" && arrayMethods.has(key)) return new IntrinsicReference(source, key)
|
||||
if (Object.hasOwn(source, key)) return (source as Record<string, unknown> & Array<unknown>)[key]
|
||||
if (arrayMethods.has(key)) return new IntrinsicReference(source, key)
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1826,12 +1690,6 @@ export class Interpreter<R> {
|
||||
if (callable instanceof PromiseNamespace) {
|
||||
throw new InterpreterRuntimeError("Constructor Promise requires 'new'.", node).as("TypeError")
|
||||
}
|
||||
if (callable instanceof SymbolNamespace) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Symbol is not callable; only Symbol.asyncIterator and Symbol.iterator are available.",
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
if (callable instanceof PromiseCapabilityFunction) {
|
||||
callable.settle(args[0])
|
||||
return undefined
|
||||
@@ -1942,9 +1800,6 @@ export class Interpreter<R> {
|
||||
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, property)
|
||||
objectValue[key] = value
|
||||
}
|
||||
for (const symbol of IteratorSymbols) {
|
||||
if (Object.hasOwn(spread, symbol)) Reflect.set(objectValue, symbol, Reflect.get(spread, symbol))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1975,7 +1830,7 @@ export class Interpreter<R> {
|
||||
if (isBlockedMember(String(key))) {
|
||||
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, keyNode)
|
||||
}
|
||||
Reflect.set(objectValue, key, yield* self.evaluateExpression(valueNode))
|
||||
objectValue[String(key)] = yield* self.evaluateExpression(valueNode)
|
||||
}
|
||||
|
||||
return objectValue
|
||||
@@ -2100,12 +1955,6 @@ export class Interpreter<R> {
|
||||
)
|
||||
}
|
||||
|
||||
if (objectValue instanceof SymbolNamespace) {
|
||||
if (key === "asyncIterator") return new ComputedValue(AsyncIteratorSymbol)
|
||||
if (key === "iterator") return new ComputedValue(IteratorSymbol)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
|
||||
if (objectValue instanceof GlobalNamespace) {
|
||||
if (typeof key === "string" && isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available.`, propertyNode)
|
||||
@@ -2127,7 +1976,7 @@ export class Interpreter<R> {
|
||||
|
||||
if (typeof objectValue === "string") {
|
||||
if (key === "length") return new ComputedValue(objectValue.length)
|
||||
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
|
||||
const index = parseArrayIndex(key)
|
||||
if (index !== undefined) return new ComputedValue(objectValue[index])
|
||||
if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
@@ -2223,7 +2072,7 @@ export class Interpreter<R> {
|
||||
|
||||
if (Array.isArray(objectValue)) {
|
||||
if (operation === "delete") return { target: objectValue, key }
|
||||
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
|
||||
const index = parseArrayIndex(key)
|
||||
if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && index === undefined) {
|
||||
if (typeof key === "string" && Object.hasOwn(objectValue, key)) {
|
||||
return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key])
|
||||
@@ -2254,13 +2103,13 @@ export class Interpreter<R> {
|
||||
if (Array.isArray(reference.target)) {
|
||||
if (reference.key === "length") return reference.target.length
|
||||
if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key)
|
||||
return Reflect.get(reference.target, reference.key)
|
||||
return reference.target[reference.key]
|
||||
}
|
||||
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
|
||||
if (reference.target instanceof CodeModeURL) {
|
||||
return Reflect.get(reference.target.url, reference.key)
|
||||
return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)]
|
||||
}
|
||||
return Reflect.get(reference.target, reference.key)
|
||||
return reference.target[String(reference.key)]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2322,22 +2171,22 @@ export class Interpreter<R> {
|
||||
throw new InterpreterRuntimeError("Array methods cannot be assigned.", node)
|
||||
}
|
||||
}
|
||||
const key = reference.key
|
||||
const key = Array.isArray(reference.target) ? reference.key : String(reference.key)
|
||||
const { write, next, result } = yield* compute(self.readReferenceValue(reference, key))
|
||||
if (write) self.assignToReference(reference, key, next, node)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
private readReferenceValue(reference: MemberReference, key: PropertyKey): unknown {
|
||||
private readReferenceValue(reference: MemberReference, key: number | string): unknown {
|
||||
if (reference.target instanceof CodeModeURL) {
|
||||
return Reflect.get(reference.target.url, key)
|
||||
return (reference.target.url as unknown as Record<string, unknown>)[key]
|
||||
}
|
||||
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
|
||||
return Reflect.get(reference.target, key)
|
||||
return (reference.target as Record<PropertyKey, unknown>)[key]
|
||||
}
|
||||
|
||||
private assignToReference(reference: MemberReference, key: PropertyKey, next: unknown, node: AstNode): void {
|
||||
private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void {
|
||||
if (Array.isArray(reference.target)) {
|
||||
const target = reference.target
|
||||
if (typeof key !== "number" || parseArrayIndex(key) === undefined) {
|
||||
@@ -2370,19 +2219,16 @@ export class Interpreter<R> {
|
||||
return
|
||||
}
|
||||
const target = reference.target as SafeObject
|
||||
const objectKey = key as string
|
||||
rejectCircularInsertion(target, next, "Object assignment result", node)
|
||||
Reflect.set(target, key, next)
|
||||
target[objectKey] = next
|
||||
}
|
||||
|
||||
private toPropertyKey(value: unknown, node: AstNode): PropertyKey {
|
||||
private toPropertyKey(value: unknown, node: AstNode): string | number {
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return value
|
||||
}
|
||||
if (value === AsyncIteratorSymbol || value === IteratorSymbol) return value
|
||||
|
||||
throw new InterpreterRuntimeError(
|
||||
"Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator.",
|
||||
node,
|
||||
)
|
||||
throw new InterpreterRuntimeError("Property key must be a string or number.", node)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
type AstNode,
|
||||
AsyncIteratorSymbol,
|
||||
InterpreterRuntimeError,
|
||||
IteratorSymbol,
|
||||
IteratorSymbols,
|
||||
} from "../interpreter/model.js"
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { containsOpaqueReference } from "../interpreter/references.js"
|
||||
import { isBlockedMember } from "../tool-runtime.js"
|
||||
import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
|
||||
@@ -52,10 +46,7 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||
case "entries":
|
||||
return Object.entries(requireObject()).map(([key, item]) => [key, item])
|
||||
case "hasOwn":
|
||||
return Object.hasOwn(
|
||||
requireObject(),
|
||||
args[1] === AsyncIteratorSymbol || args[1] === IteratorSymbol ? args[1] : String(args[1]),
|
||||
)
|
||||
return Object.hasOwn(requireObject(), String(args[1]))
|
||||
case "is":
|
||||
if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) {
|
||||
throw new InterpreterRuntimeError("Object.is requires data values.", node, "InvalidDataValue")
|
||||
@@ -73,9 +64,6 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
|
||||
}
|
||||
for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
|
||||
for (const symbol of IteratorSymbols) {
|
||||
if (Object.hasOwn(source, symbol)) Reflect.set(out, symbol, Reflect.get(source, symbol))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
/*
|
||||
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
|
||||
* - test/language/statements/for-await-of/ticks-with-sync-iter-resolved-promise-and-constructor-lookup.js
|
||||
* - test/language/statements/for-await-of/ticks-with-async-iter-resolved-promise-and-constructor-lookup.js
|
||||
* - test/language/statements/for-await-of/async-func-dstr-let-ary-ptrn-elem-id-iter-val.js
|
||||
* - test/language/statements/for-await-of/async-func-decl-dstr-array-rest-after-element.js
|
||||
* - test/language/statements/for-await-of/iterator-close-non-throw-get-method-is-null.js
|
||||
* - test/language/statements/for-await-of/iterator-close-non-throw-get-method-non-callable.js
|
||||
* - test/language/statements/for-await-of/iterator-close-throw-get-method-non-callable.js
|
||||
*
|
||||
* Copyright (C) 2019 André Bargull. All rights reserved.
|
||||
* Copyright (C) 2020 Alexey Shvayka. All rights reserved.
|
||||
* Test262 portions are governed by the BSD license in LICENSE.test262.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
@@ -135,400 +130,10 @@ describe("Test262 for-await-of adaptations", () => {
|
||||
).toEqual([1, 3])
|
||||
})
|
||||
|
||||
test("drives a custom async iterator sequentially", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let index = 0
|
||||
const iterator = {
|
||||
[Symbol.asyncIterator]: () => iterator,
|
||||
async next() {
|
||||
index += 1
|
||||
if (index > 3) return { done: true }
|
||||
return { done: false, value: index }
|
||||
},
|
||||
}
|
||||
const values = []
|
||||
for await (const item of iterator) values.push(item)
|
||||
return values
|
||||
`),
|
||||
).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("leaves async iterator values under the iterator's control", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let done = false
|
||||
const iterator = {
|
||||
[Symbol.asyncIterator]: () => iterator,
|
||||
next: async () => done ? { done: true } : (done = true, { done: false, value: Promise.resolve(1) }),
|
||||
}
|
||||
for await (const item of iterator) return [item instanceof Promise, await item]
|
||||
`),
|
||||
).toEqual([true, 1])
|
||||
})
|
||||
|
||||
test("awaits synchronous results from an async iterator before the body", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const events = ["pre"]
|
||||
const ticks = Promise.resolve()
|
||||
.then(() => events.push("tick 1"))
|
||||
.then(() => events.push("tick 2"))
|
||||
let done = false
|
||||
const iterator = {
|
||||
[Symbol.asyncIterator]: () => iterator,
|
||||
next: () => done ? { done: true } : (done = true, { done: false, value: Promise.resolve(1) }),
|
||||
}
|
||||
for await (const item of iterator) events.push(item instanceof Promise ? "loop" : "adopted")
|
||||
events.push("post")
|
||||
await ticks
|
||||
return events
|
||||
`),
|
||||
).toEqual(["pre", "tick 1", "loop", "tick 2", "post"])
|
||||
})
|
||||
|
||||
test("falls back to a custom synchronous iterator", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let index = 0
|
||||
const iterator = {
|
||||
[Symbol.iterator]: () => iterator,
|
||||
next() {
|
||||
index += 1
|
||||
return index > 2 ? { done: true } : { done: false, value: Promise.resolve(index) }
|
||||
},
|
||||
}
|
||||
const values = []
|
||||
for await (const item of iterator) values.push(item)
|
||||
return values
|
||||
`),
|
||||
).toEqual([1, 2])
|
||||
})
|
||||
|
||||
test("adopts terminal and close values from synchronous iterators", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const terminal = {
|
||||
[Symbol.iterator]: () => terminal,
|
||||
next: () => ({ done: true, value: Promise.reject("terminal") }),
|
||||
}
|
||||
let terminalError
|
||||
try {
|
||||
for await (const item of terminal) {}
|
||||
} catch (error) {
|
||||
terminalError = error
|
||||
}
|
||||
|
||||
const closing = {
|
||||
[Symbol.iterator]: () => closing,
|
||||
next: () => ({ done: false, value: 1 }),
|
||||
return: () => ({ done: true, value: Promise.reject("close") }),
|
||||
}
|
||||
let closeError
|
||||
try {
|
||||
for await (const item of closing) break
|
||||
} catch (error) {
|
||||
closeError = error
|
||||
}
|
||||
return [terminalError, closeError]
|
||||
`),
|
||||
).toEqual(["terminal", "close"])
|
||||
})
|
||||
|
||||
test("captures the next method when acquiring the iterator", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let count = 0
|
||||
const next = () => {
|
||||
count += 1
|
||||
if (count === 1) iterator.next = () => ({ done: true })
|
||||
return count > 2 ? { done: true } : { done: false, value: count }
|
||||
}
|
||||
const iterator = { [Symbol.iterator]: () => iterator, next }
|
||||
const values = []
|
||||
for await (const item of iterator) values.push(item)
|
||||
return values
|
||||
`),
|
||||
).toEqual([1, 2])
|
||||
})
|
||||
|
||||
test("captures synchronous iterator result fields before suspending", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let count = 0
|
||||
const result = { done: false, value: 1 }
|
||||
const iterator = {
|
||||
[Symbol.iterator]: () => iterator,
|
||||
next() {
|
||||
count += 1
|
||||
if (count > 1) return { done: true }
|
||||
Promise.resolve().then(() => {
|
||||
result.done = true
|
||||
result.value = 2
|
||||
})
|
||||
return result
|
||||
},
|
||||
}
|
||||
const values = []
|
||||
for await (const item of iterator) values.push(item)
|
||||
return values
|
||||
`),
|
||||
).toEqual([1])
|
||||
})
|
||||
|
||||
test("preserves the async close turn for a sync iterator without return", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const events = []
|
||||
const iterator = {
|
||||
[Symbol.iterator]: () => iterator,
|
||||
next: () => ({ done: false, value: 1 }),
|
||||
}
|
||||
for await (const item of iterator) {
|
||||
Promise.resolve().then(() => events.push("reaction"))
|
||||
break
|
||||
}
|
||||
events.push("after")
|
||||
return events
|
||||
`),
|
||||
).toEqual(["reaction", "after"])
|
||||
})
|
||||
|
||||
test("defers synchronous iterator protocol errors", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const events = []
|
||||
const throwing = {
|
||||
[Symbol.iterator]: () => throwing,
|
||||
next() {
|
||||
Promise.resolve().then(() => events.push("next reaction"))
|
||||
throw "next"
|
||||
},
|
||||
}
|
||||
try {
|
||||
for await (const item of throwing) {}
|
||||
} catch (error) {
|
||||
events.push("next catch")
|
||||
}
|
||||
|
||||
const malformed = {
|
||||
[Symbol.iterator]: () => malformed,
|
||||
next() {
|
||||
Promise.resolve().then(() => events.push("result reaction"))
|
||||
return 1
|
||||
},
|
||||
}
|
||||
try {
|
||||
for await (const item of malformed) {}
|
||||
} catch (error) {
|
||||
events.push("result catch")
|
||||
}
|
||||
|
||||
const closing = {
|
||||
[Symbol.iterator]: () => closing,
|
||||
next: () => ({ done: false, value: 1 }),
|
||||
return() {
|
||||
Promise.resolve().then(() => events.push("return reaction"))
|
||||
throw "return"
|
||||
},
|
||||
}
|
||||
try {
|
||||
for await (const item of closing) break
|
||||
} catch (error) {
|
||||
events.push("return catch")
|
||||
}
|
||||
return events
|
||||
`),
|
||||
).toEqual(["next reaction", "next catch", "result reaction", "result catch", "return reaction", "return catch"])
|
||||
})
|
||||
|
||||
test("prefers Symbol.asyncIterator over Symbol.iterator", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let done = false
|
||||
const asyncIterator = {
|
||||
next: async () => done ? { done: true } : (done = true, { done: false, value: "async" }),
|
||||
}
|
||||
const syncIterator = { next: () => ({ done: true }) }
|
||||
const iterable = {
|
||||
[Symbol.asyncIterator]: () => asyncIterator,
|
||||
[Symbol.iterator]: () => syncIterator,
|
||||
}
|
||||
const values = []
|
||||
for await (const item of iterable) values.push(item)
|
||||
return values
|
||||
`),
|
||||
).toEqual(["async"])
|
||||
})
|
||||
|
||||
test("closes a custom iterator on abrupt loop completion", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let closed = 0
|
||||
const iterator = {
|
||||
[Symbol.asyncIterator]: () => iterator,
|
||||
next: async () => ({ done: false, value: 1 }),
|
||||
return: async () => (closed += 1, { done: true }),
|
||||
}
|
||||
for await (const item of iterator) break
|
||||
try {
|
||||
for await (const item of iterator) throw "stop"
|
||||
} catch (error) {}
|
||||
return closed
|
||||
`),
|
||||
).toBe(2)
|
||||
})
|
||||
|
||||
test("treats a null iterator return method as absent", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let count = 0
|
||||
const iterator = {
|
||||
[Symbol.asyncIterator]: () => iterator,
|
||||
next: async () => ({ done: false, value: 1 }),
|
||||
return: null,
|
||||
}
|
||||
for await (const item of iterator) {
|
||||
count += 1
|
||||
break
|
||||
}
|
||||
return count
|
||||
`),
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
test("a non-callable return method replaces break with TypeError", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const iterator = {
|
||||
[Symbol.asyncIterator]: () => iterator,
|
||||
next: async () => ({ done: false, value: 1 }),
|
||||
return: true,
|
||||
}
|
||||
try {
|
||||
for await (const item of iterator) break
|
||||
} catch (error) {
|
||||
return error.name
|
||||
}
|
||||
return "missed"
|
||||
`),
|
||||
).toBe("TypeError")
|
||||
})
|
||||
|
||||
test("a body throw wins over a non-callable return method", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const iterator = {
|
||||
[Symbol.asyncIterator]: () => iterator,
|
||||
next: async () => ({ done: false, value: 1 }),
|
||||
return: true,
|
||||
}
|
||||
try {
|
||||
for await (const item of iterator) throw "body"
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
return "missed"
|
||||
`),
|
||||
).toBe("body")
|
||||
})
|
||||
|
||||
test("rejects a primitive iterator return result", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const iterator = {
|
||||
[Symbol.asyncIterator]: () => iterator,
|
||||
next: async () => ({ done: false, value: 1 }),
|
||||
return: async () => null,
|
||||
}
|
||||
try {
|
||||
for await (const item of iterator) break
|
||||
} catch (error) {
|
||||
return error.name
|
||||
}
|
||||
return "missed"
|
||||
`),
|
||||
).toBe("TypeError")
|
||||
})
|
||||
|
||||
test("propagates iterator acquisition failures", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const iterator = {
|
||||
[Symbol.asyncIterator]: () => { throw "acquire" },
|
||||
}
|
||||
try {
|
||||
for await (const item of iterator) {}
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
return "missed"
|
||||
`),
|
||||
).toBe("acquire")
|
||||
})
|
||||
|
||||
test("rejects malformed iterator acquisition methods and results", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const names = []
|
||||
const invalid = [
|
||||
{ [Symbol.asyncIterator]: true },
|
||||
{ [Symbol.asyncIterator]: () => 1 },
|
||||
{ [Symbol.asyncIterator]: () => ({ next: true }) },
|
||||
]
|
||||
for (const iterator of invalid) {
|
||||
try {
|
||||
for await (const item of iterator) {}
|
||||
} catch (error) {
|
||||
names.push(error.name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
`),
|
||||
).toEqual(["TypeError", "TypeError", "TypeError"])
|
||||
})
|
||||
|
||||
test("preserves iterator protocol keys through object copies", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let done = false
|
||||
const iterable = {
|
||||
plain: true,
|
||||
[Symbol.asyncIterator]: () => iterable,
|
||||
next: async () => done ? { done: true } : (done = true, { done: false, value: 1 }),
|
||||
}
|
||||
const spread = { ...iterable }
|
||||
const { plain, ...rest } = iterable
|
||||
const assigned = Object.assign({}, iterable)
|
||||
const values = []
|
||||
for await (const item of spread) values.push(item)
|
||||
return [
|
||||
values,
|
||||
Object.hasOwn(spread, Symbol.asyncIterator),
|
||||
Object.hasOwn(rest, Symbol.asyncIterator),
|
||||
Object.hasOwn(assigned, Symbol.asyncIterator),
|
||||
]
|
||||
`),
|
||||
).toEqual([[1], true, true, true])
|
||||
})
|
||||
|
||||
test("rejects malformed iterator protocol results", async () => {
|
||||
const result = await execute(`
|
||||
const iterator = {
|
||||
[Symbol.asyncIterator]: () => iterator,
|
||||
next: async () => 1,
|
||||
}
|
||||
for await (const item of iterator) {}
|
||||
`)
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.error.message).toContain("Iterator next() result must be an object")
|
||||
})
|
||||
|
||||
test("rejects objects without an iterator protocol method", async () => {
|
||||
test("keeps custom iterator objects outside the supported subset", async () => {
|
||||
const result = await execute(`for await (const item of { values: [1, 2] }) {}`)
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.error.message).toContain("or custom iterator value")
|
||||
expect(result.error.message).toContain("for await...of requires an array, string, Map, Set, or URLSearchParams")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -717,18 +717,6 @@ describe("destructuring assignment", () => {
|
||||
).toEqual({ first: 1, rest: [2, 3], entry: "a4" })
|
||||
})
|
||||
|
||||
test("excludes computed numeric keys from object rest", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const { [0]: declared, ...declarationRest } = { 0: "a", 1: "b" }
|
||||
let assigned
|
||||
let assignmentRest
|
||||
;({ [0]: assigned, ...assignmentRest } = { 0: "c", 1: "d" })
|
||||
return { declared, declarationRest, assigned, assignmentRest }
|
||||
`),
|
||||
).toEqual({ declared: "a", declarationRest: { 1: "b" }, assigned: "c", assignmentRest: { 1: "d" } })
|
||||
})
|
||||
|
||||
test("rejects computed keys that are not confined property keys", async () => {
|
||||
const err = await error(`const key = {}; const { [key]: value } = {}`)
|
||||
expect(err.message).toContain("Property key must be a string or number")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export * as AISDK from "./aisdk"
|
||||
|
||||
import { AsyncLocalStorage } from "node:async_hooks"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import type {
|
||||
JSONSchema7,
|
||||
@@ -30,7 +29,7 @@ import {
|
||||
type ToolDefinition,
|
||||
type UsageInput,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Auth, Endpoint, type AnyRoute, type CallOptions } from "@opencode-ai/ai/route"
|
||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
@@ -41,12 +40,6 @@ type SDK = any
|
||||
type UserContent = Extract<LanguageModelV3Message, { role: "user" }>["content"]
|
||||
type AssistantContent = Extract<LanguageModelV3Message, { role: "assistant" }>["content"]
|
||||
type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result" }>
|
||||
type TransformRequest = NonNullable<CallOptions["transformRequest"]>
|
||||
|
||||
interface AISDKPrepared {
|
||||
readonly call: LanguageModelV3CallOptions
|
||||
readonly transformRequest?: TransformRequest
|
||||
}
|
||||
|
||||
export interface SDKEvent {
|
||||
readonly model: ModelV2.Info
|
||||
@@ -110,7 +103,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
})
|
||||
}
|
||||
|
||||
function prepareOptions(model: ModelV2.Info, pkg: string, requests: AsyncLocalStorage<TransformRequest>) {
|
||||
function prepareOptions(model: ModelV2.Info, pkg: string) {
|
||||
const projected = mapBodyToProviderOptions(model, pkg)
|
||||
const options: Record<string, any> = {
|
||||
name: model.providerID,
|
||||
@@ -157,21 +150,6 @@ function prepareOptions(model: ModelV2.Info, pkg: string, requests: AsyncLocalSt
|
||||
}
|
||||
}
|
||||
|
||||
const headers = new Headers(opts.headers)
|
||||
const transformRequest = requests.getStore()
|
||||
if (transformRequest) {
|
||||
if (typeof opts.body !== "string") throw new Error("Session request hooks require a JSON request body")
|
||||
const prepared = await Effect.runPromise(
|
||||
transformRequest({
|
||||
headers: Object.fromEntries(headers.entries()),
|
||||
body: JSON.parse(opts.body),
|
||||
}),
|
||||
)
|
||||
opts.headers = prepared.headers
|
||||
opts.body = JSON.stringify(prepared.body)
|
||||
}
|
||||
if (!transformRequest) opts.headers = headers
|
||||
|
||||
const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, {
|
||||
...opts,
|
||||
timeout: false,
|
||||
@@ -216,7 +194,6 @@ export const locationLayer = Layer.effect(
|
||||
let languageHooks: ((event: LanguageEvent) => Effect.Effect<void> | void)[] = []
|
||||
const languages = new Map<string, LanguageModelV3>()
|
||||
const sdks = new Map<string, SDK>()
|
||||
const requests = new AsyncLocalStorage<TransformRequest>()
|
||||
const functionIDs = new WeakMap<object, number>()
|
||||
let nextFunctionID = 0
|
||||
const cacheKey = (input: unknown) =>
|
||||
@@ -290,7 +267,7 @@ export const locationLayer = Layer.effect(
|
||||
})
|
||||
|
||||
const packageName = ProviderV2.packageName(model.package)
|
||||
const options = prepareOptions(model, packageName, requests)
|
||||
const options = prepareOptions(model, packageName)
|
||||
const sdkKey = cacheKey({
|
||||
providerID: model.providerID,
|
||||
package: packageName,
|
||||
@@ -315,7 +292,7 @@ export const locationLayer = Layer.effect(
|
||||
return language
|
||||
}),
|
||||
model: Effect.fn("AISDK.model")(function* (model) {
|
||||
return modelFromLanguage(model, yield* service.language(model), requests)
|
||||
return modelFromLanguage(model, yield* service.language(model))
|
||||
}),
|
||||
})
|
||||
return service
|
||||
@@ -324,11 +301,7 @@ export const locationLayer = Layer.effect(
|
||||
|
||||
export const defaultLayer = locationLayer
|
||||
|
||||
function modelFromLanguage(
|
||||
info: ModelV2.Info,
|
||||
language: LanguageModelV3,
|
||||
requests: AsyncLocalStorage<TransformRequest>,
|
||||
) {
|
||||
function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
|
||||
const packageName = ProviderV2.packageName(info.package!)
|
||||
const projected = mapBodyToProviderOptions(info, packageName)
|
||||
const optionKey = providerOptionKey(packageName, info.providerID)
|
||||
@@ -368,13 +341,8 @@ function modelFromLanguage(
|
||||
},
|
||||
with: () => route,
|
||||
model: (input) => Model.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
|
||||
prepareTransport: (body, _request, options) =>
|
||||
Effect.succeed({ call: body as LanguageModelV3CallOptions, transformRequest: options?.transformRequest }),
|
||||
streamPrepared: (prepared) => {
|
||||
const request = prepared as AISDKPrepared
|
||||
if (!request.transformRequest) return streamLanguage(language, request.call)
|
||||
return streamLanguage(language, request.call, requests, request.transformRequest)
|
||||
},
|
||||
prepareTransport: (body) => Effect.succeed(body),
|
||||
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
|
||||
}
|
||||
return Model.make({ id: info.modelID ?? info.id, provider: info.providerID, route })
|
||||
}
|
||||
@@ -565,21 +533,13 @@ function providerOptions(input: LLMRequest["providerOptions"]): SharedV3Provider
|
||||
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
|
||||
}
|
||||
|
||||
function streamLanguage(
|
||||
language: LanguageModelV3,
|
||||
options: LanguageModelV3CallOptions,
|
||||
requests?: AsyncLocalStorage<TransformRequest>,
|
||||
transformRequest?: TransformRequest,
|
||||
) {
|
||||
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions) {
|
||||
const state = { step: 0, toolNames: {} as Record<string, string> }
|
||||
return Stream.concat(
|
||||
Stream.make(LLMEvent.stepStart({ index: state.step })),
|
||||
Stream.unwrap(
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
requests && transformRequest
|
||||
? requests.run(transformRequest, () => language.doStream(options))
|
||||
: language.doStream(options),
|
||||
try: () => language.doStream(options),
|
||||
catch: (error) => llmError("doStream", error),
|
||||
}).pipe(
|
||||
Effect.map((result) =>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { isAbsolute, join } from "path"
|
||||
import { DatabaseMigration } from "./migration"
|
||||
import { InstallationChannel } from "@opencode-ai/util/installation/version"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
|
||||
@@ -39,12 +40,20 @@ const databaseLayer = Layer.effect(
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
|
||||
export function layer(options: Options = { path: ":memory:" }) {
|
||||
export function layer(options?: Options) {
|
||||
return Layer.suspend(() => {
|
||||
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
return provide(join(Global.Path.data, filename))
|
||||
if (options?.path === ":memory:" || (options?.path && isAbsolute(options.path))) return provide(options.path)
|
||||
if (options?.path) return provide(join(Global.Path.data, options.path))
|
||||
if (
|
||||
["latest", "beta", "prod"].includes(InstallationChannel) ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
|
||||
)
|
||||
return provide(join(Global.Path.data, "opencode.db"))
|
||||
return provide(
|
||||
join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,6 @@ export interface Domains {
|
||||
type Callback<Event> = (event: Event) => Effect.Effect<void>
|
||||
|
||||
export interface Interface {
|
||||
readonly has: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
|
||||
domain: Domain,
|
||||
name: Name,
|
||||
) => boolean
|
||||
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
|
||||
domain: Domain,
|
||||
name: Name,
|
||||
@@ -64,7 +60,7 @@ const layer = Layer.effect(
|
||||
return event
|
||||
})
|
||||
|
||||
return Service.of({ has: (domain, name) => (callbacks.get(key(domain, name))?.length ?? 0) > 0, register, trigger })
|
||||
return Service.of({ register, trigger })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { SessionV1 } from "./v1/session"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { Slug } from "./util/slug"
|
||||
import { ProjectTable } from "./project/sql"
|
||||
import path from "path"
|
||||
@@ -352,7 +351,7 @@ const layer = Layer.effect(
|
||||
const info = SessionV1.SessionInfo.make({
|
||||
id: sessionID,
|
||||
slug: Slug.create(),
|
||||
version: InstallationVersion,
|
||||
version: "unknown",
|
||||
projectID: project.id,
|
||||
parentID: input.parentID,
|
||||
directory: location.directory,
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
export * as SessionCompaction from "./compaction"
|
||||
|
||||
import { LLM, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/ai"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Config } from "../config"
|
||||
import { EventV2 } from "../event"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Client } from "@opencode-ai/util/client"
|
||||
import { llmClient } from "../effect/app-node-platform"
|
||||
import { SessionEvent } from "./event"
|
||||
import type { SessionMessage } from "./message"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionModelStream } from "./model-stream"
|
||||
import { Client } from "@opencode-ai/util/client"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { toSessionError } from "./to-session-error"
|
||||
@@ -65,7 +63,9 @@ type Settings = {
|
||||
type Dependencies = {
|
||||
readonly client: string
|
||||
readonly events: EventV2.Interface
|
||||
readonly llm: SessionModelStream.Interface
|
||||
readonly llm: {
|
||||
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
|
||||
}
|
||||
readonly models: SessionRunnerModel.Interface
|
||||
readonly config: Settings
|
||||
}
|
||||
@@ -73,9 +73,7 @@ type Dependencies = {
|
||||
export type AutoInput = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly model: LLMRequest["model"]
|
||||
readonly agent: Agent.ID
|
||||
readonly modelRef: Model.Ref
|
||||
readonly model: Model
|
||||
readonly cost: ModelV2.Info["cost"]
|
||||
}
|
||||
|
||||
@@ -83,14 +81,11 @@ export type ManualInput = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly inputID: SessionMessage.ID
|
||||
readonly agent: Agent.ID
|
||||
}
|
||||
|
||||
type Plan = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly model: LLMRequest["model"]
|
||||
readonly agent: Agent.ID
|
||||
readonly modelRef: Model.Ref
|
||||
readonly model: Model
|
||||
readonly cost: ModelV2.Info["cost"]
|
||||
readonly reason: SessionMessage.Compaction["reason"]
|
||||
readonly prompt: string
|
||||
@@ -262,17 +257,14 @@ const make = (dependencies: Dependencies) => {
|
||||
: Effect.void,
|
||||
)
|
||||
yield* dependencies.llm
|
||||
.stream({
|
||||
sessionID: plan.session.id,
|
||||
agent: plan.agent,
|
||||
model: plan.modelRef,
|
||||
request: LLM.request({
|
||||
.stream(
|
||||
LLM.request({
|
||||
model: plan.model,
|
||||
http: { headers: SessionModelHeaders.make(plan.session, dependencies.client) },
|
||||
messages: [Message.user(plan.prompt)],
|
||||
tools: [],
|
||||
}),
|
||||
})
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
@@ -339,8 +331,6 @@ const make = (dependencies: Dependencies) => {
|
||||
session: input.session,
|
||||
model: input.model,
|
||||
cost: input.cost,
|
||||
agent: input.agent,
|
||||
modelRef: input.modelRef,
|
||||
reason: "auto",
|
||||
...content,
|
||||
})
|
||||
@@ -390,8 +380,6 @@ const make = (dependencies: Dependencies) => {
|
||||
session: input.session,
|
||||
model: resolved.model,
|
||||
cost: resolved.cost,
|
||||
agent: input.agent,
|
||||
modelRef: resolved.ref,
|
||||
reason: "manual",
|
||||
inputID: input.inputID,
|
||||
...content,
|
||||
@@ -408,7 +396,7 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const llm = yield* SessionModelStream.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const config = yield* Config.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const client = yield* Client.Name
|
||||
@@ -419,5 +407,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [EventV2.node, SessionModelStream.node, Config.node, SessionRunnerModel.node, Client.node],
|
||||
deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, Client.node],
|
||||
})
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
export * as SessionModelRequest from "./model-request"
|
||||
|
||||
import { LLM, Message, SystemPart, type LLMRequest, type ToolContent } from "@opencode-ai/ai"
|
||||
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Client } from "@opencode-ai/util/client"
|
||||
import { ModelV2 } from "../model"
|
||||
import { PluginHooks } from "../plugin/hooks"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { SessionContext } from "./context"
|
||||
@@ -28,45 +27,6 @@ interface PrepareInput {
|
||||
readonly step: number
|
||||
}
|
||||
|
||||
const mimeToModality = (mime: string) => {
|
||||
if (mime.startsWith("image/")) return "image"
|
||||
if (mime.startsWith("audio/")) return "audio"
|
||||
if (mime.startsWith("video/")) return "video"
|
||||
if (mime === "application/pdf") return "pdf"
|
||||
}
|
||||
|
||||
const unsupportedMedia = (mime: string, name: string | undefined, capabilities: ModelV2.Capabilities) => {
|
||||
const modality = mimeToModality(mime)
|
||||
if (!modality || capabilities.input.some((item) => item.startsWith(modality))) return
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: `ERROR: Cannot read ${name ? `"${name}"` : modality} (this model does not support ${modality} input). Inform the user.`,
|
||||
}
|
||||
}
|
||||
|
||||
export const unsupportedParts = (messages: LLMRequest["messages"], capabilities: ModelV2.Capabilities) =>
|
||||
messages.map((message) =>
|
||||
Message.make({
|
||||
...message,
|
||||
content: message.content.map((part) => {
|
||||
if (part.type === "media") {
|
||||
return unsupportedMedia(part.mediaType, part.filename, capabilities) ?? part
|
||||
}
|
||||
if (part.type !== "tool-result" || part.result.type !== "content") return part
|
||||
return {
|
||||
...part,
|
||||
result: {
|
||||
...part.result,
|
||||
value: part.result.value.map((item: ToolContent) => {
|
||||
if (item.type !== "file") return item
|
||||
return unsupportedMedia(item.mime, item.name, capabilities) ?? item
|
||||
}),
|
||||
},
|
||||
}
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds an outbound model request and captures the tool-call capability that
|
||||
* must remain paired with it. It does not execute the request or mutate
|
||||
@@ -127,7 +87,7 @@ export const layer = Layer.effect(
|
||||
},
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
system: contextEvent.system,
|
||||
messages: unsupportedParts(contextEvent.messages, resolved.capabilities),
|
||||
messages: contextEvent.messages,
|
||||
tools: hookedTools,
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
export * as SessionModelStream from "./model-stream"
|
||||
|
||||
import { LLMClient, type LLMError, type LLMEvent, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { llmClient } from "../effect/app-node-platform"
|
||||
import { PluginHooks } from "../plugin/hooks"
|
||||
|
||||
export interface Input {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly request: LLMRequest
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly stream: (input: Input) => Stream.Stream<LLMEvent, LLMError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionModelStream") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
return Service.of({
|
||||
stream: (input) => {
|
||||
if (!hooks.has("session", "request")) return llm.stream(input.request)
|
||||
return llm.stream(input.request, {
|
||||
transformRequest: (request) =>
|
||||
hooks
|
||||
.trigger("session", "request", {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
})
|
||||
.pipe(Effect.map((event) => ({ headers: event.headers, body: event.body }))),
|
||||
})
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [PluginHooks.node, llmClient] })
|
||||
@@ -1,10 +1,9 @@
|
||||
export * as SessionRunnerLLM from "./llm"
|
||||
|
||||
import { LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai"
|
||||
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { Database } from "../../database/database"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { EventV2 } from "../../event"
|
||||
import { PermissionV2 } from "../../permission"
|
||||
import { QuestionTool } from "../../tool/question"
|
||||
@@ -15,7 +14,6 @@ import { SessionContext } from "../context"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionPending } from "../pending"
|
||||
import { SessionModelRequest } from "../model-request"
|
||||
import { SessionModelStream } from "../model-stream"
|
||||
import { SessionMessage } from "../message"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
@@ -24,6 +22,7 @@ import { Service } from "./index"
|
||||
import { createLLMEventPublisher } from "./publish-llm-event"
|
||||
import { Snapshot } from "../../snapshot"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { llmClient } from "../../effect/app-node-platform"
|
||||
import { StepFailedError } from "../error"
|
||||
import { toSessionError } from "../to-session-error"
|
||||
import { SessionRunnerRetry } from "./retry"
|
||||
@@ -33,7 +32,7 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const llm = yield* SessionModelStream.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const context = yield* SessionContext.Service
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
@@ -41,7 +40,6 @@ const layer = Layer.effect(
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
// Title generation is a side effect of the first step; it must not delay step continuation.
|
||||
// Tracked per process so repeated wakes before the second user message arrives don't
|
||||
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
|
||||
@@ -104,14 +102,7 @@ const layer = Layer.effect(
|
||||
const agent = loaded.agent
|
||||
const resolved = loaded.model
|
||||
const model = resolved.model
|
||||
const compactionInput = {
|
||||
session,
|
||||
messages: loaded.messages,
|
||||
model,
|
||||
agent: agent.id,
|
||||
modelRef: resolved.ref,
|
||||
cost: resolved.cost,
|
||||
}
|
||||
const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost }
|
||||
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
|
||||
const compacted = yield* compaction.compact(compactionInput)
|
||||
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const
|
||||
@@ -141,69 +132,67 @@ const layer = Layer.effect(
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm
|
||||
.stream({ sessionID: session.id, agent: agent.id, model: resolved.ref, request: prepared.request })
|
||||
.pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
if (LLMEvent.is.providerError(event)) {
|
||||
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
|
||||
overflowFailure = event
|
||||
return
|
||||
}
|
||||
}
|
||||
yield* publish(event)
|
||||
if (LLMEvent.is.toolInputError(event)) {
|
||||
if (prepared.resolveToolCall(event.name).type === "settle") needsContinuation = true
|
||||
const providerStream = llm.stream(prepared.request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
if (LLMEvent.is.providerError(event)) {
|
||||
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
|
||||
overflowFailure = event
|
||||
return
|
||||
}
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
const tool = prepared.resolveToolCall(event.name)
|
||||
if (tool.type === "reject") {
|
||||
yield* serialized(publisher.failUnsettledTools(tool.error))
|
||||
return
|
||||
}
|
||||
needsContinuation = true
|
||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||
ownedToolFibers.push(
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
restore(
|
||||
tool.settle({
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
messageID: assistantMessageID,
|
||||
call: event,
|
||||
progress: (update) =>
|
||||
serialized(
|
||||
events.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID: session.id,
|
||||
assistantMessageID,
|
||||
callID: event.id,
|
||||
structured: { ...update.structured },
|
||||
content: [...update.content],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatMap((settlement) =>
|
||||
publish(
|
||||
LLMEvent.toolResult({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
}
|
||||
yield* publish(event)
|
||||
if (LLMEvent.is.toolInputError(event)) {
|
||||
if (prepared.resolveToolCall(event.name).type === "settle") needsContinuation = true
|
||||
return
|
||||
}
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
const tool = prepared.resolveToolCall(event.name)
|
||||
if (tool.type === "reject") {
|
||||
yield* serialized(publisher.failUnsettledTools(tool.error))
|
||||
return
|
||||
}
|
||||
needsContinuation = true
|
||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||
ownedToolFibers.push(
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
restore(
|
||||
tool.settle({
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
messageID: assistantMessageID,
|
||||
call: event,
|
||||
progress: (update) =>
|
||||
serialized(
|
||||
events.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID: session.id,
|
||||
assistantMessageID,
|
||||
callID: event.id,
|
||||
structured: { ...update.structured },
|
||||
content: [...update.content],
|
||||
}),
|
||||
settlement.error,
|
||||
),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatMap((settlement) =>
|
||||
publish(
|
||||
LLMEvent.toolResult({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
}),
|
||||
settlement.error,
|
||||
),
|
||||
),
|
||||
).pipe(FiberSet.run(toolFibers)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(serialized(publisher.flush())),
|
||||
)
|
||||
),
|
||||
).pipe(FiberSet.run(toolFibers)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(serialized(publisher.flush())),
|
||||
)
|
||||
|
||||
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
|
||||
cost: SessionUsage.calculateCost(resolved.cost, settlement.tokens),
|
||||
@@ -250,7 +239,8 @@ const layer = Layer.effect(
|
||||
recoverOverflow &&
|
||||
!publisher.hasRetryEvidence() &&
|
||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||
(yield* restore(recoverOverflow(compactionInput))).status === "completed"
|
||||
(yield* restore(recoverOverflow({ session, messages: loaded.messages, model, cost: resolved.cost })))
|
||||
.status === "completed"
|
||||
)
|
||||
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
||||
|
||||
@@ -423,10 +413,8 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const compacted = yield* restore(
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* agents.select(session.agent)
|
||||
return yield* compaction.compactManual({
|
||||
session,
|
||||
agent: agent.id,
|
||||
messages: yield* store.context(sessionID),
|
||||
inputID: pending.id,
|
||||
})
|
||||
@@ -505,13 +493,12 @@ export const node = makeLocationNode({
|
||||
layer,
|
||||
deps: [
|
||||
EventV2.node,
|
||||
SessionModelStream.node,
|
||||
llmClient,
|
||||
SessionContext.node,
|
||||
SessionModelRequest.node,
|
||||
SessionStore.node,
|
||||
SessionCompaction.node,
|
||||
SessionTitle.node,
|
||||
AgentV2.node,
|
||||
Snapshot.node,
|
||||
Database.node,
|
||||
],
|
||||
|
||||
@@ -82,8 +82,6 @@ export interface Resolved {
|
||||
readonly model: Model
|
||||
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
|
||||
readonly ref: ModelV2.Ref
|
||||
/** Catalog capabilities used to shape requests before provider lowering. */
|
||||
readonly capabilities: ModelV2.Capabilities
|
||||
/** Catalog pricing in dollars per million tokens. */
|
||||
readonly cost: ModelV2.Info["cost"]
|
||||
}
|
||||
@@ -98,22 +96,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
||||
|
||||
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
|
||||
export const resolved = (
|
||||
model: Model,
|
||||
options: {
|
||||
readonly capabilities: ModelV2.Capabilities
|
||||
readonly variant?: ModelV2.VariantID
|
||||
readonly cost: ModelV2.Info["cost"]
|
||||
},
|
||||
): Resolved => ({
|
||||
export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({
|
||||
model,
|
||||
ref: ModelV2.Ref.make({
|
||||
id: ModelV2.ID.make(model.id),
|
||||
providerID: ProviderV2.ID.make(model.provider),
|
||||
...(options.variant === undefined ? {} : { variant: options.variant }),
|
||||
...(variant === undefined ? {} : { variant }),
|
||||
}),
|
||||
capabilities: options.capabilities,
|
||||
cost: options.cost,
|
||||
cost,
|
||||
})
|
||||
|
||||
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
|
||||
@@ -369,7 +359,6 @@ const layer = Layer.effect(
|
||||
providerID: selected.providerID,
|
||||
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
|
||||
}),
|
||||
capabilities: selected.capabilities,
|
||||
cost: selected.cost,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export * as SessionTitle from "./title"
|
||||
|
||||
import { LLM, LLMEvent, Message } from "@opencode-ai/ai"
|
||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Client } from "@opencode-ai/util/client"
|
||||
import { llmClient } from "../effect/app-node-platform"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionModelStream } from "./model-stream"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionUsage } from "./usage"
|
||||
@@ -20,7 +20,9 @@ const MAX_LENGTH = 100
|
||||
type Dependencies = {
|
||||
readonly client: string
|
||||
readonly events: EventV2.Interface
|
||||
readonly llm: SessionModelStream.Interface
|
||||
readonly llm: {
|
||||
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
|
||||
}
|
||||
readonly agents: AgentV2.Interface
|
||||
readonly models: SessionRunnerModel.Interface
|
||||
}
|
||||
@@ -63,18 +65,15 @@ const make = (dependencies: Dependencies) => {
|
||||
: Effect.void,
|
||||
)
|
||||
const streamed = yield* dependencies.llm
|
||||
.stream({
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
request: LLM.request({
|
||||
.stream(
|
||||
LLM.request({
|
||||
model: resolved.model,
|
||||
http: { headers: SessionModelHeaders.make(session, dependencies.client) },
|
||||
system: agent.system,
|
||||
messages: [Message.user(firstUser.text)],
|
||||
tools: [],
|
||||
}),
|
||||
})
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event)) failed = true
|
||||
@@ -109,7 +108,7 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const llm = yield* SessionModelStream.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const database = yield* Database.Service
|
||||
@@ -124,5 +123,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [EventV2.node, SessionModelStream.node, AgentV2.node, SessionRunnerModel.node, Database.node, Client.node],
|
||||
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node, Client.node],
|
||||
})
|
||||
|
||||
@@ -189,7 +189,6 @@ export const Plugin = {
|
||||
catch: (error) => new ToolFailure({ message: `patch verification failed: ${String(error)}` }),
|
||||
})
|
||||
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
|
||||
if (moveTarget) targets.push(moveTarget)
|
||||
if (moveTarget?.externalDirectory) {
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
|
||||
@@ -3,17 +3,12 @@ import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { LLM, LLMError, LLMEvent, Message } from "@opencode-ai/ai"
|
||||
import { LLMClient, RequestExecutor, type RequestData } from "@opencode-ai/ai/route"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.merge(
|
||||
AISDK.locationLayer,
|
||||
LLMClient.layer.pipe(Layer.provide(Layer.mock(RequestExecutor.Service)({ execute: () => Effect.die("unused") }))),
|
||||
),
|
||||
)
|
||||
const it = testEffect(AISDK.locationLayer)
|
||||
|
||||
const model = (packageName: string, settings: Record<string, unknown> = {}) =>
|
||||
ModelV2.Info.make({
|
||||
@@ -104,64 +99,6 @@ it.effect("projects request settings, headers, and body overlays", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("isolates request transforms across cached AI SDK streams", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const upstream: Array<{ headers: Headers; body: Record<string, unknown> }> = []
|
||||
let sdkFetch: typeof fetch | undefined
|
||||
const input = model("test-sdk")
|
||||
if (!input.settings) return yield* Effect.die("AI SDK test model settings are missing")
|
||||
Object.assign(input.settings, {
|
||||
fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
upstream.push({
|
||||
headers: new Headers(init?.headers),
|
||||
body: JSON.parse(String(init?.body)),
|
||||
})
|
||||
return new Response()
|
||||
},
|
||||
})
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
sdkFetch = event.options.fetch
|
||||
event.sdk = {
|
||||
languageModel: () => ({
|
||||
doStream: async (options: LanguageModelV3CallOptions) => {
|
||||
if (!sdkFetch) throw new Error("AI SDK fetch was not installed")
|
||||
await sdkFetch("https://provider.test/model", {
|
||||
method: "POST",
|
||||
headers: Object.fromEntries(
|
||||
Object.entries(options.headers ?? {}).filter(
|
||||
(entry): entry is [string, string] => entry[1] !== undefined,
|
||||
),
|
||||
),
|
||||
body: JSON.stringify({ model: "api-model", remove: true }),
|
||||
})
|
||||
return { stream: new ReadableStream({ start: (controller) => controller.close() }) }
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
const resolved = yield* aisdk.model(input)
|
||||
const llm = yield* LLMClient.Service
|
||||
const run = (session: string) =>
|
||||
llm
|
||||
.stream(LLM.request({ model: resolved, prompt: session }), {
|
||||
transformRequest: (request) =>
|
||||
Effect.sync(() => {
|
||||
const body: RequestData["body"] = { ...request.body, session }
|
||||
delete body.remove
|
||||
return { headers: { ...request.headers, "x-session": session }, body }
|
||||
}),
|
||||
})
|
||||
.pipe(Stream.runDrain)
|
||||
|
||||
yield* Effect.all([run("one"), run("two")], { concurrency: "unbounded" })
|
||||
|
||||
expect(upstream.map((request) => request.headers.get("x-session")).toSorted()).toEqual(["one", "two"])
|
||||
expect(upstream.map((request) => request.body.session).toSorted()).toEqual(["one", "two"])
|
||||
expect(upstream.every((request) => !("remove" in request.body))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps pro reasoning bodies to AI SDK provider options", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
||||
@@ -40,18 +40,6 @@ describe("Patch", () => {
|
||||
expect(() => parse("*** Begin Patch\n*** Add File: add.txt\n+added")).toThrow(
|
||||
"The last line of the patch must be '*** End Patch'",
|
||||
)
|
||||
expect(() => parse("extra\n*** Begin Patch\n*** End Patch")).toThrow(
|
||||
"The first line of the patch must be '*** Begin Patch'",
|
||||
)
|
||||
expect(() => parse("*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nextra")).toThrow(
|
||||
"The last line of the patch must be '*** End Patch'",
|
||||
)
|
||||
})
|
||||
|
||||
test("allows whitespace after the end marker", () => {
|
||||
expect(parse("*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\n \t\n")).toEqual([
|
||||
{ type: "add", path: "add.txt", contents: "added" },
|
||||
])
|
||||
})
|
||||
|
||||
test("strips a heredoc wrapper", () => {
|
||||
@@ -181,41 +169,6 @@ describe("Patch", () => {
|
||||
).toBe('He said "hi"\n')
|
||||
})
|
||||
|
||||
test("matches Unicode minus signs and spaces", () => {
|
||||
expect(
|
||||
Patch.derive("minus.txt", [{ oldLines: ["value - 1"], newLines: ["value - 2"] }], "value − 1\n")
|
||||
.content,
|
||||
).toBe("value - 2\n")
|
||||
const spaces = ["\u00A0", "\u2002", "\u2003", "\u2004", "\u2005", "\u2006", "\u2007", "\u2008", "\u2009", "\u200A", "\u202F", "\u205F", "\u3000"]
|
||||
spaces.forEach(
|
||||
(space) => {
|
||||
expect(
|
||||
Patch.derive(
|
||||
"spaces.txt",
|
||||
[{ oldLines: ["hello world"], newLines: ["hello there"] }],
|
||||
`hello${space}world\n`,
|
||||
).content,
|
||||
).toBe("hello there\n")
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test("does not normalize ellipses", () => {
|
||||
expect(() =>
|
||||
Patch.derive("ellipsis.txt", [{ oldLines: ["wait..."], newLines: ["done"] }], "wait…\n"),
|
||||
).toThrow("Failed to find expected lines")
|
||||
})
|
||||
|
||||
test("prefers a later exact match over an earlier normalized match", () => {
|
||||
expect(
|
||||
Patch.derive(
|
||||
"quotes.txt",
|
||||
[{ oldLines: ['He said "hello"'], newLines: ['He said "goodbye"'] }],
|
||||
'He said “hello”\nmiddle\nHe said "hello"\n',
|
||||
).content,
|
||||
).toBe('He said “hello”\nmiddle\nHe said "goodbye"\n')
|
||||
})
|
||||
|
||||
test("matches EOF-anchored chunks from the end", () => {
|
||||
expect(
|
||||
Patch.derive(
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { PluginHooks } from "../src/plugin/hooks"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -29,7 +28,7 @@ describe("PluginHooks", () => {
|
||||
event.messages = [Message.user("changed")]
|
||||
}),
|
||||
)
|
||||
const event: SessionHooks["context"] = {
|
||||
const event = {
|
||||
sessionID: Session.ID.make("ses_hooks"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||
@@ -43,34 +42,4 @@ describe("PluginHooks", () => {
|
||||
expect(event.messages).toEqual([Message.user("changed")])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers session request hooks and triggers them sequentially", () =>
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "request", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.headers.authorization = "Bearer changed"
|
||||
delete event.body.max_output_tokens
|
||||
event.body.store = false
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "request", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.headers = { ...event.headers, "x-store": String(event.body.store) }
|
||||
event.body = { input: event.body.input ?? [] }
|
||||
}),
|
||||
)
|
||||
const event: SessionHooks["request"] = {
|
||||
sessionID: Session.ID.make("ses_request_hooks"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||
headers: { authorization: "Bearer original" },
|
||||
body: { input: ["hello"], max_output_tokens: 1024 },
|
||||
}
|
||||
|
||||
expect(yield* hooks.trigger("session", "request", event)).toBe(event)
|
||||
expect(event.headers).toEqual({ authorization: "Bearer changed", "x-store": "false" })
|
||||
expect(event.body).toEqual({ input: ["hello"] })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -218,37 +218,6 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards session request hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* PluginPromise.fromPromise(
|
||||
Plugin.define({
|
||||
id: "promise-session-request",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("request", (event) => {
|
||||
event.headers["x-plugin"] = "promise"
|
||||
delete event.body.max_output_tokens
|
||||
})
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
const event: SessionHooks["request"] = {
|
||||
sessionID: SessionV2.ID.make("ses_promise_session_request"),
|
||||
agent: AgentV2.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||
headers: {},
|
||||
body: { max_output_tokens: 1024 },
|
||||
}
|
||||
|
||||
yield* hooks.trigger("session", "request", event)
|
||||
|
||||
expect(event.headers).toEqual({ "x-plugin": "promise" })
|
||||
expect(event.body).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disposes a hook registration on request", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
|
||||
@@ -49,14 +49,7 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
generate: () => Effect.die("unused"),
|
||||
})
|
||||
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||
const models = SessionRunnerModel.layerWith(() =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
}),
|
||||
),
|
||||
)
|
||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
|
||||
const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -69,13 +68,7 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
})
|
||||
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
resolve: () =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost,
|
||||
}),
|
||||
),
|
||||
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
|
||||
})
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
@@ -182,7 +175,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
agent: AgentV2.ID.make("build"),
|
||||
messages: [userMessage],
|
||||
inputID: SessionMessage.ID.make("msg_manual_compaction"),
|
||||
}),
|
||||
|
||||
@@ -65,14 +65,7 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
return response
|
||||
}),
|
||||
})
|
||||
const models = SessionRunnerModel.layerWith(() =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
}),
|
||||
),
|
||||
)
|
||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
|
||||
const builtins = Layer.mock(InstructionBuiltIns.Service, {
|
||||
load: () =>
|
||||
Effect.succeed(
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Message, ToolResultPart } from "@opencode-ai/ai"
|
||||
import { unsupportedParts } from "@opencode-ai/core/session/model-request"
|
||||
|
||||
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
|
||||
|
||||
describe("SessionModelRequest.unsupportedParts", () => {
|
||||
test("replaces unsupported user media with a visible error", () => {
|
||||
const messages = unsupportedParts(
|
||||
[
|
||||
Message.user([
|
||||
Message.text("Describe this image"),
|
||||
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "logo.png" },
|
||||
]),
|
||||
],
|
||||
capabilities(["text"]),
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
Message.text("Describe this image"),
|
||||
Message.text('ERROR: Cannot read "logo.png" (this model does not support image input). Inform the user.'),
|
||||
])
|
||||
})
|
||||
|
||||
test("replaces unsupported media nested in tool results", () => {
|
||||
const messages = unsupportedParts(
|
||||
[
|
||||
Message.tool(
|
||||
ToolResultPart.make({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "logo.png" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
capabilities(["text"]),
|
||||
)
|
||||
|
||||
expect(messages[0]?.content[0]).toMatchObject({
|
||||
type: "tool-result",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{
|
||||
type: "text",
|
||||
text: 'ERROR: Cannot read "logo.png" (this model does not support image input). Inform the user.',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves supported media", () => {
|
||||
const message = Message.user({ type: "media", mediaType: "image/png", data: "aGVsbG8=" })
|
||||
expect(unsupportedParts([message], capabilities(["text", "image"]))[0]?.content).toEqual(message.content)
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { LLM, LLMClient } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import type { RequestData } from "@opencode-ai/ai/route"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { SessionModelStream } from "@opencode-ai/core/session/model-stream"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const prepared: RequestData[] = []
|
||||
const client = Layer.mock(LLMClient.Service)({
|
||||
prepare: () => Effect.die("unused"),
|
||||
generate: () => Effect.die("unused"),
|
||||
stream: (_request, options) =>
|
||||
Stream.unwrap(
|
||||
options?.transformRequest
|
||||
? options.transformRequest({ headers: { original: "true" }, body: { remove: true } }).pipe(
|
||||
Effect.map((request) => {
|
||||
prepared.push(request)
|
||||
return Stream.empty
|
||||
}),
|
||||
)
|
||||
: Effect.die("request transform was not provided"),
|
||||
),
|
||||
})
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([PluginHooks.node, SessionModelStream.node]), [[llmClient, client]]),
|
||||
)
|
||||
|
||||
it.effect("forwards session identity and applies request hook mutations", () =>
|
||||
Effect.gen(function* () {
|
||||
prepared.length = 0
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const stream = yield* SessionModelStream.Service
|
||||
const sessionID = Session.ID.make("ses_model_stream")
|
||||
const agent = Agent.ID.make("build")
|
||||
const model = Model.Ref.make({
|
||||
providerID: Provider.ID.make("test"),
|
||||
id: Model.ID.make("catalog-model"),
|
||||
})
|
||||
yield* hooks.register("session", "request", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.sessionID).toBe(sessionID)
|
||||
expect(event.agent).toBe(agent)
|
||||
expect(event.model).toEqual(model)
|
||||
event.headers["x-plugin"] = "enabled"
|
||||
delete event.body.remove
|
||||
}),
|
||||
)
|
||||
yield* stream
|
||||
.stream({
|
||||
sessionID,
|
||||
agent,
|
||||
model,
|
||||
request: LLM.request({
|
||||
model: OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" } })
|
||||
.model({ id: "api-model" }),
|
||||
prompt: "Hello",
|
||||
}),
|
||||
})
|
||||
.pipe(Stream.runDrain)
|
||||
|
||||
expect(prepared).toEqual([{ headers: { original: "true", "x-plugin": "enabled" }, body: {} }])
|
||||
}),
|
||||
)
|
||||
@@ -73,14 +73,7 @@ const model = OpenAIChat.route
|
||||
generation: { maxTokens: 20, temperature: 0 },
|
||||
})
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
const models = SessionRunnerModel.layerWith(() =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
}),
|
||||
),
|
||||
)
|
||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
|
||||
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
|
||||
@@ -283,11 +283,10 @@ let currentModel = model
|
||||
const models = SessionRunnerModel.layerWith((session) =>
|
||||
modelResolveHook.pipe(
|
||||
Effect.as(
|
||||
SessionRunnerModel.resolved(session.model?.id === "replacement" ? replacementModel : currentModel, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
variant: session.model?.variant,
|
||||
}),
|
||||
SessionRunnerModel.resolved(
|
||||
session.model?.id === "replacement" ? replacementModel : currentModel,
|
||||
session.model?.variant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -65,13 +65,7 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
generate: () => Effect.die("unused"),
|
||||
})
|
||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
resolve: () =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost,
|
||||
}),
|
||||
),
|
||||
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
|
||||
})
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
|
||||
@@ -351,28 +351,6 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("includes the move destination in edit permission resources", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(directory, "old", "name.txt")
|
||||
yield* Effect.promise(() => fs.mkdir(path.dirname(source), { recursive: true }))
|
||||
yield* Effect.promise(() => fs.writeFile(source, "old content\n"))
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch",
|
||||
),
|
||||
)
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
action: "edit",
|
||||
resources: ["old/name.txt", "renamed/dir/name.txt"],
|
||||
},
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("inserts lines with an insert-only hunk", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -4,11 +4,8 @@ import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { JsonSchema } from "effect"
|
||||
import type { SessionRequest } from "../session.js"
|
||||
import type { Hooks } from "./registration.js"
|
||||
|
||||
export type { RequestBody, RequestValue, SessionRequest } from "../session.js"
|
||||
|
||||
export interface SessionContext {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
@@ -20,7 +17,6 @@ export interface SessionContext {
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly request: SessionRequest
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -4,11 +4,8 @@ import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { JsonSchema } from "effect"
|
||||
import type { SessionRequest } from "../session.js"
|
||||
import type { Hooks } from "./registration.js"
|
||||
|
||||
export type { RequestBody, RequestValue, SessionRequest } from "../session.js"
|
||||
|
||||
export interface SessionContext {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
@@ -20,7 +17,6 @@ export interface SessionContext {
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly request: SessionRequest
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { RequestData } from "@opencode-ai/ai/route"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
export type RequestValue = RequestData["body"][string]
|
||||
|
||||
export type RequestBody = RequestData["body"]
|
||||
|
||||
export interface SessionRequest {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
headers: Record<string, string>
|
||||
body: RequestBody
|
||||
}
|
||||
@@ -122,19 +122,6 @@ export const Info = Schema.Struct({
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Session transcript presentation settings" }),
|
||||
mini: Schema.optional(
|
||||
Schema.Struct({
|
||||
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||
description: "Show or hide model reasoning in Mini",
|
||||
}),
|
||||
shell_output: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||
description: "Show or hide raw shell tool output in Mini",
|
||||
}),
|
||||
turn_summary: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||
description: "Show or hide the agent, model, and duration summary in Mini scrollback",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Mini transcript presentation settings" }),
|
||||
hints: Schema.optional(
|
||||
Schema.Struct({
|
||||
onboarding: Schema.optional(Schema.Boolean).annotate({ description: "Show getting-started guidance" }),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { toolEntryBody } from "./tool"
|
||||
import type { RunEntryBody, ScrollbackOptions, StreamCommit } from "./types"
|
||||
import type { RunEntryBody, StreamCommit } from "./types"
|
||||
|
||||
export type EntryFlags = {
|
||||
startOnNewLine: boolean
|
||||
@@ -162,7 +162,7 @@ export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolea
|
||||
return commit.kind === "assistant" || commit.kind === "reasoning"
|
||||
}
|
||||
|
||||
export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): RunEntryBody {
|
||||
export function entryBody(commit: StreamCommit): RunEntryBody {
|
||||
if (commit.summary) {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
@@ -174,7 +174,7 @@ export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): Ru
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
return toolEntryBody(commit, raw, options) ?? RUN_ENTRY_NONE
|
||||
return toolEntryBody(commit, raw) ?? RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
if (commit.kind === "assistant") {
|
||||
|
||||
@@ -5,15 +5,7 @@ import fuzzysort from "fuzzysort"
|
||||
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
||||
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type {
|
||||
FooterQueuedPrompt,
|
||||
FooterSubagentTab,
|
||||
MiniSettingChange,
|
||||
MiniSettings,
|
||||
RunCommand,
|
||||
RunInput,
|
||||
RunProvider,
|
||||
} from "./types"
|
||||
import type { FooterQueuedPrompt, FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types"
|
||||
|
||||
type PanelEntry = RunFooterMenuItem & {
|
||||
category: string
|
||||
@@ -28,7 +20,6 @@ type CommandEntry =
|
||||
| (PanelEntry & { action: "subagent" })
|
||||
| (PanelEntry & { action: "variant.cycle" })
|
||||
| (PanelEntry & { action: "variant.list" })
|
||||
| (PanelEntry & { action: "settings" })
|
||||
| (PanelEntry & { action: "slash"; name: string })
|
||||
| (PanelEntry & { action: "exit" })
|
||||
|
||||
@@ -57,10 +48,6 @@ type QueuedEntry = PanelEntry & {
|
||||
prompt: FooterQueuedPrompt
|
||||
}
|
||||
|
||||
type SettingEntry = PanelEntry & {
|
||||
key: keyof MiniSettings
|
||||
}
|
||||
|
||||
const PANEL_PAD = 2
|
||||
const PANEL_LIST_ROWS = 10
|
||||
const PANEL_FRAME_ROWS = 6
|
||||
@@ -279,7 +266,6 @@ function PanelShell(props: {
|
||||
inputRef: (input: InputRenderable) => void
|
||||
onQuery: (query: string) => void
|
||||
children: JSX.Element
|
||||
hint?: string
|
||||
dark?: boolean
|
||||
chrome?: "default" | "minimal"
|
||||
}) {
|
||||
@@ -308,7 +294,7 @@ function PanelShell(props: {
|
||||
) : null}
|
||||
<box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
|
||||
<text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
|
||||
{props.hint ? `${props.hint} · ` : ""}esc
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box height={1} flexShrink={0} backgroundColor={background()} />
|
||||
@@ -414,7 +400,6 @@ export function RunCommandMenuBody(props: {
|
||||
onQueued: () => void
|
||||
onVariant: () => void
|
||||
onVariantCycle: () => void
|
||||
onSettings: () => void
|
||||
onCommand: (name: string) => void
|
||||
onNew: () => void
|
||||
onExit: () => void
|
||||
@@ -422,7 +407,7 @@ export function RunCommandMenuBody(props: {
|
||||
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
|
||||
const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length)
|
||||
const entries = createMemo<CommandEntry[]>(() => {
|
||||
const builtins = ["editor", "new", "settings"]
|
||||
const builtins = ["editor", "new"]
|
||||
const session: CommandEntry[] = [
|
||||
{
|
||||
action: "editor",
|
||||
@@ -530,13 +515,6 @@ export function RunCommandMenuBody(props: {
|
||||
...prompt,
|
||||
...agent,
|
||||
...commands,
|
||||
{
|
||||
action: "settings",
|
||||
category: "System",
|
||||
display: "Open settings",
|
||||
footer: "/settings",
|
||||
keywords: "/settings settings preferences configuration",
|
||||
},
|
||||
{ action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" },
|
||||
]
|
||||
})
|
||||
@@ -576,11 +554,6 @@ export function RunCommandMenuBody(props: {
|
||||
return
|
||||
}
|
||||
|
||||
if (item.action === "settings") {
|
||||
props.onSettings()
|
||||
return
|
||||
}
|
||||
|
||||
if (item.action === "exit") {
|
||||
props.onExit()
|
||||
return
|
||||
@@ -633,95 +606,6 @@ export function RunCommandMenuBody(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function RunSettingsBody(props: {
|
||||
theme: Accessor<RunFooterTheme>
|
||||
settings: Accessor<MiniSettings>
|
||||
onClose: () => void
|
||||
onChange: (change: MiniSettingChange) => void | Promise<void>
|
||||
}) {
|
||||
const [saving, setSaving] = createSignal<keyof MiniSettings>()
|
||||
const entries = createMemo<SettingEntry[]>(() => [
|
||||
{
|
||||
category: "Transcript",
|
||||
display: "Thinking",
|
||||
description: "future sessions",
|
||||
footer: saving() === "thinking" ? "saving" : props.settings().thinking,
|
||||
keywords: `thinking reasoning ${props.settings().thinking}`,
|
||||
key: "thinking",
|
||||
},
|
||||
{
|
||||
category: "Transcript",
|
||||
display: "Shell tool output",
|
||||
description: "model-issued commands",
|
||||
footer: saving() === "shell_output" ? "saving" : props.settings().shell_output,
|
||||
keywords: `shell tool command output ${props.settings().shell_output}`,
|
||||
key: "shell_output",
|
||||
},
|
||||
{
|
||||
category: "Transcript",
|
||||
display: "Turn summary",
|
||||
description: "agent, model, and duration",
|
||||
footer: saving() === "turn_summary" ? "saving" : props.settings().turn_summary,
|
||||
keywords: `turn summary agent model duration ${props.settings().turn_summary}`,
|
||||
key: "turn_summary",
|
||||
},
|
||||
])
|
||||
const change = (item: SettingEntry) => {
|
||||
if (saving()) return
|
||||
const current = props.settings()[item.key]
|
||||
setSaving(item.key)
|
||||
void Promise.resolve(props.onChange({ key: item.key, value: current === "show" ? "hide" : "show" }))
|
||||
.catch(() => {})
|
||||
.finally(() => setSaving())
|
||||
}
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
limit: PANEL_LIST_ROWS,
|
||||
onClose: props.onClose,
|
||||
onSelect: change,
|
||||
onKey(event, item) {
|
||||
const name = event.name.toLowerCase()
|
||||
if (name !== "left" && name !== "right") return false
|
||||
event.preventDefault()
|
||||
if (item) change(item)
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
title="Settings"
|
||||
countVisible={false}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
placeholder="Search"
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
hint="left/right change"
|
||||
dark
|
||||
chrome="minimal"
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
empty="No settings found"
|
||||
border={false}
|
||||
paddingLeft={PANEL_PAD}
|
||||
paddingRight={PANEL_PAD}
|
||||
grouped={!controller.query().trim()}
|
||||
background
|
||||
headerColor={props.theme().muted}
|
||||
/>
|
||||
</PanelShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function RunSubagentSelectBody(props: {
|
||||
theme: Accessor<RunFooterTheme>
|
||||
tabs: Accessor<FooterSubagentTab[]>
|
||||
|
||||
@@ -51,7 +51,7 @@ type Auto = RunFooterMenuItem & {
|
||||
type SlashOption = RunFooterMenuItem & {
|
||||
kind: "slash"
|
||||
name: string
|
||||
action?: "skill-menu" | "editor" | "settings"
|
||||
action?: "skill-menu" | "editor"
|
||||
}
|
||||
|
||||
type PromptOption = Auto | SlashOption
|
||||
@@ -79,7 +79,6 @@ type PromptInput = {
|
||||
onExitRequest?: () => boolean
|
||||
onExit: () => void
|
||||
onSkillMenu: () => void
|
||||
onSettings: () => void
|
||||
onRows: (rows: number) => void
|
||||
onStatus: (text: string) => void
|
||||
}
|
||||
@@ -381,13 +380,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
display: "/editor",
|
||||
description: "compose in your external editor",
|
||||
} satisfies SlashOption,
|
||||
{
|
||||
kind: "slash",
|
||||
action: "settings" as const,
|
||||
name: "settings",
|
||||
display: "/settings",
|
||||
description: "configure Mini transcript output",
|
||||
} satisfies SlashOption,
|
||||
{ kind: "slash", name: "new", display: "/new", description: "start a new session" } satisfies SlashOption,
|
||||
{ kind: "slash", name: "exit", display: "/exit", description: "close OpenCode" } satisfies SlashOption,
|
||||
]
|
||||
@@ -823,12 +815,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
return
|
||||
}
|
||||
|
||||
if (next.action === "settings" && !shell()) {
|
||||
cancelAutocomplete()
|
||||
input.onSettings()
|
||||
return
|
||||
}
|
||||
|
||||
const cursor = area.cursorOffset
|
||||
const head = parseSlashHead(area.plainText)
|
||||
const local = !shell() && (next.name === "new" || next.name === "exit")
|
||||
@@ -936,7 +922,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
if (current === "skill") return false
|
||||
if (current === "model") return false
|
||||
if (current === "variant") return false
|
||||
if (current === "settings") return false
|
||||
if (current === "queued-menu") return false
|
||||
if (current === "subagent-menu") return false
|
||||
return true
|
||||
@@ -1134,12 +1119,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
return
|
||||
}
|
||||
|
||||
if (!command && next.mode !== "shell" && next.text.trim().toLowerCase() === "/settings") {
|
||||
resetDraft()
|
||||
input.onSettings()
|
||||
return
|
||||
}
|
||||
|
||||
const parsed =
|
||||
command || next.mode === "shell" || isNewCommand(next.text)
|
||||
? undefined
|
||||
|
||||
@@ -56,7 +56,6 @@ export function RunFooterSubagentBody(props: {
|
||||
// Formatted interrupt shortcut from the registered keymap binding; the
|
||||
// command itself is dispatched through the keymap in footer.view.
|
||||
interrupt?: () => string | undefined
|
||||
shellOutput?: () => boolean
|
||||
}) {
|
||||
const theme = createMemo(() => props.theme())
|
||||
const footer = createMemo(() => theme().footer)
|
||||
@@ -87,7 +86,7 @@ export function RunFooterSubagentBody(props: {
|
||||
const rows = indexArray(commits, (commit, index) => (
|
||||
<box flexDirection="column" gap={0} flexShrink={0}>
|
||||
{index > 0 && separatorRows(commits()[index - 1], commit()) > 0 ? <box height={1} flexShrink={0} /> : null}
|
||||
<RunEntryContent commit={commit()} theme={theme()} opts={{ shellOutput: props.shellOutput?.() ?? true }} />
|
||||
<RunEntryContent commit={commit()} theme={theme()} />
|
||||
</box>
|
||||
))
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
@@ -48,8 +48,6 @@ import type {
|
||||
FooterView,
|
||||
FormCancel,
|
||||
FormReply,
|
||||
MiniSettingChange,
|
||||
MiniSettings,
|
||||
PermissionReply,
|
||||
RunAgent,
|
||||
RunCommand,
|
||||
@@ -83,10 +81,6 @@ type RunFooterOptions = {
|
||||
history?: RunPrompt[]
|
||||
theme: RunTheme
|
||||
tuiConfig: RunTuiConfig
|
||||
miniSettings: {
|
||||
current: MiniSettings
|
||||
update?: (change: MiniSettingChange) => Promise<MiniSettings>
|
||||
}
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onFormReply: (input: FormReply) => void | Promise<void>
|
||||
onFormCancel: (input: FormCancel) => void | Promise<void>
|
||||
@@ -103,7 +97,11 @@ type RunFooterOptions = {
|
||||
|
||||
const PERMISSION_ROWS = 12
|
||||
const FORM_ROWS = 14
|
||||
const COMMAND_ROWS = RUN_COMMAND_PANEL_ROWS
|
||||
const SKILL_ROWS = RUN_COMMAND_PANEL_ROWS
|
||||
const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS
|
||||
const MODEL_ROWS = RUN_COMMAND_PANEL_ROWS
|
||||
const VARIANT_ROWS = RUN_COMMAND_PANEL_ROWS
|
||||
const NOTICE_DURATION = 3000
|
||||
const THEME_REFRESH_DELAYS = [1000, 1000] as const
|
||||
|
||||
@@ -187,8 +185,6 @@ export class RunFooter implements FooterApi {
|
||||
private setQueuedPrompts: Setter<FooterQueuedPrompt[]>
|
||||
private history: Accessor<RunPrompt[]>
|
||||
private setHistory: Setter<RunPrompt[]>
|
||||
private miniSettings: Accessor<MiniSettings>
|
||||
private setMiniSettings: Setter<MiniSettings>
|
||||
private promptRoute: FooterPromptRoute = { type: "composer" }
|
||||
private subagentMenuRows = SUBAGENT_ROWS
|
||||
private interruptTimeout: NodeJS.Timeout | undefined
|
||||
@@ -213,7 +209,6 @@ export class RunFooter implements FooterApi {
|
||||
.catch(() => {})
|
||||
.finally(() => this.destroyTheme(theme))
|
||||
},
|
||||
shellOutput: () => this.miniSettings().shell_output === "show",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -274,9 +269,6 @@ export class RunFooter implements FooterApi {
|
||||
const [history, setHistory] = createSignal(options.history ?? [])
|
||||
this.history = history
|
||||
this.setHistory = setHistory
|
||||
const [miniSettings, setMiniSettings] = createSignal(options.miniSettings.current)
|
||||
this.miniSettings = miniSettings
|
||||
this.setMiniSettings = setMiniSettings
|
||||
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
|
||||
this.scrollback = this.createScrollback(options.wrote ?? false)
|
||||
|
||||
@@ -308,7 +300,6 @@ export class RunFooter implements FooterApi {
|
||||
currentVariant: footer.currentVariant,
|
||||
theme: footer.theme,
|
||||
tuiConfig: options.tuiConfig,
|
||||
miniSettings: footer.miniSettings,
|
||||
history: footer.history,
|
||||
onSubmit: footer.handlePrompt,
|
||||
onPermissionReply: footer.handlePermissionReply,
|
||||
@@ -327,7 +318,6 @@ export class RunFooter implements FooterApi {
|
||||
onRows: footer.syncRows,
|
||||
onLayout: footer.syncLayout,
|
||||
onStatus: footer.setStatus,
|
||||
onMiniSettingChange: footer.handleMiniSettingChange,
|
||||
onSubagentSelect: options.onSubagentSelect,
|
||||
onSubagentInterrupt: options.onSubagentInterrupt,
|
||||
})
|
||||
@@ -384,7 +374,6 @@ export class RunFooter implements FooterApi {
|
||||
}
|
||||
|
||||
if (next.type === "turn.duration") {
|
||||
if (this.miniSettings().turn_summary === "hide") return
|
||||
const current = this.currentModel()
|
||||
this.flush()
|
||||
this.flushing = this.flushing
|
||||
@@ -673,19 +662,26 @@ export class RunFooter implements FooterApi {
|
||||
// get fixed extra rows; the prompt view scales with textarea line count.
|
||||
private applyHeight(): void {
|
||||
const type = this.view().type
|
||||
const route = this.promptRoute.type
|
||||
const height =
|
||||
type === "permission"
|
||||
? this.base + PERMISSION_ROWS
|
||||
: type === "form"
|
||||
? this.base + FORM_ROWS
|
||||
: ["command", "skill", "model", "variant", "settings"].includes(route)
|
||||
? 1 + RUN_COMMAND_PANEL_ROWS
|
||||
: route === "queued-menu" || route === "subagent-menu"
|
||||
? 1 + this.subagentMenuRows
|
||||
: route === "subagent"
|
||||
? this.base + SUBAGENT_INSPECTOR_ROWS
|
||||
: this.base + Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, this.rows))
|
||||
: this.promptRoute.type === "command"
|
||||
? 1 + COMMAND_ROWS
|
||||
: this.promptRoute.type === "skill"
|
||||
? 1 + SKILL_ROWS
|
||||
: this.promptRoute.type === "model"
|
||||
? 1 + MODEL_ROWS
|
||||
: this.promptRoute.type === "variant"
|
||||
? 1 + VARIANT_ROWS
|
||||
: this.promptRoute.type === "queued-menu"
|
||||
? 1 + this.subagentMenuRows
|
||||
: this.promptRoute.type === "subagent-menu"
|
||||
? 1 + this.subagentMenuRows
|
||||
: this.promptRoute.type === "subagent"
|
||||
? this.base + SUBAGENT_INSPECTOR_ROWS
|
||||
: this.base + Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, this.rows))
|
||||
|
||||
if (height !== this.renderer.footerHeight) {
|
||||
this.renderer.footerHeight = height
|
||||
@@ -868,21 +864,6 @@ export class RunFooter implements FooterApi {
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
private handleMiniSettingChange = async (change: MiniSettingChange): Promise<void> => {
|
||||
if (!this.options.miniSettings.update) {
|
||||
this.setNotice("settings are unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.setMiniSettings(await this.options.miniSettings.update(change))
|
||||
this.setNotice("settings updated")
|
||||
} catch (error) {
|
||||
this.setNotice("failed to save settings")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private clearInterruptTimer(): void {
|
||||
if (!this.interruptTimeout) {
|
||||
return
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
RunCommandMenuBody,
|
||||
RunModelSelectBody,
|
||||
RunQueuedPromptSelectBody,
|
||||
RunSettingsBody,
|
||||
RunSkillSelectBody,
|
||||
RunSubagentSelectBody,
|
||||
RunVariantSelectBody,
|
||||
@@ -40,8 +39,6 @@ import type {
|
||||
FooterView,
|
||||
FormCancel,
|
||||
FormReply,
|
||||
MiniSettingChange,
|
||||
MiniSettings,
|
||||
PermissionReply,
|
||||
RunAgent,
|
||||
RunCommand,
|
||||
@@ -85,7 +82,6 @@ type RunFooterViewProps = {
|
||||
queuedPrompts?: () => FooterQueuedPrompt[]
|
||||
theme: () => RunTheme
|
||||
tuiConfig: RunTuiConfig
|
||||
miniSettings: () => MiniSettings
|
||||
history?: () => RunPrompt[]
|
||||
onSubmit: (input: RunPrompt) => boolean
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
@@ -104,7 +100,6 @@ type RunFooterViewProps = {
|
||||
onRows: (rows: number) => void
|
||||
onLayout: (input: { route: FooterPromptRoute; subagentRows: number }) => void
|
||||
onStatus: (text: string) => void
|
||||
onMiniSettingChange: (change: MiniSettingChange) => void | Promise<void>
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
onSubagentInterrupt?: (sessionID: string) => void
|
||||
}
|
||||
@@ -136,7 +131,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const skilling = createMemo(() => active().type === "prompt" && route().type === "skill")
|
||||
const modeling = createMemo(() => active().type === "prompt" && route().type === "model")
|
||||
const varianting = createMemo(() => active().type === "prompt" && route().type === "variant")
|
||||
const setting = createMemo(() => active().type === "prompt" && route().type === "settings")
|
||||
const panel = createMemo(
|
||||
() =>
|
||||
active().type === "permission" ||
|
||||
@@ -146,8 +140,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
commanding() ||
|
||||
skilling() ||
|
||||
modeling() ||
|
||||
varianting() ||
|
||||
setting(),
|
||||
varianting(),
|
||||
)
|
||||
const selected = createMemo(() => {
|
||||
const current = route()
|
||||
@@ -264,11 +257,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
props.onSubagentSelect?.(undefined)
|
||||
}
|
||||
|
||||
const openSettings = () => {
|
||||
setRoute({ type: "settings" })
|
||||
props.onSubagentSelect?.(undefined)
|
||||
}
|
||||
|
||||
const openSubagentMenu = () => {
|
||||
if (tabs().length === 0) {
|
||||
return
|
||||
@@ -335,7 +323,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
onExitRequest: props.onExitRequest,
|
||||
onExit: props.onExit,
|
||||
onSkillMenu: openSkillMenu,
|
||||
onSettings: openSettings,
|
||||
onRows: props.onRows,
|
||||
onStatus: props.onStatus,
|
||||
})
|
||||
@@ -572,7 +559,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
current.type !== "skill" &&
|
||||
current.type !== "model" &&
|
||||
current.type !== "variant" &&
|
||||
current.type !== "settings" &&
|
||||
current.type !== "queued-menu" &&
|
||||
current.type !== "subagent-menu"
|
||||
) {
|
||||
@@ -682,7 +668,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
onSubagent={openSubagentMenu}
|
||||
onQueued={openQueuedMenu}
|
||||
onVariant={openVariant}
|
||||
onSettings={openSettings}
|
||||
onVariantCycle={() => {
|
||||
props.onCycle()
|
||||
closePanel()
|
||||
@@ -741,14 +726,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={setting()}>
|
||||
<RunSettingsBody
|
||||
theme={theme}
|
||||
settings={props.miniSettings}
|
||||
onClose={closePanel}
|
||||
onChange={props.onMiniSettingChange}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active().type === "permission"}>
|
||||
<RunPermissionBody
|
||||
request={permission()!.request}
|
||||
@@ -927,7 +904,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
onCycle={cycleTab}
|
||||
onClose={closeTab}
|
||||
interrupt={() => subagentInterruptShortcut() || undefined}
|
||||
shellOutput={() => props.miniSettings().shell_output === "show"}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { LocationRef } from "@opencode-ai/client/promise"
|
||||
import { resolve } from "../config"
|
||||
import { loadRunProviders } from "./catalog.shared"
|
||||
import { resolveCurrentSession, sessionHistory } from "./session.shared"
|
||||
import type { MiniSettings, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
|
||||
import type { RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
|
||||
import { pickVariant } from "./variant.shared"
|
||||
|
||||
export type ModelInfo = {
|
||||
@@ -83,11 +83,3 @@ export async function resolveRunTuiConfig(
|
||||
.then((value) => value ?? defaultRunTuiConfig(platform))
|
||||
.catch(() => defaultRunTuiConfig(platform))
|
||||
}
|
||||
|
||||
export function resolveMiniSettings(config?: { mini?: Partial<MiniSettings> }): MiniSettings {
|
||||
return {
|
||||
thinking: config?.mini?.thinking ?? "hide",
|
||||
shell_output: config?.mini?.shell_output ?? "hide",
|
||||
turn_summary: config?.mini?.turn_summary ?? "show",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ import type {
|
||||
FooterApi,
|
||||
FormCancel,
|
||||
FormReply,
|
||||
MiniSettingChange,
|
||||
MiniSettings,
|
||||
MiniHost,
|
||||
PermissionReply,
|
||||
RunAgent,
|
||||
@@ -28,7 +26,6 @@ import type {
|
||||
RunReference,
|
||||
RunTuiConfig,
|
||||
} from "./types"
|
||||
import { resolveMiniSettings } from "./runtime.boot"
|
||||
import { formatModelLabel } from "./variant.shared"
|
||||
|
||||
const FOOTER_HEIGHT = 4
|
||||
@@ -65,7 +62,6 @@ export type LifecycleInput = {
|
||||
model: RunInput["model"]
|
||||
variant: string | undefined
|
||||
tuiConfig: RunTuiConfig | Promise<RunTuiConfig>
|
||||
onMiniSettingChange?: (change: MiniSettingChange) => Promise<MiniSettings>
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onFormReply: (input: FormReply) => void | Promise<void>
|
||||
onFormCancel: (input: FormCancel) => void | Promise<void>
|
||||
@@ -228,10 +224,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
theme,
|
||||
wrote,
|
||||
tuiConfig,
|
||||
miniSettings: {
|
||||
current: resolveMiniSettings(tuiConfig),
|
||||
update: input.onMiniSettingChange,
|
||||
},
|
||||
onPermissionReply: input.onPermissionReply,
|
||||
onFormReply: input.onFormReply,
|
||||
onFormCancel: input.onFormCancel,
|
||||
|
||||
@@ -10,27 +10,11 @@
|
||||
// 4. runs the prompt queue until the footer closes.
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { LocationRef } from "@opencode-ai/client/promise"
|
||||
import type { Config } from "../config"
|
||||
import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared"
|
||||
import {
|
||||
resolveMiniSettings,
|
||||
resolveModelInfo,
|
||||
resolveModelInfoStrict,
|
||||
resolveRunTuiConfig,
|
||||
resolveSessionInfo,
|
||||
} from "./runtime.boot"
|
||||
import { resolveModelInfo, resolveModelInfoStrict, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
|
||||
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
||||
import { cycleVariant, formatModelLabel, resolveVariant } from "./variant.shared"
|
||||
import type {
|
||||
LocalReplayRow,
|
||||
MiniHost,
|
||||
MiniSettings,
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
RunProvider,
|
||||
RunTuiConfig,
|
||||
StreamCommit,
|
||||
} from "./types"
|
||||
import type { LocalReplayRow, MiniHost, RunInput, RunPrompt, RunProvider, RunTuiConfig, StreamCommit } from "./types"
|
||||
|
||||
type BootContext = Pick<RunInput, "sdk" | "agent" | "model" | "variant"> & {
|
||||
location: LocationRef
|
||||
@@ -59,7 +43,6 @@ type RunRuntimeInput = {
|
||||
replayLimit?: number
|
||||
demo?: RunInput["demo"]
|
||||
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
|
||||
config?: Pick<Config.Interface, "update">
|
||||
}
|
||||
|
||||
export type RunDeferredInput = {
|
||||
@@ -79,7 +62,6 @@ export type RunDeferredInput = {
|
||||
replayLimit?: number
|
||||
demo?: RunInput["demo"]
|
||||
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
|
||||
config?: Pick<Config.Interface, "update">
|
||||
}
|
||||
|
||||
type StreamTransportModule = Pick<
|
||||
@@ -192,12 +174,7 @@ function abortable<A>(task: Promise<A>, signal: AbortSignal): Promise<A | undefi
|
||||
async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise<void> {
|
||||
const start = input.host.startup.now()
|
||||
const log = input.host.diagnostics.trace
|
||||
const config = input.config
|
||||
const configState: { current: MiniSettings } = { current: resolveMiniSettings() }
|
||||
const tuiConfigTask = resolveRunTuiConfig(input.tuiConfig, input.host.platform).then((tuiConfig) => {
|
||||
configState.current = resolveMiniSettings(tuiConfig)
|
||||
return tuiConfig
|
||||
})
|
||||
const tuiConfigTask = resolveRunTuiConfig(input.tuiConfig, input.host.platform)
|
||||
const ctx = await input.boot()
|
||||
const runtimeController = new AbortController()
|
||||
const session = {
|
||||
@@ -249,16 +226,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
tuiConfig: tuiConfigTask,
|
||||
onMiniSettingChange: config
|
||||
? async (change) => {
|
||||
const info = await config.update((draft) => {
|
||||
if (!draft.mini || typeof draft.mini !== "object") draft.mini = {}
|
||||
draft.mini[change.key] = change.value
|
||||
})
|
||||
configState.current = resolveMiniSettings(info)
|
||||
return configState.current
|
||||
}
|
||||
: undefined,
|
||||
onPermissionReply: async (next) => {
|
||||
if (state.demo?.permission(next)) {
|
||||
return
|
||||
@@ -392,8 +359,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
})
|
||||
},
|
||||
})
|
||||
await tuiConfigTask
|
||||
const thinking = () => input.thinking ?? configState.current.thinking === "show"
|
||||
const tuiConfig = await tuiConfigTask
|
||||
const thinking = input.thinking ?? tuiConfig.session?.thinking !== "hide"
|
||||
const footer = shell.footer
|
||||
const firstPaint = footer.idle().catch(() => {})
|
||||
const offRuntimeClose = footer.onClose(() => runtimeController.abort())
|
||||
@@ -712,7 +679,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
return createRunDemo({
|
||||
footer,
|
||||
sessionID: state.sessionID,
|
||||
thinking: thinking(),
|
||||
thinking,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -755,7 +722,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
readTextFile: input.host.files.readText,
|
||||
location: state.location,
|
||||
sessionID: state.sessionID,
|
||||
thinking: thinking(),
|
||||
thinking,
|
||||
replay: input.replay,
|
||||
replayLimit: input.replayLimit,
|
||||
footer,
|
||||
@@ -1051,7 +1018,6 @@ export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?:
|
||||
replayLimit: input.replayLimit,
|
||||
demo: input.demo,
|
||||
tuiConfig: input.tuiConfig,
|
||||
config: input.config,
|
||||
reconnect: input.reconnect,
|
||||
resolveSession: input.target,
|
||||
createSession: input.createSession,
|
||||
|
||||
@@ -88,7 +88,6 @@ export class RunScrollbackStream {
|
||||
private active: ActiveEntry | undefined
|
||||
private treeSitterClient: TreeSitterClient | undefined
|
||||
private wrote: boolean
|
||||
private shellOutput: () => boolean
|
||||
private pendingThemes: RunTheme[] = []
|
||||
|
||||
constructor(
|
||||
@@ -98,12 +97,10 @@ export class RunScrollbackStream {
|
||||
wrote?: boolean
|
||||
treeSitterClient?: TreeSitterClient
|
||||
onThemeRelease?: (theme: RunTheme) => void
|
||||
shellOutput?: () => boolean
|
||||
} = {},
|
||||
) {
|
||||
this.treeSitterClient = options.treeSitterClient
|
||||
this.wrote = options.wrote ?? false
|
||||
this.shellOutput = options.shellOutput ?? (() => true)
|
||||
this.onThemeRelease = options.onThemeRelease
|
||||
}
|
||||
|
||||
@@ -357,7 +354,7 @@ export class RunScrollbackStream {
|
||||
return
|
||||
}
|
||||
|
||||
const body = entryBody(commit, { shellOutput: this.shellOutput() })
|
||||
const body = entryBody(commit)
|
||||
if (body.type === "none") {
|
||||
if (entryDone(commit)) {
|
||||
this.markRendered(await this.finishActive(false))
|
||||
|
||||
@@ -74,7 +74,7 @@ export function RunEntryContent(props: {
|
||||
opts?: ScrollbackOptions
|
||||
}) {
|
||||
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
|
||||
const body = createMemo(() => props.body ?? entryBody(props.commit, props.opts))
|
||||
const body = createMemo(() => props.body ?? entryBody(props.commit))
|
||||
const style = createMemo(() => entryLook(props.commit, theme().entry))
|
||||
const syntax = createMemo(() => entrySyntax(theme()))
|
||||
const color = createMemo(() => entryColor(props.commit, theme()))
|
||||
|
||||
@@ -758,12 +758,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
client.session.active(options),
|
||||
])
|
||||
if (!current(attempt)) return
|
||||
state.pending = new Map(
|
||||
pending.flatMap((item) => {
|
||||
const prompt = pendingPrompt(item)
|
||||
return prompt ? [[prompt.messageID, prompt] as const] : []
|
||||
}),
|
||||
)
|
||||
state.pending = new Map(pending.flatMap((item) => {
|
||||
const prompt = pendingPrompt(item)
|
||||
return prompt ? [[prompt.messageID, prompt] as const] : []
|
||||
}))
|
||||
syncPending()
|
||||
state.permissions = permissions
|
||||
pruneToolSources()
|
||||
|
||||
@@ -1273,11 +1273,7 @@ function shellOutput(command: string, raw: string): string | undefined {
|
||||
return `\n${body}`
|
||||
}
|
||||
|
||||
export function toolEntryBody(
|
||||
commit: StreamCommit,
|
||||
raw: string,
|
||||
options?: { shellOutput?: boolean },
|
||||
): RunEntryBody | undefined {
|
||||
export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody | undefined {
|
||||
if (commit.shell) {
|
||||
if (commit.phase === "start") {
|
||||
return textBody(`$ ${commit.shell.command}`)
|
||||
@@ -1298,8 +1294,6 @@ export function toolEntryBody(
|
||||
const ctx = toolFrame(commit, raw)
|
||||
const view = toolView(ctx.name)
|
||||
|
||||
if (ctx.name === "shell" && commit.phase === "progress" && options?.shellOutput === false) return undefined
|
||||
|
||||
if (ctx.name === "subagent") {
|
||||
if (commit.phase === "start") {
|
||||
return undefined
|
||||
|
||||
@@ -184,7 +184,6 @@ export type TurnSummary = {
|
||||
|
||||
export type ScrollbackOptions = {
|
||||
suppressBackgrounds?: boolean
|
||||
shellOutput?: boolean
|
||||
}
|
||||
|
||||
export type ToolCodeSnapshot = {
|
||||
@@ -294,7 +293,6 @@ export type FooterPromptRoute =
|
||||
| { type: "skill" }
|
||||
| { type: "model" }
|
||||
| { type: "variant" }
|
||||
| { type: "settings" }
|
||||
|
||||
export type FooterSubagentTab = {
|
||||
sessionID: string
|
||||
@@ -391,18 +389,7 @@ export type FormCancel = {
|
||||
location?: LocationRef
|
||||
}
|
||||
|
||||
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "session" | "mini">
|
||||
|
||||
export type MiniSettings = {
|
||||
thinking: "show" | "hide"
|
||||
shell_output: "show" | "hide"
|
||||
turn_summary: "show" | "hide"
|
||||
}
|
||||
|
||||
export type MiniSettingChange = {
|
||||
key: keyof MiniSettings
|
||||
value: "show" | "hide"
|
||||
}
|
||||
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "session">
|
||||
|
||||
// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
|
||||
// appends content (coalesced in the footer queue), "final" closes it.
|
||||
|
||||
@@ -389,6 +389,7 @@ describe("run entry body", () => {
|
||||
type: "text",
|
||||
content: "$ pwd",
|
||||
})
|
||||
|
||||
expect(
|
||||
entryBody(
|
||||
commit({
|
||||
@@ -410,15 +411,6 @@ describe("run entry body", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("hides shell tool output but not direct shell output", () => {
|
||||
const output = commit({ kind: "tool", text: "output", phase: "progress", source: "tool", tool: "shell" })
|
||||
expect(entryBody(output, { shellOutput: false })).toEqual({ type: "none" })
|
||||
expect(entryBody({ ...output, shell: { command: "pwd" } }, { shellOutput: false })).toEqual({
|
||||
type: "text",
|
||||
content: "\noutput",
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back to patch summary when patch has no visible diff items", () => {
|
||||
expect(
|
||||
entryBody(
|
||||
|
||||
@@ -55,7 +55,6 @@ test("down opens subagents from an empty prompt", async () => {
|
||||
subagent={subagents}
|
||||
theme={() => RUN_THEME_FALLBACK}
|
||||
tuiConfig={config}
|
||||
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show" })}
|
||||
onSubmit={() => true}
|
||||
onPermissionReply={() => {}}
|
||||
onFormReply={() => {}}
|
||||
@@ -70,7 +69,6 @@ test("down opens subagents from an empty prompt", async () => {
|
||||
onRows={() => {}}
|
||||
onLayout={() => {}}
|
||||
onStatus={() => {}}
|
||||
onMiniSettingChange={() => {}}
|
||||
/>
|
||||
</Keymap.Provider>
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
RunCommandMenuBody,
|
||||
RunModelSelectBody,
|
||||
RunQueuedPromptSelectBody,
|
||||
RunSettingsBody,
|
||||
RunSkillSelectBody,
|
||||
RunSubagentSelectBody,
|
||||
RunVariantSelectBody,
|
||||
@@ -24,8 +23,6 @@ import type {
|
||||
FooterSubagentState,
|
||||
FooterSubagentTab,
|
||||
FooterView,
|
||||
MiniSettingChange,
|
||||
MiniSettings,
|
||||
RunCommand,
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
@@ -120,8 +117,6 @@ async function renderFooter(
|
||||
onSubmit?: (prompt: RunPrompt) => boolean
|
||||
view?: FooterView
|
||||
onFormReply?: (input: unknown) => void
|
||||
miniSettings?: MiniSettings
|
||||
onMiniSettingChange?: (change: MiniSettingChange) => void
|
||||
} = {},
|
||||
) {
|
||||
const [view, setView] = createSignal<FooterView>(input.view ?? { type: "prompt" })
|
||||
@@ -130,9 +125,6 @@ async function renderFooter(
|
||||
)
|
||||
const state = footerState(input.state)
|
||||
const config = input.tuiConfig ?? tuiConfig
|
||||
const [miniSettings] = createSignal<MiniSettings>(
|
||||
input.miniSettings ?? { thinking: "hide", shell_output: "hide", turn_summary: "show" },
|
||||
)
|
||||
function Harness() {
|
||||
return (
|
||||
<Keymap.Provider config={config}>
|
||||
@@ -151,7 +143,6 @@ async function renderFooter(
|
||||
subagent={subagents}
|
||||
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
|
||||
tuiConfig={config}
|
||||
miniSettings={miniSettings}
|
||||
onSubmit={input.onSubmit ?? (() => true)}
|
||||
onPermissionReply={() => {}}
|
||||
onFormReply={(value) => input.onFormReply?.(value)}
|
||||
@@ -166,7 +157,6 @@ async function renderFooter(
|
||||
onRows={() => {}}
|
||||
onLayout={() => {}}
|
||||
onStatus={() => {}}
|
||||
onMiniSettingChange={(change) => input.onMiniSettingChange?.(change)}
|
||||
/>
|
||||
</Keymap.Provider>
|
||||
)
|
||||
@@ -379,7 +369,6 @@ test("direct command panel renders grouped command palette", async () => {
|
||||
onQueued={() => {}}
|
||||
onVariant={() => {}}
|
||||
onVariantCycle={() => {}}
|
||||
onSettings={() => {}}
|
||||
onCommand={() => {}}
|
||||
onNew={() => {}}
|
||||
onExit={() => {}}
|
||||
@@ -419,52 +408,6 @@ test("direct command panel renders grouped command palette", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("direct settings panel changes Mini transcript preferences", async () => {
|
||||
const [settings, setSettings] = createSignal<MiniSettings>({
|
||||
thinking: "hide",
|
||||
shell_output: "hide",
|
||||
turn_summary: "show",
|
||||
})
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
|
||||
<RunSettingsBody
|
||||
theme={() => RUN_THEME_FALLBACK.footer}
|
||||
settings={settings}
|
||||
onClose={() => {}}
|
||||
onChange={(change) => {
|
||||
setSettings((current) => ({ ...current, [change.key]: change.value }))
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
),
|
||||
{ width: 100, height: RUN_COMMAND_PANEL_ROWS },
|
||||
)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Settings")
|
||||
expect(app.captureCharFrame()).toContain("Thinking")
|
||||
expect(app.captureCharFrame()).toContain("Shell tool output")
|
||||
expect(app.captureCharFrame()).toContain("Turn summary")
|
||||
expect(app.captureCharFrame()).toContain("left/right change")
|
||||
|
||||
app.mockInput.pressKey("ARROW_RIGHT")
|
||||
await app.renderOnce()
|
||||
|
||||
expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "show" })
|
||||
|
||||
app.mockInput.pressKey("ARROW_DOWN")
|
||||
app.mockInput.pressKey("ARROW_DOWN")
|
||||
app.mockInput.pressKey("ARROW_RIGHT")
|
||||
await app.renderOnce()
|
||||
|
||||
expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "hide" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct skill panel renders searchable skill list", async () => {
|
||||
const [commands] = createSignal<RunCommand[] | undefined>([
|
||||
command({ name: "review", description: "Review code" }),
|
||||
@@ -575,7 +518,6 @@ test("direct command panel shows subagent entry when available", async () => {
|
||||
onQueued={() => {}}
|
||||
onVariant={() => {}}
|
||||
onVariantCycle={() => {}}
|
||||
onSettings={() => {}}
|
||||
onCommand={() => {}}
|
||||
onNew={() => {}}
|
||||
onExit={() => {}}
|
||||
@@ -624,7 +566,6 @@ test("direct command panel keeps completed subagents available", async () => {
|
||||
onQueued={() => {}}
|
||||
onVariant={() => {}}
|
||||
onVariantCycle={() => {}}
|
||||
onSettings={() => {}}
|
||||
onCommand={() => {}}
|
||||
onNew={() => {}}
|
||||
onExit={() => {}}
|
||||
@@ -881,7 +822,7 @@ test("direct footer submits slash autocomplete selections without dispatching sh
|
||||
await app.renderOnce()
|
||||
|
||||
app.mockInput.pressKey("!")
|
||||
"/settings".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||
"/rev".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
@@ -893,7 +834,7 @@ test("direct footer submits slash autocomplete selections without dispatching sh
|
||||
{ text: "/new ", parts: [] },
|
||||
{ text: "/new ", parts: [] },
|
||||
])
|
||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe("/settings ")
|
||||
expect(app.captureCharFrame()).toContain("/review")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
@@ -926,26 +867,6 @@ test("direct footer slash autocomplete keeps a real skills command", async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer closes settings with ctrl-c instead of arming exit", async () => {
|
||||
const app = await renderFooter({ height: 20 })
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
"/settings".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Shell tool output")
|
||||
|
||||
app.mockInput.pressKey("c", { ctrl: true })
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).not.toContain("Shell tool output")
|
||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe("")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("selectedCommand backfills the catalog source for bound drafts", () => {
|
||||
const catalog = [command({ name: "opencode-ts", description: "TS skill", source: "skill" })]
|
||||
|
||||
@@ -1113,7 +1034,6 @@ test("direct footer shows authoritative pending work while running", async () =>
|
||||
]}
|
||||
theme={() => RUN_THEME_FALLBACK}
|
||||
tuiConfig={tuiConfig}
|
||||
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show" })}
|
||||
onSubmit={() => true}
|
||||
onPermissionReply={() => {}}
|
||||
onFormReply={() => {}}
|
||||
@@ -1128,7 +1048,6 @@ test("direct footer shows authoritative pending work while running", async () =>
|
||||
onRows={() => {}}
|
||||
onLayout={() => {}}
|
||||
onStatus={() => {}}
|
||||
onMiniSettingChange={() => {}}
|
||||
/>
|
||||
</Keymap.Provider>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import type { Resolved } from "../../src/config"
|
||||
import { resolveMiniSettings, resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot"
|
||||
import { resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot"
|
||||
import { catalogModel, catalogProvider } from "./fixture/catalog"
|
||||
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||
|
||||
@@ -91,24 +91,18 @@ describe("run runtime boot", () => {
|
||||
expect(result.keybinds.get("leader")).toEqual([])
|
||||
})
|
||||
|
||||
test("preserves shared config while resolving independent Mini defaults", async () => {
|
||||
test("preserves current theme mode, leader, and thinking config", async () => {
|
||||
const result = await resolveRunTuiConfig(
|
||||
createTuiResolvedConfig({
|
||||
theme: { mode: "light" },
|
||||
leader_timeout: 450,
|
||||
session: { thinking: "show" },
|
||||
session: { thinking: "hide" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.theme).toEqual({ mode: "light" })
|
||||
expect(result.leader.timeout).toBe(450)
|
||||
expect(result.session?.thinking).toBe("show")
|
||||
expect(resolveMiniSettings(result)).toEqual({ thinking: "hide", shell_output: "hide", turn_summary: "show" })
|
||||
expect(resolveMiniSettings({ mini: { thinking: "show", shell_output: "show", turn_summary: "hide" } })).toEqual({
|
||||
thinking: "show",
|
||||
shell_output: "show",
|
||||
turn_summary: "hide",
|
||||
})
|
||||
expect(result.session?.thinking).toBe("hide")
|
||||
})
|
||||
|
||||
test("loads v2 providers and models for model selector data", async () => {
|
||||
|
||||
@@ -47,8 +47,8 @@ export function parse(patchText: string): Result.Result<ReadonlyArray<Hunk>, Par
|
||||
const lines = stripHeredoc(patchText.trim())
|
||||
.split("\n")
|
||||
.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
|
||||
const begin = lines[0]?.trim() === "*** Begin Patch" ? 0 : -1
|
||||
const end = lines.at(-1)?.trim() === "*** End Patch" ? lines.length - 1 : -1
|
||||
const begin = lines.findIndex((line) => line.trim() === "*** Begin Patch")
|
||||
const end = lines.findIndex((line) => line.trim() === "*** End Patch")
|
||||
if (begin === -1) return Result.fail(new BoundaryError({ boundary: "first" }))
|
||||
if (end === -1 || begin >= end) return Result.fail(new BoundaryError({ boundary: "last" }))
|
||||
|
||||
@@ -227,8 +227,9 @@ const normalize = (value: string) =>
|
||||
value
|
||||
.replace(/[‘’‚‛]/g, "'")
|
||||
.replace(/[“”„‟]/g, '"')
|
||||
.replace(/[‐‑‒–—―−]/g, "-")
|
||||
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ")
|
||||
.replace(/[‐‑‒–—―]/g, "-")
|
||||
.replace(/…/g, "...")
|
||||
.replace(/ /g, " ")
|
||||
const splitBom = (text: string) =>
|
||||
text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
|
||||
const stripHeredoc = (input: string) =>
|
||||
|
||||
Reference in New Issue
Block a user