Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline 2e0f414e78 fix(core): relocate AI SDK tool media 2026-08-11 15:31:28 -05:00
20 changed files with 253 additions and 301 deletions
@@ -0,0 +1,68 @@
---
name: ideal-pseudocode
description: Function-by-function refactoring loop driven by ideal pseudocode. Use when the user says "ideal pseudocode", asks to make a function read like its pseudocode, or wants a dense module cleaned up one function at a time.
---
# Ideal Pseudocode
Clean up one function at a time by writing the pseudocode it _should_ read as, naming every delta between that and the real code, and closing only the gaps the user approves.
## Loop
One function per round. Never touch code before the user picks a direction.
1. **Pick the target** with the user — usually the next function up or down the call chain from the last round.
2. **Read the current code** fresh from disk. It may have unsaved or parallel edits; ask before overwriting anything unexpected.
3. **Distill.** Write the function's ideal pseudocode in a `ts`-fenced code block — TypeScript-flavored for syntax highlighting, but pseudocode: comments over mechanics, one line per idea, every arm of a loop visible as an arm. For a dense or unfamiliar function, first show the _current_ structure as pseudocode, then the ideal.
4. **Name the deltas.** A numbered list; each delta is one concrete gap: control flow smuggled through combinators, a flag-typed parameter, colliding names, side-effect plumbing drowning a loop body, twin functions differing only by a filter. For each, give an honest cost/benefit and a decisive recommendation — including "leave it" when the density has reasons (state the reasons).
5. **Verify before proposing.** Any API the proposal builds on (a combinator, schedule, matcher) gets checked against the installed version's source, not memory.
6. **Get buy-in, then apply.** Minimal edits. Quarantine irreducible ugliness inside a small helper whose name is the pseudocode line it implements. Collapse shallow sibling functions into one deep function only when the distinguishing argument is a domain noun, not a behavior switch.
7. **Validate.** Typecheck and the focused test suites from the package directory; rerun timing-sensitive tests repeatedly. Flag any semantic change the restructure introduced, even when tests pass.
8. **Offer the next target** and stop.
## Style
- Keep each round tight: pseudocode block, deltas, recommendation, one question.
- State mutation belongs in one visible place (usually the loop tail), never spread across error channels or callback closures.
- Prefer early return and `if` chains for control flow; matchers are for producing values.
- Compare names against the question each caller is actually asking; rename toward the domain noun.
## Example round
Every round should read like this (abridged from a real one, on an Effect step-runner):
> `runStep` it is. The ideal pseudocode:
>
> ```ts
> // One logical step: one settled model call, however many attempts it takes.
> // An attempt can end without settling in two ways:
> // transient provider failure -> retry the same call, same assistant message
> // compaction rewrote history -> rebuild the request and call again
> function runStep(session, promotable, step) {
> while (true) {
> const result = callModel(session, promotable, step)
>
> if (result.completed) return result
>
> if (result.retryable) {
> wait(retrySchedule) // exhausted -> publish Step.Failed, fail drain
> promotable = none // never re-promote on a second attempt
> continue
> }
>
> // compaction restarted the step: fresh request from rewritten history
> promotable = none
> step = result.step
> }
> }
> ```
>
> Comparing against the real thing, three deltas:
>
> 1. **The retry arm doesn't live in the loop.** In the pseudocode, retry is just another loop arm. In the real code it's smuggled through the error channel: `Effect.tapError` mutating loop variables via `Effect.sync` closures, then `Effect.retryOrElse` re-running a suspended call. Four mutable variables mutated from three different places — the state flow is invisible unless you already know the trick.
> 2. **`recoverOverflow: typeof compaction.compact | undefined`** — a function-typed parameter used as a one-shot armed/disarmed flag. The callee already closes over `compaction`; a boolean says what it is.
> 3. **`assistantMessageID`** exists solely to resume the same durable message across retries — fine, but only legible once the retry arm is visible in the loop.
>
> The unifying move for (1): a schedule driver, so a retryable failure becomes a caught value handled in the loop body exactly like the compaction arm. One honest trade: it's more manual than the blessed combinator — but the manual version is the loop the combinator was hiding, and the loop already exists for restarts. Two restart mechanisms, one control structure.
>
> Want me to apply it — unified loop, simplified schedule input, boolean `recoverOverflow`?
+44 -8
View File
@@ -33,6 +33,7 @@ import {
} from "@opencode-ai/ai"
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
import type { Content } from "@opencode-ai/schema/tool"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import type { ID, Info } from "./model"
import { Provider } from "./provider"
@@ -40,9 +41,15 @@ import { State } from "./state"
type SDK = any
type UserContent = Extract<LanguageModelV3Message, { role: "user" }>["content"]
type UserFileContent = Extract<UserContent[number], { type: "file" }>
type AssistantContent = Extract<LanguageModelV3Message, { role: "assistant" }>["content"]
type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result" }>
const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:"
const TOOL_RESULT_ATTACHMENT_TEXT = "Media attached in following user message."
const isToolResultMedia = (item: Content): item is Extract<Content, { type: "file" }> =>
item.type === "file" && (item.mime.toLowerCase().startsWith("image/") || item.mime === "application/pdf")
export interface SDKEvent {
readonly model: Info
readonly package: string
@@ -435,21 +442,34 @@ function prompt(request: LLMRequest): LanguageModelV3Prompt {
.map((part) => part.text)
.filter(Boolean)
.join("\n\n")
const messages = request.messages.flatMap(message)
const messages: LanguageModelV3Message[] = []
const media: UserFileContent[] = []
const flushMedia = () => {
if (media.length === 0) return
messages.push({
role: "user",
content: [{ type: "text", text: SYNTHETIC_ATTACHMENT_PROMPT }, ...media.splice(0)],
})
}
for (const input of request.messages) {
if (input.role !== "tool") flushMedia()
messages.push(...message(input, media))
}
flushMedia()
if (!system.length) return messages
return [{ role: "system", content: system }, ...messages]
}
function message(input: LLMRequest["messages"][number]): LanguageModelV3Message[] {
function message(input: LLMRequest["messages"][number], media: UserFileContent[]): LanguageModelV3Message[] {
switch (input.role) {
case "system":
return [{ role: "system", content: input.content.flatMap(text).join("\n\n") }]
case "user":
return [{ role: "user", content: input.content.flatMap(userPart) }]
case "assistant":
return [{ role: "assistant", content: input.content.flatMap(assistantPart) }]
return [{ role: "assistant", content: input.content.flatMap((part) => assistantPart(part, media)) }]
case "tool": {
const content = input.content.flatMap(toolResultPart)
const content = input.content.flatMap((part) => toolResultPart(part, media))
return content.length ? [{ role: "tool", content }] : []
}
}
@@ -466,7 +486,7 @@ function userPart(part: ContentPart): UserContent {
return []
}
function assistantPart(part: ContentPart): AssistantContent {
function assistantPart(part: ContentPart, media: UserFileContent[]): AssistantContent {
switch (part.type) {
case "text":
return [{ type: "text", text: part.text }]
@@ -486,18 +506,34 @@ function assistantPart(part: ContentPart): AssistantContent {
},
]
case "tool-result":
return toolResultPart(part)
return toolResultPart(part, media)
}
}
function toolResultPart(part: ContentPart): ToolResultContent[] {
function toolResultPart(part: ContentPart, media: UserFileContent[]): ToolResultContent[] {
if (part.type !== "tool-result") return []
const result = (() => {
if (part.result.type !== "content") return part.result
const extracted = part.result.value.filter(isToolResultMedia)
if (extracted.length === 0) return part.result
media.push(
...extracted.map((item) => ({
type: "file" as const,
mediaType: item.mime,
data: item.uri,
filename: item.name,
})),
)
const content = part.result.value.filter((item) => !isToolResultMedia(item))
if (content.length === 0) return { type: "text" as const, value: TOOL_RESULT_ATTACHMENT_TEXT }
return { type: "content" as const, value: content }
})()
return [
{
type: "tool-result",
toolCallId: part.id,
toolName: part.name,
output: toolOutput(part.result),
output: toolOutput(result),
providerOptions: providerOptions(part.providerMetadata),
},
]
+1 -7
View File
@@ -126,12 +126,6 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
const image =
(remote.capabilities.supports.vision ?? false) ||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
const pdf =
(remote.capabilities.supports.vision ?? false) &&
(remote.capabilities.limits.vision?.supported_media_types.includes("application/pdf") ?? false)
const input = ["text"]
if (image) input.push("image")
if (pdf) input.push("pdf")
const prices = remote.billing?.token_prices
// Copilot reports AIC per billing batch; OpenCode stores USD per million tokens.
const usdPerMillion = prices && prices.batch_size > 0 ? 10_000 / prices.batch_size : 0
@@ -156,7 +150,7 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
body: previous?.body,
capabilities: {
tools: remote.capabilities.supports.tool_calls,
input,
input: image ? ["text", "image"] : ["text"],
output: ["text"],
},
variants: variants(remote, messages),
@@ -5,7 +5,6 @@ import { Credential } from "../../credential"
import { Bus } from "../../bus"
import { CopilotModels } from "../../github-copilot/models"
import { App } from "../../app"
import { Agent } from "../../agent"
import { Integration } from "../../integration"
import { Model } from "../../model"
import { define } from "@opencode-ai/plugin/effect/plugin"
@@ -243,10 +242,6 @@ export const GithubCopilotPlugin = define({
yield* ctx.session.hook("http.request", (evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
if (evt.agent === Agent.ID.make("title"))
evt.request.headers.set("X-Interaction-Type", "conversation-background")
if (evt.agent === Agent.ID.make("compaction"))
evt.request.headers.set("X-Interaction-Type", "conversation-compaction")
const token = evt.request.headers.get("x-api-key")
if (!token) return
const text = yield* Effect.promise(() => evt.request.clone().text())
+6 -25
View File
@@ -1,7 +1,6 @@
export * as SessionCompaction from "./compaction"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Context, Effect, Layer, Stream } from "effect"
@@ -12,17 +11,14 @@ import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
import type { SessionMessage } from "./message"
import { SessionModelHeaders } from "./model-headers"
import { SessionModelHttp } from "./model-http"
import { SessionPromptCacheKey } from "./prompt-cache-key"
import { App } from "../app"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { toSessionError } from "./to-session-error"
import { Token } from "../util/token"
import type { Info, Ref } from "../model"
import type { Info } from "../model"
import { SessionUsage } from "./usage"
import { PluginHooks } from "../plugin/hooks"
import { Agent } from "../agent"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000
@@ -70,18 +66,16 @@ type Dependencies = {
readonly app: App.Info
readonly bus: Bus.Interface
readonly llm: {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, AIError>
}
readonly models: SessionRunnerModel.Interface
readonly config: Settings
readonly hooks: PluginHooks.Interface
}
export type AutoInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
readonly model: LanguageModel
readonly ref: Ref
readonly cost: Info["cost"]
}
@@ -91,12 +85,9 @@ export type ManualInput = {
readonly inputID: SessionMessage.ID
}
type RequiredInput = Omit<AutoInput, "ref">
type Plan = {
readonly session: SessionSchema.Info
readonly model: LanguageModel
readonly ref: Ref
readonly cost: Info["cost"]
readonly reason: SessionMessage.Compaction["reason"]
readonly prompt: string
@@ -109,7 +100,7 @@ export type Outcome =
| Pick<SessionMessage.CompactionFailed, "status" | "error">
export interface Interface {
readonly required: (input: RequiredInput) => boolean
readonly required: (input: AutoInput) => boolean
readonly compact: (input: AutoInput) => Effect.Effect<Outcome>
readonly compactManual: (input: ManualInput) => Effect.Effect<Outcome>
}
@@ -274,13 +265,6 @@ const make = (dependencies: Dependencies) => {
messages: [Message.user(plan.prompt)],
tools: [],
}),
{
http: SessionModelHttp.middleware(dependencies.hooks, {
sessionID: plan.session.id,
agent: Agent.ID.make("compaction"),
model: plan.ref,
}),
},
)
.pipe(
Stream.runForEach((event) => {
@@ -347,7 +331,6 @@ const make = (dependencies: Dependencies) => {
return yield* execute({
session: input.session,
model: input.model,
ref: input.ref,
cost: input.cost,
reason: "auto",
...content,
@@ -359,7 +342,7 @@ const make = (dependencies: Dependencies) => {
error,
})
})
const required = (input: RequiredInput) => {
const required = (input: AutoInput) => {
if (!config.auto) return false
const context = input.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
@@ -402,7 +385,6 @@ const make = (dependencies: Dependencies) => {
return yield* execute({
session: input.session,
model: resolved.model,
ref: resolved.ref,
cost: resolved.cost,
reason: "manual",
inputID: input.inputID,
@@ -424,13 +406,12 @@ export const layer = Layer.effect(
const config = yield* Config.Service
const models = yield* SessionRunnerModel.Service
const app = yield* App.Metadata
const hooks = yield* PluginHooks.Service
return make({ bus, llm, models, config: settings(yield* config.entries()), app, hooks })
return make({ bus, llm, models, config: settings(yield* config.entries()), app })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node, PluginHooks.node],
deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node],
})
-39
View File
@@ -1,39 +0,0 @@
export * as SessionModelHttp from "./model-http"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import { Effect, Stream } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { PluginHooks } from "../plugin/hooks"
export const middleware =
(
hooks: PluginHooks.Interface,
input: { readonly sessionID: Session.ID; readonly agent: Agent.ID; readonly model: Model.Ref },
): NonNullable<StreamOptions["http"]> =>
(request, handler) =>
Effect.gen(function* () {
const before = yield* hooks.trigger("session", "http.request", {
...input,
request: yield* HttpClientRequest.toWeb(request),
})
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", {
...input,
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)))))
+32 -7
View File
@@ -4,7 +4,8 @@ import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { Content } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app"
import { Model } from "../model"
@@ -14,7 +15,6 @@ import { QuestionTool } from "../tool/plugin/question"
import { Tool } from "../tool"
import { SessionContext } from "./context"
import { SessionModelHeaders } from "./model-headers"
import { SessionModelHttp } from "./model-http"
import { SessionPromptCacheKey } from "./prompt-cache-key"
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics"
import { MAX_STEPS_PROMPT } from "./runner/max-steps"
@@ -227,11 +227,36 @@ export const layer = Layer.effect(
toolChoice: stepLimitReached ? "none" : undefined,
})
const options: StreamOptions = {
http: SessionModelHttp.middleware(hooks, {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
}),
http: (request, handler) =>
Effect.gen(function* () {
const before = yield* hooks.trigger("session", "http.request", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
request: yield* HttpClientRequest.toWeb(request),
})
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) {
const current = PromptCacheDiagnostics.snapshot(request)
+1 -1
View File
@@ -244,7 +244,7 @@ const layer = Layer.effect(
const model = resolved.model
// Make room: history must fit the context window before the call. A pending manual
// compaction owns this instead; the runner executes it between steps.
const compactionInput = { session, messages: loaded.messages, model, ref: resolved.ref, cost: resolved.cost }
const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost }
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed")
+3 -24
View File
@@ -1,7 +1,6 @@
export * as SessionTitle from "./title"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { Agent } from "../agent"
import { Database } from "../database/database"
@@ -10,11 +9,9 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
import { App } from "../app"
import { llmClient } from "../effect/app-node-platform"
import { PluginHooks } from "../plugin/hooks"
import { SessionEvent } from "./event"
import { SessionHistory } from "./history"
import { SessionModelHeaders } from "./model-headers"
import { SessionModelHttp } from "./model-http"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { SessionUsage } from "./usage"
@@ -27,12 +24,11 @@ type Dependencies = {
readonly app: App.Info
readonly bus: Bus.Interface
readonly llm: {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, AIError>
}
readonly agents: Agent.Interface
readonly models: SessionRunnerModel.Interface
readonly store: SessionStore.Interface
readonly hooks: PluginHooks.Interface
}
export interface Interface {
@@ -89,13 +85,6 @@ const make = (dependencies: Dependencies) => {
messages: [Message.user(firstUser.text)],
tools: [],
}),
{
http: SessionModelHttp.middleware(dependencies.hooks, {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
}),
},
)
.pipe(
Stream.runForEach((event) => {
@@ -146,8 +135,7 @@ export const layer = Layer.effect(
const store = yield* SessionStore.Service
const database = yield* Database.Service
const app = yield* App.Metadata
const hooks = yield* PluginHooks.Service
const title = make({ bus, llm, agents, models, store, app, hooks })
const title = make({ bus, llm, agents, models, store, app })
return Service.of({
generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID),
})
@@ -157,14 +145,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [
Bus.node,
llmClient,
Agent.node,
SessionRunnerModel.node,
SessionStore.node,
Database.node,
App.node,
PluginHooks.node,
],
deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, SessionStore.node, Database.node, App.node],
})
+85 -10
View File
@@ -275,7 +275,7 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
}),
)
it.effect("preserves tool result content in AI SDK prompts", () =>
it.effect("moves tool result images and PDFs into an AI SDK user message", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
@@ -323,21 +323,96 @@ it.effect("preserves tool result content in AI SDK prompts", () =>
type: "content",
value: [
{ type: "text", text: "attachments" },
{ type: "image-data", data: "AAAA", mediaType: "image/png" },
{
type: "file-data",
data: "JVBERg==",
mediaType: "application/pdf",
filename: "document.pdf",
},
{ type: "file-data", data: "SUQz", mediaType: "audio/mpeg", filename: "clip.mp3" },
{ type: "image-url", url: "https://example.com/pixel.png" },
{ type: "file-url", url: "https://example.com/document.pdf" },
],
},
},
],
},
{
role: "user",
content: [
{ type: "text", text: "Attached media from tool result:" },
{ type: "file", mediaType: "image/png", data: "data:image/png;base64,AAAA", filename: "pixel.png" },
{
type: "file",
mediaType: "application/pdf",
data: "data:application/pdf;charset=utf-8;base64,JVBERg==",
filename: "document.pdf",
},
{ type: "file", mediaType: "image/png", data: "https://example.com/pixel.png" },
{ type: "file", mediaType: "application/pdf", data: "https://example.com/document.pdf" },
],
},
])
}),
)
it.effect("groups consecutive AI SDK tool media and keeps file-only results non-empty", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model(model("test-ai-sdk"))
const prepared = yield* compileRequest(
LLM.request({
model: resolved,
messages: [
Message.tool({
id: "call_1",
name: "read",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "one.png" }],
},
}),
Message.tool({
id: "call_2",
name: "read",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,BBBB", mime: "image/png", name: "two.png" }],
},
}),
Message.assistant("Images received"),
],
}),
)
expect(prepared.body.prompt).toEqual([
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_1",
toolName: "read",
output: { type: "text", value: "Media attached in following user message." },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_2",
toolName: "read",
output: { type: "text", value: "Media attached in following user message." },
},
],
},
{
role: "user",
content: [
{ type: "text", text: "Attached media from tool result:" },
{ type: "file", mediaType: "image/png", data: "data:image/png;base64,AAAA", filename: "one.png" },
{ type: "file", mediaType: "image/png", data: "data:image/png;base64,BBBB", filename: "two.png" },
],
},
{ role: "assistant", content: [{ type: "text", text: "Images received" }] },
])
}),
)
@@ -1,7 +1,7 @@
import fs from "fs/promises"
import os from "os"
import { Effect } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EffectFlock } from "@opencode-ai/util/effect-flock"
import { Global } from "@opencode-ai/util/global"
@@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
log: os.tmpdir(),
})
const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]])
const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]])
async function job() {
if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))
@@ -27,32 +27,8 @@ test("defensively syncs advertised Copilot models", async () => {
max_context_window_tokens: 200000,
max_output_tokens: 16384,
max_prompt_tokens: 180000,
vision: {
max_prompt_image_size: 10000000,
max_prompt_images: 10,
supported_media_types: ["image/png", "application/pdf"],
},
},
supports: { tool_calls: true, vision: true, reasoning_effort: ["low", "high"] },
},
},
{
model_picker_enabled: true,
id: "vision-only",
name: "Vision only",
version: "vision-only-2026-06-01",
capabilities: {
family: "vision",
limits: {
max_output_tokens: 16384,
max_prompt_tokens: 180000,
vision: {
max_prompt_image_size: 10000000,
max_prompt_images: 10,
supported_media_types: ["image/png"],
},
},
supports: { tool_calls: true, vision: true },
supports: { tool_calls: true, reasoning_effort: ["low", "high"] },
},
},
{
@@ -91,8 +67,6 @@ test("defensively syncs advertised Copilot models", async () => {
Model.VariantID.make("low"),
Model.VariantID.make("high"),
])
expect(model?.capabilities.input).toEqual(["text", "image", "pdf"])
expect(models.get(Model.ID.make("vision-only"))?.capabilities.input).toEqual(["text", "image"])
expect(models.get(Model.ID.make("utility"))?.enabled).toBe(false)
expect(models.has(Model.ID.make("stale"))).toBe(false)
expect(models.has(Model.ID.make("incomplete"))).toBe(false)
+2 -1
View File
@@ -4,6 +4,7 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { Command } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
@@ -34,7 +35,7 @@ const npmLayer = Layer.succeed(
}),
)
export const PluginTestLayer = LayerNode.compile(
export const PluginTestLayer = AppNodeBuilder.build(
LayerNode.group([
FileSystem.node,
FSUtil.node,
@@ -141,32 +141,6 @@ describe("GithubCopilotPlugin", () => {
}),
)
it.effect("classifies title generation as a background interaction", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
sessionID: Session.ID.make("ses_title"),
agent: Agent.ID.make("title"),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4-nano") }),
request: new Request("https://api.githubcopilot.com/chat/completions"),
})
expect(event.request.headers.get("x-interaction-type")).toBe("conversation-background")
}),
)
it.effect("classifies compaction requests", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
sessionID: Session.ID.make("ses_compaction"),
agent: Agent.ID.make("compaction"),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4") }),
request: new Request("https://api.githubcopilot.com/responses"),
})
expect(event.request.headers.get("x-interaction-type")).toBe("conversation-compaction")
}),
)
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
+2 -1
View File
@@ -5,6 +5,7 @@ import path from "path"
import os from "os"
import { Cause, Effect, Exit } from "effect"
import { testEffect } from "../lib/effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EffectFlock } from "@opencode-ai/util/effect-flock"
import { Global } from "@opencode-ai/util/global"
@@ -109,7 +110,7 @@ const testGlobal = Global.layerWith({
log: os.tmpdir(),
})
const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]])
const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]])
// ---------------------------------------------------------------------------
// Tests
+1 -28
View File
@@ -30,7 +30,7 @@ import {
batch,
Show,
} from "solid-js"
import { createStore, unwrap } from "solid-js/store"
import { createStore } from "solid-js/store"
import {
TuiLifecycleProvider,
TuiAppProvider,
@@ -62,7 +62,6 @@ import { useConnected } from "./component/use-connected"
import { DialogMcp } from "./component/dialog-mcp"
import { DialogStatus } from "./component/dialog-status"
import { DialogConfig } from "./component/dialog-config"
import { DialogExperiments } from "./component/dialog-experiments"
import { DialogDebug } from "./component/dialog-debug"
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
import { DialogThemeList } from "./component/dialog-theme-list"
@@ -658,22 +657,8 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
slash: { name: "new", aliases: ["clear"] },
run: () => {
// With per-tab drafts, a new session is an explicit "this belongs
// elsewhere" gesture: move the in-progress draft instead of leaving
// a copy behind on the tab it came from.
const carried = (() => {
if (config.data.experimental?.tab_drafts !== true) return undefined
const current = promptRef.current
if (!current?.current.text) return undefined
// Copy before reset: reset() merges an empty prompt into the same
// underlying store object that unwrap exposes.
const prompt = { ...unwrap(current.current) }
current.reset()
return prompt
})()
route.navigate({
type: "home",
prompt: carried,
location:
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
@@ -885,18 +870,6 @@ function App(props: { pair?: DialogPairCredentials }) {
},
category: "System",
},
{
// Deliberately absent from the command palette; reachable only by the
// secret /baldbeard incantation.
name: "opencode.experiments",
title: "Experiments",
palette: undefined,
slash: { name: "baldbeard" },
run: () => {
dialog.replace(() => <DialogExperiments />)
},
category: "System",
},
{
name: "opencode.status",
title: "View status",
@@ -1,63 +0,0 @@
import { createMemo, createSignal } from "solid-js"
import { useConfig } from "../config"
import { DialogSelect } from "../ui/dialog-select"
import { useToast } from "../ui/toast"
type Experiment = {
id: "tab_drafts"
title: string
description: string
}
// In-flight features anyone can opt into. Each entry is temporary: an
// experiment either graduates (delete the entry, make the behavior
// unconditional) or dies (delete the entry and the branch it gated).
export const experiments: Experiment[] = [
{
id: "tab_drafts",
title: "Per-tab prompt drafts",
description: "Keep unsent prompt drafts on the tab where they were written. New session moves the current draft.",
},
]
export function DialogExperiments() {
const config = useConfig()
const toast = useToast()
const [saving, setSaving] = createSignal(false)
const enabled = (experiment: Experiment) => config.data.experimental?.[experiment.id] === true
const options = createMemo(() =>
experiments.map((experiment, index) => ({
title: experiment.title,
description: experiment.description,
category: "Experiments",
footer: enabled(experiment) ? "on" : "off",
value: index,
})),
)
async function toggle(index: number) {
if (saving()) return
const experiment = experiments[index]
if (!experiment) return
const next = !enabled(experiment)
setSaving(true)
await config
.update((draft) => {
if (!draft.experimental || typeof draft.experimental !== "object") draft.experimental = {}
draft.experimental[experiment.id] = next
})
.catch(toast.error)
.finally(() => setSaving(false))
}
return (
<DialogSelect
title="Experiments"
options={options()}
onSelect={(option) => void toggle(option.value)}
footerHints={[{ title: "enter", label: "toggle" }]}
/>
)
}
+3 -18
View File
@@ -130,11 +130,7 @@ function formatEditorContext(selection: EditorSelection) {
return `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n`
}
// One in-progress draft survives remounts. By default a single slot follows
// focus across tabs; the tab_drafts experiment keys drafts to the tab (session
// or home) they were written in.
let stashed: { prompt: PromptInfo; cursor: number } | undefined
const stashedByTab = new Map<string, { prompt: PromptInfo; cursor: number }>()
function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
const head = parseSlashHead(input, /\s/)
@@ -651,17 +647,9 @@ export function Prompt(props: PromptProps) {
},
}
// Captured once: the session route is keyed by sessionID, so this Prompt
// instance belongs to exactly one tab. Reading props.sessionID lazily would
// observe the *next* route during onCleanup and stash under the wrong tab.
const stashSessionID = props.sessionID
const stashKey = () => (config.experimental?.tab_drafts === true ? (stashSessionID ?? "home") : undefined)
onMount(() => {
const key = stashKey()
const saved = key === undefined ? stashed : stashedByTab.get(key)
if (key === undefined) stashed = undefined
else stashedByTab.delete(key)
const saved = stashed
stashed = undefined
if (store.prompt.text) return
if (saved && saved.prompt.text) {
input.setText(saved.prompt.text)
@@ -674,10 +662,7 @@ export function Prompt(props: PromptProps) {
onCleanup(() => {
disposed = true
if (store.prompt.text) {
const entry = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
const key = stashKey()
if (key === undefined) stashed = entry
else stashedByTab.set(key, entry)
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
setInputTarget(undefined)
props.ref?.(undefined)
-7
View File
@@ -189,13 +189,6 @@ export const Info = Schema.Struct({
}),
}),
).annotate({ description: "Debugging settings" }),
experimental: Schema.optional(
Schema.Struct({
tab_drafts: Schema.optional(Schema.Boolean).annotate({
description: "Keep unsent prompt drafts on the tab where they were written",
}),
}),
).annotate({ description: "Experimental features that may change or be removed at any time" }),
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }),
cursor: Schema.optional(Cursor),
@@ -161,8 +161,8 @@ Skills are keyed by ID. If several sources define the same ID, the later source
wins. Sources are registered in this order, from lower to higher precedence:
1. Built-in skills
2. `.claude/skills` sources, global first and then from the farthest ancestor toward the current directory
3. `.agents/skills` sources, global first and then from the farthest ancestor toward the current directory
2. `.claude/skills` sources, global first and then from the current directory upward
3. `.agents/skills` sources, global first and then from the current directory upward
4. `~/.config/opencode/skills`
5. Project `.opencode/skills`, from the project root toward the current directory
6. Explicit `skills` config entries, in config priority and array order