Compare commits

..

3 Commits

Author SHA1 Message Date
Simon Klee 2db3bd72ee tui: simplify prompt input sync
Drop the burst-aware key interceptor that tried to keep derived
prompt state current for every control binding. Frame-batch content
updates only, and let exit, clear, stash, and autocomplete read or
flush live textarea state at the command boundary instead.
2026-08-04 14:45:45 +02:00
Simon Klee a18e770c15 tui: flush prompt sync before commands
Coalescing burst text to one frame update hid pending
input from submit and other prompt keys. Flush before
bound commands so they see the full text while plain
typing stays frame-batched.
2026-08-04 14:45:44 +02:00
Simon Klee c0c1fecad2 tui: coalesce prompt sync on text bursts
Per-keystroke store, autocomplete, and extmark work made large
stdin paste bursts stall the input path. Defer one microtask
sync per burst and skip mention scanning when text has no @.
2026-08-04 14:45:44 +02:00
22 changed files with 245 additions and 760 deletions
+3 -3
View File
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor"
import { Framing } from "./framing"
import { HttpTransport } from "./transport"
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
@@ -155,7 +155,7 @@ export interface Interface {
}
export interface StreamOptions {
readonly http?: HttpMiddleware
readonly transform?: HttpRequestTransform
}
export interface StreamMethod {
@@ -307,7 +307,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
middleware: options?.http,
transform: options?.transform,
}),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}`
+5 -22
View File
@@ -20,18 +20,9 @@ import { classifyProviderFailure } from "../provider-error"
export interface Interface {
readonly execute: (
request: HttpClientRequest.HttpClientRequest,
middleware?: HttpMiddleware,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, AIError>
}
export type HttpHandler = (
request: HttpClientRequest.HttpClientRequest,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error>
export type HttpMiddleware = (
request: HttpClientRequest.HttpClientRequest,
handler: HttpHandler,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error>
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
const BODY_LIMIT = 16_384
@@ -270,7 +261,7 @@ const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: u
return transportError({ message: error.message, kind: "Timeout" })
}
if (!HttpClientError.isHttpClientError(error)) {
return transportError({ message: error instanceof Error ? error.message : "HTTP transport failed" })
return transportError({ message: "HTTP transport failed" })
}
const request = "request" in error ? error.request : undefined
if (error.reason._tag === "TransportError") {
@@ -291,20 +282,12 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
Service,
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
const executeOnce = (request: HttpClientRequest.HttpClientRequest) =>
Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
if (!middleware)
return yield* http
.execute(request)
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
const response = yield* middleware(request, (input) =>
http
.execute(input)
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
).pipe(Effect.mapError(toHttpError(redactedNames)))
return yield* statusError(response.request, redactedNames)(response)
return yield* http
.execute(request)
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
})
return Service.of({
execute: executeOnce,
+1 -1
View File
@@ -23,4 +23,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol"
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport"
export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
+9 -10
View File
@@ -3,7 +3,7 @@ import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Auth } from "../auth"
import { render as renderEndpoint } from "../endpoint"
import { Framing } from "../framing"
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index"
import type { Transport, TransportPrepareInput } from "./index"
import * as ProviderShared from "../../protocols/shared"
import { mergeJsonRecords, type LLMRequest } from "../../schema"
@@ -19,7 +19,6 @@ export interface JsonRequestParts<Body = unknown> {
export interface HttpPrepared<Frame> {
readonly request: HttpClientRequest.HttpClientRequest
readonly framing: Framing.Definition<Frame>
readonly middleware?: HttpMiddleware
}
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
@@ -75,21 +74,21 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
prepare: (prepareInput) =>
Effect.gen(function* () {
const parts = yield* jsonRequestParts({ ...prepareInput })
const request = ProviderShared.jsonPost({
url: parts.url,
body: parts.bodyText,
headers: parts.headers,
})
const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
yield* (prepareInput.transform?.(request) ?? Effect.void)
return {
request,
request: ProviderShared.jsonPost({
url: request.url,
body: request.body ?? "",
headers: Headers.fromInput(request.headers),
}),
framing: input.framing,
middleware: prepareInput.middleware,
}
}),
frames: (prepared, request, runtime) =>
Stream.unwrap(
runtime.http
.execute(prepared.request, prepared.middleware)
.execute(prepared.request)
.pipe(
Effect.map((response) =>
prepared.framing.frame(
+11 -3
View File
@@ -1,7 +1,7 @@
import type { Effect, Stream } from "effect"
import { Endpoint } from "../endpoint"
import { Auth } from "../auth"
import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor"
import type { Interface as RequestExecutorInterface } from "../executor"
import type { Interface as WebSocketExecutorInterface } from "./websocket"
import type { AIError, LLMRequest } from "../../schema"
@@ -10,6 +10,15 @@ export interface TransportRuntime {
readonly webSocket?: WebSocketExecutorInterface
}
export interface HttpRequest {
url: string
readonly method: string
headers: Record<string, string>
body: string | undefined
}
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
export interface Transport<Body, Prepared, Frame> {
readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError>
@@ -23,9 +32,8 @@ export interface TransportPrepareInput<Body> {
readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly middleware?: HttpMiddleware
readonly transform?: HttpRequestTransform
}
export * as HttpTransport from "./http"
export type { HttpHandler, HttpMiddleware } from "../executor"
export { WebSocketExecutor, WebSocketTransport } from "./websocket"
+8 -90
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Effect, Ref, Schema } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, mergeProviderOptions } from "../src"
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
import { Auth, LLMClient } from "../src/route"
@@ -146,16 +146,12 @@ describe("request option precedence", () => {
prompt: "Say hello.",
}),
{
http: (request, handler) =>
Effect.gen(function* () {
return yield* handler(
request.pipe(
HttpClientRequest.setUrl("https://proxy.test/v1/chat/completions"),
HttpClientRequest.setMethod("PUT"),
HttpClientRequest.setHeader("x-plugin", "transformed"),
HttpClientRequest.bodyText(JSON.stringify({ transformed: true }), "application/custom+json"),
),
)
transform: (request) =>
Effect.sync(() => {
expect(request.headers.authorization).toBe("Bearer fresh-key")
request.url = "https://proxy.test/v1/chat/completions"
request.headers["x-plugin"] = "transformed"
request.body = JSON.stringify({ transformed: true })
}),
},
).pipe(
@@ -164,9 +160,7 @@ describe("request option precedence", () => {
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.url).toBe("https://proxy.test/v1/chat/completions")
expect(web.method).toBe("PUT")
expect(web.headers.get("x-plugin")).toBe("transformed")
expect(web.headers.get("content-type")).toBe("application/custom+json")
expect(decodeJson(input.text)).toEqual({ transformed: true })
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
@@ -177,82 +171,6 @@ describe("request option precedence", () => {
),
)
it.effect("transforms the HTTP response before protocol decoding", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
}),
{
http: (request, handler) =>
Effect.gen(function* () {
const response = yield* handler(request)
return HttpClientResponse.fromWeb(
response.request,
new Response((yield* response.text).replace("network", "hooked"), {
status: response.status,
headers: response.headers,
}),
)
}),
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.succeed(
input.respond(sseEvents(deltaChunk({ content: "network" }, "stop")), {
headers: { "content-type": "text/event-stream" },
}),
),
),
),
)
expect(response.text).toBe("hooked")
}),
)
it.effect("can inspect an error response and retry the native request", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const response = yield* LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("stale") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
}),
{
http: (request, handler) =>
Effect.gen(function* () {
const response = yield* handler(request)
expect(response.status).toBe(401)
return yield* handler(HttpClientRequest.setHeader(request, "authorization", "Bearer refreshed"))
}),
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
yield* Ref.update(attempts, (value) => value + 1)
if (input.request.headers.authorization !== "Bearer refreshed")
return input.respond("unauthorized", { status: 401 })
return input.respond(sseEvents(deltaChunk({ content: "retried" }, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
expect(response.text).toBe("retried")
expect(yield* Ref.get(attempts)).toBe(2)
}),
)
it.effect("applies raw body overlays after protocol lowering", () =>
LLMClient.generate(
LLM.request({
-12
View File
@@ -67,18 +67,6 @@ const expectAIError = (error: unknown) => {
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
describe("RequestExecutor", () => {
it.effect("preserves middleware error messages", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor
.execute(request, () => Effect.fail(new Error("plugin rejected request")))
.pipe(Effect.flip)
expectAIError(error)
expect(error.reason.message).toBe("plugin rejected request")
}).pipe(Effect.provide(responsesLayer([]))),
)
it.effect("classifies context overflow responses", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
+1 -51
View File
@@ -209,12 +209,9 @@ export const fromCatalogModel = (
body: Provider.mergeOverlay(mapping?.body, resolved.body),
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
}
const finalized = specifier.startsWith("@opencode-ai/ai/providers/amazon-bedrock")
? yield* withBedrockCredentials(settings, configured)
: settings
return yield* Effect.try({
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, finalized)
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return LanguageModel.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
@@ -248,53 +245,6 @@ const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
return rest
}
// Chain providers cache and refresh resolved credentials internally, so reuse one per
// AWS profile instead of re-walking SSO, shared config, and instance metadata each turn.
const bedrockChains = new Map<
string,
() => Promise<{ accessKeyId: string; secretAccessKey: string; sessionToken?: string }>
>()
// Bedrock signs requests with static credentials captured at model build time; the route
// never refreshes them. Model resolution runs before every provider turn, so falling back
// to the AWS default chain (env, shared config, SSO, process, instance roles) here keeps
// credentials fresh without persisting them.
const withBedrockCredentials = Effect.fnUntraced(function* (
settings: Readonly<Record<string, unknown>>,
configured: Readonly<Record<string, unknown>>,
) {
const base =
typeof settings.region !== "string" && process.env.AWS_REGION
? { ...settings, region: process.env.AWS_REGION }
: settings
if (typeof base.apiKey === "string" || base.credentials !== undefined) return base
if (process.env.AWS_BEARER_TOKEN_BEDROCK) return { ...base, apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK }
const profile = typeof configured.profile === "string" ? configured.profile : process.env.AWS_PROFILE
const identity = yield* Effect.tryPromise(() => {
const chain = bedrockChains.get(profile ?? "")
if (chain) return chain()
return import("@aws-sdk/credential-providers").then((mod) => {
const created = mod.fromNodeProviderChain(profile === undefined ? {} : { profile })
bedrockChains.set(profile ?? "", created)
return created()
})
}).pipe(
Effect.catch((cause) =>
Effect.logWarning("AWS credential chain resolution failed for Bedrock", { cause }).pipe(Effect.as(undefined)),
),
)
if (!identity) return base
return {
...base,
credentials: {
region: typeof base.region === "string" ? base.region : "us-east-1",
accessKeyId: identity.accessKeyId,
secretAccessKey: identity.secretAccessKey,
...(identity.sessionToken === undefined ? {} : { sessionToken: identity.sessionToken }),
},
}
})
const unsupported = (model: Info) =>
new UnsupportedPackageError({
providerID: model.providerID,
+3 -61
View File
@@ -2,7 +2,6 @@ export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
import type { Info } from "@opencode-ai/plugin/promise/tool"
import { Agent } from "@opencode-ai/schema/agent"
import { Integration } from "@opencode-ai/schema/integration"
@@ -58,62 +57,6 @@ export function fromPromise(plugin: Plugin) {
}),
)
function sessionHook<Name extends keyof SessionHooks>(
name: Name,
callback: (event: SessionHooks[Name]) => Promise<void> | void,
): Promise<Registration>
function sessionHook(
...registration: {
[Name in keyof SessionHooks]: [
name: Name,
callback: (event: SessionHooks[Name]) => Promise<void> | void,
]
}[keyof SessionHooks]
) {
if (registration[0] !== "http")
return register(
host.session.hook(registration[0], (event) =>
Effect.promise(() => Promise.resolve(registration[1](event))),
),
)
return register(
host.session.hook("http", (event) => {
const middlewares: SessionHttpMiddleware[] = []
const output: SessionHttp = {
...event,
use: (item) => {
middlewares.push(item)
},
}
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
Effect.flatMap(() =>
Effect.forEach(
middlewares,
(item) =>
event.use((input, next) =>
Effect.tryPromise({
try: (signal) => {
const inputSignal = AbortSignal.any([signal, input.signal])
return Promise.resolve(
item(new Request(input, { signal: inputSignal }), (request) => {
const requestSignal = AbortSignal.any([signal, request.signal])
return Effect.runPromiseWith(
context,
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
}),
)
},
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
}),
),
{ discard: true },
),
),
)
}),
)
}
const context2: Context = {
app: host.app,
options: host.options,
@@ -251,9 +194,7 @@ export function fromPromise(plugin: Plugin) {
),
),
refresh:
refresh === undefined
? undefined
: (credential) => Effect.promise(() => refresh(credential)),
refresh === undefined ? undefined : (credential) => Effect.promise(() => refresh(credential)),
})
},
remove: draft.method.remove,
@@ -322,7 +263,8 @@ export function fromPromise(plugin: Plugin) {
),
},
session: {
hook: sessionHook,
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
create: (input) =>
run(
host.session.create(
+9 -8
View File
@@ -225,14 +225,15 @@ export const OpenAIPlugin = define({
})
}
})
yield* ctx.session.hook("http", (evt) =>
evt.use((request, next) => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
const url = new URL(request.url)
request.headers.set("originator", "opencode")
request.headers.set("session-id", evt.sessionID)
if (url.origin !== "https://api.openai.com") return next(request)
return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
yield* ctx.session.hook("request", (evt) =>
Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const url = new URL(evt.url)
if (url.origin === "https://api.openai.com") {
evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`
}
evt.headers.originator = "opencode"
evt.headers["session-id"] = evt.sessionID
}),
)
+20 -42
View File
@@ -2,11 +2,9 @@ export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
import type { Content } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app"
import { Model } from "../model"
@@ -50,7 +48,9 @@ interface Prepared {
* One request-scoped execution operation. Unknown, hook-removed, and
* step-limit-violating calls fail individually through the same seam.
*/
readonly executeTool: (input: Parameters<Tool.Snapshot["execute"]>[0]) => Effect.Effect<Tool.Result, ExecuteError>
readonly executeTool: (
input: Parameters<Tool.Snapshot["execute"]>[0],
) => Effect.Effect<Tool.Result, ExecuteError>
/** True when this request is the final Step; violating calls are rejected and no continuation follows. */
readonly stepLimitReached: boolean
}
@@ -137,7 +137,8 @@ export const boundImages = (messages: LLMRequest["messages"]) => {
result: {
...part.result,
value: part.result.value.map((item: Content) => {
if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET) return item
if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET)
return item
removed += Buffer.byteLength(item.uri)
return { type: "text" as const, text: IMAGE_REMOVED }
}),
@@ -228,47 +229,24 @@ export const layer = Layer.effect(
toolChoice: stepLimitReached ? "none" : undefined,
})
const options: StreamOptions = {
http: (request, handler) =>
Effect.gen(function* () {
let latest = request
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
const middlewares: SessionHttpMiddleware[] = []
const web = yield* HttpClientRequest.toWeb(request)
yield* hooks.trigger("session", "http", {
transform: (request) =>
hooks
.trigger("session", "request", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
...request,
})
const send = (input: Request) =>
Effect.gen(function* () {
let sent = HttpClientRequest.fromWeb(input)
if (input.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())),
input.headers.get("content-type") ?? undefined,
)
latest = sent
const response = yield* handler(sent)
const body = [204, 205, 304].includes(response.status)
? null
: yield* Stream.toReadableStreamEffect(response.stream)
const output = new Response(body, { status: response.status, headers: response.headers })
origins.set(output, sent)
return output
})
const dispatch = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
send,
)
const response = yield* dispatch(web)
const origin = origins.get(response) ?? latest
return HttpClientResponse.fromWeb(origin, response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
.pipe(
Effect.tap((event) =>
Effect.sync(() => {
request.url = event.url
request.headers = event.headers
request.body = event.body
}),
),
Effect.asVoid,
),
}
if (promptCacheSnapshots) {
const current = PromptCacheDiagnostics.snapshot(request)
-109
View File
@@ -21,22 +21,6 @@ interface ModelOptions {
readonly limit?: Info["limit"]
}
const withEnv =
(env: Record<string, string | undefined>) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) => {
const saved = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]))
const apply = (values: Record<string, string | undefined>) => {
for (const [key, value] of Object.entries(values)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
}
return Effect.sync(() => apply(env)).pipe(
Effect.andThen(effect),
Effect.ensuring(Effect.sync(() => apply(saved))),
)
}
const model = (packageName: string | undefined, options: ModelOptions = {}) =>
Info.make({
id: ID.make("test-model"),
@@ -495,97 +479,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("resolves Bedrock credentials from the AWS default chain when none are configured", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
)
const env = {
AWS_ACCESS_KEY_ID: "chain-access",
AWS_SECRET_ACCESS_KEY: "chain-secret",
AWS_SESSION_TOKEN: "chain-session",
AWS_REGION: "eu-west-1",
AWS_PROFILE: undefined,
AWS_BEARER_TOKEN_BEDROCK: undefined,
}
yield* Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/amazon-bedrock", { settings: {} }),
undefined,
{
loadPackage: () =>
Effect.succeed({
model: (modelID, settings) => {
expect(settings.credentials).toEqual({
region: "eu-west-1",
accessKeyId: "chain-access",
secretAccessKey: "chain-secret",
sessionToken: "chain-session",
})
expect(settings).not.toHaveProperty("apiKey")
return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route })
},
}),
},
)
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
}).pipe(withEnv(env))
}),
)
it.effect("uses the Bedrock bearer token env before the AWS default chain", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
)
yield* Effect.gen(function* () {
yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/amazon-bedrock", { settings: { region: "us-west-2" } }),
undefined,
{
loadPackage: () =>
Effect.succeed({
model: (modelID, settings) => {
expect(settings.apiKey).toBe("bearer-token")
expect(settings).not.toHaveProperty("credentials")
return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route })
},
}),
},
)
}).pipe(withEnv({ AWS_BEARER_TOKEN_BEDROCK: "bearer-token" }))
}),
)
it.effect("keeps explicitly configured Bedrock credentials untouched", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
)
const credentials = { region: "us-east-1", accessKeyId: "configured", secretAccessKey: "configured-secret" }
yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/amazon-bedrock", { settings: { credentials } }),
undefined,
{
loadPackage: () =>
Effect.succeed({
model: (modelID, settings) => {
expect(settings.credentials).toEqual(credentials)
expect(settings).not.toHaveProperty("apiKey")
return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route })
},
}),
},
)
}),
)
it.effect("maps OAuth credentials to native provider auth settings", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(
@@ -732,14 +625,12 @@ describe("ModelResolver", () => {
settings: { region: "us-east-1", topP: 0.8, serviceTier: "priority" },
body: {},
}),
Credential.Key.make({ type: "key", key: "secret" }),
)
const mantle = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
modelID: "openai.gpt-oss-120b",
settings: { region: "us-east-1" },
}),
Credential.Key.make({ type: "key", key: "secret" }),
)
expect(google.route.id).toBe("gemini")
+15 -114
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
import { DateTime, Effect, Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
@@ -15,7 +15,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
import { Tool } from "@opencode-ai/core/tool"
import { Provider } from "@opencode-ai/core/provider"
import { define } from "@opencode-ai/plugin/promise/plugin"
import type { SessionHooks, SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
import { host as testHost } from "./host"
@@ -148,9 +148,7 @@ describe("fromPromise", () => {
expect((await ctx.agent.get({ agentID: Agent.ID.make("reviewer") })).data).toMatchObject({
description: "Reviews code",
})
await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow(
"Agent not found: missing",
)
await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow("Agent not found: missing")
const models = (await ctx.catalog.model.list()).data
expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({
modelID: "gpt-5",
@@ -223,105 +221,6 @@ describe("fromPromise", () => {
}),
)
it.effect("adapts promise session HTTP hooks", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
const bodies: string[] = []
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http",
setup: async (ctx) => {
await ctx.session.hook("http", (event) => {
event.use(async (request, next) => {
request.headers.set("x-hook", "promise")
await next(request)
const response = await next(request)
return new Response(`${await response.text()}-response`)
})
})
await ctx.session.hook("http", (event) => {
event.use(async (request, next) => {
const response = await next(request)
return new Response(`${await response.text()}-outer`)
})
})
},
}),
).effect(host)
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
const event: PluginHooks.Domains["session"]["http"] = {
sessionID: Session.ID.make("ses_promise_session_http"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
}
yield* hooks.trigger("session", "http", event)
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
(input: Request) =>
Effect.promise(() => input.text()).pipe(
Effect.tap((body) => Effect.sync(() => bodies.push(body))),
Effect.as(new Response(input.headers.get("x-hook") ?? "missing")),
),
)
const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" }))
expect(bodies).toEqual(["payload", "payload"])
expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer")
}),
)
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http-interrupt",
setup: async (ctx) => {
await ctx.session.hook("http", (event) => {
event.use((request, next) => next(request))
})
},
}),
).effect(host)
const started = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
const event: PluginHooks.Domains["session"]["http"] = {
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
}
yield* hooks.trigger("session", "http", event)
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
() =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
),
)
const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
}),
)
it.effect("disposes a hook registration on request", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
@@ -416,17 +315,19 @@ describe("fromPromise", () => {
id: "promise-tool",
setup: async (ctx) => {
await ctx.tool.transform((tools) => {
tools.add({
name: "hello",
options: { codemode: false },
description: "Hello",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: async ({ name }, context) => {
await context.progress({ phase: "greeting" })
return { output: `Hello, ${name}!` }
tools.add(
{
name: "hello",
options: { codemode: false },
description: "Hello",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: async ({ name }, context) => {
await context.progress({ phase: "greeting" })
return { output: `Hello, ${name}!` }
},
},
})
)
})
},
})
@@ -12,7 +12,6 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { Provider } from "@opencode-ai/core/provider"
import type { SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -30,29 +29,6 @@ function required<T>(value: T | undefined): T {
return value
}
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
yield* (yield* PluginHooks.Service).trigger("session", "http", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
})
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
(input: Request) => {
const headers = new Headers(input.headers)
headers.set("x-seen-url", input.url)
return Effect.succeed(new Response(null, { headers }))
},
)
const response = yield* request(new Request(url, { method: "POST", body: "{}" }))
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
})
describe("OpenAIPlugin", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () {
@@ -124,9 +100,33 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses")
const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us")
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const custom = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("custom-openai"), id: Model.ID.make("gpt-5.5") }),
url: "https://custom.example/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const proxy = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://proxy.example/v1/responses?region=us",
method: "POST",
headers: {},
body: "{}",
})
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
@@ -134,7 +134,7 @@ describe("OpenAIPlugin", () => {
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(custom.headers).not.toHaveProperty("originator")
expect(custom.headers).toEqual({})
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
@@ -184,13 +184,21 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(request.headers).not.toHaveProperty("originator")
expect(request.headers).toEqual({})
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
}),
)
@@ -41,7 +41,6 @@ import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm"
import { Effect, Layer, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import path from "node:path"
import { testEffect } from "./lib/effect"
import { agentHost, catalogHost, host } from "./plugin/host"
@@ -105,39 +104,37 @@ const promptCatalog = Layer.mock(Catalog.Service, {
small: () => Effect.succeed(undefined),
},
})
const runnerLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer],
[LayerNodePlatform.llmClient, llmClient],
[SessionRunnerModel.node, models],
[InstructionBuiltIns.node, systemContext],
[InstructionDiscovery.node, instructionContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillInstructions.node, skillInstructions],
[ReferenceInstructions.node, referenceInstructions],
[McpInstructions.node, mcpInstructions],
[Config.node, config],
[Permission.node, permission],
[PluginSupervisor.node, pluginSupervisor],
])
const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const sessionRunner = yield* SessionRunner.Service
const coordinator = yield* SessionRunCoordinator.make<Session.ID, SessionRunner.RunError>({
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
})
return SessionExecution.Service.of({
active: coordinator.active,
resume: coordinator.run,
wake: coordinator.wake,
interrupt: coordinator.interrupt,
awaitIdle: coordinator.awaitIdle,
})
}),
).pipe(Layer.provide(runnerLayer(llmClient)))
const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer],
[LayerNodePlatform.llmClient, client],
[SessionRunnerModel.node, models],
[InstructionBuiltIns.node, systemContext],
[InstructionDiscovery.node, instructionContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillInstructions.node, skillInstructions],
[ReferenceInstructions.node, referenceInstructions],
[McpInstructions.node, mcpInstructions],
[Config.node, config],
[Permission.node, permission],
[PluginSupervisor.node, pluginSupervisor],
])
const execution = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const sessionRunner = yield* SessionRunner.Service
const coordinator = yield* SessionRunCoordinator.make<Session.ID, SessionRunner.RunError>({
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
})
return SessionExecution.Service.of({
active: coordinator.active,
resume: coordinator.run,
wake: coordinator.wake,
interrupt: coordinator.interrupt,
awaitIdle: coordinator.awaitIdle,
})
}),
).pipe(Layer.provide(runnerLayer))
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
@@ -159,7 +156,7 @@ const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
Session.node,
]),
[
[LayerNodePlatform.llmClient, llmClient],
[LayerNodePlatform.llmClient, client],
[Permission.node, permission],
[Catalog.node, promptCatalog],
[SessionRunnerModel.node, models],
@@ -171,10 +168,10 @@ const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
[Config.node, config],
[Snapshot.node, Snapshot.noopLayer],
[PluginSupervisor.node, pluginSupervisor],
[SessionExecution.node, execution(llmClient)],
[SessionExecution.node, execution],
],
)
const it = testEffect(testLayer(client))
),
)
const sessionID = Session.ID.make("ses_runner_recorded")
describe("SessionRunnerLLM recorded", () => {
@@ -250,90 +247,3 @@ describe("SessionRunnerLLM recorded", () => {
}),
)
})
describe("SessionModelRequest HTTP bridge", () => {
const bodies: Uint8Array[] = []
const methods: string[] = []
const response = [
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
"data: [DONE]",
"",
].join("\n\n")
const transport = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
methods.push(request.method)
bodies.push(request.body.body.slice())
return HttpClientResponse.fromWeb(
request,
new Response(response, { headers: { "content-type": "text/event-stream" } }),
)
}),
),
)
const retryIt = testEffect(
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
)
retryIt.effect("lets an Effect plugin send the same POST Request twice", () =>
Effect.gen(function* () {
bodies.length = 0
methods.length = 0
const agents = yield* Agent.Service
const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("build"), (agent) => {
agent.mode = "primary"
agent.permissions.push({ action: "execute", resource: "*", effect: "deny" })
}),
)
const pluginHost = host({
agent: agentHost(agents),
catalog: catalogHost(catalog),
session: { hook: (name, callback) => hooks.register("session", name, callback) },
})
yield* pluginHost.session.hook("http", (event) =>
event.use((request, next) =>
Effect.gen(function* () {
yield* next(request).pipe(Effect.flatMap((response) => Effect.promise(() => response.text())))
return yield* next(request)
}),
),
)
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const retrySessionID = Session.ID.make("ses_model_request_http_retry")
yield* db
.insert(SessionTable)
.values({
id: retrySessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const session = yield* Session.Service
yield* session.prompt({ sessionID: retrySessionID, text: "Say hello.", resume: false })
yield* session.resume(retrySessionID)
expect(methods).toEqual(["POST", "POST"])
expect(bodies).toHaveLength(2)
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
expect(bodies[1]).toEqual(bodies[0])
}),
)
})
+4 -11
View File
@@ -1,9 +1,10 @@
import type { SessionApi } from "@opencode-ai/client/effect/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } 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"
import type { Effect, JsonSchema } from "effect"
import type { JsonSchema } from "effect"
import type { Hooks } from "./registration.js"
export interface SessionContext {
@@ -15,23 +16,15 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHttp {
export interface SessionRequest extends HttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect<void>
}
export type SessionHttpHandler = (request: Request) => Effect.Effect<Response, Error>
export type SessionHttpMiddleware = (
request: Request,
next: SessionHttpHandler,
) => Effect.Effect<Response, Error>
export interface SessionHooks {
readonly context: SessionContext
readonly http: SessionHttp
readonly request: SessionRequest
}
export type SessionDomain = Pick<
+3 -10
View File
@@ -1,5 +1,6 @@
import type { SessionApi } from "@opencode-ai/client/promise/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } 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"
@@ -15,23 +16,15 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHttp {
export interface SessionRequest extends HttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly use: (middleware: SessionHttpMiddleware) => void
}
export type SessionHttpHandler = (request: Request) => Promise<Response>
export type SessionHttpMiddleware = (
request: Request,
next: SessionHttpHandler,
) => Promise<Response> | Response
export interface SessionHooks {
readonly context: SessionContext
readonly http: SessionHttp
readonly request: SessionRequest
}
export type SessionDomain = Pick<
+7 -9
View File
@@ -15,6 +15,7 @@ import {
MouseButton,
type CliRenderer,
type CliRendererConfig,
type KeyEvent,
type ThemeMode,
} from "@opentui/core"
import { RouteProvider, useRoute } from "./context/route"
@@ -971,7 +972,11 @@ function App(props: { pair?: DialogPairCredentials }) {
name: "app.exit",
title: "Exit the app",
slash: { name: "exit", aliases: ["quit", "q"] },
run: () => exit(),
run: (_input: string | undefined, event?: KeyEvent) => {
const current = promptRef.current
if (event?.sequence && current?.focused && !current.empty) return false
exit()
},
category: "System",
},
{
@@ -1127,14 +1132,7 @@ function App(props: { pair?: DialogPairCredentials }) {
bindings: pinnedSessionBindingCommands,
}))
Keymap.createLayer(() => ({
enabled: () => {
const current = promptRef.current
if (!current?.focused) return true
return current.current.text === ""
},
bindings: ["app.exit"],
}))
Keymap.createLayer(() => ({ bindings: ["app.exit"] }))
event.on("tui.command.execute", (evt, { workspace }) => {
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
@@ -70,15 +70,10 @@ export function Autocomplete(props: {
visible: false as AutocompleteRef["visible"],
input: "keyboard" as "keyboard" | "mouse",
})
let popMode: (() => void) | undefined
const [positionTick, setPositionTick] = createSignal(0)
createEffect(() => {
if (!store.visible) return
const popMode = keymap.mode.push("autocomplete")
onCleanup(popMode)
})
createEffect(() => {
if (store.visible) {
let lastPos = { x: 0, y: 0, width: 0 }
@@ -272,7 +267,7 @@ export function Autocomplete(props: {
const { filename, part } = createFilePart({ path: item, type: "file" }, input.filePath, lineRange)
const index = store.visible === "@" ? store.index : props.input().cursorOffset
setStore("visible", false)
hide(false)
setStore("index", index)
insertPart(filename, part)
}
@@ -501,6 +496,7 @@ export function Autocomplete(props: {
function move(direction: -1 | 1) {
if (!store.visible) return
syncSearch()
if (!options().length) return
moveTo(moveSelection(store.selected, { count: options().length, delta: direction, policy: "wrap" }))
}
@@ -518,6 +514,7 @@ export function Autocomplete(props: {
}
function select() {
syncSearch()
const selected = options()[store.selected]
if (!selected) return
hide()
@@ -589,6 +586,7 @@ export function Autocomplete(props: {
title: "Complete autocomplete item",
group: "Autocomplete",
run() {
syncSearch()
const selected = options()[store.selected]
if (selected?.isDirectory) {
expandDirectory()
@@ -602,15 +600,16 @@ export function Autocomplete(props: {
}))
function show(mode: "@" | "/") {
popMode ??= keymap.mode.push("autocomplete")
setStore({
visible: mode,
index: props.input().cursorOffset,
})
}
function hide() {
function hide(clear = true) {
const text = props.input().plainText
if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
if (clear && store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
// Sync the prompt store immediately since onContentChange is async
@@ -619,6 +618,8 @@ export function Autocomplete(props: {
})
}
setStore("visible", false)
popMode?.()
popMode = undefined
}
onMount(() => {
@@ -630,35 +631,33 @@ export function Autocomplete(props: {
unsubscribeMention()
})
props.ref({
const ref = {
get visible() {
return store.visible
},
onInput(value) {
onInput(value?: string) {
if (!props.input().focused) return
if (store.visible) {
if (
// Typed text before the trigger
props.input().cursorOffset <= store.index ||
// There is a space between the trigger and the cursor
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) ||
// "/<command>" is not the sole content
(store.visible === "/" && value.match(/^\S+\s+\S+\s*$/))
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/)
) {
hide()
hide(false)
}
return
}
// Check if autocomplete should reopen (e.g., after backspace deleted a space)
const offset = props.input().cursorOffset
if (offset === 0) return
// Check for "/" at position 0 - reopen slash commands
if (value.startsWith("/") && !value.slice(0, offset).match(/\s/)) {
const text = value ?? (props.input().getTextRange(0, 1) === "/" ? props.input().getTextRange(0, offset) : "")
if (text.startsWith("/") && !text.slice(0, offset).match(/\s/)) {
show("/")
setStore("index", 0)
return
}
if (value === undefined) return
// Check for "@" trigger - find the nearest "@" before cursor with no whitespace between
const idx = mentionTriggerIndex(value, offset)
@@ -667,9 +666,22 @@ export function Autocomplete(props: {
setStore("index", idx)
}
},
}
props.ref(ref)
const stopInputSync = keymap.intercept("key", () => ref.onInput())
onCleanup(() => {
stopInputSync()
popMode?.()
})
})
function syncSearch() {
const next = props.input().getTextRange(store.index + 1, props.input().cursorOffset)
if (next === search()) return
setSearch(next)
setStore("selected", 0)
}
const height = createMemo(() => {
const count = options().length || 1
if (!store.visible) return Math.min(10, count)
+34 -10
View File
@@ -82,6 +82,7 @@ function pastedFilepath(value: string, platform: string) {
export type PromptRef = {
focused: boolean
empty: boolean
current: PromptInfo
set(prompt: PromptInfo): void
reset(): void
@@ -144,6 +145,7 @@ function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
export function Prompt(props: PromptProps) {
let input: TextareaRenderable
let anchor: BoxRenderable
let promptSyncQueued = false
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
const leader = Keymap.useLeaderActive()
@@ -342,6 +344,7 @@ export function Prompt(props: PromptProps) {
category: "Prompt",
palette: undefined,
run: () => {
if (input.getTextRange(0, 1) === "") return false
clearPrompt()
dialog.clear()
},
@@ -446,6 +449,7 @@ export function Prompt(props: PromptProps) {
name: "prompt.editor",
slash: { name: "editor" },
run: async () => {
if (promptSyncQueued) await flushPromptSync()
dialog.clear()
const editorPrompt = expandPromptInputPastedText(store.prompt, store.prompt.pasted)
@@ -538,6 +542,9 @@ export function Prompt(props: PromptProps) {
get focused() {
return input.focused
},
get empty() {
return input.getTextRange(0, 1) === ""
},
get current() {
return store.prompt
},
@@ -577,6 +584,7 @@ export function Prompt(props: PromptProps) {
})
onCleanup(() => {
if (promptSyncQueued) void flushPromptSync()
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
@@ -702,9 +710,9 @@ export function Prompt(props: PromptProps) {
title: "Stash prompt",
name: "prompt.stash",
category: "Prompt",
enabled: !!store.prompt.text,
run: () => {
if (!store.prompt.text) return
if (input.getTextRange(0, 1) === "") return false
void flushPromptSync()
stash.push({ prompt: store.prompt })
input.extmarks.clear()
input.clear()
@@ -775,7 +783,7 @@ export function Prompt(props: PromptProps) {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
enabled: () => inputTarget() !== undefined && !props.disabled,
bindings: ["prompt.clear"],
}
})
@@ -799,6 +807,10 @@ export function Prompt(props: PromptProps) {
title: "Shell mode",
group: "Prompt",
run: () => {
if (input.visualCursor.offset !== 0) {
input.insertText("!")
return
}
setStore("placeholder", randomIndex(shell().length))
setStore("mode", "shell")
},
@@ -921,6 +933,7 @@ export function Prompt(props: PromptProps) {
}
async function submitInner() {
if (promptSyncQueued) await flushPromptSync()
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads.
@@ -1223,6 +1236,22 @@ export function Prompt(props: PromptProps) {
}, 0)
}
async function flushPromptSync() {
promptSyncQueued = false
renderer.removeFrameCallback(flushPromptSync)
if (!input || input.isDestroyed) return
const value = input.plainText
setStore("prompt", "text", value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
}
function queuePromptSync() {
if (promptSyncQueued) return
promptSyncQueued = true
renderer.setFrameCallback(flushPromptSync)
}
async function pasteAttachment(file: { filename?: string; uri: string }) {
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
@@ -1264,6 +1293,7 @@ export function Prompt(props: PromptProps) {
}
function clearPrompt() {
if (promptSyncQueued) void flushPromptSync()
if (
store.prompt.text.trim().length >= DRAFT_RETENTION_MIN_CHARS ||
store.prompt.pasted.length > 0 ||
@@ -1390,13 +1420,7 @@ export function Prompt(props: PromptProps) {
focusedTextColor={leader() ? theme.text.subdued : theme.text.default}
minHeight={1}
maxHeight={maxHeight()}
onContentChange={() => {
const value = input.plainText
setStore("prompt", "text", value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
setCursorVersion((value) => value + 1)
}}
onContentChange={queuePromptSync}
onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={(e: { preventDefault(): void }) => {
if (props.disabled) {
+3 -2
View File
@@ -37,8 +37,9 @@ export function displayCharAt(value: string, offset: number) {
}
}
export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(value)) {
const text = displaySlice(value, 0, offset)
export function mentionTriggerIndex(value: string, offset?: number) {
if (!value.includes("@")) return
const text = displaySlice(value, 0, offset ?? promptOffsetWidth(value))
const index = text.lastIndexOf("@")
if (index === -1) return
+5 -18
View File
@@ -239,29 +239,16 @@ without restarting OpenCode.
### Runtime hooks
Runtime hooks intercept live operations:
Runtime hooks intercept live operations. Their event objects expose specific
mutable fields:
| Hook | Mutable fields |
| ------------------------------------------- | ------------------------------------------------------------------------------ |
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
| `ctx.session.hook("http", callback)` | `use`, registering request and response handling |
| `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
HTTP hooks can modify requests, inspect responses, retry, or return a
response without calling the provider. It applies to native models; AI SDK
models do not currently pass through this hook.
```ts
await ctx.session.hook("http", (event) => {
event.use((request, next) => {
request.headers.set("x-session-id", event.sessionID)
return next(request)
})
})
```
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
For example, remove a tool from selected model requests and normalize another
tool's input:
@@ -272,7 +259,7 @@ import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({
id: "acme.guards",
setup: async (ctx) => {
await ctx.session.hook("context", (event) => {
await ctx.session.hook("request", (event) => {
delete event.tools.write
})