Compare commits

..

1 Commits

Author SHA1 Message Date
Dax Raad dfadb61a54 fix(core): connect custom providers 2026-08-05 22:41:29 +00:00
57 changed files with 479 additions and 217 deletions
+14
View File
@@ -489,6 +489,18 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.8",
"dependencies": {
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.18.8",
@@ -2055,6 +2067,8 @@
"@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"],
"@opencode-ai/effect-sqlite-node": ["@opencode-ai/effect-sqlite-node@workspace:packages/effect-sqlite-node"],
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

+36 -44
View File
@@ -152,19 +152,16 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
interface Options {
export interface LayerOptions {
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
/** Maximum durable rows read per page while replaying or tailing an aggregate log. */
readonly logReadPageSize?: number
/** Retain durable event payloads for historical log reads and replay. */
readonly persist?: boolean
}
export function configured(options?: Options) {
return makeGlobalNode({
service: Service,
deps: [Database.node],
layer: Layer.effect(Service, Effect.gen(function* () {
export const layerWith = (options?: LayerOptions) =>
Layer.effect(
Service,
Effect.gen(function* () {
const pubsub = {
live: yield* PubSub.unbounded<Event.Payload>(),
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
@@ -174,7 +171,6 @@ export function configured(options?: Options) {
const listeners = new Array<Subscriber>()
const { db } = yield* Database.Service
const logReadPageSize = options?.logReadPageSize ?? 512
const persist = options?.persist ?? false
const getOrCreate = (definition: Event.Definition) =>
Effect.gen(function* () {
@@ -255,7 +251,6 @@ export function configured(options?: Options) {
)
}
if (input && input.seq <= latest) {
if (!persist) return
const stored = yield* db
.select()
.from(EventTable)
@@ -297,21 +292,19 @@ export function configured(options?: Options) {
}),
)
}
if (persist) {
const stored = yield* db
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.id, event.id))
.get()
.pipe(Effect.orDie)
if (stored)
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}),
)
}
const stored = yield* db
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.id, event.id))
.get()
.pipe(Effect.orDie)
if (stored)
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}),
)
const committed = {
...event,
durable: { aggregateID, seq, version: durable.version },
@@ -332,21 +325,20 @@ export function configured(options?: Options) {
})
.run()
.pipe(Effect.orDie)
if (persist)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)),
type: versionedType(definition.type, durable.version),
data: encoded,
},
])
.run()
.pipe(Effect.orDie)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)),
type: versionedType(definition.type, durable.version),
data: encoded,
},
])
.run()
.pipe(Effect.orDie)
return { aggregateID, seq }
}),
{ behavior: "immediate" },
@@ -691,8 +683,8 @@ export function configured(options?: Options) {
remove,
claim,
})
})),
})
}
}),
)
export const node = configured()
export const layer = layerWith()
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
+6 -8
View File
@@ -13,17 +13,15 @@ export const Plugin = define({
const loaded = { entries: yield* config.entries() }
yield* ctx.integration.transform((integrations) => {
const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredIntegrations = new Set(
files.flatMap((file) =>
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
provider.env === undefined ? [] : [id],
),
),
)
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const integrationID = id
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
if (!integrations.get(integrationID)) {
integrations.method.update({
integrationID,
method: { type: "key", label: "Manually enter API Key" },
})
}
integrations.update(integrationID, (integration) => {
integration.name = item.name ?? integration.name
})
+1 -1
View File
@@ -28,7 +28,7 @@ const layer = Layer.effect(
read: Effect.sync(() =>
[
"<env>",
` Current conversation session ID: ${sessionID}`,
` Session ID: ${sessionID}`,
` Working directory: ${location.directory}`,
` Workspace root folder: ${location.project.directory}`,
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
@@ -1,5 +1,4 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { shouldUseResponsesApi } from "@opencode-ai/ai/providers/github-copilot"
import { Effect, Option, Schema, Semaphore, Stream } from "effect"
import { Catalog } from "../../catalog"
import { Credential } from "../../credential"
@@ -141,6 +140,14 @@ const oauth = (app: App.Info) => ({
}),
}) satisfies IntegrationOAuthMethodRegistration
function shouldUseResponses(modelID: string) {
// Copilot supports Responses for GPT-5 class models, except mini variants
// which still need the chat-completions endpoint.
const match = /^gpt-(\d+)/.exec(modelID)
if (!match) return false
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
}
export const GithubCopilotPlugin = define({
id: "opencode.provider.github-copilot",
effect: Effect.fn(function* (ctx) {
@@ -262,7 +269,7 @@ export const GithubCopilotPlugin = define({
return
}
const id = evt.model.modelID ?? evt.model.id
evt.language = shouldUseResponsesApi(id) ? evt.sdk.responses(id) : evt.sdk.chat(id)
evt.language = shouldUseResponses(id) ? evt.sdk.responses(id) : evt.sdk.chat(id)
}),
)
}),
+21 -66
View File
@@ -100,19 +100,9 @@ const tail = (bus: Bus.Interface, input: { aggregateID: string; after?: number }
bus.log({ ...input, follow: true }).pipe(Stream.filter((item): item is Event.Payload => !Bus.isSynced(item)))
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, Location.node]), [
[Location.node, locationLayer],
[Bus.node, Bus.configured({ persist: true })],
]),
)
const itWithoutLocation = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
[Bus.node, Bus.configured({ persist: true })],
]),
)
const itWithoutPersistence = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node])),
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, Location.node]), [[Location.node, locationLayer]]),
)
const itWithoutLocation = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node])))
describe("Bus", () => {
it.effect("subscribes to multiple event definitions with a discriminated payload union", () =>
@@ -264,27 +254,6 @@ describe("Bus", () => {
}),
)
itWithoutPersistence.effect("projects durable events without retaining their payloads", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const aggregateID = Event.ID.create()
yield* db.run("CREATE TABLE IF NOT EXISTS event_commit_probe (value text NOT NULL)")
yield* bus.project(SyncMessage, () =>
db.run("INSERT INTO event_commit_probe (value) VALUES ('projected')").pipe(Effect.orDie, Effect.asVoid),
)
const event = yield* bus.publish(SyncMessage, { id: aggregateID, text: "hello" })
expect(event.durable?.seq).toBe(Event.Seq.make(0))
expect(yield* db.all("SELECT value FROM event_commit_probe")).toEqual([{ value: "projected" }])
expect(yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).toEqual([])
expect(
yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all(),
).toEqual([{ aggregate_id: aggregateID, seq: 0, owner_id: null }])
}),
)
it.effect("rejects local commit hooks on live-only events", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
@@ -503,18 +472,12 @@ describe("Bus", () => {
const readStarted = yield* Deferred.make<void>()
const continueRead = yield* Deferred.make<void>()
let pause = true
const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
[
Bus.node,
Bus.configured({
persist: true,
beforeAggregateRead: () =>
pause
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
: Effect.void,
}),
],
])
const eventLayer = Bus.layerWith({
beforeAggregateRead: () =>
pause
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
: Effect.void,
}).pipe(Layer.provide(LayerNode.compile(Database.node)))
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
@@ -529,7 +492,7 @@ describe("Bus", () => {
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, durableData(aggregateID, "during handoff")],
])
}).pipe(Effect.provide(eventLayer))
}).pipe(Effect.provide(Layer.merge(LayerNode.compile(Database.node), eventLayer)))
}),
)
@@ -1272,9 +1235,7 @@ describe("Bus", () => {
it.effect("log replays across configured read pages", () =>
Effect.gen(function* () {
const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
[Bus.node, Bus.configured({ persist: true, logReadPageSize: 2 })],
])
const eventLayer = Bus.layerWith({ logReadPageSize: 2 }).pipe(Layer.provide(LayerNode.compile(Database.node)))
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
@@ -1296,7 +1257,7 @@ describe("Bus", () => {
"log.synced",
])
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID, seq: Event.Seq.make(4) })
}).pipe(Effect.provide(eventLayer))
}).pipe(Effect.provide(Layer.merge(LayerNode.compile(Database.node), eventLayer)))
}),
)
@@ -1305,21 +1266,15 @@ describe("Bus", () => {
const readStarted = yield* Deferred.make<void>()
const releaseRead = yield* Deferred.make<void>()
const firstRead = yield* Ref.make(true)
const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
[
Bus.node,
Bus.configured({
persist: true,
beforeAggregateRead: () =>
Ref.getAndSet(firstRead, false).pipe(
Effect.flatMap((shouldBlock) => {
if (!shouldBlock) return Effect.void
return Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseRead)))
}),
),
}),
],
])
const eventLayer = Bus.layerWith({
beforeAggregateRead: () =>
Ref.getAndSet(firstRead, false).pipe(
Effect.flatMap((shouldBlock) => {
if (!shouldBlock) return Effect.void
return Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseRead)))
}),
),
}).pipe(Layer.provide(LayerNode.compile(Database.node)))
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
@@ -1339,7 +1294,7 @@ describe("Bus", () => {
{ type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
Event.Seq.make(1),
])
}).pipe(Effect.provide(eventLayer))
}).pipe(Effect.provide(Layer.merge(LayerNode.compile(Database.node), eventLayer)))
}),
)
@@ -49,6 +49,33 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("adds key auth for custom providers without env credentials", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const entries = [
new Config.Document({
type: "document",
info: decode({
providers: {
litellm: {
package: "aisdk:@ai-sdk/openai-compatible",
models: { chat: {} },
},
},
}),
}),
]
yield* addPlugin(entries)
expect(yield* integrations.get(Integration.ID.make("litellm"))).toMatchObject({
id: "litellm",
name: "litellm",
methods: [{ type: "key", label: "Manually enter API Key" }],
})
}),
)
it.effect("defaults custom models to agent capabilities", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
+1 -5
View File
@@ -17,11 +17,7 @@ import { SessionSchema } from "@opencode-ai/core/session/schema"
import { InstructionBlobTable, InstructionStateTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [
[Bus.node, Bus.configured({ persist: true })],
]),
)
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node])))
const source = (name: string, read: Effect.Effect<string | Instructions.Unavailable | Instructions.Removed>) =>
Instructions.make({
@@ -44,7 +44,7 @@ describe("InstructionBuiltIns", () => {
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Current conversation session ID: ${sessionID}`,
` Session ID: ${sessionID}`,
` Working directory: ${directory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
@@ -81,7 +81,6 @@ const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, SessionCompaction.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[llmClient, client],
[Config.node, config],
[SessionRunnerModel.node, models],
+1 -5
View File
@@ -40,7 +40,6 @@ const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, projects],
[SessionExecution.node, SessionExecution.noopLayer],
],
@@ -563,10 +562,7 @@ describe("Session.create", () => {
const targetDatabase = Database.layer({ path: path.join(tmp.path, "target.sqlite") })
const targetLayer = AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
[
[Database.node, targetDatabase],
[Bus.node, Bus.configured({ persist: true })],
],
[[Database.node, targetDatabase]],
)
yield* Effect.gen(function* () {
@@ -133,7 +133,6 @@ const it = testEffect(
SessionGenerateNode.node,
]),
[
[Bus.node, Bus.configured({ persist: true })],
[llmClient, client],
[SessionRunnerModel.node, models],
[InstructionBuiltIns.node, builtins],
+4 -3
View File
@@ -29,7 +29,6 @@ const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, projects],
[SessionExecution.node, SessionExecution.noopLayer],
],
@@ -46,7 +45,9 @@ describe("Session.log", () => {
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
expect(items.map((item) => item.type)).toEqual(["session.created", "session.renamed", "log.synced"])
// Session creation commits a non-public durable event, so the marker's
// seq covers more of the aggregate than the public events emitted.
expect(items.map((item) => item.type)).toEqual(["session.renamed", "log.synced"])
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(1) })
}),
)
@@ -56,7 +57,7 @@ describe("Session.log", () => {
const session = yield* Session.Service
const created = yield* session.create({ location })
const fiber = yield* session
.log({ sessionID: created.id, after: Event.Seq.make(0), follow: true })
.log({ sessionID: created.id, follow: true })
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
+1 -5
View File
@@ -31,11 +31,7 @@ import {
import { testEffect } from "./lib/effect"
import { Snapshot } from "@opencode-ai/core/snapshot"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [
[Bus.node, Bus.configured({ persist: true })],
]),
)
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node])))
const sessionsLayer = AppNodeBuilder.build(Session.node, [[SessionExecution.node, SessionExecution.noopLayer]])
const sessionID = Session.ID.make("ses_projector_test")
const created = DateTime.makeUnsafe(0)
@@ -68,7 +68,6 @@ const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[SessionExecution.node, execution],
[LocationServiceMap.node, locations],
],
@@ -159,7 +159,6 @@ const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
Session.node,
]),
[
[Bus.node, Bus.configured({ persist: true })],
[LayerNodePlatform.llmClient, llmClient],
[Permission.node, permission],
[Catalog.node, promptCatalog],
@@ -423,7 +423,6 @@ const it = testEffect(
Session.node,
]),
[
[Bus.node, Bus.configured({ persist: true })],
[LayerNodePlatform.llmClient, client],
[Permission.node, permission],
[Catalog.node, promptCatalog],
@@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
import { asc, eq } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Agent } from "@opencode-ai/core/agent"
@@ -19,11 +18,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [
[Bus.node, Bus.configured({ persist: true })],
]),
)
const it = testEffect(LayerNode.compile(LayerNode.group([Database.node, Bus.node, SessionProjector.node])))
const timestamp = DateTime.makeUnsafe(1)
const model = { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }
+1
View File
@@ -12,6 +12,7 @@ const updaterHandler = (_: unknown, state: UpdaterState) => {
const api: ElectronAPI = {
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
installCli: () => ipcRenderer.invoke("install-cli"),
awaitInitialization: () => ipcRenderer.invoke("await-initialization"),
wslServers: {
getState: () => ipcRenderer.invoke("wsl-servers-get-state"),
+1
View File
@@ -43,6 +43,7 @@ export type FatalRendererError = {
export type ElectronAPI = {
killSidecar: () => Promise<void>
installCli: () => Promise<string>
awaitInitialization: () => Promise<ServerReadyData>
wslServers: WslServersAPI
updater: UpdaterAPI
+12
View File
@@ -0,0 +1,12 @@
import { initI18n, t } from "./i18n"
export async function installCli(): Promise<void> {
await initI18n()
try {
const path = await window.api.installCli()
window.alert(t("desktop.cli.installed.message", { path }))
} catch (e) {
window.alert(t("desktop.cli.failed.message", { error: String(e) }))
}
}
+6
View File
@@ -1,5 +1,6 @@
export const dict = {
"desktop.menu.checkForUpdates": "التحقق من وجود تحديثات...",
"desktop.menu.installCli": "تثبيت CLI...",
"desktop.menu.reloadWebview": "إعادة تحميل Webview",
"desktop.menu.restart": "إعادة تشغيل",
@@ -17,4 +18,9 @@ export const dict = {
"desktop.updater.downloaded.prompt": "تم تنزيل إصدار {{version}} من OpenCode، هل ترغب في تثبيته وإعادة تشغيله؟",
"desktop.updater.installFailed.title": "فشل التحديث",
"desktop.updater.installFailed.message": "فشل تثبيت التحديث",
"desktop.cli.installed.title": "تم تثبيت CLI",
"desktop.cli.installed.message": "تم تثبيت CLI في {{path}}\n\nأعد تشغيل الطرفية لاستخدام الأمر 'opencode'.",
"desktop.cli.failed.title": "فشل التثبيت",
"desktop.cli.failed.message": "فشل تثبيت CLI: {{error}}",
}
+6
View File
@@ -1,5 +1,6 @@
export const dict = {
"desktop.menu.checkForUpdates": "Verificar atualizações...",
"desktop.menu.installCli": "Instalar CLI...",
"desktop.menu.reloadWebview": "Recarregar Webview",
"desktop.menu.restart": "Reiniciar",
@@ -18,4 +19,9 @@ export const dict = {
"A versão {{version}} do OpenCode foi baixada. Você gostaria de instalá-la e reiniciar?",
"desktop.updater.installFailed.title": "Falha na atualização",
"desktop.updater.installFailed.message": "Falha ao instalar a atualização",
"desktop.cli.installed.title": "CLI instalada",
"desktop.cli.installed.message": "CLI instalada em {{path}}\n\nReinicie seu terminal para usar o comando 'opencode'.",
"desktop.cli.failed.title": "Falha na instalação",
"desktop.cli.failed.message": "Falha ao instalar a CLI: {{error}}",
}
+7
View File
@@ -1,5 +1,6 @@
export const dict = {
"desktop.menu.checkForUpdates": "Provjeri ažuriranja...",
"desktop.menu.installCli": "Instaliraj CLI...",
"desktop.menu.reloadWebview": "Ponovo učitavanje webview-a",
"desktop.menu.restart": "Restartuj",
@@ -18,4 +19,10 @@ export const dict = {
"Verzija {{version}} OpenCode-a je preuzeta. Želiš li da je instaliraš i ponovo pokreneš aplikaciju?",
"desktop.updater.installFailed.title": "Ažuriranje nije uspjelo",
"desktop.updater.installFailed.message": "Neuspjela instalacija ažuriranja",
"desktop.cli.installed.title": "CLI instaliran",
"desktop.cli.installed.message":
"CLI je instaliran u {{path}}\n\nRestartuj terminal da bi koristio komandu 'opencode'.",
"desktop.cli.failed.title": "Instalacija nije uspjela",
"desktop.cli.failed.message": "Neuspjela instalacija CLI-a: {{error}}",
}
+7
View File
@@ -1,5 +1,6 @@
export const dict = {
"desktop.menu.checkForUpdates": "Tjek for opdateringer...",
"desktop.menu.installCli": "Installer CLI...",
"desktop.menu.reloadWebview": "Genindlæs Webview",
"desktop.menu.restart": "Genstart",
@@ -18,4 +19,10 @@ export const dict = {
"Version {{version}} af OpenCode er blevet downloadet. Vil du installere den og genstarte?",
"desktop.updater.installFailed.title": "Opdatering mislykkedes",
"desktop.updater.installFailed.message": "Kunne ikke installere opdateringen",
"desktop.cli.installed.title": "CLI installeret",
"desktop.cli.installed.message":
"CLI installeret i {{path}}\n\nGenstart din terminal for at bruge 'opencode'-kommandoen.",
"desktop.cli.failed.title": "Installation mislykkedes",
"desktop.cli.failed.message": "Kunne ikke installere CLI: {{error}}",
}
+7
View File
@@ -1,5 +1,6 @@
export const dict = {
"desktop.menu.checkForUpdates": "Nach Updates suchen...",
"desktop.menu.installCli": "CLI installieren...",
"desktop.menu.reloadWebview": "Webview neu laden",
"desktop.menu.restart": "Neustart",
@@ -18,4 +19,10 @@ export const dict = {
"Version {{version}} von OpenCode wurde heruntergeladen. Möchten Sie sie installieren und neu starten?",
"desktop.updater.installFailed.title": "Update fehlgeschlagen",
"desktop.updater.installFailed.message": "Update konnte nicht installiert werden",
"desktop.cli.installed.title": "CLI installiert",
"desktop.cli.installed.message":
"CLI wurde in {{path}} installiert\n\nStarten Sie Ihr Terminal neu, um den Befehl 'opencode' zu verwenden.",
"desktop.cli.failed.title": "Installation fehlgeschlagen",
"desktop.cli.failed.message": "CLI konnte nicht installiert werden: {{error}}",
}
+6
View File
@@ -1,5 +1,6 @@
export const dict = {
"desktop.menu.checkForUpdates": "Check for Updates...",
"desktop.menu.installCli": "Install CLI...",
"desktop.menu.reloadWebview": "Reload Webview",
"desktop.menu.restart": "Restart",
@@ -18,4 +19,9 @@ export const dict = {
"Version {{version}} of OpenCode has been downloaded, would you like to install it and relaunch?",
"desktop.updater.installFailed.title": "Update Failed",
"desktop.updater.installFailed.message": "Failed to install update",
"desktop.cli.installed.title": "CLI Installed",
"desktop.cli.installed.message": "CLI installed to {{path}}\n\nRestart your terminal to use the 'opencode' command.",
"desktop.cli.failed.title": "Installation Failed",
"desktop.cli.failed.message": "Failed to install CLI: {{error}}",
}
+6
View File
@@ -1,5 +1,6 @@
export const dict = {
"desktop.menu.checkForUpdates": "Buscar actualizaciones...",
"desktop.menu.installCli": "Instalar CLI...",
"desktop.menu.reloadWebview": "Recargar Webview",
"desktop.menu.restart": "Reiniciar",
@@ -18,4 +19,9 @@ export const dict = {
"Se ha descargado la versión {{version}} de OpenCode. ¿Quieres instalarla y reiniciar?",
"desktop.updater.installFailed.title": "Actualización fallida",
"desktop.updater.installFailed.message": "No se pudo instalar la actualización",
"desktop.cli.installed.title": "CLI instalada",
"desktop.cli.installed.message": "CLI instalada en {{path}}\n\nReinicia tu terminal para usar el comando 'opencode'.",
"desktop.cli.failed.title": "Instalación fallida",
"desktop.cli.failed.message": "No se pudo instalar la CLI: {{error}}",
}
+7
View File
@@ -1,5 +1,6 @@
export const dict = {
"desktop.menu.checkForUpdates": "Vérifier les mises à jour...",
"desktop.menu.installCli": "Installer la CLI...",
"desktop.menu.reloadWebview": "Recharger la Webview",
"desktop.menu.restart": "Redémarrer",
@@ -18,4 +19,10 @@ export const dict = {
"La version {{version}} d'OpenCode a été téléchargée. Voulez-vous l'installer et redémarrer ?",
"desktop.updater.installFailed.title": "Échec de la mise à jour",
"desktop.updater.installFailed.message": "Impossible d'installer la mise à jour",
"desktop.cli.installed.title": "CLI installée",
"desktop.cli.installed.message":
"CLI installée dans {{path}}\n\nRedémarrez votre terminal pour utiliser la commande 'opencode'.",
"desktop.cli.failed.title": "Échec de l'installation",
"desktop.cli.failed.message": "Impossible d'installer la CLI : {{error}}",
}
+7
View File
@@ -1,5 +1,6 @@
export const dict = {
"desktop.menu.checkForUpdates": "アップデートを確認...",
"desktop.menu.installCli": "CLI をインストール...",
"desktop.menu.reloadWebview": "Webview を再読み込み",
"desktop.menu.restart": "再起動",
@@ -18,4 +19,10 @@ export const dict = {
"OpenCode のバージョン {{version}} がダウンロードされました。インストールして再起動しますか?",
"desktop.updater.installFailed.title": "アップデートに失敗しました",
"desktop.updater.installFailed.message": "アップデートをインストールできませんでした",
"desktop.cli.installed.title": "CLI をインストールしました",
"desktop.cli.installed.message":
"CLI を {{path}} にインストールしました\n\nターミナルを再起動して 'opencode' コマンドを使用してください。",
"desktop.cli.failed.title": "インストールに失敗しました",
"desktop.cli.failed.message": "CLI のインストールに失敗しました: {{error}}",
}
+7
View File
@@ -1,5 +1,6 @@
export const dict = {
"desktop.menu.checkForUpdates": "업데이트 확인...",
"desktop.menu.installCli": "CLI 설치...",
"desktop.menu.reloadWebview": "Webview 새로고침",
"desktop.menu.restart": "다시 시작",
@@ -17,4 +18,10 @@ export const dict = {
"desktop.updater.downloaded.prompt": "OpenCode {{version}} 버전을 다운로드했습니다. 설치하고 다시 실행할까요?",
"desktop.updater.installFailed.title": "업데이트 실패",
"desktop.updater.installFailed.message": "업데이트를 설치하지 못했습니다",
"desktop.cli.installed.title": "CLI 설치됨",
"desktop.cli.installed.message":
"CLI가 {{path}}에 설치되었습니다\n\n터미널을 다시 시작하여 'opencode' 명령을 사용하세요.",
"desktop.cli.failed.title": "설치 실패",
"desktop.cli.failed.message": "CLI 설치 실패: {{error}}",
}
+7
View File
@@ -1,5 +1,6 @@
export const dict = {
"desktop.menu.checkForUpdates": "Se etter oppdateringer...",
"desktop.menu.installCli": "Installer CLI...",
"desktop.menu.reloadWebview": "Last inn Webview på nytt",
"desktop.menu.restart": "Start på nytt",
@@ -18,4 +19,10 @@ export const dict = {
"Versjon {{version}} av OpenCode er lastet ned. Vil du installere den og starte på nytt?",
"desktop.updater.installFailed.title": "Oppdatering mislyktes",
"desktop.updater.installFailed.message": "Kunne ikke installere oppdateringen",
"desktop.cli.installed.title": "CLI installert",
"desktop.cli.installed.message":
"CLI installert til {{path}}\n\nStart terminalen på nytt for å bruke 'opencode'-kommandoen.",
"desktop.cli.failed.title": "Installasjon mislyktes",
"desktop.cli.failed.message": "Kunne ikke installere CLI: {{error}}",
}
+7
View File
@@ -1,5 +1,6 @@
export const dict = {
"desktop.menu.checkForUpdates": "Sprawdź aktualizacje...",
"desktop.menu.installCli": "Zainstaluj CLI...",
"desktop.menu.reloadWebview": "Przeładuj Webview",
"desktop.menu.restart": "Restartuj",
@@ -18,4 +19,10 @@ export const dict = {
"Pobrano wersję {{version}} OpenCode. Czy chcesz ją zainstalować i uruchomić ponownie?",
"desktop.updater.installFailed.title": "Aktualizacja nie powiodła się",
"desktop.updater.installFailed.message": "Nie udało się zainstalować aktualizacji",
"desktop.cli.installed.title": "CLI zainstalowane",
"desktop.cli.installed.message":
"CLI zainstalowane w {{path}}\n\nUruchom ponownie terminal, aby użyć polecenia 'opencode'.",
"desktop.cli.failed.title": "Instalacja nie powiodła się",
"desktop.cli.failed.message": "Nie udało się zainstalować CLI: {{error}}",
}
+7
View File
@@ -1,5 +1,6 @@
export const dict = {
"desktop.menu.checkForUpdates": "Проверить обновления...",
"desktop.menu.installCli": "Установить CLI...",
"desktop.menu.reloadWebview": "Перезагрузить Webview",
"desktop.menu.restart": "Перезапустить",
@@ -17,4 +18,10 @@ export const dict = {
"desktop.updater.downloaded.prompt": "Версия OpenCode {{version}} загружена. Хотите установить и перезапустить?",
"desktop.updater.installFailed.title": "Обновление не удалось",
"desktop.updater.installFailed.message": "Не удалось установить обновление",
"desktop.cli.installed.title": "CLI установлен",
"desktop.cli.installed.message":
"CLI установлен в {{path}}\n\nПерезапустите терминал, чтобы использовать команду 'opencode'.",
"desktop.cli.failed.title": "Ошибка установки",
"desktop.cli.failed.message": "Не удалось установить CLI: {{error}}",
}
+7
View File
@@ -1,5 +1,6 @@
export const dict = {
"desktop.menu.checkForUpdates": "Перевірити оновлення...",
"desktop.menu.installCli": "Встановити CLI...",
"desktop.menu.reloadWebview": "Перезавантажити Webview",
"desktop.menu.restart": "Перезапустити",
@@ -18,4 +19,10 @@ export const dict = {
"Версію {{version}} OpenCode завантажено. Бажаєте встановити її та перезапустити?",
"desktop.updater.installFailed.title": "Помилка оновлення",
"desktop.updater.installFailed.message": "Не вдалося встановити оновлення",
"desktop.cli.installed.title": "CLI встановлено",
"desktop.cli.installed.message":
"CLI встановлено до {{path}}\n\nПерезапустіть термінал, щоб використовувати команду 'opencode'.",
"desktop.cli.failed.title": "Не вдалося встановити",
"desktop.cli.failed.message": "Не вдалося встановити CLI: {{error}}",
}
+6
View File
@@ -1,5 +1,6 @@
export const dict = {
"desktop.menu.checkForUpdates": "检查更新...",
"desktop.menu.installCli": "安装 CLI...",
"desktop.menu.reloadWebview": "重新加载 Webview",
"desktop.menu.restart": "重启",
@@ -17,4 +18,9 @@ export const dict = {
"desktop.updater.downloaded.prompt": "已下载 OpenCode {{version}} 版本,是否安装并重启?",
"desktop.updater.installFailed.title": "更新失败",
"desktop.updater.installFailed.message": "无法安装更新",
"desktop.cli.installed.title": "CLI 已安装",
"desktop.cli.installed.message": "CLI 已安装到 {{path}}\n\n重启终端以使用 'opencode' 命令。",
"desktop.cli.failed.title": "安装失败",
"desktop.cli.failed.message": "无法安装 CLI: {{error}}",
}
@@ -1,5 +1,6 @@
export const dict = {
"desktop.menu.checkForUpdates": "檢查更新...",
"desktop.menu.installCli": "安裝 CLI...",
"desktop.menu.reloadWebview": "重新載入 Webview",
"desktop.menu.restart": "重新啟動",
@@ -17,4 +18,9 @@ export const dict = {
"desktop.updater.downloaded.prompt": "已下載 OpenCode {{version}} 版本,是否安裝並重新啟動?",
"desktop.updater.installFailed.title": "更新失敗",
"desktop.updater.installFailed.message": "無法安裝更新",
"desktop.cli.installed.title": "CLI 已安裝",
"desktop.cli.installed.message": "CLI 已安裝到 {{path}}\n\n重新啟動終端機以使用 'opencode' 命令。",
"desktop.cli.failed.title": "安裝失敗",
"desktop.cli.failed.message": "無法安裝 CLI: {{error}}",
}
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.8",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"typecheck": "tsgo --noEmit"
},
"exports": {
".": "./src/index.ts"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:"
},
"dependencies": {
"effect": "catalog:"
}
}
+171
View File
@@ -0,0 +1,171 @@
export * as NodeSqliteClient from "./index"
import { DatabaseSync, type SQLInputValue } from "node:sqlite"
import { identity } from "effect/Function"
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import * as Fiber from "effect/Fiber"
import * as Layer from "effect/Layer"
import * as Scope from "effect/Scope"
import * as Semaphore from "effect/Semaphore"
import * as Stream from "effect/Stream"
import * as Reactivity from "effect/unstable/reactivity/Reactivity"
import * as Client from "effect/unstable/sql/SqlClient"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import * as Statement from "effect/unstable/sql/Statement"
const ATTR_DB_SYSTEM_NAME = "db.system.name"
export const TypeId: TypeId = "~@opencode-ai/effect-sqlite-node/NodeSqliteClient"
export type TypeId = "~@opencode-ai/effect-sqlite-node/NodeSqliteClient"
export interface SqliteClient extends Client.SqlClient {
readonly [TypeId]: TypeId
readonly config: SqliteClientConfig
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
readonly updateValues: never
}
export const SqliteClient = Context.Service<SqliteClient>("@opencode-ai/effect-sqlite-node/NodeSqliteClient")
export interface SqliteClientConfig {
readonly filename: string
readonly readonly?: boolean | undefined
readonly create?: boolean | undefined
readonly readwrite?: boolean | undefined
readonly disableWAL?: boolean | undefined
readonly timeout?: number | undefined
readonly allowExtension?: boolean | undefined
readonly spanAttributes?: Record<string, unknown> | undefined
readonly transformResultNames?: ((str: string) => string) | undefined
readonly transformQueryNames?: ((str: string) => string) | undefined
}
interface SqliteConnection extends Connection {
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
}
export const make = (
options: SqliteClientConfig,
): Effect.Effect<SqliteClient, never, Scope.Scope | Reactivity.Reactivity> =>
Effect.gen(function* () {
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
const transformRows = options.transformResultNames
? Statement.defaultTransforms(options.transformResultNames).array
: undefined
const makeConnection = Effect.gen(function* () {
const db = new DatabaseSync(options.filename, {
readOnly: options.readonly,
timeout: options.timeout,
allowExtension: options.allowExtension,
enableForeignKeyConstraints: true,
open: true,
})
yield* Effect.addFinalizer(() => Effect.sync(() => db.close()))
if (options.disableWAL !== true && options.readonly !== true) {
db.exec("PRAGMA journal_mode = WAL;")
}
const run = (sql: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
const statement = db.prepare(sql)
statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers))
try {
return Effect.succeed(statement.all(...(params as SQLInputValue[])) as Array<Record<string, unknown>>)
} catch (cause) {
return Effect.fail(
new SqlError({
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
}),
)
}
})
const runValues = (sql: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<ReadonlyArray<ReadonlyArray<unknown>>, SqlError>((fiber) => {
const statement = db.prepare(sql)
statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers))
statement.setReturnArrays(true)
try {
return Effect.succeed(
statement.all(...(params as SQLInputValue[])) as unknown as ReadonlyArray<ReadonlyArray<unknown>>,
)
} catch (cause) {
return Effect.fail(
new SqlError({
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
}),
)
}
})
return identity<SqliteConnection>({
execute(sql, params, transformRows) {
return transformRows ? Effect.map(run(sql, params), transformRows) : run(sql, params)
},
executeRaw(sql, params) {
return run(sql, params)
},
executeValues(sql, params) {
return runValues(sql, params)
},
executeValuesUnprepared(sql, params) {
return runValues(sql, params)
},
executeUnprepared(sql, params, transformRows) {
return this.execute(sql, params, transformRows)
},
executeStream() {
return Stream.die("executeStream not implemented")
},
loadExtension: (path) =>
Effect.try({
try: () => db.loadExtension(path),
catch: (cause) =>
new SqlError({
reason: classifySqliteError(cause, { message: "Failed to load extension", operation: "loadExtension" }),
}),
}),
})
})
const semaphore = yield* Semaphore.make(1)
const connection = yield* makeConnection
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
const fiber = Fiber.getCurrent()!
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
return Effect.as(
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
connection,
)
})
return Object.assign(
(yield* Client.make({
acquirer,
compiler,
transactionAcquirer,
spanAttributes: [
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
[ATTR_DB_SYSTEM_NAME, "sqlite"],
],
transformRows,
})) as SqliteClient,
{
[TypeId]: TypeId as TypeId,
config: options,
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
},
)
})
export const layer = (config: SqliteClientConfig): Layer.Layer<SqliteClient | Client.SqlClient> =>
Layer.effectContext(
Effect.map(make(config), (client) =>
Context.make(SqliteClient, client).pipe(Context.add(Client.SqlClient, client)),
),
).pipe(Layer.provide(Reactivity.layer))
+10
View File
@@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false,
"plugins": [
{
"name": "@effect/language-service",
"transform": "@effect/language-service/transform",
"namespaceImportPackages": ["effect", "@effect/*"]
}
]
}
}
+2 -2
View File
@@ -207,7 +207,7 @@ it.live(
() =>
withEmbedded("opencode-embedded-", (fixture) =>
Effect.gen(function* () {
const opencode = yield* fixture.sdk.OpenCode.create({ events: { persist: true } })
const opencode = yield* fixture.sdk.OpenCode.create()
const id = sessionID(fixture)
const model = fixture.sdk.Model.Ref.make({
id: fixture.sdk.Model.ID.make("embedded"),
@@ -266,7 +266,7 @@ it.live(
const wakeContext = yield* opencode.sessions.context({ sessionID: id })
const pendingAfterPromote = yield* opencode.sessions.pending.list({ sessionID: id })
const event = yield* opencode.sessions.log({ sessionID: id }).pipe(
Stream.filter((item) => item.type === "session.model.selected"),
Stream.filter((item) => item.type !== "log.synced"),
Stream.take(1),
Stream.runHead,
Effect.map(Option.getOrUndefined),
-5
View File
@@ -18,11 +18,6 @@ export const ServerOptions = Schema.Struct({
password: Schema.optional(Schema.String),
simulation: Schema.optional(Schema.Boolean),
database: Schema.optional(Database.Options),
events: Schema.optional(
Schema.Struct({
persist: Schema.optional(Schema.Boolean),
}),
),
models: Schema.optional(ModelsDev.Options),
observability: Schema.optional(Observability.Options),
config: Schema.optional(
-1
View File
@@ -83,7 +83,6 @@ function makeRoutes<AuthError, AuthServices>(
const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [
[Database.node, Database.configured(options.database)],
[Bus.node, Bus.configured({ persist: options.events?.persist })],
[App.node, App.configured(options.app)],
[ModelsDev.node, ModelsDev.configured(options.models)],
[Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })],
-4
View File
@@ -18,7 +18,3 @@ test("accepts optional app metadata", () => {
Option.getOrThrow(decode({ app: { name: "sdk", version: "1.2.3", channel: "beta" } })).app,
).toEqual({ name: "sdk", version: "1.2.3", channel: "beta" })
})
test("accepts durable event persistence configuration", () => {
expect(Option.getOrThrow(decode({ events: { persist: true } })).events).toEqual({ persist: true })
})
+9 -6
View File
@@ -157,9 +157,13 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
// Load lightweight session metadata concurrently so persisted tabs can resolve their project
// labels immediately. Delay the heavier per-tab data so the visible session keeps the first
// connection slots and switches still render from a warm cache.
// Warm open tabs' session data so first switches render from cache instead of fetching inside
// the switch gesture. Uses only existing sync methods (each dedupes internally), so reruns on
// tab-set or connection changes are no-ops for already-warm sessions, and reconnects double as
// a cache refresh after an SSE gap. The delay lets the current session's own mount syncs get
// the first connection slots. The effect tracks only the id set: reorders, tab switches, and
// title updates neither restart the timer nor an in-flight warm pass; the timer callback
// itself runs untracked, where the current session is skipped.
const openTabSessions = createMemo(() =>
state()
.tabs.map((tab) => tab.sessionID)
@@ -169,9 +173,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
createEffect(() => {
if (!enabled()) return
if (client.connection.status() !== "connected") return
const sessionIDs = openTabSessions()
if (sessionIDs === "") return
void Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID)))
if (openTabSessions() === "") return
let stale = false
const timer = setTimeout(async () => {
const sessions = state()
@@ -180,6 +182,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
for (const sessionID of sessions) {
if (stale) return
await Promise.allSettled([
data.session.sync(sessionID),
data.session.message.sync(sessionID),
data.session.pending.sync(sessionID),
data.session.permission.sync(sessionID),
@@ -2,7 +2,7 @@
import { afterAll, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { mkdirSync, mkdtempSync, readdirSync, rmSync, watch } from "fs"
import { mkdtempSync, readdirSync, rmSync, watch } from "fs"
import { tmpdir } from "os"
import path from "path"
import { ConfigProvider } from "../../src/config"
@@ -49,33 +49,15 @@ function stateDir(prefix: string) {
return dir
}
async function renderSessionTabs(
initialSessionID: string,
options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise<void> },
) {
async function renderSessionTabs(initialSessionID: string, options?: { state?: string; title?: string }) {
const state = options?.state ?? stateDir("opencode-session-tabs-")
if (options?.persisted) {
const file = path.join(state, "test", "tui", "tabs.json")
mkdirSync(path.dirname(file), { recursive: true })
await Bun.write(
file,
JSON.stringify({
global: { tabs: options.persisted.map((sessionID) => ({ sessionID })), unread: {} },
cwd: {},
}),
)
}
const events = createEventStream()
const sessions: string[] = []
const calls = createFetch(async (url) => {
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
if (!sessionID) return undefined
sessions.push(sessionID)
await options?.sessionGate
const calls = createFetch((url) => {
if (url.pathname !== `/api/session/${initialSessionID}`) return
return json({
data: {
id: sessionID,
title: sessionID === initialSessionID ? options?.title : undefined,
id: initialSessionID,
title: options?.title,
projectID: "project",
location: { directory },
cost: 0,
@@ -102,9 +84,7 @@ async function renderSessionTabs(
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<RouteProvider
initialRoute={options?.home ? { type: "home" } : { type: "session", sessionID: initialSessionID }}
>
<RouteProvider initialRoute={{ type: "session", sessionID: initialSessionID }}>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<SessionTabsProvider>
@@ -124,7 +104,6 @@ async function renderSessionTabs(
tabs,
route,
data,
sessions,
state,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
destroy() {
@@ -133,26 +112,6 @@ async function renderSessionTabs(
}
}
test("loads persisted tab metadata concurrently on connect", async () => {
let release!: () => void
const sessionGate = new Promise<void>((resolve) => (release = resolve))
const setup = await renderSessionTabs("first", {
home: true,
persisted: ["first", "second"],
sessionGate,
})
try {
await wait(() => setup.sessions.length === 2)
expect(setup.sessions.toSorted()).toEqual(["first", "second"])
release()
await wait(() => setup.data.session.get("first") !== undefined && setup.data.session.get("second") !== undefined)
} finally {
release()
setup.destroy()
}
})
test("stores session tabs globally by default", async () => {
const setup = await renderSessionTabs("first")