Compare commits

...

12 Commits

Author SHA1 Message Date
Aiden Cline 9422b49cbf fix(image): disable attachment resizing by default 2026-05-12 17:16:13 -05:00
opencode-agent[bot] 2cb697b720 chore: generate 2026-05-12 21:24:39 +00:00
Aiden Cline cb511f78ff fix(plugin): preserve tool attachments (#27157) 2026-05-12 16:23:15 -05:00
Musa 159964b172 feat(plugin): add DigitalOcean OAuth + Inference Routers (#26095) 2026-05-12 16:22:40 -05:00
Kit Langton d9f9f1553b test: use Effect file services in migrated tests (#27154) 2026-05-12 20:48:07 +00:00
Kit Langton 0fb55b4f1a test(project): migrate global project tests to Effect runner (#27142) 2026-05-12 20:45:39 +00:00
Kit Langton 3c34f6704b test: migrate auth override plugin test (#27140) 2026-05-12 20:41:34 +00:00
Kit Langton 4cf088ae84 test: migrate instance bootstrap to Effect runner (#27144) 2026-05-12 20:34:12 +00:00
Aiden Cline ca28dd02ec fix(compaction): restore tail turns after summarization (#27145) 2026-05-12 15:33:16 -05:00
Kit Langton 2017dc165c test: migrate negative tokens regression to Effect runner (#27141) 2026-05-12 20:32:23 +00:00
Kit Langton dc9d6a08cb test: migrate agent color config tests (#27139) 2026-05-12 20:31:38 +00:00
Kit Langton af4ab017cb test(session): migrate structured output integration test (#27143) 2026-05-12 20:30:35 +00:00
33 changed files with 1374 additions and 591 deletions
+4 -1
View File
@@ -124,6 +124,7 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
yield* put(saveProvider, {
type: "api",
key: result.key,
...(result.metadata ? { metadata: result.metadata } : {}),
})
}
yield* spinner.stop("Login successful")
@@ -156,6 +157,7 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
yield* put(saveProvider, {
type: "api",
key: result.key,
...(result.metadata ? { metadata: result.metadata } : {}),
})
}
yield* Prompt.log.success("Login successful")
@@ -191,10 +193,11 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
const merged = { ...(metadata.metadata ?? {}), ...(result.metadata ?? {}) }
yield* put(saveProvider, {
type: "api",
key: result.key ?? apiKey,
...metadata,
...(Object.keys(merged).length ? { metadata: merged } : {}),
})
yield* Prompt.log.success("Login successful")
}
+6 -1
View File
@@ -4,8 +4,13 @@ import { Schema } from "effect"
import { PositiveInt } from "@opencode-ai/core/schema"
export const Image = Schema.Struct({
enforce_limits: Schema.optional(Schema.Boolean).annotate({
description:
"Enforce image attachment size limits before sending images to the model. When false, images pass through unchanged (default: false)",
}),
auto_resize: Schema.optional(Schema.Boolean).annotate({
description: "Resize images before sending them to the model when they exceed configured limits (default: true)",
description:
"Resize images before sending them to the model when they exceed configured limits. Requires enforce_limits to be true (default: false)",
}),
max_width: Schema.optional(PositiveInt).annotate({
description: "Maximum image width before resizing or rejecting the attachment (default: 2000)",
+2 -2
View File
@@ -261,10 +261,10 @@ export const Info = Schema.Struct({
}),
tail_turns: Schema.optional(NonNegativeInt).annotate({
description:
"Number of recent user turns, including their following assistant/tool responses, to serialize into the compaction summary (default: 2)",
"Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)",
}),
preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({
description: "Maximum number of tokens from recent turns to serialize into the compaction summary",
description: "Maximum number of tokens from recent turns to preserve verbatim after compaction",
}),
reserved: Schema.optional(NonNegativeInt).annotate({
description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",
+4 -1
View File
@@ -6,7 +6,8 @@ import { Context, Effect, Layer, Schema } from "effect"
const MAX_BASE64_BYTES = 4.5 * 1024 * 1024
const MAX_WIDTH = 2000
const MAX_HEIGHT = 2000
const AUTO_RESIZE = true
const ENFORCE_LIMITS = false
const AUTO_RESIZE = false
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
const log = Log.create({ service: "image" })
@@ -76,11 +77,13 @@ export const layer = Layer.effect(
const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) {
const image = (yield* config.get()).attachment?.image
const info = {
enforceLimits: image?.enforce_limits ?? ENFORCE_LIMITS,
autoResize: image?.auto_resize ?? AUTO_RESIZE,
maxWidth: image?.max_width ?? MAX_WIDTH,
maxHeight: image?.max_height ?? MAX_HEIGHT,
maxBase64Bytes: image?.max_base64_bytes ?? MAX_BASE64_BYTES,
}
if (!info.enforceLimits) return input
if (!input.url.startsWith("data:") || !input.url.includes(";base64,"))
return yield* new InvalidDataUrlError({ url: input.url })
@@ -0,0 +1,411 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Model } from "@opencode-ai/sdk/v2"
import * as Log from "@opencode-ai/core/util/log"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { createServer } from "http"
const log = Log.create({ service: "plugin.digitalocean" })
const DO_OAUTH_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82"
const DO_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize"
const DO_API_BASE = "https://api.digitalocean.com"
const DO_INFERENCE_BASE = "https://inference.do-ai.run/v1"
const OAUTH_PORT = 1456
const OAUTH_REDIRECT_PATH = "/auth/callback"
const OAUTH_TOKEN_PATH = "/auth/token"
const ROUTER_REFRESH_INTERVAL_MS = 5 * 60 * 1000
const MAK_NAME_PREFIX = "opencode-oauth"
interface ImplicitTokenPayload {
access_token: string
expires_in: number
state: string
}
interface PendingOAuth {
state: string
resolve: (tokens: ImplicitTokenPayload) => void
reject: (error: Error) => void
}
interface ApiKeyInfo {
uuid: string
name: string
secret_key: string
}
interface RouterEntry {
name: string
uuid?: string
description?: string
}
let oauthServer: ReturnType<typeof createServer> | undefined
let pendingOAuth: PendingOAuth | undefined
function generateState(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
}
function redirectUri(): string {
return `http://localhost:${OAUTH_PORT}${OAUTH_REDIRECT_PATH}`
}
function buildAuthorizeUrl(state: string): string {
const params = new URLSearchParams({
response_type: "token",
client_id: DO_OAUTH_CLIENT_ID,
redirect_uri: redirectUri(),
scope: "read write",
state,
})
return `${DO_AUTHORIZE_URL}?${params.toString()}`
}
const HTML_CALLBACK = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>OpenCode - DigitalOcean Authorization</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0b1220; color: #e8eef9; }
.container { text-align: center; padding: 2rem; max-width: 32rem; }
h1 { color: #e8eef9; margin-bottom: 1rem; }
p { color: #9aa9c0; }
.error { color: #ff917b; font-family: monospace; margin-top: 1rem; padding: 1rem; background: #3c140d; border-radius: 0.5rem; }
</style>
</head>
<body>
<div class="container">
<h1 id="title">Finishing sign-in...</h1>
<p id="msg">You can close this window once it says you're signed in.</p>
</div>
<script>
(async function() {
const params = new URLSearchParams((window.location.hash || "").slice(1))
const search = new URLSearchParams(window.location.search)
const error = params.get("error") || search.get("error")
const errorDescription = params.get("error_description") || search.get("error_description")
const titleEl = document.getElementById("title")
const msgEl = document.getElementById("msg")
try {
const body = error
? { error, error_description: errorDescription || "" }
: { access_token: params.get("access_token") || "", expires_in: params.get("expires_in") || "0", state: params.get("state") || "" }
await fetch(${JSON.stringify(OAUTH_TOKEN_PATH)}, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (error) {
titleEl.textContent = "Authorization Failed"
msgEl.textContent = errorDescription || error
msgEl.className = "error"
return
}
titleEl.textContent = "Authorization Successful"
msgEl.textContent = "You can close this window and return to OpenCode."
setTimeout(function () { window.close() }, 2000)
} catch (e) {
titleEl.textContent = "Authorization Failed"
msgEl.textContent = String(e && e.message ? e.message : e)
msgEl.className = "error"
}
})()
</script>
</body>
</html>`
async function startOAuthServer(): Promise<void> {
if (oauthServer) return
oauthServer = createServer((req, res) => {
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) {
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_CALLBACK)
return
}
if (req.method === "POST" && url.pathname === OAUTH_TOKEN_PATH) {
const chunks: Buffer[] = []
req.on("data", (chunk: Buffer) => chunks.push(chunk))
req.on("end", () => {
const raw = Buffer.concat(chunks).toString("utf8")
let body: Record<string, string> = {}
try {
body = raw ? JSON.parse(raw) : {}
} catch {
body = {}
}
if (!pendingOAuth) {
res.writeHead(409, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "no_pending_oauth" }))
return
}
if (body.error) {
const message = body.error_description || body.error || "OAuth error"
pendingOAuth.reject(new Error(String(message)))
pendingOAuth = undefined
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ ok: true }))
return
}
if (!body.access_token) {
pendingOAuth.reject(new Error("Missing access_token in callback"))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "missing_access_token" }))
return
}
if (body.state !== pendingOAuth.state) {
pendingOAuth.reject(new Error("Invalid state - potential CSRF attack"))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "invalid_state" }))
return
}
const expires = parseInt(body.expires_in || "0", 10)
pendingOAuth.resolve({
access_token: body.access_token,
expires_in: Number.isFinite(expires) && expires > 0 ? expires : 60 * 60 * 24 * 30,
state: body.state,
})
pendingOAuth = undefined
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ ok: true }))
})
return
}
res.writeHead(404)
res.end("Not found")
})
await new Promise<void>((resolve, reject) => {
oauthServer!.listen(OAUTH_PORT, () => {
log.info("digitalocean oauth server started", { port: OAUTH_PORT })
resolve()
})
oauthServer!.on("error", reject)
})
}
function stopOAuthServer() {
if (!oauthServer) return
oauthServer.close(() => log.info("digitalocean oauth server stopped"))
oauthServer = undefined
}
function waitForOAuthCallback(state: string): Promise<ImplicitTokenPayload> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(
() => {
if (pendingOAuth) {
pendingOAuth = undefined
reject(new Error("OAuth callback timeout - authorization took too long"))
}
},
5 * 60 * 1000,
)
pendingOAuth = {
state,
resolve: (tokens) => {
clearTimeout(timeout)
resolve(tokens)
},
reject: (error) => {
clearTimeout(timeout)
reject(error)
},
}
})
}
async function createModelAccessKey(bearer: string): Promise<ApiKeyInfo> {
// Suffix-on-collision strategy keeps re-`/connect` non-destructive.
const name = `${MAK_NAME_PREFIX}-${Math.floor(Date.now() / 1000)}`
const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/api_keys`, {
method: "POST",
headers: {
Authorization: `Bearer ${bearer}`,
"Content-Type": "application/json",
"User-Agent": `opencode/${InstallationVersion}`,
},
body: JSON.stringify({ name }),
})
if (!res.ok) {
const body = await res.text().catch(() => "")
throw new Error(`Failed to create Model Access Key (${res.status}): ${body}`)
}
const data = (await res.json()) as { api_key_info?: ApiKeyInfo }
if (!data.api_key_info?.secret_key) throw new Error("Model Access Key response missing secret_key")
return data.api_key_info
}
async function listRouters(
bearer: string,
): Promise<{ ok: true; routers: RouterEntry[] } | { ok: false; status: number }> {
const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/routers`, {
headers: {
Authorization: `Bearer ${bearer}`,
Accept: "application/json",
"User-Agent": `opencode/${InstallationVersion}`,
},
signal: AbortSignal.timeout(10_000),
}).catch(() => undefined)
if (!res) return { ok: false, status: 0 }
if (!res.ok) return { ok: false, status: res.status }
const body = (await res.json().catch(() => undefined)) as { model_routers?: RouterEntry[] } | undefined
return { ok: true, routers: body?.model_routers ?? [] }
}
function routerModel(router: RouterEntry, providerID: string): Model {
const id = `router:${router.name}`
return {
id,
providerID,
name: router.name,
family: "digitalocean-inference-routers",
api: { id, url: DO_INFERENCE_BASE, npm: "@ai-sdk/openai-compatible" },
status: "active",
headers: {},
options: {},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 128_000, output: 8_192 },
capabilities: {
temperature: true,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
release_date: "",
variants: {},
}
}
function parseRoutersJSON(raw: string | undefined): RouterEntry[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.flatMap((r) =>
r && typeof r.name === "string" ? [{ name: r.name, uuid: r.uuid, description: r.description }] : [],
)
} catch {
return []
}
}
export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks> {
return {
provider: {
id: "digitalocean",
async models(provider, ctx) {
const baseModels = provider.models
if (ctx.auth?.type !== "api") return baseModels
const metadata = ctx.auth.metadata ?? {}
const oauthAccess = metadata["oauth_access"]
const oauthExpires = parseInt(metadata["oauth_expires"] || "0", 10)
const fetchedAt = parseInt(metadata["routers_fetched_at"] || "0", 10)
const cached = parseRoutersJSON(metadata["routers"])
let routers = cached
const stale = Date.now() - fetchedAt > ROUTER_REFRESH_INTERVAL_MS
const bearerValid = oauthAccess && oauthExpires > Date.now()
if (bearerValid && stale) {
const result = await listRouters(oauthAccess)
if (result.ok) {
routers = result.routers
const updated: Record<string, string> = {
...metadata,
routers: JSON.stringify(routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description }))),
routers_fetched_at: String(Date.now()),
}
await input.client.auth
.set({
path: { id: "digitalocean" },
body: { type: "api", key: ctx.auth.key, metadata: updated },
})
.catch((err) => log.warn("failed to persist refreshed routers", { error: err }))
} else if (result.status === 401 || result.status === 403) {
log.warn("digitalocean oauth bearer rejected; using cached routers", { status: result.status })
} else if (result.status !== 0) {
log.warn("digitalocean router refresh failed", { status: result.status })
}
}
const merged: Record<string, Model> = { ...baseModels }
for (const router of routers) {
const id = `router:${router.name}`
if (merged[id]) continue
merged[id] = routerModel(router, "digitalocean")
}
return merged
},
},
auth: {
provider: "digitalocean",
methods: [
{
type: "oauth",
label: "Login with DigitalOcean",
async authorize() {
await startOAuthServer()
const state = generateState()
const callbackPromise = waitForOAuthCallback(state)
return {
url: buildAuthorizeUrl(state),
instructions:
"Sign in to DigitalOcean in your browser. OpenCode will create a Model Access Key named opencode-oauth-* and load your Inference Routers. Re-run /connect to refresh routers later.",
method: "auto" as const,
async callback() {
try {
const tokens = await callbackPromise
const apiKeyInfo = await createModelAccessKey(tokens.access_token)
const routerResult = await listRouters(tokens.access_token)
const routers = routerResult.ok ? routerResult.routers : []
if (!routerResult.ok) {
log.warn("digitalocean initial router fetch failed", { status: routerResult.status })
}
return {
type: "success" as const,
provider: "digitalocean",
key: apiKeyInfo.secret_key,
metadata: {
mak_uuid: apiKeyInfo.uuid,
mak_name: apiKeyInfo.name,
oauth_access: tokens.access_token,
oauth_expires: String(Date.now() + tokens.expires_in * 1000),
routers: JSON.stringify(
routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description })),
),
routers_fetched_at: String(Date.now()),
},
}
} catch (err) {
log.error("digitalocean oauth callback failed", { error: err })
return { type: "failed" as const }
} finally {
stopOAuthServer()
}
},
}
},
},
{
type: "api",
label: "Paste Model Access Key",
},
],
},
}
}
+2
View File
@@ -19,6 +19,7 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
import { AzureAuthPlugin } from "./azure"
import { DigitalOceanAuthPlugin } from "./digitalocean"
import { Effect, Layer, Context, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
@@ -64,6 +65,7 @@ const INTERNAL_PLUGINS: PluginInstance[] = [
CloudflareWorkersAuthPlugin,
CloudflareAIGatewayAuthPlugin,
AzureAuthPlugin,
DigitalOceanAuthPlugin,
]
function isServerPlugin(value: unknown): value is PluginInstance {
+1
View File
@@ -197,6 +197,7 @@ export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> =
yield* auth.set(input.providerID, {
type: "api",
key: result.key,
...(result.metadata ? { metadata: result.metadata } : {}),
})
}
+24 -36
View File
@@ -79,10 +79,12 @@ Rules:
type Turn = {
start: number
end: number
id: MessageID
}
type Tail = {
start: number
id: MessageID
}
type CompletedCompaction = {
@@ -119,41 +121,19 @@ function completedCompactions(messages: MessageV2.WithParts[]) {
})
}
function buildPrompt(input: { previousSummary?: string; context: string[]; tail?: string }) {
const source = input.tail
? "the conversation history above and the serialized recent conversation tail below"
: "the conversation history above"
function buildPrompt(input: { previousSummary?: string; context: string[] }) {
const anchor = input.previousSummary
? [
`Update the anchored summary below using ${source}.`,
"Update the anchored summary below using the conversation history above.",
"Preserve still-true details, remove stale details, and merge in the new facts.",
"<previous-summary>",
input.previousSummary,
"</previous-summary>",
].join("\n")
: `Create a new anchored summary from ${source}.`
const tail = input.tail
? [
"Fold this serialized recent conversation tail into the summary; it is not provider message history.",
"<recent-conversation-tail>",
input.tail,
"</recent-conversation-tail>",
].join("\n")
: undefined
return [anchor, ...(tail ? [tail] : []), SUMMARY_TEMPLATE, ...input.context].join("\n\n")
: "Create a new anchored summary from the conversation history above."
return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
}
const serialize = Effect.fn("SessionCompaction.serialize")(function* (input: {
messages: MessageV2.WithParts[]
model: Provider.Model
}) {
const messages = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, {
stripMedia: true,
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
})
return messages.length ? JSON.stringify(messages, null, 2) : undefined
})
function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) {
return (
input.cfg.compaction?.preserve_recent_tokens ??
@@ -170,6 +150,7 @@ function turns(messages: MessageV2.WithParts[]) {
result.push({
start: i,
end: messages.length,
id: msg.info.id,
})
}
for (let i = 0; i < result.length - 1; i++) {
@@ -196,6 +177,7 @@ function splitTurn(input: {
if (size > input.budget) continue
return {
start,
id: input.messages[start]!.info.id,
} satisfies Tail
}
return undefined
@@ -262,7 +244,8 @@ export const layer: Layer.Layer<
messages: MessageV2.WithParts[]
model: Provider.Model
}) {
return Token.estimate((yield* serialize(input)) ?? "")
const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model)
return Token.estimate(JSON.stringify(msgs))
})
const select = Effect.fn("SessionCompaction.select")(function* (input: {
@@ -271,10 +254,10 @@ export const layer: Layer.Layer<
model: Provider.Model
}) {
const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS
if (limit <= 0) return { head: input.messages, tail: [] }
if (limit <= 0) return { head: input.messages, tail_start_id: undefined }
const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model })
const all = turns(input.messages)
if (!all.length) return { head: input.messages, tail: [] }
if (!all.length) return { head: input.messages, tail_start_id: undefined }
const recent = all.slice(-limit)
const sizes = yield* Effect.forEach(
recent,
@@ -293,7 +276,7 @@ export const layer: Layer.Layer<
const size = sizes[i]
if (total + size <= budget) {
total += size
keep = { start: turn.start }
keep = { start: turn.start, id: turn.id }
continue
}
const remaining = budget - total
@@ -309,10 +292,10 @@ export const layer: Layer.Layer<
break
}
if (!keep) return { head: input.messages, tail: [] }
if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined }
return {
head: input.messages.slice(0, keep.start),
tail: input.messages.slice(keep.start),
tail_start_id: keep.id,
}
})
@@ -423,10 +406,7 @@ export const layer: Layer.Layer<
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
const tailMessages = structuredClone(selected.tail)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: tailMessages })
const tail = yield* serialize({ messages: tailMessages, model })
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context, tail })
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
const msgs = structuredClone(selected.head)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
@@ -493,6 +473,13 @@ export const layer: Layer.Layer<
return "stop"
}
if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) {
yield* session.updatePart({
...compactionPart,
tail_start_id: selected.tail_start_id,
})
}
if (result === "continue" && input.auto) {
if (replay) {
const original = replay.info
@@ -588,6 +575,7 @@ export const layer: Layer.Layer<
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(Date.now()),
text: summary ?? "",
include: selected.tail_start_id,
})
}
yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
+40 -4
View File
@@ -772,13 +772,12 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
return part.metadata?.anthropic?.signature != null
})
for (const part of msg.parts) {
if (msg.info.summary && part.type !== "text") continue
if (part.type === "text") {
const text = part.text === "" && hasSignedReasoning ? " " : part.text
assistantMessage.parts.push({
type: "text",
text,
...(differentModel || msg.info.summary ? {} : { providerMetadata: part.metadata }),
...(differentModel ? {} : { providerMetadata: part.metadata }),
})
}
if (part.type === "step-start")
@@ -1004,16 +1003,53 @@ export function get(input: { sessionID: SessionID; messageID: MessageID }): With
export function filterCompacted(msgs: Iterable<WithParts>) {
const result = [] as WithParts[]
const completed = new Set<string>()
let retain: MessageID | undefined
for (const msg of msgs) {
result.push(msg)
if (msg.info.role === "user" && completed.has(msg.info.id)) {
if (msg.parts.some((item): item is CompactionPart => item.type === "compaction")) break
if (retain) {
if (msg.info.id === retain) break
continue
}
if (msg.info.role === "user" && completed.has(msg.info.id)) {
const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction")
if (!part) continue
if (!part.tail_start_id) break
retain = part.tail_start_id
if (msg.info.id === retain) break
continue
}
if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction"))
break
if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error)
completed.add(msg.info.parentID)
}
result.reverse()
const compactionIndex = result.findLastIndex(
(msg) =>
msg.info.role === "user" &&
msg.parts.some((item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined),
)
const compaction = result[compactionIndex]
const part = compaction?.parts.find(
(item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined,
)
const summaryIndex = compaction
? result.findIndex(
(msg, index) =>
index > compactionIndex &&
msg.info.role === "assistant" &&
msg.info.summary &&
msg.info.parentID === compaction.info.id,
)
: -1
const tailIndex = part?.tail_start_id ? result.findIndex((msg) => msg.info.id === part.tail_start_id) : -1
if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) {
return [
...result.slice(compactionIndex, summaryIndex + 1),
...result.slice(tailIndex, compactionIndex),
...result.slice(summaryIndex + 1),
]
}
return result
}
+3 -1
View File
@@ -160,11 +160,13 @@ export const layer: Layer.Layer<
const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))
const output = typeof result === "string" ? result : result.output
const metadata = typeof result === "string" ? {} : (result.metadata ?? {})
const attachments = typeof result === "string" ? undefined : result.attachments
const info = yield* agent.get(toolCtx.agent)
const out = yield* truncate.output(output, {}, info)
return {
title: "",
title: typeof result === "string" ? "" : (result.title ?? ""),
output: out.truncated ? out.content : output,
attachments,
metadata: {
...metadata,
truncated: out.truncated,
@@ -1,58 +1,50 @@
import { test, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import path from "path"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { Config } from "@/config/config"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { Color } from "@/util/color"
import { AppRuntime } from "../../src/effect/app-runtime"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer))
const it = testEffect(Layer.mergeAll(Config.defaultLayer, AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer))
const writeConfig = (dir: string, agent: Config.Info["agent"]) =>
Effect.promise(() =>
Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent,
}),
),
)
it.live("agent color parsed from project config", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* writeConfig(dir, {
build: { color: "#FFA500" },
plan: { color: "primary" },
})
yield* Effect.gen(function* () {
const cfg = yield* Effect.promise(() => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())))
it.instance(
"agent color parsed from project config",
() =>
Effect.gen(function* () {
const cfg = yield* Config.Service.use((svc) => svc.get())
expect(cfg.agent?.["build"]?.color).toBe("#FFA500")
expect(cfg.agent?.["plan"]?.color).toBe("primary")
}).pipe(provideInstance(dir))
}),
}),
{
git: true,
config: {
agent: {
build: { color: "#FFA500" },
plan: { color: "primary" },
},
},
},
)
it.live("Agent.get includes color from config", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* writeConfig(dir, {
plan: { color: "#A855F7" },
build: { color: "accent" },
})
yield* Effect.gen(function* () {
it.instance(
"Agent.get includes color from config",
() =>
Effect.gen(function* () {
const plan = yield* AgentSvc.Service.use((svc) => svc.get("plan"))
expect(plan?.color).toBe("#A855F7")
const build = yield* AgentSvc.Service.use((svc) => svc.get("build"))
expect(build?.color).toBe("accent")
}).pipe(provideInstance(dir))
}),
}),
{
git: true,
config: {
agent: {
plan: { color: "#A855F7" },
build: { color: "accent" },
},
},
},
)
test("Color.hexToAnsiBold converts valid hex to ANSI", () => {
+24 -2
View File
@@ -6,11 +6,24 @@ import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(Image.layer.pipe(Layer.provide(TestConfig.layer()))))
const enforcing = testEffect(
Layer.mergeAll(
Image.layer.pipe(
Layer.provide(
TestConfig.layer({
get: () => Effect.succeed({ attachment: { image: { enforce_limits: true, auto_resize: true } } }),
}),
),
),
),
)
const tiny = testEffect(
Layer.mergeAll(
Image.layer.pipe(
Layer.provide(
TestConfig.layer({ get: () => Effect.succeed({ attachment: { image: { max_base64_bytes: 1 } } }) }),
TestConfig.layer({
get: () => Effect.succeed({ attachment: { image: { enforce_limits: true, max_base64_bytes: 1 } } }),
}),
),
),
),
@@ -28,7 +41,16 @@ function part(mime: string, data: string) {
}
describe("Image", () => {
it.effect("normalizes generated png and jpeg attachments", () =>
it.effect("passes images through by default without loading the resizer", () =>
Effect.gen(function* () {
const image = yield* Image.Service
const input = part("image/png", "not-valid-image-data")
expect(yield* image.normalize(input)).toEqual(input)
}),
)
enforcing.effect("normalizes generated png and jpeg attachments when limit enforcement is enabled", () =>
Effect.gen(function* () {
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
const source = new photon.PhotonImage(
@@ -1,15 +1,19 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { pathToFileURL } from "url"
import { Effect, Layer } from "effect"
import { provideTestInstance, tmpdir } from "../fixture/fixture"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { ProviderAuth } from "@/provider/auth"
import { ProviderID } from "../../src/provider/schema"
import { Plugin } from "@/plugin"
import { Auth } from "@/auth"
import { Bus } from "@/bus"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer))
function layer(directory: string, plugins: string[]) {
return ProviderAuth.layer.pipe(
@@ -37,13 +41,15 @@ function layer(directory: string, plugins: string[]) {
}
describe("plugin.auth-override", () => {
test("user plugin overrides built-in github-copilot auth", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const pluginDir = path.join(dir, ".opencode", "plugin")
await fs.mkdir(pluginDir, { recursive: true })
it.instance(
"user plugin overrides built-in github-copilot auth",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const fs = yield* AppFileSystem.Service
const pluginDir = path.join(tmp.directory, ".opencode", "plugin")
await Bun.write(
yield* fs.writeWithDirs(
path.join(pluginDir, "custom-copilot-auth.ts"),
[
"export default {",
@@ -61,37 +67,26 @@ describe("plugin.auth-override", () => {
"",
].join("\n"),
)
},
})
await using plain = await tmpdir()
const plain = yield* tmpdirScoped({ git: true })
const plugin = pathToFileURL(path.join(pluginDir, "custom-copilot-auth.ts")).href
const methods = yield* ProviderAuth.Service.use((svc) => svc.methods()).pipe(
Effect.provide(layer(tmp.directory, [plugin])),
)
const plainMethods = yield* ProviderAuth.Service.use((svc) => svc.methods()).pipe(
Effect.provide(layer(plain, [])),
provideInstance(plain),
)
const plugin = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "custom-copilot-auth.ts")).href
const [methods, plainMethods] = await Promise.all([
provideTestInstance({
directory: tmp.path,
fn: async () => {
return Effect.runPromise(
ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(tmp.path, [plugin]))),
)
},
const copilot = methods[ProviderID.make("github-copilot")]
expect(copilot).toBeDefined()
expect(copilot.length).toBe(1)
expect(copilot[0].label).toBe("Test Override Auth")
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
}),
provideTestInstance({
directory: plain.path,
fn: async () => {
return Effect.runPromise(
ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(plain.path, []))),
)
},
}),
])
const copilot = methods[ProviderID.make("github-copilot")]
expect(copilot).toBeDefined()
expect(copilot.length).toBe(1)
expect(copilot[0].label).toBe("Test Override Auth")
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
}, 30000)
{ git: true },
30000,
)
})
const file = path.join(import.meta.dir, "../../src/plugin/index.ts")
@@ -1,11 +1,16 @@
import { afterEach, expect, test } from "bun:test"
import { afterEach, expect } from "bun:test"
import { existsSync } from "node:fs"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Effect, Layer } from "effect"
import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap"
import { WithInstance } from "../../src/project/with-instance"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { InstanceLayer } from "../../src/project/instance-layer"
import { InstanceStore } from "../../src/project/instance-store"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(InstanceLayer.layer, CrossSpawnSpawner.defaultLayer))
// InstanceBootstrap must run before any code touches the instance —
// originally tracked by PRs #25389 and #25449, now a permanent
@@ -19,58 +24,64 @@ afterEach(async () => {
await disposeAllInstances()
})
async function bootstrapFixture() {
return tmpdir({
init: async (dir) => {
const marker = path.join(dir, "config-hook-fired")
const pluginFile = path.join(dir, "plugin.ts")
await Bun.write(
pluginFile,
[
`const MARKER = ${JSON.stringify(marker)}`,
"export default async () => ({",
" config: async () => {",
' await Bun.write(MARKER, "ran")',
" },",
"})",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(pluginFile).href],
}),
)
return marker
},
})
}
test("WithInstance.provide runs InstanceBootstrap before fn", async () => {
await using tmp = await bootstrapFixture()
await WithInstance.provide({
directory: tmp.path,
fn: async () => "ok",
})
expect(existsSync(tmp.extra)).toBe(true)
const bootstrapFixture = Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const marker = path.join(dir, "config-hook-fired")
const pluginFile = path.join(dir, "plugin.ts")
yield* Effect.promise(() =>
Bun.write(
pluginFile,
[
`const MARKER = ${JSON.stringify(marker)}`,
"export default async () => ({",
" config: async () => {",
' await Bun.write(MARKER, "ran")',
" },",
"})",
"",
].join("\n"),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(pluginFile).href],
}),
),
)
return { directory: dir, marker }
})
test("CLI bootstrap runs InstanceBootstrap before callback", async () => {
await using tmp = await bootstrapFixture()
it.live("InstanceStore.provide runs InstanceBootstrap before effect", () =>
Effect.gen(function* () {
const tmp = yield* bootstrapFixture
const store = yield* InstanceStore.Service
await cliBootstrap(tmp.path, async () => "ok")
yield* store.provide({ directory: tmp.directory }, Effect.succeed("ok"))
expect(existsSync(tmp.extra)).toBe(true)
})
expect(existsSync(tmp.marker)).toBe(true)
}),
)
test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => {
await using tmp = await bootstrapFixture()
it.live("CLI bootstrap runs InstanceBootstrap before callback", () =>
Effect.gen(function* () {
const tmp = yield* bootstrapFixture
await InstanceRuntime.reloadInstance({ directory: tmp.path })
yield* Effect.promise(() => cliBootstrap(tmp.directory, async () => "ok"))
expect(existsSync(tmp.extra)).toBe(true)
})
expect(existsSync(tmp.marker)).toBe(true)
}),
)
it.live("InstanceStore.reload runs InstanceBootstrap", () =>
Effect.gen(function* () {
const tmp = yield* bootstrapFixture
const store = yield* InstanceStore.Service
yield* store.reload({ directory: tmp.directory })
expect(existsSync(tmp.marker)).toBe(true)
}),
)
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Project } from "@/project/project"
import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
@@ -8,19 +8,14 @@ import { ProjectID } from "../../src/project/schema"
import { SessionID } from "../../src/session/schema"
import * as Log from "@opencode-ai/core/util/log"
import { $ } from "bun"
import { tmpdir } from "../fixture/fixture"
import { Effect } from "effect"
import { tmpdirScoped } from "../fixture/fixture"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
Log.init({ print: false })
void Log.init({ print: false })
function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>) {
return Effect.runPromise(
Effect.gen(function* () {
const svc = yield* Project.Service
return yield* fn(svc)
}).pipe(Effect.provide(Project.defaultLayer)),
)
}
const it = testEffect(Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer))
function legacySessionID() {
// Global-session migration covers persisted IDs from before prefixed session IDs.
@@ -63,91 +58,102 @@ function ensureGlobal() {
}
describe("migrateFromGlobal", () => {
test("migrates global sessions on first project creation", async () => {
// 1. Start with git init but no commits — creates "global" project row
await using tmp = await tmpdir()
await $`git init`.cwd(tmp.path).quiet()
await $`git config user.name "Test"`.cwd(tmp.path).quiet()
await $`git config user.email "test@opencode.test"`.cwd(tmp.path).quiet()
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
const { project: pre } = await run((svc) => svc.fromDirectory(tmp.path))
expect(pre.id).toBe(ProjectID.global)
it.live("migrates global sessions on first project creation", () =>
Effect.gen(function* () {
// 1. Start with git init but no commits — creates "global" project row
const tmp = yield* tmpdirScoped()
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
yield* Effect.promise(() => $`git config user.name "Test"`.cwd(tmp).quiet())
yield* Effect.promise(() => $`git config user.email "test@opencode.test"`.cwd(tmp).quiet())
yield* Effect.promise(() => $`git config commit.gpgsign false`.cwd(tmp).quiet())
const projects = yield* Project.Service
const { project: pre } = yield* projects.fromDirectory(tmp)
expect(pre.id).toBe(ProjectID.global)
// 2. Seed a session under "global" with matching directory
const id = legacySessionID()
seed({ id, dir: tmp.path, project: ProjectID.global })
// 2. Seed a session under "global" with matching directory
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectID.global }))
// 3. Make a commit so the project gets a real ID
await $`git commit --allow-empty -m "root"`.cwd(tmp.path).quiet()
// 3. Make a commit so the project gets a real ID
yield* Effect.promise(() => $`git commit --allow-empty -m "root"`.cwd(tmp).quiet())
const { project: real } = await run((svc) => svc.fromDirectory(tmp.path))
expect(real.id).not.toBe(ProjectID.global)
const { project: real } = yield* projects.fromDirectory(tmp)
expect(real.id).not.toBe(ProjectID.global)
// 4. The session should have been migrated to the real project ID
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
expect(row!.project_id).toBe(real.id)
})
// 4. The session should have been migrated to the real project ID
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
expect(row!.project_id).toBe(real.id)
}),
)
test("migrates global sessions even when project row already exists", async () => {
// 1. Create a repo with a commit — real project ID created immediately
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).not.toBe(ProjectID.global)
it.live("migrates global sessions even when project row already exists", () =>
Effect.gen(function* () {
// 1. Create a repo with a commit — real project ID created immediately
const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).not.toBe(ProjectID.global)
// 2. Ensure "global" project row exists (as it would from a prior no-git session)
ensureGlobal()
// 2. Ensure "global" project row exists (as it would from a prior no-git session)
yield* Effect.sync(() => ensureGlobal())
// 3. Seed a session under "global" with matching directory.
// This simulates a session created before git init that wasn't
// present when the real project row was first created.
const id = legacySessionID()
seed({ id, dir: tmp.path, project: ProjectID.global })
// 3. Seed a session under "global" with matching directory.
// This simulates a session created before git init that wasn't
// present when the real project row was first created.
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectID.global }))
// 4. Call fromDirectory again — project row already exists,
// so the current code skips migration entirely. This is the bug.
await run((svc) => svc.fromDirectory(tmp.path))
// 4. Call fromDirectory again — project row already exists,
// so the current code skips migration entirely. This is the bug.
yield* projects.fromDirectory(tmp)
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
expect(row!.project_id).toBe(project.id)
})
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
expect(row!.project_id).toBe(project.id)
}),
)
test("does not claim sessions with empty directory", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).not.toBe(ProjectID.global)
it.live("does not claim sessions with empty directory", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).not.toBe(ProjectID.global)
ensureGlobal()
yield* Effect.sync(() => ensureGlobal())
// Legacy sessions may lack a directory value.
// Without a matching origin directory, they should remain global.
const id = legacySessionID()
seed({ id, dir: "", project: ProjectID.global })
// Legacy sessions may lack a directory value.
// Without a matching origin directory, they should remain global.
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: "", project: ProjectID.global }))
await run((svc) => svc.fromDirectory(tmp.path))
yield* projects.fromDirectory(tmp)
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
expect(row!.project_id).toBe(ProjectID.global)
})
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
expect(row!.project_id).toBe(ProjectID.global)
}),
)
test("does not steal sessions from unrelated directories", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).not.toBe(ProjectID.global)
it.live("does not steal sessions from unrelated directories", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).not.toBe(ProjectID.global)
ensureGlobal()
yield* Effect.sync(() => ensureGlobal())
// Seed a session under "global" but for a DIFFERENT directory
const id = legacySessionID()
seed({ id, dir: "/some/other/dir", project: ProjectID.global })
// Seed a session under "global" but for a DIFFERENT directory
const id = legacySessionID()
yield* Effect.sync(() => seed({ id, dir: "/some/other/dir", project: ProjectID.global }))
await run((svc) => svc.fromDirectory(tmp.path))
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
// Should remain under "global" — not stolen
expect(row!.project_id).toBe(ProjectID.global)
})
yield* projects.fromDirectory(tmp)
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
// Should remain under "global" — not stolen
expect(row!.project_id).toBe(ProjectID.global)
}),
)
})
@@ -0,0 +1,132 @@
import { test, expect, afterEach } from "bun:test"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { WithInstance } from "../../src/project/with-instance"
import { Provider } from "../../src/provider/provider"
import { ProviderID } from "../../src/provider/schema"
import { Env } from "../../src/env"
import { Effect } from "effect"
import { AppRuntime } from "../../src/effect/app-runtime"
import { makeRuntime } from "../../src/effect/run-service"
const envRuntime = makeRuntime(Env.Service, Env.defaultLayer)
const set = (k: string, v: string) => envRuntime.runSync((svc) => svc.set(k, v))
async function list() {
return AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.list()
}),
)
}
const DIGITALOCEAN = ProviderID.make("digitalocean")
const originalAuthContent = process.env.OPENCODE_AUTH_CONTENT
afterEach(() => {
if (originalAuthContent === undefined) delete process.env.OPENCODE_AUTH_CONTENT
else process.env.OPENCODE_AUTH_CONTENT = originalAuthContent
})
function injectAuth(metadata: Record<string, string> | undefined) {
process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({
digitalocean: {
type: "api",
key: "sk_do_test",
...(metadata ? { metadata } : {}),
},
})
}
test("digitalocean provider autoloads from DIGITALOCEAN_ACCESS_TOKEN", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("DIGITALOCEAN_ACCESS_TOKEN", "test-token")
const providers = await list()
expect(providers[DIGITALOCEAN]).toBeDefined()
expect(providers[DIGITALOCEAN].source).toBe("env")
const baseModel = Object.values(providers[DIGITALOCEAN].models)[0]
expect(baseModel.api.url).toBe("https://inference.do-ai.run/v1")
expect(baseModel.api.npm).toBe("@ai-sdk/openai-compatible")
const routerEntries = Object.keys(providers[DIGITALOCEAN].models).filter((id) => id.startsWith("router:"))
expect(routerEntries.length).toBe(0)
},
})
})
test("digitalocean provider.models surfaces cached routers from auth metadata", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
},
})
injectAuth({
routers: JSON.stringify([
{ name: "my-router", uuid: "11f1499a-aaaa-bbbb-cccc-4e013e2ddde4" },
{ name: "other-router", uuid: "22f1499a-aaaa-bbbb-cccc-4e013e2ddde4" },
]),
routers_fetched_at: String(Date.now()),
oauth_access: "doo_v1_test",
oauth_expires: String(Date.now() + 60 * 60 * 1000),
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const models = providers[DIGITALOCEAN].models
expect(models["router:my-router"]).toBeDefined()
expect(models["router:my-router"].api.id).toBe("router:my-router")
expect(models["router:my-router"].api.url).toBe("https://inference.do-ai.run/v1")
expect(models["router:my-router"].api.npm).toBe("@ai-sdk/openai-compatible")
expect(models["router:other-router"]).toBeDefined()
},
})
})
test("digitalocean provider.models skips refresh when oauth bearer is expired", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
},
})
injectAuth({
routers: JSON.stringify([{ name: "stale-router", uuid: "stale" }]),
routers_fetched_at: "0",
oauth_access: "doo_v1_expired",
oauth_expires: "1",
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const models = providers[DIGITALOCEAN].models
expect(models["router:stale-router"]).toBeDefined()
},
})
})
test("digitalocean provider.models passes through base models when no auth metadata", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("DIGITALOCEAN_ACCESS_TOKEN", "test-token")
const providers = await list()
const models = providers[DIGITALOCEAN].models
expect(Object.keys(models).length).toBeGreaterThan(0)
expect(Object.keys(models).filter((id) => id.startsWith("router:")).length).toBe(0)
},
})
})
@@ -5,11 +5,10 @@
// negative. The pre-fix `safe()` clamp only guarded against non-finite. The
// strict `NonNegativeInt` schema then made every load of the message list
// fail to encode, killing Desktop boot for every user with such a row.
import { afterEach, describe, expect } from "bun:test"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { eq } from "drizzle-orm"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
@@ -17,81 +16,66 @@ import { MessageID, PartID } from "../../src/session/schema"
import * as Database from "@/storage/db"
import { PartTable } from "@/session/session.sql"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
const it = testEffect(Session.defaultLayer)
function seedNegativeTokenSession(directory: string) {
return Effect.promise(async () =>
WithInstance.provide({
directory,
fn: () =>
Effect.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const info = yield* session.create({})
const message = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()
yield* session.updatePart({
id: partID,
sessionID: info.id,
messageID: message.id,
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
function seedNegativeTokenSession() {
return Effect.gen(function* () {
const session = yield* Session.Service
const info = yield* session.create({})
const message = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()
yield* session.updatePart({
id: partID,
sessionID: info.id,
messageID: message.id,
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
// Bypass the schema with a direct SQL update to install the
// negative `output` value we want to test loading.
Database.use((db) =>
db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
} as never,
})
.where(eq(PartTable.id, partID))
.run(),
)
// Bypass the schema with a direct SQL update to install the
// negative `output` value we want to test loading.
Database.use((db) =>
db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
} as never,
})
.where(eq(PartTable.id, partID))
.run(),
)
return info.id
}).pipe(Effect.provide(Session.defaultLayer)),
),
}),
)
return info.id
})
}
describe("messages endpoint tolerates legacy negative token counts", () => {
it.live(
it.instance(
"returns 200 even when a step-finish part has tokens.output < 0",
Effect.acquireRelease(
Effect.promise(() => tmpdir({ config: { formatter: false, lsp: false } })),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const sessionID = yield* seedNegativeTokenSession(tmp.path)
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}`
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400)
}),
),
),
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase()))
const test = yield* TestInstance
const sessionID = yield* seedNegativeTokenSession()
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400)
}),
{ git: true, config: { formatter: false, lsp: false } },
)
})
@@ -926,12 +926,12 @@ describe("session.compaction.process", () => {
)
itCompaction.instance(
"does not persist tail_start_id for serialized recent turns",
"persists tail_start_id for retained recent turns",
Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
yield* createUserMessage(session.id, "first")
yield* createUserMessage(session.id, "second")
const keep = yield* createUserMessage(session.id, "second")
yield* createUserMessage(session.id, "third")
yield* createSummaryCompaction(session.id)
@@ -947,18 +947,18 @@ describe("session.compaction.process", () => {
const part = yield* readCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBeUndefined()
expect(part?.tail_start_id).toBe(keep.id)
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })),
)
itCompaction.instance(
"does not persist tail_start_id when shrinking serialized tail",
"shrinks retained tail to fit preserve token budget",
Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
yield* createUserMessage(session.id, "first")
yield* createUserMessage(session.id, "x".repeat(2_000))
yield* createUserMessage(session.id, "tiny")
const keep = yield* createUserMessage(session.id, "tiny")
yield* createSummaryCompaction(session.id)
const msgs = yield* ssn.messages({ sessionID: session.id })
@@ -973,7 +973,7 @@ describe("session.compaction.process", () => {
const part = yield* readCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBeUndefined()
expect(part?.tail_start_id).toBe(keep.id)
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })),
)
@@ -1005,7 +1005,7 @@ describe("session.compaction.process", () => {
)
itCompaction.instance(
"serializes retained tail media as text in the summary input",
"falls back to full summary when retained tail media exceeds preserve token budget",
() => {
const stub = llm()
let captured = ""
@@ -1078,16 +1078,15 @@ describe("session.compaction.process", () => {
const part = yield* readCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBeUndefined()
expect(part?.tail_start_id).toBe(keep.id)
expect(captured).toContain("zzzz")
expect(captured).toContain("keep tail")
expect(captured).not.toContain("keep tail")
const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(filtered.map((msg) => msg.info.id)).toEqual([parent!, expect.any(String)])
expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id])
expect(filtered[1]?.info.role).toBe("assistant")
expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true)
expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id)
expect(filtered.map((msg) => msg.info.id)).not.toContain(keep.id)
}).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
},
{ git: true },
@@ -1354,13 +1353,13 @@ describe("session.compaction.process", () => {
)
itCompaction.instance(
"summarizes the head while serializing recent tail into summary input",
"summarizes only the head while keeping recent tail out of summary input",
() => {
const stub = llm()
let captured: LLM.StreamInput["messages"] = []
let captured = ""
stub.push(
reply("summary", (input) => {
captured = input.messages
captured = JSON.stringify(input.messages)
}),
)
return Effect.gen(function* () {
@@ -1381,15 +1380,10 @@ describe("session.compaction.process", () => {
auto: false,
})
const head = JSON.stringify(captured.slice(0, -1))
const prompt = JSON.stringify(captured.at(-1))
expect(head).toContain("older context")
expect(head).not.toContain("keep this turn")
expect(head).not.toContain("and this one too")
expect(prompt).toContain("keep this turn")
expect(prompt).toContain("and this one too")
expect(prompt).toContain("recent-conversation-tail")
expect(prompt).not.toContain("What did we do so far?")
expect(captured).toContain("older context")
expect(captured).not.toContain("keep this turn")
expect(captured).not.toContain("and this one too")
expect(captured).not.toContain("What did we do so far?")
}).pipe(withCompaction({ llm: stub.layer }))
},
{ git: true },
@@ -1437,7 +1431,7 @@ describe("session.compaction.process", () => {
{ git: true },
)
itCompaction.instance("does not replay recent pre-compaction turns across repeated compactions", () => {
itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => {
const stub = llm()
stub.push(reply("summary one"))
stub.push(reply("summary two"))
@@ -1468,8 +1462,8 @@ describe("session.compaction.process", () => {
expect(ids).not.toContain(u1.id)
expect(ids).not.toContain(u2.id)
expect(ids).not.toContain(u3.id)
expect(ids).not.toContain(u4.id)
expect(ids).toContain(u3.id)
expect(ids).toContain(u4.id)
expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true)
expect(
filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")),
@@ -1478,7 +1472,7 @@ describe("session.compaction.process", () => {
})
itCompaction.instance(
"ignores previous summaries when sizing the serialized tail",
"ignores previous summaries when sizing the retained tail",
Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const test = yield* TestInstance
@@ -1517,7 +1511,7 @@ describe("session.compaction.process", () => {
const part = yield* readCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBeUndefined()
expect(part?.tail_start_id).toBe(keep.id)
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })),
)
})
@@ -650,7 +650,7 @@ describe("MessageV2.filterCompacted", () => {
),
)
it.instance("ignores original tail when compaction stores tail_start_id", () =>
it.instance("retains original tail when compaction stores tail_start_id", () =>
withSession(({ session, sessionID }) =>
Effect.gen(function* () {
const u1 = yield* addUser(sessionID, "first")
@@ -696,12 +696,12 @@ describe("MessageV2.filterCompacted", () => {
const result = MessageV2.filterCompacted(MessageV2.stream(sessionID))
expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a3])
expect(result.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3])
}),
),
)
it.instance("fork keeps legacy tail_start_id without replaying the tail", () =>
it.instance("fork remaps compaction tail_start_id for filterCompacted", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const created = yield* session.create({})
@@ -748,7 +748,7 @@ describe("MessageV2.filterCompacted", () => {
})
const parentFiltered = MessageV2.filterCompacted(MessageV2.stream(created.id))
expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u3, a3])
expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3])
const forked = yield* session.fork({ sessionID: created.id })
const childFiltered = MessageV2.filterCompacted(MessageV2.stream(forked.id))
@@ -758,14 +758,14 @@ describe("MessageV2.filterCompacted", () => {
expect(tailPart?.type).toBe("compaction")
if (!tailPart || tailPart.type !== "compaction") throw new Error("Expected forked compaction part")
expect(tailPart.tail_start_id).toBeDefined()
expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(false)
expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(true)
yield* session.remove(forked.id)
yield* session.remove(created.id)
}),
)
it.instance("does not replay an assistant tail when compaction starts inside a turn", () =>
it.instance("retains an assistant tail when compaction starts inside a turn", () =>
withSession(({ session, sessionID }) =>
Effect.gen(function* () {
const u1 = yield* addUser(sessionID, "first")
@@ -819,7 +819,7 @@ describe("MessageV2.filterCompacted", () => {
const result = MessageV2.filterCompacted(MessageV2.stream(sessionID))
expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a4])
expect(result.map((item) => item.info.id)).toEqual([c1, s1, a3, u3, a4])
}),
),
)
@@ -891,7 +891,7 @@ describe("MessageV2.filterCompacted", () => {
const result = MessageV2.filterCompacted(MessageV2.stream(sessionID))
expect(result.map((item) => item.info.id)).toEqual([c2, s2, u4, a4])
expect(result.map((item) => item.info.id)).toEqual([c2, s2, u3, a3, u4, a4])
}),
),
)
@@ -1,250 +1,219 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { Session } from "@/session/session"
import { SessionPrompt } from "../../src/session/prompt"
import * as Log from "@opencode-ai/core/util/log"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { MessageV2 } from "../../src/session/message-v2"
import { testEffect } from "../lib/effect"
const projectRoot = path.join(__dirname, "../..")
void Log.init({ print: false })
// Skip tests if no API key is available
const hasApiKey = !!process.env.ANTHROPIC_API_KEY
// Helper to run test within Instance context
async function withInstance<T>(fn: () => Promise<T>): Promise<T> {
return WithInstance.provide({
directory: projectRoot,
fn,
})
}
function run<A, E>(fx: Effect.Effect<A, E, SessionPrompt.Service | Session.Service>) {
return Effect.runPromise(
fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
)
}
const it = testEffect(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))
const live = hasApiKey ? it.instance : it.instance.skip
describe("StructuredOutput Integration", () => {
test.skipIf(!hasApiKey)(
live(
"produces structured output with simple schema",
async () => {
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Structured Output Test" })
() =>
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Structured Output Test" })
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 2 + 2? Provide a simple answer.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
answer: { type: "number", description: "The numerical answer" },
explanation: { type: "string", description: "Brief explanation" },
},
required: ["answer"],
},
retryCount: 0,
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 2 + 2? Provide a simple answer.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
answer: { type: "number", description: "The numerical answer" },
explanation: { type: "string", description: "Brief explanation" },
},
})
required: ["answer"],
},
retryCount: 0,
},
})
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
expect(typeof result.info.structured).toBe("object")
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
expect(typeof result.info.structured).toBe("object")
const output = result.info.structured as any
expect(output.answer).toBe(4)
const output = result.info.structured as any
expect(output.answer).toBe(4)
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
{ git: true },
60000,
)
test.skipIf(!hasApiKey)(
live(
"produces structured output with nested objects",
async () => {
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Nested Schema Test" })
() =>
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Nested Schema Test" })
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Tell me about Anthropic company in a structured format.",
},
],
format: {
type: "json_schema",
schema: {
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Tell me about Anthropic company in a structured format.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
company: {
type: "object",
properties: {
company: {
type: "object",
properties: {
name: { type: "string" },
founded: { type: "number" },
},
required: ["name", "founded"],
},
products: {
type: "array",
items: { type: "string" },
},
name: { type: "string" },
founded: { type: "number" },
},
required: ["company"],
required: ["name", "founded"],
},
products: {
type: "array",
items: { type: "string" },
},
retryCount: 0,
},
})
required: ["company"],
},
retryCount: 0,
},
})
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
const output = result.info.structured as any
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
const output = result.info.structured as any
expect(output.company).toBeDefined()
expect(output.company.name).toBe("Anthropic")
expect(typeof output.company.founded).toBe("number")
expect(output.company).toBeDefined()
expect(output.company.name).toBe("Anthropic")
expect(typeof output.company.founded).toBe("number")
if (output.products) {
expect(Array.isArray(output.products)).toBe(true)
}
if (output.products) {
expect(Array.isArray(output.products)).toBe(true)
}
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
{ git: true },
60000,
)
test.skipIf(!hasApiKey)(
live(
"works with text outputFormat (default)",
async () => {
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Text Output Test" })
() =>
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Text Output Test" })
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Say hello.",
},
],
format: {
type: "text",
},
})
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Say hello.",
},
],
format: {
type: "text",
},
})
// Verify no structured output (text mode) and no error
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeUndefined()
expect(result.info.error).toBeUndefined()
}
// Verify no structured output (text mode) and no error
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeUndefined()
expect(result.info.error).toBeUndefined()
}
// Verify we got a response with parts
expect(result.parts.length).toBeGreaterThan(0)
// Verify we got a response with parts
expect(result.parts.length).toBeGreaterThan(0)
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
{ git: true },
60000,
)
test.skipIf(!hasApiKey)(
live(
"stores outputFormat on user message",
async () => {
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "OutputFormat Storage Test" })
() =>
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "OutputFormat Storage Test" })
yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 1 + 1?",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
result: { type: "number" },
},
required: ["result"],
},
retryCount: 3,
yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 1 + 1?",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
result: { type: "number" },
},
})
required: ["result"],
},
retryCount: 3,
},
})
// Get all messages from session
const messages = yield* sessions.messages({ sessionID: session.id })
const userMessage = messages.find((m) => m.info.role === "user")
// Get all messages from session
const messages = yield* sessions.messages({ sessionID: session.id })
const userMessage = messages.find((m) => m.info.role === "user")
// Verify outputFormat was stored on user message
expect(userMessage).toBeDefined()
if (userMessage?.info.role === "user") {
expect(userMessage.info.format).toBeDefined()
expect(userMessage.info.format?.type).toBe("json_schema")
if (userMessage.info.format?.type === "json_schema") {
expect(userMessage.info.format.retryCount).toBe(3)
}
}
// Verify outputFormat was stored on user message
expect(userMessage).toBeDefined()
if (userMessage?.info.role === "user") {
expect(userMessage.info.format).toBeDefined()
expect(userMessage.info.format?.type).toBe("json_schema")
if (userMessage.info.format?.type === "json_schema") {
expect(userMessage.info.format.retryCount).toBe(3)
}
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
{ git: true },
60000,
)
+22 -16
View File
@@ -1,5 +1,6 @@
import { describe, expect, beforeAll, afterAll } from "bun:test"
import { Effect } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Layer } from "effect"
import { Discovery } from "../../src/skill/discovery"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
@@ -13,7 +14,7 @@ let downloadCount = 0
const fixturePath = path.join(import.meta.dir, "../fixture/skills")
const cacheDir = path.join(Global.Path.cache, "skills")
const it = testEffect(Discovery.defaultLayer)
const it = testEffect(Layer.mergeAll(Discovery.defaultLayer, AppFileSystem.defaultLayer))
beforeAll(async () => {
await rm(cacheDir, { recursive: true, force: true })
@@ -49,36 +50,37 @@ afterAll(async () => {
})
describe("Discovery.pull", () => {
const pull = Effect.fn("DiscoveryTest.pull")(function* (url: string) {
return yield* Discovery.Service.use((s) => s.pull(url))
})
it.live("downloads skills from cloudflare url", () =>
Effect.gen(function* () {
const dirs = yield* pull(CLOUDFLARE_SKILLS_URL)
const fsys = yield* AppFileSystem.Service
const discovery = yield* Discovery.Service
const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
expect(dirs.length).toBeGreaterThan(0)
for (const dir of dirs) {
expect(dir).toStartWith(cacheDir)
const md = path.join(dir, "SKILL.md")
expect(yield* Effect.promise(() => Filesystem.exists(md))).toBe(true)
expect(yield* fsys.existsSafe(md)).toBe(true)
}
}),
)
it.live("url without trailing slash works", () =>
Effect.gen(function* () {
const dirs = yield* pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
const fsys = yield* AppFileSystem.Service
const discovery = yield* Discovery.Service
const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
expect(dirs.length).toBeGreaterThan(0)
for (const dir of dirs) {
const md = path.join(dir, "SKILL.md")
expect(yield* Effect.promise(() => Filesystem.exists(md))).toBe(true)
expect(yield* fsys.existsSafe(md)).toBe(true)
}
}),
)
it.live("returns empty array for invalid url", () =>
Effect.gen(function* () {
const dirs = yield* pull(`http://localhost:${server.port}/invalid-url/`)
const discovery = yield* Discovery.Service
const dirs = yield* discovery.pull(`http://localhost:${server.port}/invalid-url/`)
expect(dirs).toEqual([])
}),
)
@@ -86,20 +88,23 @@ describe("Discovery.pull", () => {
it.live("returns empty array for non-json response", () =>
Effect.gen(function* () {
// any url not explicitly handled in server returns 404 text "Not Found"
const dirs = yield* pull(`http://localhost:${server.port}/some-other-path/`)
const discovery = yield* Discovery.Service
const dirs = yield* discovery.pull(`http://localhost:${server.port}/some-other-path/`)
expect(dirs).toEqual([])
}),
)
it.live("downloads reference files alongside SKILL.md", () =>
Effect.gen(function* () {
const dirs = yield* pull(CLOUDFLARE_SKILLS_URL)
const fsys = yield* AppFileSystem.Service
const discovery = yield* Discovery.Service
const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
// find a skill dir that should have reference files (e.g. agents-sdk)
const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk"))
expect(agentsSdk).toBeDefined()
if (agentsSdk) {
const refs = path.join(agentsSdk, "references")
expect(yield* Effect.promise(() => Filesystem.exists(path.join(agentsSdk, "SKILL.md")))).toBe(true)
expect(yield* fsys.existsSafe(path.join(agentsSdk, "SKILL.md"))).toBe(true)
// agents-sdk has reference files per the index
const refDir = yield* Effect.promise(() =>
Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true })),
@@ -114,15 +119,16 @@ describe("Discovery.pull", () => {
// clear dir and downloadCount
yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true }))
downloadCount = 0
const discovery = yield* Discovery.Service
// first pull to populate cache
const first = yield* pull(CLOUDFLARE_SKILLS_URL)
const first = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
expect(first.length).toBeGreaterThan(0)
const firstCount = downloadCount
expect(firstCount).toBeGreaterThan(0)
// second pull should return same results from cache
const second = yield* pull(CLOUDFLARE_SKILLS_URL)
const second = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
expect(second.length).toBe(first.length)
expect(second.sort()).toEqual(first.sort())
@@ -1,15 +1,15 @@
import { afterEach, expect } from "bun:test"
import { $ } from "bun"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import fs from "fs/promises"
import path from "path"
import { Effect, Fiber } from "effect"
import { Effect, Fiber, Layer } from "effect"
import { Snapshot } from "../../src/snapshot"
import { Filesystem } from "@/util/filesystem"
import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Snapshot.defaultLayer)
const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, AppFileSystem.defaultLayer))
// Git always outputs /-separated paths internally. Snapshot.patch() joins them
// with path.join (which produces \ on Windows) then normalizes back to /.
@@ -27,17 +27,13 @@ const exec = (cwd: string, command: string[]) =>
if (code !== 0) throw new Error(`${command.join(" ")} failed: ${await new Response(proc.stderr).text()}`)
})
const write = (file: string, content: string | Uint8Array) => Effect.promise(() => Filesystem.write(file, content))
const readText = (file: string) => Effect.promise(() => fs.readFile(file, "utf-8"))
const exists = (file: string) =>
Effect.promise(() =>
fs
.access(file)
.then(() => true)
.catch(() => false),
)
const mkdirp = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
const rm = (file: string) => Effect.promise(() => fs.rm(file, { recursive: true, force: true }))
const write = (file: string, content: string | Uint8Array) =>
AppFileSystem.Service.use((fs) => fs.writeWithDirs(file, content))
const readText = (file: string) => AppFileSystem.Service.use((fs) => fs.readFileString(file))
const exists = (file: string) => AppFileSystem.Service.use((fs) => fs.existsSafe(file))
const mkdirp = (dir: string) => AppFileSystem.Service.use((fs) => fs.ensureDir(dir))
const rm = (file: string) =>
AppFileSystem.Service.use((fs) => fs.remove(file, { recursive: true, force: true }).pipe(Effect.ignore))
const initialize = Effect.fn("SnapshotTest.initialize")(function* (dir: string) {
const unique = Math.random().toString(36).slice(2)
@@ -1,4 +1,28 @@
{
"digitalocean": {
"id": "digitalocean",
"env": ["DIGITALOCEAN_ACCESS_TOKEN"],
"npm": "@ai-sdk/openai-compatible",
"api": "https://inference.do-ai.run/v1",
"name": "DigitalOcean",
"doc": "https://docs.digitalocean.com/products/genai-platform/",
"models": {
"openai-gpt-oss-120b": {
"id": "openai-gpt-oss-120b",
"name": "GPT OSS 120B",
"attachment": false,
"reasoning": false,
"tool_call": true,
"temperature": true,
"release_date": "2025-08-05",
"last_updated": "2025-08-05",
"modalities": { "input": ["text"], "output": ["text"] },
"open_weights": false,
"cost": { "input": 0.35, "output": 0.75 },
"limit": { "context": 128000, "output": 16384 }
}
}
},
"ollama-cloud": {
"id": "ollama-cloud",
"env": ["OLLAMA_API_KEY"],
@@ -5,6 +5,7 @@ import { pathToFileURL } from "url"
import { Effect, Layer, Result, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ToolRegistry } from "@/tool/registry"
import { Tool } from "@/tool/tool"
import { Flag } from "@opencode-ai/core/flag/flag"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -29,6 +30,7 @@ import { InstanceState } from "@/effect/instance-state"
import { Reference } from "@/reference/reference"
import { ProviderID, ModelID } from "@/provider/schema"
import { ToolJsonSchema } from "@/tool/json-schema"
import { MessageID, SessionID } from "@/session/schema"
const node = CrossSpawnSpawner.defaultLayer
const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT
@@ -193,6 +195,51 @@ describe("tool.registry", () => {
}),
)
it.instance("preserves attachments from structured custom tool results", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const customTools = path.join(test.directory, ".opencode", "tools")
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(customTools, "image.ts"),
[
`import { tool } from ${JSON.stringify(pluginTool)}`,
"export default tool({",
" description: 'image tool',",
" args: {},",
" execute: async () => ({",
" output: 'here is an image',",
" attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],",
" }),",
"})",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const loaded = (yield* registry.all()).find((tool) => tool.id === "image")
if (!loaded) throw new Error("custom image tool was not loaded")
const agents = yield* Agent.Service
const result = yield* loaded.execute({}, {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
agent: (yield* agents.defaultInfo()).name,
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
} satisfies Tool.Context)
expect(result.output).toBe("here is an image")
expect(result.attachments).toEqual([
{ type: "file", mime: "image/png", filename: "picture.png", url: "data:image/png;base64,AAAA" },
])
}),
)
it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () =>
Effect.gen(function* () {
const test = yield* TestInstance
+4 -4
View File
@@ -8,10 +8,9 @@ import type {
UserMessage,
Message,
Part,
Auth,
Config as SDKConfig,
} from "@opencode-ai/sdk"
import type { Provider as ProviderV2, Model as ModelV2 } from "@opencode-ai/sdk/v2"
import type { Provider as ProviderV2, Model as ModelV2, Auth } from "@opencode-ai/sdk/v2"
import type { BunShell } from "./shell.js"
import { type ToolDefinition } from "./tool.js"
@@ -153,6 +152,7 @@ export type AuthHook = {
type: "success"
key: string
provider?: string
metadata?: Record<string, string>
}
| {
type: "failed"
@@ -177,7 +177,7 @@ export type AuthOAuthResult = { url: string; instructions: string } & (
accountId?: string
enterpriseUrl?: string
}
| { key: string }
| { key: string; metadata?: Record<string, string> }
))
| {
type: "failed"
@@ -198,7 +198,7 @@ export type AuthOAuthResult = { url: string; instructions: string } & (
accountId?: string
enterpriseUrl?: string
}
| { key: string }
| { key: string; metadata?: Record<string, string> }
))
| {
type: "failed"
+15 -1
View File
@@ -27,7 +27,21 @@ type AskInput = {
metadata: { [key: string]: any }
}
export type ToolResult = string | { output: string; metadata?: { [key: string]: any } }
export type ToolAttachment = {
type: "file"
mime: string
url: string
filename?: string
}
export type ToolResult =
| string
| {
title?: string
output: string
metadata?: { [key: string]: any }
attachments?: ToolAttachment[]
}
export function tool<Args extends z.ZodRawShape>(input: {
description: string
+3
View File
@@ -1666,6 +1666,9 @@ export type OAuth = {
export type ApiAuth = {
type: "api"
key: string
metadata?: {
[key: string]: string
}
}
export type WellKnownAuth = {
+1
View File
@@ -1143,6 +1143,7 @@ export type McpRemoteConfig = {
export type LayoutConfig = "auto" | "stretch"
export type ImageAttachmentConfig = {
enforce_limits?: boolean
auto_resize?: boolean
max_width?: number
max_height?: number
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="-14 -14 100.8 100.8" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<polygon fill-rule="evenodd" points="36.4 58.7 22.4 58.7 22.4 44.6 22.4 44.6 36.4 44.6 36.4 44.6 36.4 58.7"/>
<polygon fill-rule="evenodd" points="22.4 69.5 11.6 69.5 11.6 69.5 11.6 58.7 22.4 58.7 22.4 69.5"/>
<polygon fill-rule="evenodd" points="11.6 58.7 2.5 58.7 2.5 58.7 2.5 49.6 2.5 49.6 11.5 49.6 11.6 49.6 11.6 58.7"/>
<path d="M36.4,0C16.3,0,0,16.3,0,36.4h14.1c0-12.3,10-22.3,22.3-22.3s22.3,10,22.3,22.3-10,22.3-22.3,22.3h0v14.1h0c20.1,0,36.4-16.3,36.4-36.4S56.5,0,36.4,0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 613 B

@@ -854,6 +854,20 @@
d="M79.01 5.863c-4.066 0-6.511 2.92-6.511 6.535 0 3.635 2.445 6.555 6.511 6.555 4.046 0 6.512-2.92 6.512-6.555s-2.466-6.535-6.512-6.535Zm0 10.968c-2.633 0-4.172-1.933-4.172-4.433s1.539-4.455 4.172-4.455c2.635 0 4.151 1.933 4.151 4.434 0 2.521-1.516 4.454-4.15 4.454Zm14.393 2.096c3.393 0 5.542-1.808 5.837-4.539h-2.36c-.316 1.555-1.517 2.437-3.477 2.437-2.423 0-3.878-1.68-3.878-4.433 0-2.774 1.476-4.434 3.878-4.434 1.96 0 3.14.862 3.477 2.5h2.36c-.295-2.773-2.444-4.622-5.837-4.622-3.856 0-6.217 2.669-6.217 6.535 0 3.887 2.36 6.556 6.217 6.556Zm-29.543-.311h2.36v-6.01c0-2.752 1.348-4.244 3.772-4.244h2.276V6.177h-2.255c-2.128 0-3.288.735-3.898 2.605l-.443-.063.527-2.542h-2.36v12.439h.02Zm-24.445-7.332c.106-2.101 1.517-3.53 3.793-3.53 2.276 0 3.646 1.345 3.646 3.53h-7.439Zm9.778.4c0-3.426-2.381-5.821-5.943-5.821-3.73 0-6.174 2.563-6.174 6.535 0 4.013 2.423 6.555 6.28 6.555 2.929 0 5.247-1.597 5.669-3.887h-2.36c-.507 1.156-1.666 1.828-3.31 1.828-2.38 0-3.877-1.408-3.94-3.803h9.694c.042-.588.084-.861.084-1.408Zm5.69 6.932h1.939l5.5-12.44h-2.529L56 15.99l-.316.021-3.793-9.833h-2.508l5.5 12.439ZM32.23 12.35c0-.882-.359-1.701-.99-2.437a8.594 8.594 0 0 1-1.497 1.093c.337.42.527.861.527 1.345 0 2.731-5.837 4.811-14.14 4.811-8.281.021-14.118-2.059-14.118-4.811 0-.463.168-.925.505-1.345a8.13 8.13 0 0 1-1.475-1.093c-.632.736-.99 1.555-.99 2.438 0 4.034 7.207 6.534 16.1 6.534 8.87.021 16.078-2.5 16.078-6.535Zm-3.351 1.534c-.906-.462-1.96-.861-3.16-1.197-1.37.378-2.909.672-4.553.861 2.318.294 4.341.778 5.9 1.408.76-.336 1.37-.693 1.813-1.072Zm-17.849-.357a31.902 31.902 0 0 1-4.467-.84c-1.18.336-2.255.735-3.16 1.197.42.379 1.01.715 1.748 1.05 1.539-.63 3.52-1.113 5.88-1.407Zm21.2-6.808c0-4.013-7.207-6.534-16.079-6.534C7.26.185.051 2.706.051 6.719c0 4.035 7.208 6.535 16.1 6.535 8.872.021 16.079-2.5 16.079-6.535Zm-1.94 0c0 2.732-5.836 4.812-14.139 4.812-8.302.021-14.14-2.06-14.14-4.812 0-2.731 5.838-4.811 14.14-4.811 7.86 0 14.14 2.08 14.14 4.811Zm-3.223 2.564c.758-.336 1.37-.694 1.812-1.072-2.95-1.513-7.544-2.353-12.728-2.353s-9.799.84-12.728 2.353c.422.378 1.012.715 1.75 1.05 2.507-1.05 6.363-1.68 10.978-1.68 4.404 0 8.324.651 10.916 1.702ZM1.042 15.628c-.632.736-.99 1.534-.99 2.438 0 4.034 7.207 6.534 16.1 6.534 8.892 0 16.099-2.521 16.099-6.534 0-.883-.359-1.702-.99-2.438-.422.4-.907.757-1.497 1.093.337.42.527.861.527 1.345 0 2.731-5.837 4.811-14.14 4.811-8.302 0-14.14-2.08-14.14-4.811 0-.463.17-.925.506-1.345a10.73 10.73 0 0 1-1.475-1.093Z"
></path>
</symbol>
<symbol viewBox="-14 -14 100.8 100.8" fill="currentColor" id="digitalocean">
<polygon
fill-rule="evenodd"
points="36.4 58.7 22.4 58.7 22.4 44.6 22.4 44.6 36.4 44.6 36.4 44.6 36.4 58.7"
></polygon>
<polygon fill-rule="evenodd" points="22.4 69.5 11.6 69.5 11.6 69.5 11.6 58.7 22.4 58.7 22.4 69.5"></polygon>
<polygon
fill-rule="evenodd"
points="11.6 58.7 2.5 58.7 2.5 58.7 2.5 49.6 2.5 49.6 11.5 49.6 11.6 49.6 11.6 58.7"
></polygon>
<path
d="M36.4,0C16.3,0,0,16.3,0,36.4h14.1c0-12.3,10-22.3,22.3-22.3s22.3,10,22.3,22.3-10,22.3-22.3,22.3h0v14.1h0c20.1,0,36.4-16.3,36.4-36.4S56.5,0,36.4,0Z"
></path>
</symbol>
<symbol viewBox="0 0 40 40" id="deepseek">
<path
d="M35.6638 9.91965C35.3251 9.75432 35.1785 10.0703 34.9811 10.2316C34.9131 10.2836 34.8558 10.3516 34.7985 10.413C34.3025 10.9423 33.7238 11.289 32.9678 11.2476C31.8625 11.1863 30.9186 11.533 30.0839 12.3783C29.9066 11.3356 29.3173 10.7143 28.4213 10.3143C27.9519 10.1063 27.4773 9.89965 27.148 9.44766C26.9186 9.12633 26.856 8.76767 26.7413 8.41568C26.668 8.20235 26.5946 7.98502 26.3506 7.94902C26.084 7.90769 25.98 8.13035 25.876 8.31702C25.4587 9.07967 25.2973 9.91965 25.3133 10.7703C25.3493 12.6849 26.1573 14.2102 27.764 15.2942C27.9466 15.4182 27.9933 15.5435 27.9359 15.7249C27.8266 16.0982 27.696 16.4609 27.5813 16.8355C27.508 17.0742 27.3986 17.1248 27.1426 17.0222C26.2777 16.6504 25.4919 16.1164 24.828 15.4489C23.6854 14.3449 22.6534 13.1263 21.3654 12.1716C21.067 11.9511 20.7606 11.7416 20.4468 11.5436C19.1335 10.2676 20.6201 9.21967 20.9641 9.09567C21.3241 8.965 21.0881 8.51968 19.9254 8.52501C18.7628 8.53035 17.6988 8.91834 16.3428 9.43699C16.1413 9.51421 15.934 9.57529 15.7229 9.61966C14.4557 9.38091 13.1598 9.33506 11.8789 9.48366C9.36565 9.76365 7.35902 10.953 5.88305 12.9809C4.10975 15.4182 3.69243 18.1888 4.20308 21.0768C4.74041 24.122 6.29504 26.6433 8.683 28.6139C11.1603 30.6579 14.0122 31.6592 17.2668 31.4672C19.2428 31.3539 21.4441 31.0886 23.9254 28.9873C24.552 29.2993 25.208 29.4233 26.2986 29.5166C27.1386 29.5953 27.9466 29.4766 28.5719 29.3459C29.5519 29.1379 29.4839 28.23 29.1306 28.0646C26.2573 26.726 26.888 27.2713 26.3133 26.83C27.7746 25.102 29.9746 23.3074 30.8359 17.4928C30.9026 17.0302 30.8452 16.7395 30.8359 16.3662C30.8306 16.1395 30.8826 16.0502 31.1426 16.0249C31.8639 15.95 32.5637 15.7349 33.2025 15.3915C35.0638 14.3742 35.8158 12.7049 35.9931 10.7023C36.0198 10.3956 35.9878 10.081 35.6638 9.91965ZM19.4414 27.9433C16.6562 25.754 15.3055 25.0327 14.7482 25.0634C14.2256 25.0954 14.3202 25.6913 14.4349 26.0807C14.5549 26.4647 14.7109 26.7286 14.9295 27.066C15.0815 27.2886 15.1855 27.6206 14.7789 27.87C13.8816 28.4246 12.3229 27.6833 12.2496 27.6473C10.435 26.578 8.91632 25.1673 7.84834 23.2381C6.81637 21.3808 6.21638 19.3888 6.11771 17.2622C6.09105 16.7475 6.24171 16.5662 6.7537 16.4729C7.42583 16.3442 8.11451 16.3267 8.79233 16.4209C11.6349 16.8368 14.0536 18.1075 16.0828 20.1194C17.2402 21.2661 18.1161 22.6354 19.0188 23.974C19.9788 25.3953 21.0108 26.75 22.3254 27.8593C22.7894 28.2486 23.1587 28.5446 23.5134 28.7619C22.4441 28.8819 20.6601 28.9086 19.4414 27.9433ZM20.7748 19.3568C20.7745 19.2906 20.7904 19.2253 20.8211 19.1666C20.8517 19.1078 20.8962 19.0575 20.9507 19.0198C21.0052 18.9821 21.068 18.9583 21.1337 18.9503C21.1995 18.9424 21.2662 18.9505 21.3281 18.9741C21.407 19.0024 21.475 19.0546 21.5228 19.1235C21.5706 19.1923 21.5958 19.2743 21.5947 19.3581C21.5949 19.4123 21.5843 19.4659 21.5636 19.5159C21.5428 19.5659 21.5123 19.6113 21.4738 19.6494C21.4354 19.6875 21.3897 19.7176 21.3395 19.7378C21.2893 19.7581 21.2356 19.7682 21.1814 19.7675C21.1277 19.7676 21.0745 19.7571 21.0248 19.7365C20.9752 19.7158 20.9302 19.6855 20.8925 19.6473C20.8548 19.609 20.825 19.5636 20.805 19.5138C20.785 19.4639 20.7739 19.4105 20.7748 19.3568ZM24.9213 21.4848C24.6547 21.5928 24.3893 21.6861 24.1347 21.6981C23.7516 21.7114 23.3756 21.5918 23.0707 21.3594C22.7054 21.0528 22.4441 20.8821 22.3347 20.3488C22.297 20.0881 22.3042 19.823 22.3561 19.5648C22.4494 19.1288 22.3454 18.8488 22.0374 18.5955C21.7881 18.3875 21.4694 18.3302 21.1201 18.3302C21.0005 18.3232 20.8843 18.2875 20.7814 18.2262C20.6348 18.1542 20.5148 17.9728 20.6294 17.7488C20.6668 17.6768 20.8428 17.5008 20.8854 17.4688C21.3601 17.1995 21.9081 17.2875 22.4134 17.4902C22.8827 17.6822 23.2374 18.0342 23.748 18.5328C24.2694 19.1341 24.364 19.3008 24.6613 19.7515C24.896 20.1048 25.1093 20.4674 25.2547 20.8821C25.344 21.1421 25.2293 21.3541 24.9213 21.4848Z"

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 276 KiB

@@ -78,6 +78,7 @@ export const iconNames = [
"fireworks-ai",
"fastrouter",
"evroc",
"digitalocean",
"deepseek",
"deepinfra",
"cortecs",
+33
View File
@@ -345,6 +345,39 @@ You can manage the tools an LLM can use through the `tools` option.
---
### Attachments
You can configure how image attachments are processed before they are sent to the model through the `attachment.image` option.
By default OpenCode does not enforce image attachment limits and does not resize images. Images pass through unchanged unless you explicitly enable limit enforcement.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"attachment": {
"image": {
"enforce_limits": true,
"auto_resize": false,
"max_width": 2000,
"max_height": 2000,
"max_base64_bytes": 4718592
}
}
}
```
Available options:
- `enforce_limits` - Enforce configured image limits before sending images to the model. Defaults to `false`, which passes images through unchanged.
- `auto_resize` - Resize images that exceed configured limits. This only applies when `enforce_limits` is `true`. Defaults to `false`.
- `max_width` - Maximum image width when limit enforcement is enabled. Defaults to `2000`.
- `max_height` - Maximum image height when limit enforcement is enabled. Defaults to `2000`.
- `max_base64_bytes` - Maximum base64 payload size when limit enforcement is enabled. Defaults to `4718592`.
When `enforce_limits` is `true` and `auto_resize` is `false`, images over the configured limits are omitted. When both are `true`, OpenCode attempts to resize oversized images before omitting them.
---
### Models
You can configure the providers and models you want to use in your OpenCode config through the `provider`, `model` and `small_model` options.
@@ -721,6 +721,88 @@ Cloudflare Workers AI lets you run AI models on Cloudflare's global network dire
---
### DigitalOcean
DigitalOcean's [Inference Engine](https://docs.digitalocean.com/products/inference/) provides access to open models like GPT-OSS, Llama, Qwen, and DeepSeek, plus custom [Inference Routers](https://docs.digitalocean.com/products/genai-platform/concepts/inference-routers/) that route each request to the cheapest, fastest, or best-fit model for a task.
OpenCode supports two authentication methods:
- **OAuth (Recommended)** — Sign in to your DigitalOcean account; OpenCode auto-creates a Model Access Key and discovers your available Models & Inference Routers.
- **Model Access Key** — Paste an existing key from the DigitalOcean console.
#### OAuth (Recommended)
1. Run the `/connect` command and search for **DigitalOcean**.
```txt
/connect
```
2. Select **Login with DigitalOcean**.
```txt
┌ Select auth method
│
│ Login with DigitalOcean
│ Paste Model Access Key
└
```
3. Your browser opens to authorize OpenCode. Sign in and approve.
:::note
OpenCode creates a Model Access Key named `opencode-oauth-<timestamp>` in your DigitalOcean account. You can rotate or revoke it from the **Model Access Keys** page in the "Manage" section of the DigitalOcean console under Inference.
:::
4. Run the `/models` command. Your Inference Routers appear as the format `router:` in the model selection.
```txt
/models
```
5. To pick up newly created Inference Routers, re-run `/connect` and select **DigitalOcean** again.
#### Using a Model Access Key
If you'd rather paste a key directly:
1. Head over to the **Manage** page in the Inference section of the [DigitalOcean console](https://cloud.digitalocean.com/) and create a new key.
2. Run the `/connect` command and select **DigitalOcean**, then **Paste Model Access Key**.
```txt
┌ Enter your DigitalOcean Model Access Key
│
│
└ enter
```
:::note
Inference Routers are not auto-discovered with this method. To surface them in the model picker, sign in via OAuth instead.
:::
3. Run the `/models` command to select a model.
```txt
/models
```
#### Environment Variable
Alternatively, set your Model Access Key as an environment variable.
```bash frame="none"
export DIGITALOCEAN_ACCESS_TOKEN=your-model-access-key
```
#### Inference Routers
Inference Routers let you define a routing policy across multiple models — picking the cheapest, fastest, or most appropriate model per request based on the task. After OAuth, OpenCode surfaces each router as `router:<router-name>` in the model picker.
Selecting a router model is a drop-in replacement for any other model — OpenCode forwards your request and DigitalOcean picks the underlying model based on your router's policy. Learn more about [Inference Routers](https://docs.digitalocean.com/products/inference/how-to/use-inference-router/)
---
### FrogBot
1. Head over to the [FrogBot dashboard](https://app.frogbot.ai/signup), create an account, and generate an API key.