Compare commits

...

22 Commits

Author SHA1 Message Date
Kit Langton 93bfa34b57 fix(core): discover local plugin packages 2026-08-11 12:08:04 -04:00
opencode-agent[bot] 964f7f4254 chore: generate 2026-08-11 15:53:52 +00:00
Aiden Cline 8c27c8485e feat(session): persist previous selections (#41771) 2026-08-11 10:52:14 -05:00
opencode-agent[bot] 6721ff5328 fix(core): omit deprecated models.dev models (#41769)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-11 10:17:04 -05:00
Kit Langton 026e0c634f refactor(core): reuse plugin directory names (#41754) 2026-08-11 10:40:36 -04:00
Kit Langton c581b59b7f refactor(merman): simplify spatial span assembly (#41757) 2026-08-11 10:40:22 -04:00
Kit Langton 5cc7811ec2 refactor(core): defer database global lookup (#41755) 2026-08-11 10:40:03 -04:00
Kit Langton 5d32845b02 refactor(tui): simplify marquee reset (#41758) 2026-08-11 10:39:51 -04:00
Kit Langton e9f5842f29 refactor: simplify attachment identity tracking (#41756) 2026-08-11 10:39:42 -04:00
Kit Langton 7b9734593a refactor(core): generate static imports for the migration manifest (#41657) 2026-08-11 10:35:19 -04: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
Kit Langton 518af92c5b refactor(core): move the models.dev catalog cache from disk to KV (#41649) 2026-08-10 22:40:41 -04:00
52 changed files with 1264 additions and 352 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:",
@@ -69,6 +69,63 @@ describe("v2 session reducer", () => {
})
})
test("prefers durable selection predecessors and derives them for older events", () => {
const source: SessionMessageInfo[] = [
{ id: "msg_previous_agent", type: "agent-switched", agent: "build", time: { created: 1 } },
{
id: "msg_previous_model",
type: "model-switched",
model: { id: "old", providerID: "provider" },
time: { created: 1 },
},
]
const reducer = createV2SessionReducer()
const agent = reducer.reduce(
source,
event({
...base,
id: "evt_agent",
type: "session.agent.selected",
data: { sessionID: "ses_1", agent: "plan", previous: "review" },
}),
)
const model = reducer.reduce(
source,
event({
...base,
id: "evt_model",
type: "session.model.selected",
data: {
sessionID: "ses_1",
model: { id: "new", providerID: "provider" },
previous: { id: "durable", providerID: "provider" },
},
}),
)
const legacyAgent = reducer.reduce(
source,
event({
...base,
id: "evt_legacy_agent",
type: "session.agent.selected",
data: { sessionID: "ses_1", agent: "plan" },
}),
)
expect(agent?.messages.at(-1)).toMatchObject({ type: "agent-switched", agent: "plan", previous: "review" })
expect(model?.messages.at(-1)).toMatchObject({
type: "model-switched",
model: { id: "new" },
previous: { id: "durable" },
})
expect(legacyAgent?.messages.at(-1)).toMatchObject({
type: "agent-switched",
agent: "plan",
previous: "build",
})
})
test("folds tool, retry, and completion events", () => {
const reducer = createV2SessionReducer()
let messages: SessionMessageInfo[] = []
@@ -61,6 +61,12 @@ export function createV2SessionReducer() {
type: "agent-switched",
metadata: event.metadata,
agent: event.data.agent,
previous:
event.data.previous ??
source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "agent-switched" | "assistant" }> =>
item.type === "agent-switched" || item.type === "assistant",
)?.agent,
time: { created: event.created },
})
case "session.model.selected":
@@ -69,10 +75,12 @@ export function createV2SessionReducer() {
type: "model-switched",
metadata: event.metadata,
model: event.data.model,
previous: source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
item.type === "model-switched" || item.type === "assistant",
)?.model,
previous:
event.data.previous ??
source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
item.type === "model-switched" || item.type === "assistant",
)?.model,
time: { created: event.created },
})
case "session.synthetic":
+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,
+10 -2
View File
@@ -339,7 +339,11 @@ export type Endpoint5_31Output =
readonly type: "session.agent.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly agent: Agent.ID }
readonly data: {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly previous?: Agent.ID | undefined
}
}
| {
readonly id: Event.ID
@@ -348,7 +352,11 @@ export type Endpoint5_31Output =
readonly type: "session.model.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly model: Model.Ref }
readonly data: {
readonly sessionID: Session.ID
readonly model: Model.Ref
readonly previous?: Model.Ref | undefined
}
}
| {
readonly id: Event.ID
@@ -41,6 +41,7 @@ export type SessionMessageAgentSelected = {
time: { created: number }
type: "agent-switched"
agent: string
previous?: string
}
export type PromptBase64 = string
@@ -435,7 +436,7 @@ export type SessionAgentSelected = {
type: "session.agent.selected"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; agent: string }
data: { sessionID: string; agent: string; previous?: string }
}
export type SessionModelSelected = {
@@ -445,7 +446,7 @@ export type SessionModelSelected = {
type: "session.model.selected"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; model: ModelRef }
data: { sessionID: string; model: ModelRef; previous?: ModelRef }
}
export type SessionMoved = {
@@ -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
+4 -5
View File
@@ -194,11 +194,10 @@ async function formatTypescript(input: string) {
function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration"
${names.map((name, index) => `import m${index.toString().padStart(2, "0")} from "./migration/${name}"`).join("\n")}
export const migrations: DatabaseMigration.Migration[] = (
await Promise.all([
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
])
).map((module) => module.default)
export const migrations = [
${names.map((_, index) => ` m${index.toString().padStart(2, "0")},`).join("\n")}
] satisfies DatabaseMigration.Migration[]
`
}
+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 {
+41 -4
View File
@@ -4,7 +4,7 @@ import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
import { Context, Effect, Layer, Option, PubSub, Schema, Scope, Stream } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { Config } from "../../config"
@@ -153,10 +153,18 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
})
})
const sourceDirectories = ["plugin", "plugins"] as const
const Package = Schema.Struct({
exports: Schema.optional(Schema.Unknown),
module: Schema.optional(Schema.Unknown),
main: Schema.optional(Schema.Unknown),
})
const decodePackage = Schema.decodeUnknownOption(Package)
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
.scan("{plugin,plugins}/*.{ts,js}", {
.scan(`{${sourceDirectories.join(",")}}/*.{ts,js}`, {
cwd: directory,
absolute: true,
include: "file",
@@ -164,11 +172,40 @@ function discoverDirectory(fs: FSUtil.Interface, directory: string) {
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
const children = yield* fs
.scan(`{${sourceDirectories.join(",")}}/*`, {
cwd: directory,
absolute: true,
include: "all",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
const directories = yield* Effect.filter(children.sort(), fs.isDir)
const packages = yield* Effect.forEach(directories, (child) => discoverPackage(fs, child))
return [...files.sort(), ...packages.filter((target): target is string => typeof target === "string")].map(
(target): Operation => ({ type: "add", target, options: {} }),
)
})
}
const sourceDirectories = ["plugin", "plugins"] as const
function discoverPackage(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const manifest = yield* fs
.readJson(path.join(directory, "package.json"))
.pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none))
const configured = Option.isSome(manifest)
? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(
(entry): entry is string => typeof entry === "string",
)
: []
const target = yield* Effect.findFirst(
[...configured, "index.ts", "index.js"].map((entry) => path.resolve(directory, entry)),
fs.isFile,
)
return Option.getOrUndefined(target)
})
}
function isPluginSource(entries: readonly Entry[], file: string) {
return entries.some(
+1 -1
View File
@@ -42,10 +42,10 @@ const databaseLayer = Layer.effect(
export function layer(options: Options = { path: ":memory:" }) {
return Layer.unwrap(
Effect.gen(function* () {
const global = yield* Global.Service
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
const global = yield* Global.Service
return provide(join(global.data, filename))
}),
)
+84 -45
View File
@@ -1,47 +1,86 @@
import type { DatabaseMigration } from "./migration"
import m00 from "./migration/20260127222353_familiar_lady_ursula"
import m01 from "./migration/20260211171708_add_project_commands"
import m02 from "./migration/20260213144116_wakeful_the_professor"
import m03 from "./migration/20260225215848_workspace"
import m04 from "./migration/20260227213759_add_session_workspace_id"
import m05 from "./migration/20260228203230_blue_harpoon"
import m06 from "./migration/20260303231226_add_workspace_fields"
import m07 from "./migration/20260309230000_move_org_to_state"
import m08 from "./migration/20260312043431_session_message_cursor"
import m09 from "./migration/20260323234822_events"
import m10 from "./migration/20260410174513_workspace-name"
import m11 from "./migration/20260413175956_chief_energizer"
import m12 from "./migration/20260423070820_add_icon_url_override"
import m13 from "./migration/20260427172553_slow_nightmare"
import m14 from "./migration/20260428004200_add_session_path"
import m15 from "./migration/20260501142318_next_venus"
import m16 from "./migration/20260504145000_add_sync_owner"
import m17 from "./migration/20260507164347_add_workspace_time"
import m18 from "./migration/20260510033149_session_usage"
import m19 from "./migration/20260511000411_data_migration_state"
import m20 from "./migration/20260511173437_session-metadata"
import m21 from "./migration/20260601010001_normalize_storage_paths"
import m22 from "./migration/20260601202201_amazing_prowler"
import m23 from "./migration/20260602002951_lowly_union_jack"
import m24 from "./migration/20260602182828_add_project_directories"
import m25 from "./migration/20260603001617_session_message_projection_indexes"
import m26 from "./migration/20260603040000_session_message_projection_order"
import m27 from "./migration/20260603141458_session_input_inbox"
import m28 from "./migration/20260603160727_jittery_ezekiel_stane"
import m29 from "./migration/20260604172448_event_sourced_session_input"
import m30 from "./migration/20260605003541_add_session_context_snapshot"
import m31 from "./migration/20260605042240_add_context_epoch_agent"
import m32 from "./migration/20260611035744_credential"
import m33 from "./migration/20260611192811_lush_chimera"
import m34 from "./migration/20260612174303_project_dir_strategy"
import m35 from "./migration/20260622142730_simplify_session_context_epoch"
import m36 from "./migration/20260622170816_reset_v2_session_state"
import m37 from "./migration/20260622202450_simplify_session_input"
import m38 from "./migration/20260804233008_loose_psylocke"
import m39 from "./migration/20260805200742_import_legacy_credentials"
import m40 from "./migration/20260808023530_workspace_domain"
export const migrations: DatabaseMigration.Migration[] = (
await Promise.all([
import("./migration/20260127222353_familiar_lady_ursula"),
import("./migration/20260211171708_add_project_commands"),
import("./migration/20260213144116_wakeful_the_professor"),
import("./migration/20260225215848_workspace"),
import("./migration/20260227213759_add_session_workspace_id"),
import("./migration/20260228203230_blue_harpoon"),
import("./migration/20260303231226_add_workspace_fields"),
import("./migration/20260309230000_move_org_to_state"),
import("./migration/20260312043431_session_message_cursor"),
import("./migration/20260323234822_events"),
import("./migration/20260410174513_workspace-name"),
import("./migration/20260413175956_chief_energizer"),
import("./migration/20260423070820_add_icon_url_override"),
import("./migration/20260427172553_slow_nightmare"),
import("./migration/20260428004200_add_session_path"),
import("./migration/20260501142318_next_venus"),
import("./migration/20260504145000_add_sync_owner"),
import("./migration/20260507164347_add_workspace_time"),
import("./migration/20260510033149_session_usage"),
import("./migration/20260511000411_data_migration_state"),
import("./migration/20260511173437_session-metadata"),
import("./migration/20260601010001_normalize_storage_paths"),
import("./migration/20260601202201_amazing_prowler"),
import("./migration/20260602002951_lowly_union_jack"),
import("./migration/20260602182828_add_project_directories"),
import("./migration/20260603001617_session_message_projection_indexes"),
import("./migration/20260603040000_session_message_projection_order"),
import("./migration/20260603141458_session_input_inbox"),
import("./migration/20260603160727_jittery_ezekiel_stane"),
import("./migration/20260604172448_event_sourced_session_input"),
import("./migration/20260605003541_add_session_context_snapshot"),
import("./migration/20260605042240_add_context_epoch_agent"),
import("./migration/20260611035744_credential"),
import("./migration/20260611192811_lush_chimera"),
import("./migration/20260612174303_project_dir_strategy"),
import("./migration/20260622142730_simplify_session_context_epoch"),
import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260804233008_loose_psylocke"),
import("./migration/20260805200742_import_legacy_credentials"),
import("./migration/20260808023530_workspace_domain"),
])
).map((module) => module.default)
export const migrations = [
m00,
m01,
m02,
m03,
m04,
m05,
m06,
m07,
m08,
m09,
m10,
m11,
m12,
m13,
m14,
m15,
m16,
m17,
m18,
m19,
m20,
m21,
m22,
m23,
m24,
m25,
m26,
m27,
m28,
m29,
m30,
m31,
m32,
m33,
m34,
m35,
m36,
m37,
m38,
m39,
m40,
] satisfies DatabaseMigration.Migration[]
+70 -55
View File
@@ -1,11 +1,8 @@
import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } 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"
import { App } from "./app"
import { Global } from "@opencode-ai/util/global"
import { Flock } from "@opencode-ai/util/flock"
import { Hash } from "@opencode-ai/util/hash"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bus } from "./bus"
@@ -13,6 +10,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Model } from "./model"
import { Provider } from "./provider"
import { KV } from "./kv"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
@@ -537,6 +535,18 @@ export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
const Cache = Schema.Struct({
updatedAt: Schema.Number,
body: CatalogJson,
})
const defaultSource = "https://models.opencode.ai"
function cacheKey(source: string) {
if (source === defaultSource) return "models-dev:catalog"
return `models-dev:catalog:${Hash.fast(source)}`
}
export const layer = (options?: Options) =>
Layer.effect(
Service,
@@ -544,7 +554,7 @@ export const layer = (options?: Options) =>
const fs = yield* FSUtil.Service
const bus = yield* Bus.Service
const app = yield* App.Metadata
const global = yield* Global.Service
const kv = yield* KV.Service
const http = HttpClient.filterStatusOk(
(yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({
@@ -555,21 +565,28 @@ export const layer = (options?: Options) =>
),
)
const source = options?.url || "https://models.opencode.ai"
const source = options?.url || defaultSource
const fetch = options?.fetch ?? true
const userAgent = App.useragent(app)
const filepath = path.join(
global.cache,
source === "https://models.opencode.ai" ? "models.json" : `models-${Hash.fast(source)}.json`,
)
const key = cacheKey(source)
const ttl = Duration.minutes(5)
const lockKey = `models-dev:${filepath}`
const lock = Semaphore.makeUnsafe(1)
const loadFromCache = Effect.fnUntraced(function* () {
const value = yield* kv.get(key)
const cached = Schema.decodeUnknownOption(Cache)(value)
if (Option.isSome(cached))
return {
catalog: cached.value.body as Record<string, SourceProvider>,
updatedAt: cached.value.updatedAt,
}
if (value !== undefined) yield* kv.remove(key)
})
const fresh = Effect.fnUntraced(function* () {
const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!stat) return false
const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime()
return Date.now() - mtime < Duration.toMillis(ttl)
const cached = yield* loadFromCache()
if (!cached) return false
return Date.now() - cached.updatedAt < Duration.toMillis(ttl)
})
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
@@ -581,15 +598,12 @@ export const layer = (options?: Options) =>
)
})
const loadFromDisk = fs.readJson(options?.file ?? filepath).pipe(
Effect.map((input) => input as Record<string, SourceProvider>),
Effect.catch((error) => {
if (options?.file === undefined && error._tag === "FileSystemError" && error.method === "readJson") {
return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined))
}
return Effect.succeed(undefined)
}),
)
const loadFromFile = options?.file
? fs.readJson(options.file).pipe(
Effect.map((input) => input as Record<string, SourceProvider>),
Effect.catch(() => Effect.succeed(undefined)),
)
: Effect.succeed(undefined)
const loadSnapshot = Effect.sync(() =>
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
@@ -597,33 +611,36 @@ export const layer = (options?: Options) =>
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp`
yield* fs.writeWithDirs(tempfile, text).pipe(
Effect.andThen(fs.rename(tempfile, filepath)),
Effect.catch((error) =>
Effect.gen(function* () {
yield* fs.remove(tempfile, { force: true }).pipe(Effect.ignore)
return yield* Effect.fail(error)
}),
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
// 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 text
return catalog
})
const populate = Effect.gen(function* () {
const fromDisk = yield* loadFromDisk
if (fromDisk) return normalize(fromDisk)
const fromFile = yield* loadFromFile
if (fromFile) return normalize(fromFile)
const cached = options?.file ? undefined : yield* loadFromCache()
if (cached) return normalize(cached.catalog)
const bundled = yield* loadSnapshot
if (bundled) return normalize(bundled)
if (!fetch) return []
// Flock is cross-process: concurrent opencode CLIs can race on this cache file.
const text = yield* Effect.scoped(
const catalog = yield* lock.withPermit(
Effect.gen(function* () {
yield* Flock.effect(lockKey)
const stored = options?.file ? undefined : yield* loadFromCache()
if (stored) return stored.catalog
return yield* fetchAndWrite()
}),
)
return normalize(JSON.parse(text) as Record<string, SourceProvider>)
return normalize(catalog)
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
@@ -631,21 +648,19 @@ export const layer = (options?: Options) =>
const get = (): Effect.Effect<readonly Snapshot[]> => cachedGet
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
if (!force && (yield* fresh())) return
yield* Effect.scoped(
Effect.gen(function* () {
yield* Flock.effect(lockKey)
// Re-check under the lock: another process may have refreshed between
// our outer check and lock acquisition.
if (!force && (yield* fresh())) return
yield* fetchAndWrite()
yield* invalidate
yield* bus.publish(ModelsDev.Event.Refreshed, {})
}),
).pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
Effect.ignore,
)
yield* lock
.withPermit(
Effect.gen(function* () {
if (!force && (yield* fresh())) return
yield* fetchAndWrite()
yield* invalidate
yield* bus.publish(ModelsDev.Event.Refreshed, {})
}),
)
.pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
Effect.ignore,
)
})
if (fetch && !process.argv.includes("--get-yargs-completions")) {
@@ -661,7 +676,7 @@ export function configured(options?: Options) {
return makeGlobalNode({
service: Service,
layer: layer(options),
deps: [FSUtil.node, Bus.node, App.node, Global.node, httpClient],
deps: [FSUtil.node, Bus.node, App.node, KV.node, httpClient],
})
}
+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
+1
View File
@@ -36,6 +36,7 @@ export const ModelsDevPlugin = define({
draft.integrationID = Integration.ID.make(provider.info.id)
})
for (const model of provider.models) {
if (model.status === "deprecated") continue
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
}
}
+3 -1
View File
@@ -716,10 +716,11 @@ const layer = Layer.effect(
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}),
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
yield* result.get(input.sessionID)
const session = yield* result.get(input.sessionID)
yield* bus.publish(SessionEvent.AgentSelected, {
sessionID: input.sessionID,
agent: input.agent,
previous: session.agent,
})
}),
switchModel: Effect.fn("Session.switchModel")(function* (input) {
@@ -733,6 +734,7 @@ const layer = Layer.effect(
yield* bus.publish(SessionEvent.ModelSelected, {
sessionID: input.sessionID,
model: input.model,
previous: session.model,
})
}),
rename: Effect.fn("Session.rename")(function* (input) {
+15 -10
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,19 +60,23 @@ 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 = event.data.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* () {
const previous = yield* adapter.getModel()
const previous = event.data.previous ?? (yield* adapter.getModel())
yield* adapter.appendMessage(
SessionMessage.ModelSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
+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, Set<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 payloads = seen.get(metadata) ?? new Set<string>()
if (payloads.has(file.data)) return []
payloads.add(file.data)
seen.set(metadata, payloads)
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
+67
View File
@@ -168,6 +168,69 @@ describe("PluginSupervisor config", () => {
),
)
it.live("loads auto-discovered plugin packages from package metadata", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("package-metadata")
}),
false,
async (directory) => {
const plugin = path.join(directory, ".opencode", "plugins", "package-metadata")
await fs.mkdir(plugin, { recursive: true })
await fs.writeFile(path.join(plugin, "package.json"), JSON.stringify({ exports: "./entry.ts" }))
await fs.writeFile(path.join(plugin, "entry.ts"), discoveredPlugin("package-metadata"))
},
),
)
it.live("loads auto-discovered plugin packages from index fallback", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("index-fallback")
}),
false,
async (directory) => {
const plugin = path.join(directory, ".opencode", "plugins", "index-fallback")
await fs.mkdir(plugin, { recursive: true })
await fs.writeFile(path.join(plugin, "index.js"), discoveredPlugin("index-fallback"))
},
),
)
it.live("prefers package metadata over index fallback", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
expect(ids).toContain("metadata-precedence")
expect(ids).not.toContain("module-collision")
expect(ids).not.toContain("main-collision")
expect(ids).not.toContain("index-collision")
}),
false,
async (directory) => {
const plugin = path.join(directory, ".opencode", "plugins", "collision")
await fs.mkdir(plugin, { recursive: true })
await fs.writeFile(
path.join(plugin, "package.json"),
JSON.stringify({ exports: "./entry.js", module: "./module.js", main: "./main.js" }),
)
await fs.writeFile(path.join(plugin, "entry.js"), discoveredPlugin("metadata-precedence"))
await fs.writeFile(path.join(plugin, "module.js"), discoveredPlugin("module-collision"))
await fs.writeFile(path.join(plugin, "main.js"), discoveredPlugin("main-collision"))
await fs.writeFile(path.join(plugin, "index.js"), discoveredPlugin("index-collision"))
},
),
)
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
@@ -389,3 +452,7 @@ export default Plugin.define({
})
`
}
function discoveredPlugin(id: string) {
return `export default { id: ${JSON.stringify(id)}, setup() {} }`
}
+91 -53
View File
@@ -1,19 +1,17 @@
import { describe, expect, beforeEach, afterAll, test } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { KV } from "@opencode-ai/core/kv"
import { Model } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@opencode-ai/core/provider"
import { it } from "./lib/effect"
import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path"
const cacheFile = path.join(Global.Path.cache, "models.json")
const cacheKey = "models-dev:catalog"
test("normalizes permissive interleaved values to compatibility", () => {
expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
@@ -164,7 +162,18 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
}),
)
const buildLayer = (state: Ref.Ref<MockState>, options: ModelsDev.Options = { fetch: false }) =>
interface MockCache {
readonly values: Map<string, KV.Value>
}
const makeMockKV = (cache: MockCache) =>
Layer.mock(KV.Service, {
get: (key) => Effect.sync(() => cache.values.get(key)),
set: (key, value) => Effect.sync(() => cache.values.set(key, value)).pipe(Effect.asVoid),
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
})
const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: ModelsDev.Options = { fetch: false }) =>
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
// and Effect.provide uses a process-global MemoMap by default — without fresh,
// every test would reuse the cachedInvalidateWithTTL state from the first run.
@@ -172,31 +181,29 @@ const buildLayer = (state: Ref.Ref<MockState>, options: ModelsDev.Options = { fe
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured(options)],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeMockKV(cache)],
]),
)
const writeCacheText = (text: string, mtimeMs?: number) =>
Effect.promise(async () => {
await mkdir(Global.Path.cache, { recursive: true })
await writeFile(cacheFile, text)
if (mtimeMs !== undefined) {
const t = mtimeMs / 1000
await utimes(cacheFile, t, t)
}
// 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 writeCache = (data: object, mtimeMs?: number) => writeCacheText(JSON.stringify(data), mtimeMs)
const makeCache = (): MockCache => ({ values: new Map() })
const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
eff.pipe(Effect.provide(buildLayer(state)))
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
cache.values.set(cacheKey, { updatedAt, body: text })
beforeEach(async () => {
await rm(cacheFile, { force: true })
})
const writeCache = (cache: MockCache, data: object, updatedAt?: number) =>
writeCacheText(cache, JSON.stringify(data), updatedAt)
afterAll(async () => {
await rm(cacheFile, { force: true })
})
const provided = <A, E>(state: Ref.Ref<MockState>, cache: MockCache, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
eff.pipe(Effect.provide(buildLayer(state, cache)))
const initialState: MockState = {
body: JSON.stringify(fixture),
@@ -205,12 +212,14 @@ const initialState: MockState = {
}
describe("ModelsDev Service", () => {
it.live("get() returns normalized snapshots from disk when cache file exists", () =>
it.live("get() returns normalized snapshots from KV when a cache entry exists", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make(initialState)
const result = yield* provided(
state,
cache,
ModelsDev.Service.use((s) => s.get()),
)
expect(result).toEqual(fixtureSnapshot)
@@ -219,11 +228,13 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() returns empty catalog when disk empty, fetch disabled, and no bundled snapshot is injected", () =>
it.live("get() returns empty catalog when KV is empty, fetch disabled, and no bundled snapshot is injected", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
const result = yield* provided(
state,
cache,
ModelsDev.Service.use((s) => s.get()),
)
expect(result).toEqual([])
@@ -232,14 +243,34 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() recovers from a corrupted cache file by fetching a fresh catalog", () =>
it.live("get() recovers from a corrupted KV entry by fetching a fresh catalog", () =>
Effect.gen(function* () {
yield* writeCacheText("{")
const cache = makeCache()
writeCacheText(cache, "{")
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const context = yield* Layer.build(buildLayer(state, { fetch: true }))
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true }))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
expect(result).toEqual(fixture2Snapshot)
expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2))
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
)
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)
}),
@@ -247,9 +278,10 @@ describe("ModelsDev Service", () => {
it.live("uses the default models URL when the configured URL is empty", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
yield* ModelsDev.Service.use((service) => service.get()).pipe(
Effect.provide(buildLayer(state, { url: "", fetch: true })),
Effect.provide(buildLayer(state, cache, { url: "", fetch: true })),
)
expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://models.opencode.ai/api.json")
}),
@@ -257,32 +289,31 @@ describe("ModelsDev Service", () => {
it.live("get() is single-flight under concurrent calls", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
const state = yield* Ref.make(initialState)
const results = yield* provided(
state,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
concurrency: "unbounded",
})
}),
)
const results = yield* Effect.gen(function* () {
const svc = yield* ModelsDev.Service
return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
concurrency: "unbounded",
})
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: true })))
for (const result of results) expect(result).toEqual(fixtureSnapshot)
expect((yield* Ref.get(state)).calls.length).toBe(1)
}),
)
it.live("get() caches across calls (later disk writes are ignored until invalidate)", () =>
it.live("get() caches across calls (later KV writes are ignored until invalidate)", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make(initialState)
const first = yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
const a = yield* svc.get()
// mutate disk between calls — cache should mask the change
yield* writeCache(fixture2)
writeCache(cache, fixture2)
const b = yield* svc.get()
return { a, b }
}),
@@ -294,10 +325,12 @@ describe("ModelsDev Service", () => {
it.live("refresh(true) fetches via HttpClient and updates the cache", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const result = yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
const before = yield* svc.get()
@@ -308,6 +341,7 @@ describe("ModelsDev Service", () => {
)
expect(result.before).toEqual(fixtureSnapshot)
expect(result.after).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(final.calls[0].url).toContain("/api.json")
@@ -315,13 +349,14 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh(false) skips fetch when on-disk file is fresh", () =>
it.live("refresh(false) skips fetch when the KV entry is fresh", () =>
Effect.gen(function* () {
// Fresh: mtime within the 5-minute TTL.
yield* writeCache(fixture, Date.now() - 1000)
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 1000)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
yield* provided(
state,
cache,
ModelsDev.Service.use((s) => s.refresh(false)),
)
const final = yield* Ref.get(state)
@@ -329,13 +364,14 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh(false) fetches when on-disk file is stale", () =>
it.live("refresh(false) fetches when the KV entry is stale", () =>
Effect.gen(function* () {
// Stale: mtime 10 minutes ago, beyond the 5-minute TTL.
yield* writeCache(fixture, Date.now() - 10 * 60 * 1000)
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const after = yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
yield* svc.refresh(false)
@@ -350,10 +386,12 @@ describe("ModelsDev Service", () => {
it.live("refresh swallows HTTP errors and leaves cache intact", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
const result = yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
yield* svc.refresh(true)
+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([])
}),
)
})
@@ -215,6 +215,66 @@ describe("ModelsDevPlugin", () => {
}),
)
it.effect("omits deprecated models from the catalog", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("acme")
const activeID = Model.ID.make("current")
const deprecatedID = Model.ID.make("legacy")
const model = {
modelID: activeID,
providerID,
name: "Current",
capabilities: { tools: true, input: [], output: [] },
variants: [],
time: { released: Date.parse("2026-01-01") },
cost: [],
status: "active",
enabled: true,
limit: { context: 128_000, output: 32_000 },
} satisfies Omit<Model.Info, "id">
const snapshots = [
{
info: {
id: providerID,
name: "Acme",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
},
environment: [],
models: [
{ id: activeID, ...model },
{
id: deprecatedID,
...model,
modelID: deprecatedID,
name: "Legacy",
status: "deprecated" as const,
},
],
},
] satisfies readonly ModelsDev.Snapshot[]
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
).pipe(
Effect.provideService(
ModelsDev.Service,
ModelsDev.Service.of({
get: () => Effect.succeed(snapshots),
refresh: () => Effect.void,
}),
),
)
expect(yield* catalog.model.get(providerID, activeID)).toBeDefined()
expect(yield* catalog.model.get(providerID, deprecatedID)).toBeUndefined()
}),
)
it.effect("registers key methods for providers with environment variables", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+15 -4
View File
@@ -647,14 +647,17 @@ 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") })
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
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" } }])
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan", previous: "build" } }])
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
{ type: "agent-switched", agent: "plan", previous: "build" },
])
}),
)
@@ -675,7 +678,12 @@ describe("Session.create", () => {
it.effect("switches the selected model through the durable Session event", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
const previous = Model.Ref.make({
id: Model.ID.make("haiku"),
providerID: Provider.ID.anthropic,
variant: Model.VariantID.make("default"),
})
const created = yield* session.create({ location, model: previous })
const model = Model.Ref.make({
id: Model.ID.make("sonnet"),
providerID: Provider.ID.anthropic,
@@ -689,7 +697,10 @@ describe("Session.create", () => {
yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect),
)
expect(bus).toMatchObject([{ type: "session.model.selected" }])
expect(bus[0]?.data).toEqual({ sessionID: created.id, model })
expect(bus[0]?.data).toEqual({ sessionID: created.id, model, previous })
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
{ type: "model-switched", model, previous },
])
}),
)
@@ -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> {
+8 -7
View File
@@ -77,20 +77,21 @@ export function spatialPathSpans(points: readonly DiagramPoint[]): SpatialSpan[]
.sort(([left], [right]) => left - right)
.flatMap(([y, xs]) => {
const sorted = [...xs].sort((left, right) => left - right)
const [first, ...rest] = sorted
if (first === undefined) return []
const spans: SpatialSpan[] = []
let start = sorted[0]
let end = start
if (start === undefined) return spans
for (const x of sorted.slice(1)) {
if (x === end! + 1) {
let start = first
let end = first
for (const x of rest) {
if (x === end + 1) {
end = x
continue
}
spans.push(normalizedSpan(y, start, end!))
spans.push(normalizedSpan(y, start, end))
start = x
end = x
}
spans.push(normalizedSpan(y, start, end!))
spans.push(normalizedSpan(y, start, end))
return spans
})
}
+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>
}
+9
View File
@@ -12733,6 +12733,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["id", "time", "type", "agent"],
@@ -14370,6 +14373,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["sessionID", "agent"],
@@ -14438,6 +14444,9 @@
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
}
},
"required": ["sessionID", "model"],
+2
View File
@@ -69,6 +69,7 @@ export const AgentSelected = Event.durable({
schema: {
...Base,
agent: Agent.ID,
previous: Agent.ID.pipe(optional),
},
})
export type AgentSelected = typeof AgentSelected.Type
@@ -79,6 +80,7 @@ export const ModelSelected = Event.durable({
schema: {
...Base,
model: Model.Ref,
previous: Model.Ref.pipe(optional),
},
})
export type ModelSelected = typeof ModelSelected.Type
+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> {}
+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 + " "
+1 -5
View File
@@ -62,13 +62,9 @@ function createMarquee(hovered: () => string | undefined, animations: () => bool
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
createEffect(() => {
if (!hovered()) {
setOffset(0)
leading.jump({ opacity: 0 })
return
}
setOffset(0)
leading.jump({ opacity: 0 })
if (!hovered()) return
let interval: ReturnType<typeof setInterval> | undefined
const delay = setTimeout(() => {
setOffset(1)
+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, Set<string>>()
return items.filter((item) => {
const key = identity(item)
if (!key) return true
const payloads = seen.get(key.metadata) ?? new Set<string>()
if (payloads.has(key.payload)) return false
payloads.add(key.payload)
seen.set(key.metadata, payloads)
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([file.name ?? null, file.description ?? null, 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])
})
})
+9
View File
@@ -12733,6 +12733,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["id", "time", "type", "agent"],
@@ -14370,6 +14373,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["sessionID", "agent"],
@@ -14438,6 +14444,9 @@
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
}
},
"required": ["sessionID", "model"],
+9
View File
@@ -12733,6 +12733,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["id", "time", "type", "agent"],
@@ -14370,6 +14373,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["sessionID", "agent"],
@@ -14438,6 +14444,9 @@
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
}
},
"required": ["sessionID", "model"],
+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`