Compare commits

..

8 Commits

Author SHA1 Message Date
Kit Langton d3bc3a61dc fix(core): import credentials from previous channel database 2026-08-06 16:11:56 -04:00
Kit Langton c66d84169a fix(tui): open authorization links (#40912) 2026-08-06 15:26:13 -04:00
Aiden Cline 7dbe8c4c13 refactor(mcp): remove unused registration status (#40904) 2026-08-06 13:59:22 -05:00
Aiden Cline 8864b01d0b feat(core): increase retained compaction context (#40906) 2026-08-06 13:45:06 -05:00
Aiden Cline ec95b27308 fix(core): align ChatGPT context limits (#40902) 2026-08-06 13:35:48 -05:00
Aiden Cline be2f74d44a refactor(plugin): split session HTTP hooks (#40724) 2026-08-06 13:23:22 -05:00
James Long 912a801060 refactor(drizzle): declare query variance (#40891) 2026-08-06 13:58:17 -04:00
Dax 0215498f63 fix(core): load well-known config first (#40897) 2026-08-06 10:44:26 -07:00
44 changed files with 399 additions and 460 deletions
@@ -10,7 +10,6 @@ const statusLabels = {
connected: "mcp.status.connected",
failed: "mcp.status.failed",
needs_auth: "mcp.status.needs_auth",
needs_client_registration: "mcp.status.needs_client_registration",
disabled: "mcp.status.disabled",
} as const
@@ -57,7 +56,7 @@ export const DialogSelectMcp: Component = () => {
}
const error = () => {
const s = mcpStatus()
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
if (s?.status === "failed") return s.error
}
const enabled = () => status() === "connected"
return (
@@ -426,8 +426,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
"bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base":
status() === "needs_auth" || status() === "needs_client_registration",
"bg-icon-warning-base": status() === "needs_auth",
}}
/>
<span class="flex flex-col min-w-0 flex-1">
@@ -35,7 +35,6 @@ describe("hasNonBlockingServiceIssue", () => {
test("detects MCP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
})
@@ -48,7 +47,6 @@ describe("hasNonBlockingServiceIssue", () => {
describe("hasServiceNeedingAttention", () => {
test("detects MCP states that need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
})
test("ignores states that do not need user attention", () => {
@@ -2,7 +2,7 @@ import type { LspStatus } from "@/types"
import type { McpServer } from "@opencode-ai/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
return input.mcp.some((status) => status === "needs_auth")
}
export function hasNonBlockingServiceIssue(input: {
@@ -13,7 +13,6 @@ export async function toggleMcp(input: {
needs_auth: input.authenticate,
disabled: input.connect,
failed: input.connect,
needs_client_registration: input.connect,
}[input.status]()
await input.refresh()
}
@@ -34,7 +34,6 @@ function icon(status: McpServer["status"]) {
case "needs_auth":
return "⚠"
case "failed":
case "needs_client_registration":
return "✗"
default:
return "○"
@@ -45,8 +44,6 @@ function describe(status: McpServer["status"]) {
switch (status.status) {
case "needs_auth":
return "needs authentication"
case "needs_client_registration":
return `needs client registration: ${status.error}`
case "failed":
return `failed: ${status.error}`
default:
+3 -25
View File
@@ -259,8 +259,6 @@ export type McpStatusFailed = { status: "failed"; error: string }
export type McpStatusNeedsAuth = { status: "needs_auth" }
export type McpStatusNeedsClientRegistration = { status: "needs_client_registration"; error: string }
export type McpResource = { server: string; name: string; uri: string; description?: string; mimeType?: string }
export type McpResourceTemplate = {
@@ -1261,13 +1259,7 @@ export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
export type McpServer = {
name: string
status:
| McpStatusConnected
| McpStatusPending
| McpStatusDisabled
| McpStatusFailed
| McpStatusNeedsAuth
| McpStatusNeedsClientRegistration
status: McpStatusConnected | McpStatusPending | McpStatusDisabled | McpStatusFailed | McpStatusNeedsAuth
integrationID?: string
}
@@ -1937,23 +1929,9 @@ export type IntegrationInfo = {
connections: Array<ConnectionInfo>
}
export type FormInfo = {
id: string
sessionID: string
title: string
coalesce?: string
metadata?: FormMetadata
fields: FormFields
}
export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields }
export type FormInfo1 = {
id: string
sessionID: string
title: string
coalesce?: string
metadata?: FormMetadata1
fields: FormFields1
}
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 }
export type SessionInputAdmitted = {
id: string
+1 -1
View File
@@ -233,13 +233,13 @@ export const layer = (options?: Options) => Layer.effect(
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
return [
...(yield* loadWellknown().pipe(Effect.orDie)),
...claude,
...agents,
...(supplementary[0] ?? []),
...explicit,
...direct,
...supplementary.slice(1).flat(),
...(yield* loadWellknown().pipe(Effect.orDie)),
...content,
]
})
+1
View File
@@ -42,5 +42,6 @@ export const migrations = (
import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260804233008_loose_psylocke"),
import("./migration/20260805200742_import_legacy_credentials"),
import("./migration/20260806200000_import_next_credentials"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,89 @@
import path from "node:path"
import { existsSync } from "node:fs"
import { sql } from "drizzle-orm"
import { Effect, Option, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { Global } from "@opencode-ai/util/global"
import type { DatabaseMigration } from "../migration"
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeValue = Schema.decodeUnknownOption(Credential.Value)
export default {
id: "20260806200000_import_next_credentials",
up(tx) {
return importNextCredentials(tx, path.join(Global.Path.data, "opencode-next.db"))
},
} satisfies DatabaseMigration.Migration
/**
* The next channel stored credentials in its own `opencode-next.db` before the
* channel databases were consolidated into `opencode.db`. The legacy import
* only reads V1 `auth.json`, so a credential that existed only in the previous
* channel database was silently dropped. Copy those rows over, keeping any
* credential the target database already has for the same integration.
*/
export function importNextCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], sourcePath: string) {
return Effect.gen(function* () {
if (!existsSync(sourcePath)) return
for (const row of yield* readSourceCredentials(sourcePath)) {
const integrationID = typeof row.integration_id === "string" && row.integration_id.length ? row.integration_id : undefined
if (!integrationID) continue
if (typeof row.value !== "string") continue
const json = Option.getOrUndefined(decodeJson(row.value))
if (json === undefined || Option.isNone(decodeValue(json))) continue
if (yield* tx.get(sql`SELECT id FROM credential WHERE integration_id = ${integrationID}`)) continue
const now = Date.now()
yield* tx.run(sql`
INSERT OR IGNORE INTO credential (
id, integration_id, label, value, connector_id, method_id, active, time_created, time_updated
) VALUES (
${typeof row.id === "string" && row.id.length ? row.id : Credential.ID.create()},
${integrationID},
${typeof row.label === "string" && row.label.length ? row.label : "default"},
${row.value},
${typeof row.connector_id === "string" ? row.connector_id : null},
${typeof row.method_id === "string" ? row.method_id : null},
${typeof row.active === "number" ? row.active : null},
${typeof row.time_created === "number" ? row.time_created : now},
${typeof row.time_updated === "number" ? row.time_updated : now}
)
`)
}
})
}
type SourceRow = Record<string, unknown>
// An unreadable or incompatible source database skips the import instead of
// failing the migration and blocking startup; the source is never modified.
function readSourceCredentials(sourcePath: string) {
return Effect.scoped(
Effect.gen(function* () {
const sqlite = yield* Effect.promise(() => import("bun:sqlite"))
const source = yield* Effect.acquireRelease(
Effect.try({
try: () => new sqlite.Database(sourcePath, { readonly: true, strict: true }),
catch: (error) => error,
}),
(database) => Effect.sync(() => database.close()),
)
return yield* Effect.try({
try: () => {
const table = source
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'credential'")
.get()
if (!table) return [] as SourceRow[]
return source.query<SourceRow, []>("SELECT * FROM credential").all()
},
catch: (error) => error,
})
}),
).pipe(
Effect.catch((error) =>
Effect.logWarning("Skipped incompatible opencode-next.db credentials", { path: sourcePath, error }).pipe(
Effect.as([] as SourceRow[]),
),
),
)
}
-1
View File
@@ -132,7 +132,6 @@ export const layer = Layer.effect(
id,
sessionID: input.sessionID,
title: input.title,
...(input.coalesce === undefined ? {} : { coalesce: input.coalesce }),
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
fields: input.fields,
}
+2 -58
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,
@@ -322,7 +265,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 -9
View File
@@ -221,18 +221,18 @@ export const OpenAIPlugin = define({
}
draft.cost = []
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
draft.limit = { ...draft.limit, context: 400_000, input: 272_000 }
})
}
})
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("http.request", (evt) =>
Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const url = new URL(evt.request.url)
evt.request.headers.set("originator", "opencode")
evt.request.headers.set("session-id", evt.sessionID)
if (url.origin !== "https://api.openai.com") return
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
}),
)
+1 -1
View File
@@ -20,7 +20,7 @@ import type { Info } from "../model"
import { SessionUsage } from "./usage"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 8_000
const DEFAULT_KEEP_TOKENS = 15_000
const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
+21 -35
View File
@@ -2,7 +2,6 @@ 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"
@@ -230,44 +229,31 @@ export const layer = Layer.effect(
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", {
const before = yield* hooks.trigger("session", "http.request", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
request: yield* HttpClientRequest.toWeb(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)
let sent = HttpClientRequest.fromWeb(before.request)
if (before.request.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
before.request.headers.get("content-type") ?? undefined,
)
const response = yield* handler(sent)
const after = yield* hooks.trigger("session", "http.response", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
request: before.request,
response: new Response(
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
{ status: response.status, headers: response.headers },
),
})
return HttpClientResponse.fromWeb(sent, after.response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
}
if (promptCacheSnapshots) {
@@ -59,7 +59,6 @@ export const Plugin = {
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Web Search",
coalesce: `${context.messageID}:websearch-consent`,
metadata: { kind: "websearch.provider" },
fields: [
{
@@ -92,7 +91,6 @@ export const Plugin = {
? yield* forms.ask({
sessionID: context.sessionID,
title: "Choose a web search provider",
coalesce: `${context.messageID}:websearch-provider`,
metadata: { kind: "websearch.provider" },
fields: [
{
@@ -12,6 +12,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { tmpdir } from "./fixture/tmpdir"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import { importLegacyCredentials } from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import { importNextCredentials } from "@opencode-ai/core/database/migration/20260806200000_import_next_credentials"
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
Effect.runPromise(
@@ -164,6 +165,75 @@ describe("DatabaseMigration", () => {
expect(await Bun.file(source).text()).toBe(content)
})
test("imports previous channel database credentials without replacing existing integrations", async () => {
await using tmp = await tmpdir()
const source = path.join(tmp.path, "opencode-next.db")
const { Database: Sqlite } = await import("bun:sqlite")
const sourceDb = new Sqlite(source, { strict: true })
sourceDb.run(`
CREATE TABLE credential (
id text PRIMARY KEY, integration_id text, label text NOT NULL, value text NOT NULL,
connector_id text, method_id text, active integer, time_created integer NOT NULL, time_updated integer NOT NULL
)
`)
const insert = sourceDb.prepare(
"INSERT INTO credential (id, integration_id, label, value, connector_id, method_id, active, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
insert.run("cred_next_opencode", "opencode", "anomaly", JSON.stringify({ type: "key", key: "zen-key" }), null, "console", null, 100, 200)
insert.run(
"cred_next_anthropic",
"anthropic",
"default",
JSON.stringify({ type: "oauth", methodID: "oauth", refresh: "next-refresh", access: "next-access", expires: 456 }),
null,
null,
null,
100,
200,
)
insert.run("cred_next_invalid", "invalid", "default", "not-json", null, null, null, 100, 200)
sourceDb.close()
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
const now = Date.now()
yield* db.run(sql`
INSERT INTO credential (id, integration_id, label, value, time_created, time_updated)
VALUES ('existing', 'anthropic', 'Existing', ${JSON.stringify({ type: "key", key: "current-key" })}, ${now}, ${now})
`)
yield* db.transaction((tx) => importNextCredentials(tx, source))
// A second run is a no-op because the integration now has a credential.
yield* db.transaction((tx) => importNextCredentials(tx, source))
// A missing source database is a no-op.
yield* db.transaction((tx) => importNextCredentials(tx, path.join(tmp.path, "missing.db")))
expect(
yield* db.all(sql`SELECT id, integration_id, label, value, method_id, time_created FROM credential ORDER BY integration_id`),
).toEqual([
{
id: "existing",
integration_id: "anthropic",
label: "Existing",
value: JSON.stringify({ type: "key", key: "current-key" }),
method_id: null,
time_created: now,
},
{
id: "cred_next_opencode",
integration_id: "opencode",
label: "anomaly",
value: JSON.stringify({ type: "key", key: "zen-key" }),
method_id: "console",
time_created: 100,
},
])
}),
)
})
test("rolls back a failed migration without recording it", async () => {
await run(
Effect.gen(function* () {
-2
View File
@@ -15,7 +15,6 @@ const input = {
id: formID,
sessionID: SessionSchema.ID.make("ses_test"),
title: "Test form",
coalesce: "test-form",
fields: [{ key: "name", type: "string", required: true }],
} satisfies Form.CreateInput
@@ -33,7 +32,6 @@ describe("Form", () => {
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
const form = yield* Deferred.await(created)
expect(form.coalesce).toBe("test-form")
yield* service.cancel(form.id)
@@ -151,6 +151,9 @@ describe("ModelResolver", () => {
http: { body: { custom_extension: { enabled: true } } },
},
})
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body.max_output_tokens).toBeUndefined()
expect(JSON.stringify(prepared.body)).not.toContain("max_output_tokens")
}),
)
+21 -78
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"
@@ -223,102 +223,45 @@ describe("fromPromise", () => {
}),
)
it.effect("adapts promise session HTTP hooks", () =>
it.effect("adapts promise session HTTP request and response 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.request", (event) => {
event.request = new Request("https://provider.test/changed", event.request)
event.request.headers.set("x-hook", "promise")
})
await ctx.session.hook("http", (event) => {
event.use(async (request, next) => {
const response = await next(request)
return new Response(`${await response.text()}-outer`)
await ctx.session.hook("http.response", async (event) => {
event.response = new Response(`${await event.response.text()}-response`, {
status: event.response.status,
})
})
},
}),
).effect(host)
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
const event: PluginHooks.Domains["session"]["http"] = {
const context = {
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" }))
const request = yield* hooks.trigger("session", "http.request", {
...context,
request: new Request("https://provider.test", { method: "POST", body: "payload" }),
})
const response = yield* hooks.trigger("session", "http.response", {
...context,
request: request.request,
response: new Response(request.request.headers.get("x-hook") ?? "missing"),
})
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()
expect(request.request.url).toBe("https://provider.test/changed")
expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
}),
)
@@ -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"
@@ -31,26 +30,13 @@ function required<T>(value: T | undefined): T {
}
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", {
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
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)
}),
request: new Request(url, { method: "POST", body: "{}" }),
})
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()) }
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
})
describe("OpenAIPlugin", () => {
@@ -140,7 +126,7 @@ describe("OpenAIPlugin", () => {
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
expect(eligible.cost).toEqual([])
expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(eligible.enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
false,
@@ -149,14 +135,14 @@ describe("OpenAIPlugin", () => {
false,
)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
context: 272_000,
context: 400_000,
input: 272_000,
output: 64_000,
})
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
expect(gpt56.enabled).toBe(true)
expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(gpt56.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
}),
)
@@ -255,6 +255,7 @@ describe("SessionRunnerLLM recorded", () => {
describe("SessionModelRequest HTTP bridge", () => {
const bodies: Uint8Array[] = []
const methods: string[] = []
const headers: Array<string | undefined> = []
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"}]}',
@@ -268,6 +269,7 @@ describe("SessionModelRequest HTTP bridge", () => {
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
methods.push(request.method)
bodies.push(request.body.body.slice())
headers.push(request.headers["x-hook"])
return HttpClientResponse.fromWeb(
request,
new Response(response, { headers: { "content-type": "text/event-stream" } }),
@@ -275,14 +277,16 @@ describe("SessionModelRequest HTTP bridge", () => {
}),
),
)
const retryIt = testEffect(
const httpIt = 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", () =>
httpIt.effect("runs Effect HTTP request and response hooks around one provider request", () =>
Effect.gen(function* () {
bodies.length = 0
methods.length = 0
headers.length = 0
const seen: string[] = []
const agents = yield* Agent.Service
const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service
@@ -297,13 +301,20 @@ describe("SessionModelRequest HTTP bridge", () => {
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* pluginHost.session.hook("http.request", (event) =>
Effect.sync(() => {
seen.push("request")
event.request.headers.set("x-hook", "effect")
}),
)
yield* pluginHost.session.hook("http.response", (event) =>
Effect.gen(function* () {
seen.push(`response:${event.response.status}:${event.request.headers.get("x-hook")}`)
event.response = new Response(
(yield* Effect.promise(() => event.response.text())).replace("Hello!", "Hooked!"),
event.response,
)
}),
)
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
const { db } = yield* Database.Service
@@ -331,10 +342,15 @@ describe("SessionModelRequest HTTP bridge", () => {
yield* session.resume(retrySessionID)
expect(methods).toEqual(["POST", "POST"])
expect(bodies).toHaveLength(2)
expect(methods).toEqual(["POST"])
expect(headers).toEqual(["effect"])
expect(seen).toEqual(["request", "response:200:effect"])
expect(bodies).toHaveLength(1)
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
expect(bodies[1]).toEqual(bodies[0])
expect((yield* session.context(retrySessionID))[1]).toMatchObject({
type: "assistant",
content: [{ type: "text", text: "Hooked!" }],
})
}),
)
})
@@ -241,7 +241,6 @@ describe("WebSearchTool registration", () => {
{
sessionID,
title: "Web Search",
coalesce: "msg_tool_test:websearch-consent",
metadata: { kind: "websearch.provider" },
fields: [
{
@@ -299,7 +298,6 @@ describe("WebSearchTool registration", () => {
expect(formRequests[1]).toEqual({
sessionID,
title: "Choose a web search provider",
coalesce: "msg_tool_test:websearch-provider",
metadata: { kind: "websearch.provider" },
fields: [
{
@@ -44,11 +44,12 @@ export type SQLiteEffectSelectPrepare<
TEffectHKT
>
// Explicit variance prevents comparisons from recursively scanning Drizzle's conditional select types.
export class SQLiteEffectSelectBuilder<
TSelection extends SelectedFields | undefined,
TRunResult,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TBuilderMode extends "db" | "qb" = "db",
out TSelection extends SelectedFields | undefined,
out TRunResult,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TBuilderMode extends "db" | "qb" = "db",
> {
static readonly [entityKind]: string = "SQLiteEffectSelectBuilder"
@@ -303,10 +303,11 @@ export class SQLiteEffectPreparedQuery<
}
}
// Explicit variance prevents comparisons from recursively scanning the full Drizzle query-builder graph.
export abstract class SQLiteEffectSession<
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TRunResult = unknown,
TRelations extends AnyRelations = EmptyRelations,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TRunResult = unknown,
out TRelations extends AnyRelations = EmptyRelations,
> {
static readonly [entityKind]: string = "SQLiteEffectSession"
@@ -404,9 +405,9 @@ export abstract class SQLiteEffectSession<
}
export abstract class SQLiteEffectTransaction<
TEffectHKT extends QueryEffectHKTBase,
TRunResult,
TRelations extends AnyRelations = EmptyRelations,
out TEffectHKT extends QueryEffectHKTBase,
out TRunResult,
out TRelations extends AnyRelations = EmptyRelations,
> extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations> {
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
+12 -10
View File
@@ -3,7 +3,7 @@ import type { Message, SystemPart } 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 type { Effect, JsonSchema } from "effect"
import type { JsonSchema } from "effect"
import type { Hooks } from "./registration.js"
export interface SessionContext {
@@ -15,23 +15,25 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHttp {
export interface SessionHttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect<void>
request: Request
}
export type SessionHttpHandler = (request: Request) => Effect.Effect<Response, Error>
export type SessionHttpMiddleware = (
request: Request,
next: SessionHttpHandler,
) => Effect.Effect<Response, Error>
export interface SessionHttpResponse {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly request: Request
response: Response
}
export interface SessionHooks {
readonly context: SessionContext
readonly http: SessionHttp
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
}
export type SessionDomain = Pick<
+11 -9
View File
@@ -15,23 +15,25 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHttp {
export interface SessionHttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly use: (middleware: SessionHttpMiddleware) => void
request: Request
}
export type SessionHttpHandler = (request: Request) => Promise<Response>
export type SessionHttpMiddleware = (
request: Request,
next: SessionHttpHandler,
) => Promise<Response> | Response
export interface SessionHttpResponse {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly request: Request
response: Response
}
export interface SessionHooks {
readonly context: SessionContext
readonly http: SessionHttp
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
}
export type SessionDomain = Pick<
-22
View File
@@ -20687,25 +20687,6 @@
],
"additionalProperties": false
},
"Mcp.Status.NeedsClientRegistration": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"needs_client_registration"
]
},
"error": {
"type": "string"
}
},
"required": [
"status",
"error"
],
"additionalProperties": false
},
"Mcp.Server": {
"type": "object",
"properties": {
@@ -20728,9 +20709,6 @@
},
{
"$ref": "#/components/schemas/Mcp.Status.NeedsAuth"
},
{
"$ref": "#/components/schemas/Mcp.Status.NeedsClientRegistration"
}
]
},
-3
View File
@@ -124,9 +124,6 @@ const InfoBase = {
// on non-session owners anywhere else.
sessionID: Schema.String,
title: Schema.String,
coalesce: Schema.String.pipe(optional).annotate({
description: "Client-local key for displaying equivalent pending forms once and broadcasting one response.",
}),
metadata: Metadata.pipe(optional),
}
+1 -5
View File
@@ -68,13 +68,9 @@ const Failed = Schema.Struct({ status: Schema.Literal("failed"), error: Schema.S
const NeedsAuth = Schema.Struct({ status: Schema.Literal("needs_auth") }).annotate({
identifier: "Mcp.Status.NeedsAuth",
})
const NeedsClientRegistration = Schema.Struct({
status: Schema.Literal("needs_client_registration"),
error: Schema.String,
}).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" })
export type Status = typeof Status.Type
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth, NeedsClientRegistration]).pipe(
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth]).pipe(
Schema.toTaggedUnion("status"),
)
@@ -6,6 +6,7 @@ import type {
IntegrationOauthConnectOutput,
IntegrationOAuthMethod,
} from "@opencode-ai/client"
import open from "open"
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
@@ -445,6 +446,19 @@ function OAuthAuto(props: {
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{
bind: "o",
title: "Open authorization URL",
group: "Dialog",
run: () => {
open(props.attempt.url).catch(() =>
toast.show({
message: "Could not open the browser. Copy the URL and continue manually.",
variant: "error",
}),
)
},
},
{
bind: "c",
title: "Copy authorization details",
@@ -502,6 +516,7 @@ function OAuthAuto(props: {
instructions={props.attempt.instructions}
message="Waiting for authorization..."
copy
open
/>
)
}
@@ -559,7 +574,14 @@ function OAuthCode(props: {
)
}
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
function OAuthView(props: {
title: string
url?: string
instructions?: string
message: string
copy?: boolean
open?: boolean
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
return (
@@ -583,11 +605,18 @@ function OAuthView(props: { title: string; url?: string; instructions?: string;
)}
</Show>
<text fg={theme.text.subdued}>{props.message}</text>
<Show when={props.copy}>
<text fg={theme.text.default}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
</Show>
<box flexDirection="row" gap={2}>
<Show when={props.open}>
<text fg={theme.text.default}>
o <span style={{ fg: theme.text.subdued }}>open</span>
</text>
</Show>
<Show when={props.copy}>
<text fg={theme.text.default}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
</Show>
</box>
</box>
)
}
+1 -1
View File
@@ -15,7 +15,7 @@ import { useConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll"
function statusError(status: McpServer["status"]) {
if (status.status === "failed" || status.status === "needs_client_registration") return status.error
if (status.status === "failed") return status.error
return undefined
}
@@ -14,7 +14,6 @@ export function DialogStatus() {
if (status === "connected") return theme.text.feedback.success.default
if (status === "failed") return theme.text.feedback.error.default
if (status === "needs_auth") return theme.text.feedback.warning.default
if (status === "needs_client_registration") return theme.text.feedback.error.default
return theme.text.subdued
}
return (
@@ -46,9 +45,6 @@ export function DialogStatus() {
<Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match>
<Match when={item.status.status === "disabled"}>Disabled in configuration</Match>
<Match when={item.status.status === "needs_auth"}>Needs authentication</Match>
<Match when={item.status.status === "needs_client_registration" && item.status}>
{(val) => (val() as { error: string }).error}
</Match>
</Switch>
</span>
</text>
@@ -8,13 +8,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
const list = createMemo(() => props.context.data.location.mcp.server.list(session()?.location) ?? [])
const on = createMemo(() => list().filter((item) => item.status.status === "connected").length)
const bad = createMemo(
() =>
list().filter(
(item) =>
item.status.status === "failed" ||
item.status.status === "needs_auth" ||
item.status.status === "needs_client_registration",
).length,
() => list().filter((item) => item.status.status === "failed" || item.status.status === "needs_auth").length,
)
const dot = (status: string) => {
@@ -22,7 +16,6 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
if (status === "failed") return theme.text.feedback.error.default
if (status === "disabled") return theme.text.subdued
if (status === "needs_auth") return theme.text.feedback.warning.default
if (status === "needs_client_registration") return theme.text.feedback.error.default
return theme.text.subdued
}
@@ -65,7 +58,6 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
</Match>
<Match when={item.status.status === "disabled"}>Disabled</Match>
<Match when={item.status.status === "needs_auth"}>Needs auth</Match>
<Match when={item.status.status === "needs_client_registration"}>Needs client ID</Match>
</Switch>
</span>
</text>
+2 -2
View File
@@ -93,13 +93,13 @@ export function Home() {
<box width="100%" flexShrink={0}>
<PluginSlot name="home.footer" input={{}} mode="replace" />
</box>
<Show when={forms()[0]?.coalesce ?? forms()[0]?.id} keyed>
<Show when={forms()[0]?.id} keyed>
{(_) => {
const form = forms()[0]
return form ? (
<box position="absolute" zIndex={2000} left={0} right={0} bottom={1} paddingLeft={2} paddingRight={2}>
<box width="100%">
<FormPrompt form={form} forms={forms()} />
<FormPrompt form={form} />
</box>
</box>
) : null
+41 -39
View File
@@ -42,7 +42,7 @@ function requestOptions(form: FormWithLocation) {
}
}
export function FormPrompt(props: { form: FormWithLocation; forms?: readonly FormWithLocation[] }) {
export function FormPrompt(props: { form: FormWithLocation }) {
const client = useClient()
const themes = useThemes()
const theme = useTheme("elevated")
@@ -69,11 +69,6 @@ export function FormPrompt(props: { form: FormWithLocation; forms?: readonly For
let textarea: TextareaRenderable | undefined
let review: ScrollBoxRenderable | undefined
const forms = createMemo(() => {
if (!props.form.coalesce) return [props.form]
return (props.forms ?? [props.form]).filter((form) => form.coalesce === props.form.coalesce)
})
const message = createMemo(() => {
const value = props.form.metadata?.["message"]
return typeof value === "string" ? value : undefined
@@ -185,30 +180,24 @@ export function FormPrompt(props: { form: FormWithLocation; forms?: readonly For
setStore("error", "")
}
function reply(answer: Record<string, FormValue>) {
Promise.all(
forms().map((form) =>
client.api.form.reply(
{
sessionID: form.sessionID,
formID: form.id,
answer,
},
requestOptions(form),
),
),
).catch((error: unknown) => {
setStore(
"error",
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
? error.message
: "Invalid answer",
)
})
}
function replySingle(field: FormAnswerField, value: FormValue) {
reply({ [field.key]: value })
client.api.form
.reply(
{
sessionID: props.form.sessionID,
formID: props.form.id,
answer: { [field.key]: value },
},
requestOptions(props.form),
)
.catch((error: unknown) => {
setStore(
"error",
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
? error.message
: "Invalid answer",
)
})
}
function pick(value: FormValue, customValue?: string) {
@@ -361,8 +350,7 @@ export function FormPrompt(props: { form: FormWithLocation; forms?: readonly For
}
function cancel() {
for (const form of forms())
void client.api.form.cancel({ sessionID: form.sessionID, formID: form.id }, requestOptions(form))
void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
}
function openExternal() {
@@ -414,14 +402,28 @@ export function FormPrompt(props: { form: FormWithLocation; forms?: readonly For
setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
return
}
reply(
Object.fromEntries(
fields().flatMap((field) => {
const value = store.answers[field.key]
return value === undefined ? [] : [[field.key, value] as const]
}),
),
)
client.api.form
.reply(
{
sessionID: props.form.sessionID,
formID: props.form.id,
answer: Object.fromEntries(
fields().flatMap((field) => {
const value = store.answers[field.key]
return value === undefined ? [] : [[field.key, value] as const]
}),
),
},
requestOptions(props.form),
)
.catch((error: unknown) => {
setStore(
"error",
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
? error.message
: "Invalid answer",
)
})
}
onMount(() => onCleanup(keymap.mode.push(FORM_MODE)))
+2 -2
View File
@@ -1026,10 +1026,10 @@ export function Session() {
</Show>
</Match>
<Match when={forms().length > 0}>
<Show when={forms()[0]?.coalesce ?? forms()[0]?.id} keyed>
<Show when={forms()[0]?.id} keyed>
{(_) => {
const form = forms()[0]
return form ? <FormPrompt form={form} forms={forms()} /> : null
return form ? <FormPrompt form={form} /> : null
}}
</Show>
</Match>
+1 -1
View File
@@ -28,7 +28,7 @@ export function Link(props: LinkProps) {
open(props.href).catch(() => {})
}}
>
{displayText}
<a href={props.href}>{displayText}</a>
</text>
)
}
+3 -25
View File
@@ -15,7 +15,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
async function mountForm(root: string, width = 80, coalesce = false) {
async function mountForm(root: string, width = 80) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
@@ -24,7 +24,7 @@ async function mountForm(root: string, width = 80, coalesce = false) {
const events = createEventStream()
const transport = createFetch(
(url, request) =>
/^\/api\/session\/ses_test\/form\/frm_(?:test|other)\/reply$/.test(url.pathname)
url.pathname === "/api/session/ses_test/form/frm_test/reply"
? request.json().then((answer) => {
replies.push(answer)
return new Response(null, { status: 204 })
@@ -37,7 +37,6 @@ async function mountForm(root: string, width = 80, coalesce = false) {
id: "frm_test",
sessionID: "ses_test",
title: "Authorization required",
...(coalesce ? { coalesce: "authorization" } : {}),
fields: [
{
key: "authorization",
@@ -72,7 +71,7 @@ async function mountForm(root: string, width = 80, coalesce = false) {
<ClientProvider api={createApi(transport.fetch)}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<FormPrompt form={form} forms={coalesce ? [form, { ...form, id: "frm_other" }] : undefined} />
<FormPrompt form={form} />
</ToastProvider>
</ThemeProvider>
</ClientProvider>
@@ -127,24 +126,3 @@ test("includes external acknowledgements in progress", async () => {
prompt.app.renderer.destroy()
}
})
test("replies to every coalesced form", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path, 80, true)
try {
prompt.app.mockInput.pressKey("right")
await prompt.app.waitForFrame((frame) => frame.includes("(acknowledgement required)"))
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("External action must be acknowledged"))
prompt.app.mockInput.pressKey("left")
prompt.app.mockInput.pressKey("c")
await prompt.app.waitForFrame((frame) => frame.includes("press enter to confirm"))
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Acknowledged"))
prompt.app.mockInput.pressEnter()
await prompt.app.waitFor(() => prompt.replies.length === 2)
expect(prompt.replies).toEqual([{ answer: { authorization: true } }, { answer: { authorization: true } }])
} finally {
prompt.app.renderer.destroy()
}
})
@@ -83,7 +83,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
"auto": true,
"prune": false,
"keep": {
"tokens": 8000
"tokens": 15000
},
"buffer": 20000
}
@@ -94,7 +94,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
| --- | ---: | --- |
| `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. |
| `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. |
| `keep.tokens` | `8000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `keep.tokens` | `15000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. |
`keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens`
+16 -8
View File
@@ -246,19 +246,27 @@ Runtime hooks intercept live operations:
| `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("http.request", callback)` | `request`, immediately before provider dispatch |
| `ctx.session.hook("http.response", callback)` | `response`, immediately after the provider responds |
| `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.
HTTP hooks can modify requests and responses. They apply to native models; AI
SDK models do not currently pass through these hooks. Request and response
bodies are one-shot streams. Use `clone()` when you intentionally need a
separate reader, but be aware that its slower branch may buffer data. To inspect
or modify chunks while preserving streaming, replace the body with one piped
through a `TransformStream`.
```ts
await ctx.session.hook("http", (event) => {
event.use((request, next) => {
request.headers.set("x-session-id", event.sessionID)
return next(request)
await ctx.session.hook("http.request", (event) => {
event.request.headers.set("x-session-id", event.sessionID)
})
await ctx.session.hook("http.response", (event) => {
event.response = new Response(event.response.body, {
status: event.response.status,
headers: { ...Object.fromEntries(event.response.headers), "x-plugin": "enabled" },
})
})
```
+1 -1
View File
@@ -329,7 +329,7 @@ Control automatic context compaction and how much recent context it preserves.
"compaction": {
"auto": true,
"keep": {
"tokens": 8000
"tokens": 15000
},
"buffer": 20000
}
-22
View File
@@ -20687,25 +20687,6 @@
],
"additionalProperties": false
},
"Mcp.Status.NeedsClientRegistration": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"needs_client_registration"
]
},
"error": {
"type": "string"
}
},
"required": [
"status",
"error"
],
"additionalProperties": false
},
"Mcp.Server": {
"type": "object",
"properties": {
@@ -20728,9 +20709,6 @@
},
{
"$ref": "#/components/schemas/Mcp.Status.NeedsAuth"
},
{
"$ref": "#/components/schemas/Mcp.Status.NeedsClientRegistration"
}
]
},
-22
View File
@@ -20687,25 +20687,6 @@
],
"additionalProperties": false
},
"Mcp.Status.NeedsClientRegistration": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"needs_client_registration"
]
},
"error": {
"type": "string"
}
},
"required": [
"status",
"error"
],
"additionalProperties": false
},
"Mcp.Server": {
"type": "object",
"properties": {
@@ -20728,9 +20709,6 @@
},
{
"$ref": "#/components/schemas/Mcp.Status.NeedsAuth"
},
{
"$ref": "#/components/schemas/Mcp.Status.NeedsClientRegistration"
}
]
},