Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ffa0645572 | |||
| c4fa5e6619 | |||
| 6ec17e5d55 | |||
| 0405670cab | |||
| caf727ecb7 |
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()])),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = []
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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={() => {}}
|
||||
|
||||
@@ -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={() => {}}
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user