Compare commits

...

12 Commits

Author SHA1 Message Date
James Long faa92ff5ea feat(sdk): import local credentials into embedded host 2026-08-11 14:24:44 +00:00
opencode-agent[bot] 1dbfc7cfab chore: generate 2026-08-11 14:12:14 +00:00
Kit Langton 3441b95afc fix(core): models.dev catalog population must survive KV cache write failures (#41735) 2026-08-11 10:09:37 -04:00
opencode-agent[bot] 306204bad8 fix(app): handle untitled session tab info (#41700)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-11 18:02:10 +10:00
Aiden Cline 47c8d85904 feat(plugin): allow tool hooks to fail with tool errors (#41668) 2026-08-11 00:36:59 -05:00
Aiden Cline c401076b6f fix(core): preserve AI SDK tool media (#41672) 2026-08-11 00:33:33 -05:00
Luke Parker 1aef4de853 chore(desktop): skip unused beta CLI builds (#41673) 2026-08-11 15:07:21 +10:00
opencode-agent[bot] 9c94634515 fix(tui): use active model for compaction (#41608)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-10 23:50:51 -05:00
Luke Parker c2b754bb03 feat(desktop): publish v2 beta desktop (#41626) 2026-08-11 14:33:31 +10:00
Aiden Cline 2372edd5eb feat(tui): show previous agent in switch notices (#41661) 2026-08-10 23:09:26 -05:00
Kit Langton a5f7f8d3b5 fix(tui): deduplicate repeated image attachments (#41651) 2026-08-10 23:39:38 -04:00
Aiden Cline 4df276b9e8 feat(session): persist previous agent on switch (#41621) 2026-08-10 22:35:16 -05:00
43 changed files with 925 additions and 171 deletions
+13 -10
View File
@@ -75,7 +75,7 @@ jobs:
build-cli:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
with:
@@ -91,7 +91,7 @@ jobs:
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Build legacy CLI
if: github.ref_name != 'v2'
if: github.ref_name != 'v2' && github.ref_name != 'beta'
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
@@ -109,7 +109,7 @@ jobs:
GH_TOKEN: ${{ steps.committer.outputs.token }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'v2'
if: github.ref_name != 'v2' && github.ref_name != 'beta'
with:
name: opencode-cli
path: |
@@ -117,7 +117,7 @@ jobs:
packages/opencode/dist/opencode-linux*
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'v2'
if: github.ref_name != 'v2' && github.ref_name != 'beta'
with:
name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows*
@@ -132,7 +132,7 @@ jobs:
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
strategy:
fail-fast: false
matrix:
@@ -184,7 +184,7 @@ jobs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' && github.ref_name != 'beta'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -377,7 +377,7 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
RUST_TARGET: ${{ matrix.settings.target }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
- name: Build
run: bun run build
@@ -393,6 +393,7 @@ jobs:
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
- name: Package
if: needs.version.outputs.release
@@ -496,29 +497,31 @@ jobs:
registry-url: "https://registry.npmjs.org"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
if: github.ref_name != 'v2' && github.ref_name != 'beta'
with:
name: opencode-cli
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
if: github.ref_name != 'v2' && github.ref_name != 'beta'
with:
name: opencode-cli-windows
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
if: github.ref_name != 'v2' && github.ref_name != 'beta'
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'beta'
with:
name: opencode-preview-cli
path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'beta'
with:
pattern: opencode-node-cli-*
path: packages/cli/dist/node
+1
View File
@@ -439,6 +439,7 @@
"@actions/artifact": "4.0.0",
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
+2 -2
View File
@@ -351,8 +351,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const key = tabKey(tab)
const next = { title: session.title, directory: session.location.directory }
const current = info[key]
console.log({ tab, session, current })
if (current?.title === next.title && current.directory === next.directory) return
if (current && current.title === next.title && current.directory === next.directory) return
console.debug("[tabs] update persisted session info", { key, sessionID: session.id, current, next })
setInfo(key, next)
},
select: navigateTab,
@@ -41,6 +41,7 @@ export type SessionMessageAgentSelected = {
time: { created: number }
type: "agent-switched"
agent: string
previous?: string
}
export type PromptBase64 = string
@@ -2535,6 +2536,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
readonly previous?: string
}
| {
readonly id: string
@@ -2786,6 +2788,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
readonly previous?: string
}
| {
readonly id: string
@@ -3037,6 +3040,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
readonly previous?: string
}
| {
readonly id: string
+16 -1
View File
@@ -508,8 +508,23 @@ function toolOutput(result: ToolResultValue) {
case "text":
case "error":
return { type: "text" as const, value: messageValue(result.value) }
case "content":
return {
type: "content" as const,
value: result.value.map((item) => {
if (item.type === "text") return { type: "text" as const, text: item.text }
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1]
const image = item.mime.toLowerCase().startsWith("image/")
if (data !== undefined)
return image
? { type: "image-data" as const, data, mediaType: item.mime }
: { type: "file-data" as const, data, mediaType: item.mime, filename: item.name }
return image ? { type: "image-url" as const, url: item.uri } : { type: "file-url" as const, url: item.uri }
}),
}
case "json":
return { type: "json" as const, value: jsonValue(result.value) }
}
return { type: "json" as const, value: jsonValue(result.value) }
}
function tool(input: ToolDefinition): LanguageModelV3FunctionTool {
+45 -1
View File
@@ -1,9 +1,12 @@
export * as Credential from "./credential"
import { asc, eq } from "drizzle-orm"
import { asc, eq, sql } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import fs from "fs/promises"
import path from "path"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Global } from "@opencode-ai/util/global"
import { Database } from "./database/database"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { CredentialTable } from "./credential/sql"
@@ -48,6 +51,47 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Credential") {}
export const importFromDatabase = Effect.fn("Credential.importFromDatabase")(function* (input: {
readonly path: string
}) {
const database = yield* Database.Service
const filename = path.isAbsolute(input.path) ? input.path : path.join(Global.Path.data, input.path)
yield* Effect.promise(() => fs.access(filename))
yield* database.db.run(sql`ATTACH DATABASE ${filename} AS credential_snapshot`).pipe(Effect.orDie)
yield* Effect.gen(function* () {
yield* database.db.run(sql`DELETE FROM credential`)
// Snapshot OAuth credentials are access-token-only so the embedded host never rotates the user's refresh token.
yield* database.db.run(sql`
INSERT INTO credential (
id,
integration_id,
label,
value,
connector_id,
method_id,
active,
time_created,
time_updated
)
SELECT
id,
integration_id,
label,
CASE
WHEN json_extract(value, '$.type') = 'oauth'
THEN json_set(value, '$.refresh', '', '$.expires', 8640000000000000)
ELSE value
END,
connector_id,
method_id,
active,
time_created,
time_updated
FROM credential_snapshot.credential
`)
}).pipe(Effect.orDie, Effect.ensuring(database.db.run(sql`DETACH DATABASE credential_snapshot`).pipe(Effect.orDie)))
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
+11 -2
View File
@@ -1,4 +1,4 @@
import { Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Money } from "@opencode-ai/schema/money"
@@ -612,7 +612,16 @@ export const layer = (options?: Options) =>
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
yield* kv.set(key, { updatedAt: Date.now(), body: text })
// Best-effort: a cache-write failure must never kill catalog
// population. The payload has outgrown some KV backends' per-value
// limits (Durable Object SQLite caps values at 2 MB and api.json
// passed it in Aug 2026); a boot without a cache hit just refetches.
yield* kv.set(key, { updatedAt: Date.now(), body: text }).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
),
)
return catalog
})
+19 -7
View File
@@ -3,7 +3,7 @@ export * as PluginHooks from "./hooks"
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool"
import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { State } from "../state"
@@ -15,19 +15,29 @@ export interface Domains {
readonly tool: ToolHooks
}
type Callback<Event> = (event: Event) => Effect.Effect<void>
type NoFailures<Spec> = { readonly [Name in keyof Spec]: never }
// Failure channel for each hook event. Only tool execute.before may fail: a Tool.Error rejects the call before it runs.
interface Failures extends Record<keyof Domains, unknown> {
readonly aisdk: NoFailures<AISDKHooks>
readonly session: NoFailures<SessionHooks>
readonly shell: NoFailures<ShellHooks>
readonly tool: ToolFailures
}
type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
export interface Interface {
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
domain: Domain,
name: Name,
callback: Callback<Domains[Domain][Name]>,
callback: Callback<Domains[Domain][Name], Failures[Domain][Name]>,
) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
domain: Domain,
name: Name,
event: Domains[Domain][Name],
) => Effect.Effect<Domains[Domain][Name]>
) => Effect.Effect<Domains[Domain][Name], Failures[Domain][Name]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginHooks") {}
@@ -56,7 +66,9 @@ const layer = Layer.effect(
const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
for (const callback of callbacks.get(key(domain, name)) ?? []) {
const result: Effect.Effect<void> = Reflect.apply(callback, undefined, [event])
const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(callback, undefined, [
event,
])
yield* result
}
return event
+14 -9
View File
@@ -4,6 +4,7 @@ import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
export interface Adapter {
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getAssistant: (
@@ -59,15 +60,19 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.created": () => Effect.void,
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => {
return adapter.appendMessage(
SessionMessage.AgentSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "agent-switched",
metadata: event.metadata,
agent: event.data.agent,
time: { created: event.created },
}),
)
return Effect.gen(function* () {
const previous = yield* adapter.getAgent()
yield* adapter.appendMessage(
SessionMessage.AgentSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "agent-switched",
metadata: event.metadata,
agent: event.data.agent,
previous,
time: { created: event.created },
}),
)
})
},
"session.model.selected": (event) => {
return Effect.gen(function* () {
+21 -6
View File
@@ -5,6 +5,7 @@ import { DateTime, Effect, Layer, Schema, Stream } from "effect"
import { Database } from "../database/database"
import { Bus } from "../bus"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Agent } from "../agent"
import { Model } from "../model"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
@@ -230,6 +231,17 @@ function run(db: DatabaseService, event: MessageEvent) {
}
const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message)
const adapter: SessionMessageUpdater.Adapter = {
getAgent() {
return db
.select({ agent: SessionTable.agent })
.from(SessionTable)
.where(eq(SessionTable.id, event.data.sessionID))
.get()
.pipe(
Effect.orDie,
Effect.map((row) => (row?.agent ? Agent.ID.make(row.agent) : undefined)),
)
},
getModel() {
return db
.select({ model: SessionTable.model })
@@ -398,12 +410,15 @@ const layer = Layer.effectDiscard(
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.AgentSelected, (event) =>
db
.update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
Effect.gen(function* () {
yield* run(db, event)
yield* db
.update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.ModelSelected, (event) =>
Effect.gen(function* () {
@@ -59,6 +59,25 @@ const attachmentContent = (file: FileAttachment): ContentPart[] => {
return []
}
const userAttachmentContent = (files: readonly FileAttachment[]) => {
const eligible = files.filter(
(file) => imageMimes.has(file.mime) && file.source.type === "inline" && file.mention?.text,
)
if (eligible.length < 2) return files.flatMap(attachmentContent)
const seen = new Map<string, string[]>()
return files.flatMap((file) => {
if (!imageMimes.has(file.mime) || file.source.type !== "inline" || !file.mention?.text)
return attachmentContent(file)
const metadata = JSON.stringify([file.mime, file.name ?? null, file.description ?? null, file.mention.text])
const matches = seen.get(metadata)
if (matches?.includes(file.data)) return []
if (matches) matches.push(file.data)
if (!matches) seen.set(metadata, [file.data])
return attachmentContent(file)
})
}
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const providerMetadata = (
@@ -186,7 +205,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
const content = [
...(message.skills ?? []).map((skill) => Message.text(skill.text)),
...(message.text === "" ? [] : [Message.text(message.text)]),
...(message.files ?? []).flatMap(attachmentContent),
...userAttachmentContent(message.files ?? []),
]
if (content.length === 0) return []
return [
+67
View File
@@ -275,6 +275,73 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
}),
)
it.effect("preserves tool result content in AI SDK prompts", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model(model("test-ai-sdk"))
const prepared = yield* compileRequest(
LLM.request({
model: resolved,
messages: [
Message.tool({
id: "call_1",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "attachments" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "pixel.png" },
{
type: "file",
uri: "data:application/pdf;charset=utf-8;base64,JVBERg==",
mime: "application/pdf",
name: "document.pdf",
},
{ type: "file", uri: "data:audio/mpeg;base64,SUQz", mime: "audio/mpeg", name: "clip.mp3" },
{ type: "file", uri: "https://example.com/pixel.png", mime: "image/png" },
{ type: "file", uri: "https://example.com/document.pdf", mime: "application/pdf" },
],
},
}),
],
}),
)
expect(prepared.body.prompt).toEqual([
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_1",
toolName: "read",
output: {
type: "content",
value: [
{ type: "text", text: "attachments" },
{ type: "image-data", data: "AAAA", mediaType: "image/png" },
{
type: "file-data",
data: "JVBERg==",
mediaType: "application/pdf",
filename: "document.pdf",
},
{ type: "file-data", data: "SUQz", mediaType: "audio/mpeg", filename: "clip.mp3" },
{ type: "image-url", url: "https://example.com/pixel.png" },
{ type: "file-url", url: "https://example.com/document.pdf" },
],
},
},
],
},
])
}),
)
it.effect("emits malformed AI SDK tool input without executing it", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
+57 -3
View File
@@ -1,11 +1,15 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Integration } from "@opencode-ai/core/integration"
import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
import path from "path"
const it = testEffect(LayerNode.compile(Credential.node))
const it = testEffect(Layer.empty)
const credentialLayer = LayerNode.compile(Credential.node)
describe("Credential", () => {
it.effect("stores, updates, lists, and removes credentials", () =>
@@ -31,6 +35,56 @@ describe("Credential", () => {
yield* credentials.remove(replacement.id)
expect(yield* credentials.list(integrationID)).toEqual([])
}),
}).pipe(Effect.provide(credentialLayer)),
)
it.effect("imports a read-only credential snapshot", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir("opencode-credential-snapshot-")),
(directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((directory) => {
const filename = path.join(directory.path, "source.sqlite")
const source = LayerNode.compile(Credential.node, [[Database.node, Database.configured({ path: filename })]])
const keyIntegration = Integration.ID.make("openai")
const oauthIntegration = Integration.ID.make("github-copilot")
return Effect.gen(function* () {
yield* Effect.gen(function* () {
const credentials = yield* Credential.Service
yield* credentials.create({
integrationID: keyIntegration,
value: Credential.Key.make({ type: "key", key: "secret" }),
})
yield* credentials.create({
integrationID: oauthIntegration,
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
access: "access",
refresh: "refresh",
expires: 1,
}),
})
}).pipe(Effect.provide(source), Effect.scoped)
yield* Effect.gen(function* () {
yield* Credential.importFromDatabase({ path: filename })
const credentials = yield* Credential.Service
expect((yield* credentials.list(keyIntegration))[0]?.value).toEqual(
Credential.Key.make({ type: "key", key: "secret" }),
)
expect((yield* credentials.list(oauthIntegration))[0]?.value).toEqual(
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
access: "access",
refresh: "",
expires: 8640000000000000,
}),
)
}).pipe(Effect.provide(LayerNode.compile(LayerNode.group([Credential.node, Database.node]))), Effect.scoped)
})
}),
),
)
})
+28
View File
@@ -185,6 +185,15 @@ const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: Models
]),
)
// Mirrors production KV backends whose writes die as defects (e.g. Durable
// Object SQLite rejecting values over its 2 MB cap with EffectDrizzleQueryError).
const makeFailingWriteKV = (cache: MockCache) =>
Layer.mock(KV.Service, {
get: (key) => Effect.sync(() => cache.values.get(key)),
set: () => Effect.die(new Error('Failed query: insert into "kv"')),
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
})
const makeCache = (): MockCache => ({ values: new Map() })
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
@@ -248,6 +257,25 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() still populates the catalog when the KV cache write fails", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const layer = Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeFailingWriteKV(cache)],
]),
)
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.has(cacheKey)).toBe(false)
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
)
it.live("uses the default models URL when the configured URL is empty", () =>
Effect.gen(function* () {
const cache = makeCache()
+48
View File
@@ -1,4 +1,5 @@
import { describe, expect } from "bun:test"
import { ToolFailure } from "@opencode-ai/ai"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
@@ -395,4 +396,51 @@ describe("Plugin", () => {
})
}),
)
it.effect("rejects tool execution when an execute.before hook fails", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const executed: unknown[] = []
const plugin = EffectPlugin.define({
id: "tool-hook-reject",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool
.transform((draft) =>
draft.add({
name: "echo",
options: { codemode: false },
description: "Echo",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) =>
Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })),
}),
)
.pipe(Effect.orDie)
yield* ctx.tool
.hook("execute.before", () => new ToolFailure({ message: "write disabled" }))
.pipe(Effect.asVoid)
}),
})
yield* plugins.activate([versioned(plugin)])
const toolSet = yield* registry.snapshot()
const failure = yield* toolSet
.execute({
sessionID: Session.ID.make("ses_hook_reject"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_hook_reject"),
call: { type: "tool-call", id: "call-hook-reject", name: "echo", input: { text: "original" } },
})
.pipe(Effect.flip)
expect(failure).toMatchObject({ _tag: "Tool.Error", message: "write disabled" })
expect(executed).toEqual([])
}),
)
})
+4 -1
View File
@@ -647,7 +647,7 @@ describe("Session.create", () => {
it.effect("switches the selected agent through the durable Session event", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
const created = yield* session.create({ location, agent: Agent.ID.make("build") })
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("plan") })
@@ -655,6 +655,9 @@ describe("Session.create", () => {
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
{ type: "agent-switched", agent: "plan", previous: "build" },
])
}),
)
@@ -358,6 +358,7 @@ describe("SessionProjector", () => {
directory: "/project",
title: "test",
version: "test",
agent: "plan",
model: previousModel,
})
.run()
@@ -459,6 +460,10 @@ describe("SessionProjector", () => {
text: "synthetic context",
metadata: { source: "projector-test" },
})
expect(messages.find((message) => message.type === "agent-switched")).toMatchObject({
agent: build,
previous: "plan",
})
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
expect(messages.find((message) => message.type === "shell")).toMatchObject({
command: "pwd",
@@ -373,6 +373,103 @@ Recent work
])
})
test("deduplicates provider media while preserving durable attachment references", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-duplicate-image"),
type: "user",
text: "[Image 1] [Image 1] [Image 2]",
files: [
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
mention: { start: 0, end: 9, text: "[Image 1]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
mention: { start: 10, end: 19, text: "[Image 1]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
description: "alternate use",
mention: { start: 20, end: 29, text: "[Image 2]" },
}),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "[Image 1] [Image 1] [Image 2]" },
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
{
type: "media",
mediaType: "image/png",
data,
filename: "image.png",
metadata: { description: "alternate use" },
},
])
})
test("preserves provider media with distinct labels or URI sources", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-distinct-images"),
type: "user",
text: "[Image 1] [Image 2]",
files: [
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
mention: { start: 0, end: 9, text: "[Image 1]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
mention: { start: 10, end: 19, text: "[Image 2]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: "file:///project/image.png" },
name: "image.png",
mention: { start: 0, end: 9, text: "[Image 1]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
}),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content.filter((part) => part.type === "media")).toHaveLength(4)
})
test("replays durable tool media into canonical tool messages without structured base64", () => {
const messages = toLLMMessages(
[
@@ -57,35 +57,35 @@ test("keeps a hidden prod launcher for old Linux pins", async () => {
expect(desktop).toContain("NoDisplay=true")
})
test("bundles the CLI outside the dev app archive", async () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = "dev"
const module = await import("./electron-builder.config.ts?cli-resource")
const config = module.default as Configuration
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous
expect(config.files).toContain("!resources/opencode-cli*")
expect(config.extraResources).toContainEqual({
from: "resources/",
to: "",
filter: ["opencode-cli*"],
})
})
for (const channel of ["beta", "prod"] as const) {
test(`does not bundle the CLI in ${channel} builds`, async () => {
for (const channel of ["dev", "beta"] as const) {
test(`bundles the CLI outside the ${channel} app archive`, async () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = channel
const module = await import(`./electron-builder.config.ts?no-cli-resource=${channel}`)
const module = await import(`./electron-builder.config.ts?cli-resource=${channel}`)
const config = module.default as Configuration
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous
expect(config.extraResources).not.toContainEqual({
expect(config.files).toContain("!resources/opencode-cli*")
expect(config.extraResources).toContainEqual({
from: "resources/",
to: "",
filter: ["opencode-cli*"],
})
})
}
test("does not bundle the CLI in prod builds", async () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = "prod"
const module = await import("./electron-builder.config.ts?no-cli-resource=prod")
const config = module.default as Configuration
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous
expect(config.extraResources).not.toContainEqual({
from: "resources/",
to: "",
filter: ["opencode-cli*"],
})
})
+1 -1
View File
@@ -57,7 +57,7 @@ const getBase = (appId: string): Configuration => ({
},
files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"],
extraResources: [
...(channel === "dev"
...(channel !== "prod"
? [
{
from: "resources/",
+1
View File
@@ -37,6 +37,7 @@
"@actions/artifact": "4.0.0",
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
+2 -2
View File
@@ -1,9 +1,9 @@
import { $ } from "bun"
import * as path from "node:path"
import { RUST_TARGET } from "./utils"
import { CLI_TARGET } from "./utils"
if (!RUST_TARGET) throw new Error("RUST_TARGET not defined")
if (!CLI_TARGET) throw new Error("OPENCODE_CLI_TARGET not defined")
const BUNDLE_DIR = "dist"
const BUNDLES_OUT_DIR = path.join(process.cwd(), "dist/bundles")
+1
View File
@@ -8,3 +8,4 @@ await $`bun ./scripts/copy-icons.ts ${channel}`
await $`bun ./scripts/copy-metainfo.ts ${channel}`
if (channel === "dev") await downloadCliToResources()
if (channel === "beta") await downloadCliToResources("next")
+13 -13
View File
@@ -13,46 +13,46 @@ export function resolveChannel(): Channel {
return "dev"
}
export const CLI_BINARIES: Array<{ rustTarget: string; package: string; os: string; cpu: string }> = [
export const CLI_BINARIES: Array<{ target: string; package: string; os: string; cpu: string }> = [
{
rustTarget: "aarch64-apple-darwin",
target: "aarch64-apple-darwin",
package: "@opencode-ai/cli-darwin-arm64",
os: "darwin",
cpu: "arm64",
},
{
rustTarget: "x86_64-apple-darwin",
target: "x86_64-apple-darwin",
package: "@opencode-ai/cli-darwin-x64-baseline",
os: "darwin",
cpu: "x64",
},
{
rustTarget: "aarch64-pc-windows-msvc",
target: "aarch64-pc-windows-msvc",
package: "@opencode-ai/cli-windows-arm64",
os: "win32",
cpu: "arm64",
},
{
rustTarget: "x86_64-pc-windows-msvc",
target: "x86_64-pc-windows-msvc",
package: "@opencode-ai/cli-windows-x64-baseline",
os: "win32",
cpu: "x64",
},
{
rustTarget: "x86_64-unknown-linux-gnu",
target: "x86_64-unknown-linux-gnu",
package: "@opencode-ai/cli-linux-x64-baseline",
os: "linux",
cpu: "x64",
},
{
rustTarget: "aarch64-unknown-linux-gnu",
target: "aarch64-unknown-linux-gnu",
package: "@opencode-ai/cli-linux-arm64",
os: "linux",
cpu: "arm64",
},
]
export const RUST_TARGET = Bun.env.RUST_TARGET
export const CLI_TARGET = Bun.env.OPENCODE_CLI_TARGET
function nativeTarget() {
const { platform, arch } = process
@@ -62,19 +62,19 @@ function nativeTarget() {
throw new Error(`Unsupported platform: ${platform}/${arch}`)
}
export function getCurrentCli(target = RUST_TARGET ?? nativeTarget()) {
const binaryConfig = CLI_BINARIES.find((item) => item.rustTarget === target)
export function getCurrentCli(target = CLI_TARGET ?? nativeTarget()) {
const binaryConfig = CLI_BINARIES.find((item) => item.target === target)
if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`)
return binaryConfig
}
export async function downloadCliToResources() {
export async function downloadCliToResources(version = CLI_VERSION) {
const cli = getCurrentCli()
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
const dest = windowsify("resources/opencode-cli")
try {
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${CLI_VERSION}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
await copyFile(
join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"),
dest,
@@ -88,7 +88,7 @@ export async function downloadCliToResources() {
}
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
console.log(`Copied ${cli.package} to ${dest}`)
console.log(`Copied ${cli.package}@${version} to ${dest}`)
}
export function windowsify(path: string) {
+23 -48
View File
@@ -1,3 +1,4 @@
import { Service } from "@opencode-ai/client/service"
import { execFile } from "node:child_process"
import { existsSync } from "node:fs"
import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises"
@@ -8,52 +9,34 @@ import { app } from "electron"
const execFileAsync = promisify(execFile)
const root = dirname(fileURLToPath(import.meta.url))
const stateHome = process.env.XDG_STATE_HOME
const desktopStateNames = ["ai.opencode.desktop.dev", "ai.opencode.desktop.beta", "ai.opencode.desktop"]
type Logger = {
log(message: string, meta?: Record<string, unknown>): void
error(message: string, meta?: Record<string, unknown>): void
}
export async function startBackgroundCli(logger: Logger, shellStateHome?: string) {
export async function startBackgroundCli(logger: Logger) {
const bundled = app.isPackaged
? join(process.resourcesPath, executableName())
: join(root, "../../resources", executableName())
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = await run(bundled, ["--version"], logger)
const version = parseVersion(await run(bundled, ["--version"], logger))
const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled
const candidates = [
...new Set([stateHome, shellStateHome, ...desktopStateNames.map((name) => join(app.getPath("appData"), name))]),
].filter((candidate) => candidate === undefined || existsSync(candidate))
const discovered = await Promise.all(
candidates.map(async (candidate) => ({
stateHome: candidate,
url: serviceUrl(await run(binary, ["service", "status"], logger, { stateHome: candidate })),
})),
)
const found = discovered.find((candidate) => candidate.url !== undefined)
logger.log("v2 CLI background instance checked", {
detected: Boolean(found),
...endpoint(found?.url),
})
const daemonStateHome = found?.stateHome ?? stateHome
const url = await run(binary, ["service", "start"], logger, { stateHome: daemonStateHome })
const password = await run(binary, ["service", "get", "password"], logger, {
redact: true,
stateHome: daemonStateHome,
const service = await Service.ensure({
version,
command: [binary, "serve", "--service"],
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
})
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
logger.log("v2 CLI background service ready", {
existing: Boolean(found),
username: "opencode",
...endpoint(url),
username: service.auth.username,
version,
...endpoint(service.url),
})
return {
url,
username: "opencode",
password,
url: service.url,
username: service.auth.username,
password: service.auth.password,
}
}
@@ -77,21 +60,13 @@ async function installCli(source: string, version: string, logger: Logger) {
return destination
}
async function run(
binary: string,
args: string[],
logger: Logger,
options: { redact?: boolean; stateHome?: string } = {},
) {
async function run(binary: string, args: string[], logger: Logger) {
logger.log("v2 CLI command started", { binary, args })
const env = { ...process.env }
if (options.stateHome === undefined) delete env.XDG_STATE_HOME
else env.XDG_STATE_HOME = options.stateHome
return execFileAsync(binary, args, { env, windowsHide: true }).then(
return execFileAsync(binary, args, { windowsHide: true }).then(
(result) => {
const stdout = result.stdout.trim()
const stderr = result.stderr.trim()
logger.log("v2 CLI command completed", { args, stdout: options.redact ? "[redacted]" : stdout, stderr })
logger.log("v2 CLI command completed", { args, stdout, stderr })
return stdout
},
(error: unknown) => {
@@ -99,7 +74,7 @@ async function run(
logger.error("v2 CLI command failed", {
args,
error: error instanceof Error ? error.message : String(error),
stdout: options.redact && output.stdout ? "[redacted]" : (output.stdout?.trim() ?? ""),
stdout: output.stdout?.trim() ?? "",
stderr: output.stderr?.trim() ?? "",
})
throw error
@@ -107,11 +82,11 @@ async function run(
)
}
function serviceUrl(status: string) {
if (URL.canParse(status)) return status
if (!status.startsWith("running ")) return
const url = status.slice("running ".length).trim()
return URL.canParse(url) ? url : undefined
function parseVersion(output: string) {
const marker = output.lastIndexOf(" v")
const version = marker === -1 ? output : output.slice(marker + 2)
if (!version) throw new Error("V2 CLI did not provide a version")
return version
}
function endpoint(url: string | undefined) {
+2 -2
View File
@@ -181,7 +181,7 @@ const main = Effect.gen(function* () {
return
}
const shellEnv = preferAppEnv(app.getPath("userData"))
preferAppEnv()
app.on("second-instance", (_event: Event, argv: string[]) => {
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
@@ -310,7 +310,7 @@ const main = Effect.gen(function* () {
useEnvProxy()
logger.log("starting v2 background service")
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger, shellEnv?.XDG_STATE_HOME))
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger))
yield* Deferred.succeed(serverReady, {
url: sidecar.url,
username: sidecar.username,
+2 -3
View File
@@ -17,17 +17,16 @@ export function setDefaultServerUrl(url: string | null) {
getStore().delete(DEFAULT_SERVER_URL_KEY)
}
export function preferAppEnv(userDataPath: string) {
export function preferAppEnv() {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null
if (!shellEnv?.XDG_STATE_HOME) delete process.env.XDG_STATE_HOME
Object.assign(process.env, {
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
})
return shellEnv
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
+4 -2
View File
@@ -4,9 +4,11 @@ export interface Registration {
readonly dispose: Effect.Effect<void>
}
export type Hooks<Spec> = <Name extends keyof Spec>(
export type Hooks<Spec, Failures extends Record<keyof Spec, unknown> = Record<keyof Spec, never>> = <
Name extends keyof Spec,
>(
name: Name,
callback: (input: Spec[Name]) => Effect.Effect<void>,
callback: (input: Spec[Name]) => Effect.Effect<void, Failures[Name]>,
) => Effect.Effect<Registration, never, Scope.Scope>
export type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Registration, never, Scope.Scope>
+7 -1
View File
@@ -38,7 +38,13 @@ export interface ToolHooks {
)
}
// Only execute.before may fail: a Tool.Error rejects the call before the tool runs.
export interface ToolFailures extends Record<keyof ToolHooks, unknown> {
readonly "execute.before": Tool.Error
readonly "execute.after": never
}
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly hook: Hooks<ToolHooks>
readonly hook: Hooks<ToolHooks, ToolFailures>
}
+3
View File
@@ -12733,6 +12733,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["id", "time", "type", "agent"],
+1
View File
@@ -42,6 +42,7 @@ export const AgentSelected = Schema.Struct({
...Base,
type: Schema.tag("agent-switched"),
agent: Agent.ID,
previous: Agent.ID.pipe(optional),
}).annotate({ identifier: "Session.Message.AgentSelected" })
export interface ModelSelected extends Schema.Schema.Type<typeof ModelSelected> {}
+16
View File
@@ -13,6 +13,22 @@ const session = yield * opencode.sessions.get({ sessionID })
It also exports `Tool` for plugins that add tools with `ctx.tool.transform(...)`. Embedded plugins run through the ordinary discovery flow and register tools into each Location's `ToolRegistry` through the normal `Tools.Service.register(...)` path. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
Use credentials already stored by the local OpenCode installation while keeping
SDK sessions in memory:
```ts
const opencode =
yield *
OpenCode.create({
credentials: OpenCode.Credentials.fromLocalDatabase(),
})
```
The SDK copies credentials from `OPENCODE_DB` or `opencode.db` in OpenCode's
data directory. The source database is never updated.
OAuth credentials are copied without a usable refresh token; if the access
token is rejected, re-authenticate with OpenCode and create a new SDK host.
`sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message.
The same constructor is available as a service Layer:
+21 -1
View File
@@ -1,11 +1,27 @@
import { OpenCode } from "@opencode-ai/client/effect"
import { Credential } from "@opencode-ai/core/credential"
import { Database } from "@opencode-ai/core/database/database"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
import type { ServerOptions } from "@opencode-ai/server/options"
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
export const create = Effect.fn("OpenCode.create")(function* (options: ServerOptions = {}) {
export type CredentialSource = {
readonly path: string
}
export const Credentials = {
fromLocalDatabase: (input: Partial<CredentialSource> = {}): CredentialSource => ({
path: input.path ?? process.env.OPENCODE_DB ?? "opencode.db",
}),
}
export type CreateOptions = ServerOptions & {
readonly credentials?: CredentialSource
}
export const create = Effect.fn("OpenCode.create")(function* (options: CreateOptions = {}) {
const runtime = yield* Effect.acquireRelease(
Effect.sync(() =>
ManagedRuntime.make(
@@ -19,6 +35,10 @@ export const create = Effect.fn("OpenCode.create")(function* (options: ServerOpt
(runtime) => runtime.disposeEffect,
)
const context = yield* runtime.contextEffect
if (options.credentials) {
const database = Context.get(context, Database.Service)
yield* Credential.importFromDatabase(options.credentials).pipe(Effect.provideService(Database.Service, database))
}
const plugins = Context.get(context, SdkPlugins.Service)
const router = Context.get(context, HttpRouter.HttpRouter)
const handler = HttpEffect.toWebHandler(router.asHttpEffect())
+27
View File
@@ -2,6 +2,9 @@ import fs from "fs/promises"
import path from "path"
import { expect } from "bun:test"
import { Deferred, Effect, Latch, Layer, Option, Ref, Schema, Stream } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "../../util/src/effect/layer-node"
import { testEffect } from "../../core/test/lib/effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import type { OpenCodeEvent } from "../src"
@@ -25,6 +28,30 @@ const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create()
const location = (fixture: Fixture) =>
fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })
it.live("starts with credentials copied from the local database", () =>
withEmbedded("opencode-embedded-credentials-", (fixture) =>
Effect.gen(function* () {
const filename = path.join(fixture.directory, "credentials.sqlite")
const integrationID = fixture.sdk.Integration.ID.make("snapshot-test")
yield* Effect.gen(function* () {
const credentials = yield* Credential.Service
yield* credentials.create({
integrationID,
label: "Local",
value: Credential.Key.make({ type: "key", key: "secret" }),
})
}).pipe(
Effect.provide(LayerNode.compile(Credential.node, [[Database.node, Database.configured({ path: filename })]])),
Effect.scoped,
)
const opencode = yield* fixture.sdk.OpenCode.create({
credentials: fixture.sdk.OpenCode.Credentials.fromLocalDatabase({ path: filename }),
})
expect((yield* opencode.health.get()).healthy).toBe(true)
}),
),
)
it.live("exposes app metadata to plugins", () =>
withEmbedded("opencode-embedded-app-", (fixture) =>
Effect.gen(function* () {
+21 -12
View File
@@ -60,6 +60,11 @@ import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import {
deduplicatePromptImages,
preserveMentionlessPromptAttachments,
promptAttachmentLabel,
} from "../../prompt/attachment"
import { DialogImagePreview } from "../dialog-image-preview"
export type PromptProps = {
@@ -331,7 +336,7 @@ export function Prompt(props: PromptProps) {
}
const imageAttachments = createMemo(() =>
(store.prompt.files ?? []).filter((file) => typeof file.uri === "string" && file.uri.startsWith("data:image/")),
(deduplicatePromptImages(store.prompt.files) ?? []).filter((file) => file.uri.startsWith("data:image/")),
)
const imagePreviewHeight = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
const imagePreviewWidth = createMemo(() => imagePreviewHeight() * 2)
@@ -736,6 +741,7 @@ export function Prompt(props: PromptProps) {
setStore(
produce((draft) => {
const newMap = new Map<number, PromptPartRef>()
const fileExtmarks = new Map<number, NonNullable<PromptInfo["files"]>[number]>()
const files: NonNullable<PromptInfo["files"]> = []
const agents: NonNullable<PromptInfo["agents"]> = []
const skills: NonNullable<PromptInfo["skills"]> = []
@@ -749,9 +755,8 @@ export function Prompt(props: PromptProps) {
if (!part?.mention) continue
part.mention.start = extmark.start
part.mention.end = extmark.end
const index = files.length
files.push(part)
newMap.set(extmark.id, { type: "file", index })
fileExtmarks.set(extmark.id, part)
continue
}
if (ref.type === "agent") {
@@ -783,8 +788,19 @@ export function Prompt(props: PromptProps) {
newMap.set(extmark.id, { type: "pasted", index })
}
const nextFiles = preserveMentionlessPromptAttachments(draft.prompt.files, files)
const fileIndices = new Map(nextFiles.map((file, index) => [file, index]))
for (const [extmark, file] of fileExtmarks) {
const index = fileIndices.get(file)
if (index !== undefined) newMap.set(extmark, { type: "file", index })
}
draft.extmarkToPart = newMap
draft.prompt.files = files
if (
nextFiles.length !== draft.prompt.files?.length ||
nextFiles.some((file, index) => file !== draft.prompt.files?.[index])
)
draft.prompt.files = nextFiles
draft.prompt.agents = agents
draft.prompt.skills = skills
draft.prompt.pasted = pasted
@@ -1138,7 +1154,6 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
if (store.mode === "shell") {
move.startSubmit()
void client.api.session.shell({
@@ -1376,13 +1391,7 @@ export function Prompt(props: PromptProps) {
function pasteAttachment(file: { filename?: string; uri: string }) {
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
const pdf = file.uri.startsWith("data:application/pdf;")
const count = pdf
? (store.prompt.files?.filter(
(attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"),
).length ?? 0)
: imageAttachments().length
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
const virtualText = promptAttachmentLabel(store.prompt.files, { uri: file.uri, name: file.filename })
const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " "
+4 -1
View File
@@ -386,7 +386,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
}))
break
case "session.agent.selected":
case "session.agent.selected": {
const previous = store.session.info[event.data.sessionID]?.agent
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
message.update(event.data.sessionID, (draft, index) => {
@@ -394,10 +395,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
id: messageIDFromEvent(event.id),
type: "agent-switched",
agent: event.data.agent,
previous,
time: { created: event.created },
})
})
break
}
case "session.model.selected":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "model", event.data.model)
+96
View File
@@ -0,0 +1,96 @@
import type { PromptInput } from "@opencode-ai/schema"
type PromptFile = PromptInput.FileAttachment
type PromptFileIdentity = Pick<PromptFile, "uri" | "name" | "description">
type ProjectedFile = Readonly<{
data: string
mime: string
source: { type: string }
name?: string
description?: string
mention?: { text: string }
}>
function attachmentKind(uri: string) {
if (uri.startsWith("data:image/")) return "Image"
if (uri.startsWith("data:application/pdf;")) return "PDF"
return undefined
}
function attachmentMetadata(file: PromptFileIdentity) {
return JSON.stringify([file.name ?? null, file.description ?? null])
}
function deduplicateByIdentity<T>(
items: readonly T[],
identity: (item: T) => { metadata: string; payload: string } | undefined,
) {
const seen = new Map<string, string[]>()
return items.filter((item) => {
const key = identity(item)
if (!key) return true
const matches = seen.get(key.metadata)
if (matches?.includes(key.payload)) return false
if (matches) matches.push(key.payload)
if (!matches) seen.set(key.metadata, [key.payload])
return true
})
}
export function deduplicatePromptImages(files: readonly PromptFile[] | undefined) {
if (!files || files.length < 2) return files
return deduplicateByIdentity(files, (file) =>
file.uri.startsWith("data:image/") && file.mention?.text
? {
metadata: JSON.stringify([attachmentMetadata(file), file.mention.text]),
payload: file.uri,
}
: undefined,
)
}
export function preserveMentionlessPromptAttachments(
files: readonly PromptFile[] | undefined,
mentioned: PromptFile[],
) {
if (!files) return mentioned
const tracked = mentioned.values()
return files.flatMap((file) => {
if (!file.mention?.text) return [file]
const next = tracked.next()
return next.done ? [] : [next.value]
})
}
export function deduplicateVisibleImages<T extends ProjectedFile>(files: readonly T[]) {
return deduplicateByIdentity(files, (file) =>
file.mime.startsWith("image/") && file.source.type === "inline" && file.mention?.text
? {
metadata: JSON.stringify([file.mime, file.name ?? null, file.description ?? null, file.mention.text]),
payload: file.data,
}
: undefined,
)
}
export function promptAttachmentLabel(files: readonly PromptFile[] | undefined, file: PromptFileIdentity) {
const kind = attachmentKind(file.uri)
if (!kind) throw new Error(`Unsupported inline attachment: ${file.uri}`)
const metadata = attachmentMetadata(file)
const existing =
kind === "Image"
? files?.find(
(candidate) =>
candidate.uri === file.uri && attachmentMetadata(candidate) === metadata && candidate.mention?.text,
)?.mention?.text
: undefined
if (existing) return existing
const pattern = new RegExp(`^\\[${kind} (\\d+)\\]$`)
const count =
files?.reduce((highest, candidate) => {
const match = candidate.mention?.text.match(pattern)
return match ? Math.max(highest, Number(match[1])) : highest
}, 0) ?? 0
return `[${kind} ${count + 1}]`
}
+20 -4
View File
@@ -70,6 +70,7 @@ import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
import { projectedPromptInput } from "../../prompt/codec"
import { deduplicateVisibleImages } from "../../prompt/attachment"
import { useEpilogue } from "../../context/epilogue"
import { normalizePath } from "../../util/path"
import { PermissionPrompt } from "./permission"
@@ -649,8 +650,18 @@ export function Session() {
slash: {
name: "compact",
},
run: () => {
void client.api.session.compact({ sessionID: route.sessionID })
run: async () => {
const selection = local.model.current()
if (selection)
await client.api.session.switchModel({
sessionID: route.sessionID,
model: {
providerID: selection.providerID,
id: selection.modelID,
variant: local.model.variant.current(),
},
})
await client.api.session.compact({ sessionID: route.sessionID })
dialog.clear()
},
},
@@ -1651,7 +1662,12 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
const ctx = use()
const theme = useTheme()
const text = () => {
if (props.message.type === "agent-switched") return `Switched agent to ${props.message.agent}`
if (props.message.type === "agent-switched") {
const agent = Locale.titlecase(props.message.agent)
if (props.message.previous && props.message.previous !== props.message.agent)
return `Switched agent from ${Locale.titlecase(props.message.previous)} to ${agent}`
return `Switched agent to ${agent}`
}
if (props.message.type === "model-switched")
return switchLabel(props.message.model, ctx.models(), props.message.previous)
return ""
@@ -1899,7 +1915,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
const ctx = use()
const data = useData()
const local = useLocal()
const files = createMemo(() => props.message.files ?? [])
const files = createMemo(() => deduplicateVisibleImages(props.message.files ?? []))
const skills = createMemo(() => props.message.skills ?? [])
const images = createMemo(() =>
files().flatMap((file) =>
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, test } from "bun:test"
import {
deduplicatePromptImages,
deduplicateVisibleImages,
preserveMentionlessPromptAttachments,
promptAttachmentLabel,
} from "../../src/prompt/attachment"
describe("prompt attachments", () => {
test("deduplicates identical inline images while preserving other attachments", () => {
const files = [
{
uri: "data:image/png;base64,AAA",
name: "first.png",
mention: { start: 0, end: 9, text: "[Image 1]" },
},
{ uri: "file:///same", name: "first.txt" },
{ uri: "data:application/pdf;base64,CCC", name: "first.pdf" },
{
uri: "data:image/png;base64,BBB",
name: "second.png",
mention: { start: 10, end: 19, text: "[Image 2]" },
},
{
uri: "data:image/png;base64,AAA",
name: "first.png",
mention: { start: 20, end: 29, text: "[Image 1]" },
},
{
uri: "data:image/png;base64,AAA",
name: "first.png",
description: "alternate use",
mention: { start: 30, end: 39, text: "[Image 1]" },
},
{ uri: "file:///same", name: "second.txt" },
{ uri: "data:application/pdf;base64,CCC", name: "first.pdf" },
]
expect(deduplicatePromptImages(files)).toEqual([
files[0],
files[1],
files[2],
files[3],
files[5],
files[6],
files[7],
])
expect(files).toHaveLength(8)
})
test("reuses labels for identical image data", () => {
const first = "data:image/png;base64,AAA"
const second = "data:image/png;base64,BBB"
const files = [{ uri: first, mention: { start: 0, end: 9, text: "[Image 1]" } }]
expect(promptAttachmentLabel(files, { uri: first })).toBe("[Image 1]")
expect(promptAttachmentLabel([...files, { ...files[0], mention: undefined }], { uri: second })).toBe("[Image 2]")
expect(promptAttachmentLabel([{ uri: first }], { uri: first })).toBe("[Image 1]")
})
test("numbers PDFs independently from images", () => {
const files = [{ uri: "data:image/png;base64,AAA" }]
expect(promptAttachmentLabel(files, { uri: "data:application/pdf;base64,BBB" })).toBe("[PDF 1]")
})
test("does not reuse a label when attachment metadata differs", () => {
const uri = "data:image/png;base64,AAA"
const files = [{ uri, name: "one.png", mention: { start: 0, end: 9, text: "[Image 1]" } }]
expect(promptAttachmentLabel(files, { uri, name: "two.png" })).toBe("[Image 2]")
})
test("does not reuse numbers after an earlier attachment is removed", () => {
const files = [{ uri: "data:image/png;base64,BBB", mention: { start: 0, end: 9, text: "[Image 2]" } }]
expect(promptAttachmentLabel(files, { uri: "data:image/png;base64,CCC" })).toBe("[Image 3]")
})
test("preserves mentionless attachments when tracked mentions are synchronized", () => {
const mentionless = { uri: "data:image/png;base64,AAA" }
const emptyMention = {
uri: "data:image/png;base64,CCC",
mention: { start: 0, end: 0, text: "" },
}
const mentioned = {
uri: "data:image/png;base64,BBB",
mention: { start: 0, end: 9, text: "[Image 1]" },
}
const restored = preserveMentionlessPromptAttachments([mentionless, emptyMention, mentioned], [mentioned])
expect(restored).toEqual([mentionless, emptyMention, mentioned])
expect(restored.indexOf(mentioned)).toBe(2)
const another = {
uri: "data:image/png;base64,DDD",
mention: { start: 10, end: 19, text: "[Image 2]" },
}
expect(preserveMentionlessPromptAttachments([mentioned, mentionless, another], [another, mentioned])).toEqual([
another,
mentionless,
mentioned,
])
})
test("deduplicates visible inline image cards without dropping durable references", () => {
const file = {
data: "AAA",
mime: "image/png",
source: { type: "inline" },
name: "clipboard",
mention: { text: "[Image 1]" },
}
const files = [file, { ...file, mention: { text: "[Image 1]" } }]
expect(deduplicateVisibleImages(files)).toEqual([file])
expect(files).toHaveLength(2)
const distinct = [
{ ...file, mention: { text: "[Image 2]" } },
{ ...file, mention: undefined },
]
expect(deduplicateVisibleImages([file, ...distinct])).toEqual([file, ...distinct])
})
})
+17
View File
@@ -41,4 +41,21 @@ describe("prompt history", () => {
const b = entry("describe this", [{ name: "b.png", uri: "data:image/png;base64,BBB" }])
expect(isDuplicateEntry(a, b)).toBe(false)
})
test("preserves duplicate attachment mentions for prompt restoration", () => {
const value = entry("[Image 1] [Image 1]", [
{
name: "clipboard",
uri: "data:image/png;base64,AAA",
mention: { start: 0, end: 9, text: "[Image 1]" },
},
{
name: "clipboard",
uri: "data:image/png;base64,AAA",
mention: { start: 10, end: 19, text: "[Image 1]" },
},
])
expect(parsePromptHistory(JSON.stringify(value))).toEqual([value])
})
})
+3
View File
@@ -12733,6 +12733,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["id", "time", "type", "agent"],
+3
View File
@@ -12733,6 +12733,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["id", "time", "type", "agent"],
+20 -18
View File
@@ -35,32 +35,34 @@ if (Script.release && !Script.preview) {
await prepareReleaseFiles()
console.log("\n=== schema ===\n")
await $`bun ./packages/schema/script/publish.ts`
if (Script.channel !== "beta") {
console.log("\n=== schema ===\n")
await $`bun ./packages/schema/script/publish.ts`
console.log("\n=== theme ===\n")
await $`bun ./packages/theme/script/publish.ts`
console.log("\n=== theme ===\n")
await $`bun ./packages/theme/script/publish.ts`
console.log("\n=== ai ===\n")
await $`bun ./packages/ai/script/publish.ts`
console.log("\n=== ai ===\n")
await $`bun ./packages/ai/script/publish.ts`
console.log("\n=== util ===\n")
await $`bun ./packages/util/script/publish.ts`
console.log("\n=== util ===\n")
await $`bun ./packages/util/script/publish.ts`
console.log("\n=== protocol ===\n")
await $`bun ./packages/protocol/script/publish.ts`
console.log("\n=== protocol ===\n")
await $`bun ./packages/protocol/script/publish.ts`
console.log("\n=== client ===\n")
await $`bun ./packages/client/script/publish.ts`
console.log("\n=== client ===\n")
await $`bun ./packages/client/script/publish.ts`
console.log("\n=== cli ===\n")
await $`bun ./packages/cli/script/publish.ts`
console.log("\n=== cli ===\n")
await $`bun ./packages/cli/script/publish.ts`
console.log("\n=== plugin ===\n")
await $`bun ./packages/plugin/script/publish.ts`
console.log("\n=== plugin ===\n")
await $`bun ./packages/plugin/script/publish.ts`
console.log("\n=== ui ===\n")
await $`bun ./packages/ui/script/publish.ts`
console.log("\n=== ui ===\n")
await $`bun ./packages/ui/script/publish.ts`
}
if (Script.release) {
await $`bun ./packages/desktop/scripts/finalize-latest-json.ts`