Compare commits

..

5 Commits

Author SHA1 Message Date
Kit Langton ffa0645572 perf(tui): batch event propagation 2026-07-21 15:50:12 -04:00
Simon Klee c4fa5e6619 mini: add turn summary visibility setting (#38153) 2026-07-21 21:01:47 +02:00
Aiden Cline 6ec17e5d55 fix(core): align patch Unicode matching (#38160) 2026-07-21 13:47:52 -05:00
Dax 0405670cab refactor(core): move database path policy (#38159) 2026-07-21 18:44:37 +00:00
opencode-agent[bot] caf727ecb7 fix(core): filter unsupported media inputs (#38145)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-21 13:44:10 -05:00
27 changed files with 471 additions and 102 deletions
+8 -2
View File
@@ -4,7 +4,7 @@ import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path"
@@ -75,7 +75,13 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
password,
simulation: truthy(process.env.OPENCODE_SIMULATE),
database: {
path: process.env.OPENCODE_DB,
path:
process.env.OPENCODE_DB ??
(["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db"
: `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
},
models: {
url: process.env.OPENCODE_MODELS_URL,
+2 -2
View File
@@ -131,7 +131,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.prompt = { paste: "compact" }
draft.mini = { thinking: "hide", shell_output: "hide" }
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide" }
})
}),
)
@@ -139,7 +139,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
expect(config).toEqual({
animations: true,
prompt: { paste: "compact" },
mini: { thinking: "hide", shell_output: "hide" },
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide" },
})
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
} finally {
+4 -13
View File
@@ -6,7 +6,6 @@ import { Context, Effect, Layer, Schema } from "effect"
import { Global } from "@opencode-ai/util/global"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "@opencode-ai/util/installation/version"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
@@ -40,20 +39,12 @@ const databaseLayer = Layer.effect(
}).pipe(Effect.orDie),
)
export function layer(options?: Options) {
export function layer(options: Options = { path: ":memory:" }) {
return Layer.suspend(() => {
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
if (options?.path === ":memory:" || (options?.path && isAbsolute(options.path))) return provide(options.path)
if (options?.path) return provide(join(Global.Path.data, options.path))
if (
["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
)
return provide(join(Global.Path.data, "opencode.db"))
return provide(
join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
)
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
return provide(join(Global.Path.data, filename))
})
}
+42 -2
View File
@@ -1,10 +1,11 @@
export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import { LLM, Message, SystemPart, type LLMRequest, type ToolContent } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Client } from "@opencode-ai/util/client"
import { ModelV2 } from "../model"
import { PluginHooks } from "../plugin/hooks"
import { ToolRegistry } from "../tool/registry"
import { SessionContext } from "./context"
@@ -27,6 +28,45 @@ interface PrepareInput {
readonly step: number
}
const mimeToModality = (mime: string) => {
if (mime.startsWith("image/")) return "image"
if (mime.startsWith("audio/")) return "audio"
if (mime.startsWith("video/")) return "video"
if (mime === "application/pdf") return "pdf"
}
const unsupportedMedia = (mime: string, name: string | undefined, capabilities: ModelV2.Capabilities) => {
const modality = mimeToModality(mime)
if (!modality || capabilities.input.some((item) => item.startsWith(modality))) return
return {
type: "text" as const,
text: `ERROR: Cannot read ${name ? `"${name}"` : modality} (this model does not support ${modality} input). Inform the user.`,
}
}
export const unsupportedParts = (messages: LLMRequest["messages"], capabilities: ModelV2.Capabilities) =>
messages.map((message) =>
Message.make({
...message,
content: message.content.map((part) => {
if (part.type === "media") {
return unsupportedMedia(part.mediaType, part.filename, capabilities) ?? part
}
if (part.type !== "tool-result" || part.result.type !== "content") return part
return {
...part,
result: {
...part.result,
value: part.result.value.map((item: ToolContent) => {
if (item.type !== "file") return item
return unsupportedMedia(item.mime, item.name, capabilities) ?? item
}),
},
}
}),
}),
)
/**
* Builds an outbound model request and captures the tool-call capability that
* must remain paired with it. It does not execute the request or mutate
@@ -87,7 +127,7 @@ export const layer = Layer.effect(
},
providerOptions: { openai: { promptCacheKey } },
system: contextEvent.system,
messages: contextEvent.messages,
messages: unsupportedParts(contextEvent.messages, resolved.capabilities),
tools: hookedTools,
toolChoice: stepLimitReached ? "none" : undefined,
})
+14 -3
View File
@@ -82,6 +82,8 @@ export interface Resolved {
readonly model: Model
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
readonly ref: ModelV2.Ref
/** Catalog capabilities used to shape requests before provider lowering. */
readonly capabilities: ModelV2.Capabilities
/** Catalog pricing in dollars per million tokens. */
readonly cost: ModelV2.Info["cost"]
}
@@ -96,14 +98,22 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({
export const resolved = (
model: Model,
options: {
readonly capabilities: ModelV2.Capabilities
readonly variant?: ModelV2.VariantID
readonly cost: ModelV2.Info["cost"]
},
): Resolved => ({
model,
ref: ModelV2.Ref.make({
id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.provider),
...(variant === undefined ? {} : { variant }),
...(options.variant === undefined ? {} : { variant: options.variant }),
}),
cost,
capabilities: options.capabilities,
cost: options.cost,
})
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
@@ -359,6 +369,7 @@ const layer = Layer.effect(
providerID: selected.providerID,
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
}),
capabilities: selected.capabilities,
cost: selected.cost,
}
}),
-17
View File
@@ -155,23 +155,6 @@ export const Plugin = {
prepared.push({ ...hunk, target, before: original.replace(/^\uFEFF/, ""), after: "" })
return
}
if (!target.externalDirectory && !hunk.movePath) {
const resolved = resolveTarget(location, yield* fs.resolve(target.canonical))
if (resolved.externalDirectory) {
yield* permission.assert({
action: "external_directory",
resources: [resolved.externalDirectory.resource],
save: [resolved.externalDirectory.resource],
metadata: {
filepath: resolved.canonical,
parentDir: resolved.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
}
const previous = updates.get(target.canonical)
const original =
previous ??
+8 -1
View File
@@ -49,7 +49,14 @@ const client = Layer.mock(LLMClient.Service)({
generate: () => Effect.die("unused"),
})
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
@@ -68,7 +68,13 @@ const client = Layer.mock(LLMClient.Service)({
})
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost,
}),
),
})
const it = testEffect(
AppNodeBuilder.build(
+8 -1
View File
@@ -65,7 +65,14 @@ const client = Layer.mock(LLMClient.Service)({
return response
}),
})
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const builtins = Layer.mock(InstructionBuiltIns.Service, {
load: () =>
Effect.succeed(
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test"
import { Message, ToolResultPart } from "@opencode-ai/ai"
import { unsupportedParts } from "@opencode-ai/core/session/model-request"
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
describe("SessionModelRequest.unsupportedParts", () => {
test("replaces unsupported user media with a visible error", () => {
const messages = unsupportedParts(
[
Message.user([
Message.text("Describe this image"),
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "logo.png" },
]),
],
capabilities(["text"]),
)
expect(messages[0]?.content).toEqual([
Message.text("Describe this image"),
Message.text('ERROR: Cannot read "logo.png" (this model does not support image input). Inform the user.'),
])
})
test("replaces unsupported media nested in tool results", () => {
const messages = unsupportedParts(
[
Message.tool(
ToolResultPart.make({
id: "call_1",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "logo.png" },
],
},
}),
),
],
capabilities(["text"]),
)
expect(messages[0]?.content[0]).toMatchObject({
type: "tool-result",
result: {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{
type: "text",
text: 'ERROR: Cannot read "logo.png" (this model does not support image input). Inform the user.',
},
],
},
})
})
test("preserves supported media", () => {
const message = Message.user({ type: "media", mediaType: "image/png", data: "aGVsbG8=" })
expect(unsupportedParts([message], capabilities(["text", "image"]))[0]?.content).toEqual(message.content)
})
})
@@ -73,7 +73,14 @@ const model = OpenAIChat.route
generation: { maxTokens: 20, temperature: 0 },
})
.model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
+5 -4
View File
@@ -283,10 +283,11 @@ let currentModel = model
const models = SessionRunnerModel.layerWith((session) =>
modelResolveHook.pipe(
Effect.as(
SessionRunnerModel.resolved(
session.model?.id === "replacement" ? replacementModel : currentModel,
session.model?.variant,
),
SessionRunnerModel.resolved(session.model?.id === "replacement" ? replacementModel : currentModel, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
variant: session.model?.variant,
}),
),
),
)
+7 -1
View File
@@ -65,7 +65,13 @@ const client = Layer.mock(LLMClient.Service)({
generate: () => Effect.die("unused"),
})
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost,
}),
),
})
const it = testEffect(
AppNodeBuilder.build(
+2 -40
View File
@@ -825,7 +825,7 @@ describe("PatchTool", () => {
),
)
it.live("approves an external target before updating it through an internal symlink", () =>
it.live("follows an internal symlink to an external file without external permission", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
@@ -844,10 +844,7 @@ describe("PatchTool", () => {
call("*** Begin Patch\n*** Update File: link.txt\n@@\n-before\n+after\n*** End Patch"),
),
).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(assertions[0]?.resources).toEqual([
path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
])
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
),
@@ -861,41 +858,6 @@ describe("PatchTool", () => {
),
)
it.live("does not update an external symlink target when external permission is denied", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
if (process.platform === "win32") return Effect.void
denyAction = "external_directory"
const target = path.join(outside.path, "external.txt")
const link = path.join(active.path, "link.txt")
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen(Effect.promise(() => fs.symlink(target, link))),
Effect.andThen(
withTool(active.path, (registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: link.txt\n@@\n-before\n+after\n*** End Patch"),
),
).toMatchObject({ type: "error" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
expect(readsBeforeEditApproval).toBe(0)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
}),
),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("approves a relative external target before reading and requests edit permission afterward", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
+3
View File
@@ -130,6 +130,9 @@ export const Info = Schema.Struct({
shell_output: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide raw shell tool output in Mini",
}),
turn_summary: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide the agent, model, and duration summary in Mini scrollback",
}),
}),
).annotate({ description: "Mini transcript presentation settings" }),
hints: Schema.optional(
+10 -2
View File
@@ -1,8 +1,9 @@
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup, onMount } from "solid-js"
import { batch, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { errorMessage } from "../util/error"
import { createEventBatcher } from "./event-batcher"
import { createSimpleContext } from "./helper"
import { useLog } from "./log"
@@ -61,6 +62,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
const cancel = () => request.abort(controller.signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
controller.signal.addEventListener("abort", cancel, { once: true })
let queued: ReturnType<typeof createEventBatcher<OpenCodeEvent>> | undefined
const error = await (async () => {
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
log.info("event stream connecting", { attempt })
@@ -79,6 +81,11 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
log.info("event stream connected")
events.emit(first.value.type, first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
queued = createEventBatcher((pending) => {
batch(() => {
for (const event of pending) events.emit(event.type, event)
})
})
while (!abort.signal.aborted && !controller.signal.aborted) {
const event = await iterator.next()
if (abort.signal.aborted || controller.signal.aborted) return undefined
@@ -89,12 +96,13 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
aggregateID: event.value.durable.aggregateID,
seq: event.value.durable.seq,
})
events.emit(event.value.type, event.value)
queued.add(event.value)
}
return undefined
})()
.catch((error) => error)
.finally(() => {
queued?.end(abort.signal.aborted || controller.signal.aborted)
request.abort()
clearTimeout(timeout)
controller.signal.removeEventListener("abort", cancel)
+57
View File
@@ -0,0 +1,57 @@
const defaultInterval = 16
const defaultLimit = 1_024
type Options = {
interval?: number
limit?: number
now?: () => number
schedule?: (callback: () => void, delay: number) => ReturnType<typeof setTimeout>
cancel?: (timer: ReturnType<typeof setTimeout>) => void
}
export function createEventBatcher<T>(onFlush: (events: T[]) => void, options: Options = {}) {
const interval = options.interval ?? defaultInterval
const limit = options.limit ?? defaultLimit
const now = options.now ?? Date.now
const schedule = options.schedule ?? setTimeout
const cancel = options.cancel ?? clearTimeout
let queue: T[] = []
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
let ended = false
function flush() {
if (queue.length === 0) return
const pending = queue
queue = []
timer = undefined
last = now()
onFlush(pending)
}
return {
add(event: T) {
if (ended) return
queue.push(event)
if (queue.length >= limit) {
if (timer !== undefined) cancel(timer)
flush()
return
}
if (timer !== undefined) return
if (now() - last >= interval) {
flush()
return
}
timer = schedule(flush, interval)
},
end(discard: boolean) {
if (ended) return
ended = true
if (timer !== undefined) cancel(timer)
timer = undefined
if (!discard) flush()
queue = []
},
}
}
+8
View File
@@ -657,6 +657,14 @@ export function RunSettingsBody(props: {
keywords: `shell tool command output ${props.settings().shell_output}`,
key: "shell_output",
},
{
category: "Transcript",
display: "Turn summary",
description: "agent, model, and duration",
footer: saving() === "turn_summary" ? "saving" : props.settings().turn_summary,
keywords: `turn summary agent model duration ${props.settings().turn_summary}`,
key: "turn_summary",
},
])
const change = (item: SettingEntry) => {
if (saving()) return
+1
View File
@@ -384,6 +384,7 @@ export class RunFooter implements FooterApi {
}
if (next.type === "turn.duration") {
if (this.miniSettings().turn_summary === "hide") return
const current = this.currentModel()
this.flush()
this.flushing = this.flushing
+1
View File
@@ -88,5 +88,6 @@ export function resolveMiniSettings(config?: { mini?: Partial<MiniSettings> }):
return {
thinking: config?.mini?.thinking ?? "hide",
shell_output: config?.mini?.shell_output ?? "hide",
turn_summary: config?.mini?.turn_summary ?? "show",
}
}
+1
View File
@@ -396,6 +396,7 @@ export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme"
export type MiniSettings = {
thinking: "show" | "hide"
shell_output: "show" | "hide"
turn_summary: "show" | "hide"
}
export type MiniSettingChange = {
+57
View File
@@ -792,6 +792,63 @@ test("completes exploration when a queued prompt is promoted", async () => {
}
})
test("batches burst event projections into fewer reactive executions", async () => {
const events = createEventStream()
const calls = createFetch(undefined, events)
const sessionID = "session-event-burst"
let client!: ReturnType<typeof useClient>
let received = 0
let executions = 0
function Probe() {
const data = useData()
client = useClient()
client.event.on("session.input.admitted", () => received++)
createEffect(() => {
data.session.message.list(sessionID).length
executions++
})
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => client.connection.status() === "connected")
const baseline = executions
for (let index = 0; index < 10; index++) {
emitEvent(events, {
id: `evt_input_${index}`,
created: index,
type: "session.input.admitted",
durable: durable(sessionID, index),
data: {
sessionID,
inputID: `message-${index}`,
input: { type: "user", data: { text: `${index}` }, delivery: "steer" },
},
})
}
await wait(() => received === 10)
await Bun.sleep(20)
expect(received).toBe(10)
expect(executions - baseline).toBe(2)
} finally {
app.renderer.destroy()
}
})
test("classifies live tool rows independently of their call ID", async () => {
const events = createEventStream()
const sessionID = "session-tool-call-id"
+43 -5
View File
@@ -51,14 +51,12 @@ function update(version: string): OpenCodeEvent {
}
}
async function mount(
reconnect?: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>,
log?: LogSink,
) {
async function mount(reconnect?: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>, log?: LogSink) {
const events = createEventStream()
const calls = createFetch(undefined, events)
const seen: OpenCodeEvent[] = []
const workspaces: Array<string | undefined> = []
const handshakes: string[] = []
let client!: ReturnType<typeof useClient>
let done!: () => void
const ready = new Promise<void>((resolve) => {
@@ -76,24 +74,27 @@ async function mount(
}}
seen={seen}
workspaces={workspaces}
handshakes={handshakes}
/>
</ClientProvider>
</TestTuiContexts>
))
await ready
return { app, events, emit: events.emit, client, seen, workspaces }
return { app, events, emit: (event: OpenCodeEvent) => events.emit(event), client, seen, workspaces, handshakes }
}
function Probe(props: {
seen: OpenCodeEvent[]
workspaces: Array<string | undefined>
handshakes: string[]
onReady: (ctx: { client: ReturnType<typeof useClient> }) => void
}) {
const client = useClient()
const event = useEvent()
onMount(() => {
client.event.on("server.connected", () => props.handshakes.push(client.connection.status()))
event.subscribe((evt, { workspace }) => {
props.seen.push(evt)
props.workspaces.push(workspace)
@@ -105,6 +106,35 @@ function Probe(props: {
}
describe("useEvent", () => {
test("dispatches server.connected immediately", async () => {
const { app, client, handshakes } = await mount()
try {
await wait(() => client.connection.status() === "connected")
expect(handshakes).toEqual(["connecting"])
} finally {
app.renderer.destroy()
}
})
test("delivers a burst exactly once and in order", async () => {
const { app, client, emit, seen } = await mount()
try {
await wait(() => client.connection.status() === "connected")
for (const branch of ["one", "two", "three"]) emit(vcs(branch))
await wait(() => seen.length === 3)
expect(seen.map((item) => (item.type === "vcs.branch.updated" ? item.data.branch : item.type))).toEqual([
"one",
"two",
"three",
])
} finally {
app.renderer.destroy()
}
})
test("logs only durable events", async () => {
const logs: Array<{ level: LogLevel; message: string; tags: Readonly<Record<string, unknown>> }> = []
const { app, emit, seen } = await mount(undefined, (level, message, tags) => {
@@ -195,6 +225,9 @@ describe("useEvent", () => {
await wait(() => client.connection.status() === "connected")
// Reconnection only runs when the stream is down, never while connected.
expect(attempts).toEqual([])
events.emit(event(vcs("before-drop"), { directory: "/tmp/original" }))
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "before-drop"))
events.emit(event(vcs("at-drop"), { directory: "/tmp/original" }))
events.disconnect()
await wait(() => client.connection.status() === "connected" && attempts.length > 0)
replacementEvents.emit(event(vcs("rediscovered"), { directory: "/tmp/rediscovered" }))
@@ -202,6 +235,11 @@ describe("useEvent", () => {
expect(client.api).toBe(replacement.api)
expect(attempts).toEqual([1])
expect(seen.map((item) => (item.type === "vcs.branch.updated" ? item.data.branch : item.type))).toEqual([
"before-drop",
"at-drop",
"rediscovered",
])
const history = client.connection.internal.history()
expect(history.map((event) => [event.data.status, event.data.attempt])).toEqual([
["connecting", 0],
@@ -0,0 +1,89 @@
import { describe, expect, test } from "bun:test"
import { createEventBatcher } from "../../src/context/event-batcher"
function clock() {
let time = 100
const scheduled = new Map<ReturnType<typeof setTimeout>, { callback: () => void; at: number }>()
return {
now: () => time,
schedule(callback: () => void, delay: number) {
const timer = setTimeout(() => {}, 60_000)
scheduled.set(timer, { callback, at: time + delay })
return timer
},
cancel(timer: ReturnType<typeof setTimeout>) {
clearTimeout(timer)
scheduled.delete(timer)
},
advance(delay: number) {
time += delay
for (const [timer, task] of scheduled) {
if (task.at > time) continue
clearTimeout(timer)
scheduled.delete(timer)
task.callback()
}
},
pending() {
return scheduled.size
},
}
}
describe("createEventBatcher", () => {
test("preserves events in frame-bounded flushes", () => {
const time = clock()
const flushes: number[][] = []
const batcher = createEventBatcher<number>((events) => flushes.push(events), time)
batcher.add(1)
time.advance(1)
batcher.add(2)
batcher.add(3)
expect(flushes).toEqual([[1]])
expect(time.pending()).toBe(1)
time.advance(15)
expect(flushes).toEqual([[1]])
time.advance(1)
expect(flushes).toEqual([[1], [2, 3]])
expect(flushes.flat()).toEqual([1, 2, 3])
})
test("flushes a live generation and discards an obsolete generation", () => {
const time = clock()
const live: number[][] = []
const active = createEventBatcher<number>((events) => live.push(events), time)
active.add(1)
time.advance(1)
active.add(2)
active.end(false)
const obsolete: number[][] = []
const stale = createEventBatcher<number>((events) => obsolete.push(events), time)
stale.add(3)
time.advance(1)
stale.add(4)
stale.end(true)
time.advance(16)
expect(live).toEqual([[1], [2]])
expect(obsolete).toEqual([[3]])
expect(time.pending()).toBe(0)
})
test("caps a batch when timers cannot run", () => {
const time = clock()
const flushes: number[][] = []
const batcher = createEventBatcher<number>((events) => flushes.push(events), { ...time, limit: 3 })
batcher.add(1)
time.advance(1)
batcher.add(2)
batcher.add(3)
batcher.add(4)
expect(flushes).toEqual([[1], [2, 3, 4]])
expect(time.pending()).toBe(0)
})
})
@@ -55,7 +55,7 @@ test("down opens subagents from an empty prompt", async () => {
subagent={subagents}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={config}
miniSettings={() => ({ thinking: "hide", shell_output: "hide" })}
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show" })}
onSubmit={() => true}
onPermissionReply={() => {}}
onFormReply={() => {}}
+18 -4
View File
@@ -130,7 +130,9 @@ async function renderFooter(
)
const state = footerState(input.state)
const config = input.tuiConfig ?? tuiConfig
const [miniSettings] = createSignal<MiniSettings>(input.miniSettings ?? { thinking: "hide", shell_output: "hide" })
const [miniSettings] = createSignal<MiniSettings>(
input.miniSettings ?? { thinking: "hide", shell_output: "hide", turn_summary: "show" },
)
function Harness() {
return (
<Keymap.Provider config={config}>
@@ -418,7 +420,11 @@ test("direct command panel renders grouped command palette", async () => {
})
test("direct settings panel changes Mini transcript preferences", async () => {
const [settings, setSettings] = createSignal<MiniSettings>({ thinking: "hide", shell_output: "hide" })
const [settings, setSettings] = createSignal<MiniSettings>({
thinking: "hide",
shell_output: "hide",
turn_summary: "show",
})
const app = await testRender(
() => (
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
@@ -440,12 +446,20 @@ test("direct settings panel changes Mini transcript preferences", async () => {
expect(app.captureCharFrame()).toContain("Settings")
expect(app.captureCharFrame()).toContain("Thinking")
expect(app.captureCharFrame()).toContain("Shell tool output")
expect(app.captureCharFrame()).toContain("Turn summary")
expect(app.captureCharFrame()).toContain("left/right change")
app.mockInput.pressKey("ARROW_RIGHT")
await app.renderOnce()
expect(settings()).toEqual({ thinking: "show", shell_output: "hide" })
expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "show" })
app.mockInput.pressKey("ARROW_DOWN")
app.mockInput.pressKey("ARROW_DOWN")
app.mockInput.pressKey("ARROW_RIGHT")
await app.renderOnce()
expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "hide" })
} finally {
app.renderer.destroy()
}
@@ -1099,7 +1113,7 @@ test("direct footer shows authoritative pending work while running", async () =>
]}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={tuiConfig}
miniSettings={() => ({ thinking: "hide", shell_output: "hide" })}
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show" })}
onSubmit={() => true}
onPermissionReply={() => {}}
onFormReply={() => {}}
+3 -2
View File
@@ -103,10 +103,11 @@ describe("run runtime boot", () => {
expect(result.theme).toEqual({ mode: "light" })
expect(result.leader.timeout).toBe(450)
expect(result.session?.thinking).toBe("show")
expect(resolveMiniSettings(result)).toEqual({ thinking: "hide", shell_output: "hide" })
expect(resolveMiniSettings({ mini: { thinking: "show", shell_output: "show" } })).toEqual({
expect(resolveMiniSettings(result)).toEqual({ thinking: "hide", shell_output: "hide", turn_summary: "show" })
expect(resolveMiniSettings({ mini: { thinking: "show", shell_output: "show", turn_summary: "hide" } })).toEqual({
thinking: "show",
shell_output: "show",
turn_summary: "hide",
})
})