Compare commits

..

12 Commits

Author SHA1 Message Date
Kit Langton e896da02bc test: stabilize Windows integration coverage 2026-08-12 16:18:01 +00:00
Kit Langton 930b0751b1 fix(core): generate session titles before model execution (#42067) 2026-08-12 12:08:24 -04:00
Matt Robinson f06a86eeac feat(client): support service version ranges (#42023)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-08-12 09:01:37 -07:00
Kit Langton 653b7d79cd fix(tui): restore navigation keybind defaults (#42066) 2026-08-12 11:57:39 -04:00
Kit Langton 70ce0d0970 feat(tui): jump between open menu sections (#42061) 2026-08-12 15:52:51 +00:00
opencode-agent[bot] 0777e84598 fix(tui): fill image message background (#42062)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-08-12 15:51:19 +00:00
Kit Langton bef795b2fe fix(tui): smooth session tab marquees (#42055) 2026-08-12 15:49:17 +00:00
Kit Langton 70853b1e5b feat(tui): surface plugin failures (#41940) 2026-08-12 10:51:00 -04:00
opencode-agent[bot] 1fea1c2ebc fix(core): route Muse models to Meta prompt (#42036)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 09:31:59 -05:00
opencode-agent[bot] 1da591b84d fix(core): return content-only Code Mode results (#41954)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 09:14:41 -05:00
opencode-agent[bot] b990f9a5c1 chore: generate 2026-08-12 13:32:36 +00:00
Dax Raad 9769e7012c feat(core): configure package publishing 2026-08-12 09:30:34 -04:00
90 changed files with 2183 additions and 1888 deletions
+2 -5
View File
@@ -345,9 +345,6 @@
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.18.4",
"bin": {
"opencode": "./bin/opencode",
},
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
@@ -367,8 +364,6 @@
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@ff-labs/fff-bun": "0.10.1",
"@ff-labs/fff-node": "0.10.1",
"@lydell/node-pty": "catalog:",
@@ -404,6 +399,8 @@
"zod": "catalog:",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@opencode-ai/http-recorder": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
@@ -15,6 +15,8 @@ export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
const requestedDirectory = Option.getOrUndefined(input.directory)
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.forkScoped)
const preflight = UpdatePreflight.make()
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
const server = yield* ServerConnection.resolve({
@@ -34,8 +36,6 @@ export default Runtime.handler(Commands, (input) =>
Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")),
),
)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
-10
View File
@@ -358,15 +358,6 @@ export type Endpoint5_31Output =
readonly previous?: Model.Ref | undefined
}
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.move.admitted"
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 move: SessionPending.MoveData }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
@@ -376,7 +367,6 @@ export type Endpoint5_31Output =
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly moveID?: Event.ID | undefined
readonly location: Location.Ref
readonly projectID?: Project.ID | undefined
readonly subpath?: RelativePath | undefined
+4 -3
View File
@@ -10,6 +10,7 @@ import {
spawnServiceContender,
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
export * from "../service.js"
/** Contents of the local service registration file. */
@@ -37,14 +38,14 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
const info = yield* read(options.file)
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
if (found === undefined || found.legacy) return undefined
if (options.version !== undefined && found.version !== options.version) return undefined
if (!matchesVersion(found.version, options)) return undefined
return { endpoint: found.endpoint, state: found.state }
})
const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
const found = (yield* registered(options.file)).service
if (found?.state !== "ready") return undefined
if (options.version !== undefined && found.version !== options.version) return undefined
if (!matchesVersion(found.version, options)) return undefined
return found
})
@@ -93,7 +94,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
} else timeouts = undefined
if (service !== undefined) {
spawnDelay = timing.spawnDelay
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
const compatible = !service.legacy && matchesVersion(service.version, options)
if (compatible && service.state === "ready") return Option.some(service)
if (compatible && service.state === "failed")
return yield* Effect.fail(new Error("Background service failed to start"))
+2 -28
View File
@@ -420,8 +420,6 @@ export type SessionMessageLocationSwitched = {
previous?: { location: LocationRef; projectID?: string; subpath?: string }
}
export type SessionPendingMoveData = { location: LocationRef; projectID: string; subpath?: string }
export type SessionCreated = {
id: string
created: number
@@ -470,7 +468,7 @@ export type SessionMoved = {
type: "session.moved"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; moveID?: string; location: LocationRef; projectID?: string; subpath?: string }
data: { sessionID: string; location: LocationRef; projectID?: string; subpath?: string }
}
export type SessionRenamed = {
@@ -1529,24 +1527,6 @@ export type VcsInfo = { branch: VcsBranch }
export type PermissionRuleset = Array<PermissionRule>
export type SessionPendingMove = {
id: string
sessionID: string
timeCreated: number
type: "move"
data: SessionPendingMoveData
}
export type SessionMoveAdmitted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.move.admitted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; move: SessionPendingMoveData }
}
export type SessionInfo = {
id: string
parentID?: string
@@ -1934,11 +1914,7 @@ export type FormFields = [FormField, ...Array<FormField>]
export type FormFields3 = [FormField1, ...Array<FormField1>]
export type SessionPendingInfo =
| SessionPendingUser
| SessionPendingSynthetic
| SessionPendingCompaction
| SessionPendingMove
export type SessionPendingInfo = SessionPendingUser | SessionPendingSynthetic | SessionPendingCompaction
export type SessionPendingMessage = SessionPendingUserMessage | SessionPendingSyntheticMessage
@@ -2007,7 +1983,6 @@ export type SessionEventDurable =
| SessionCreated
| SessionAgentSelected
| SessionModelSelected
| SessionMoveAdmitted
| SessionMoved
| SessionRenamed
| SessionDeleted
@@ -2071,7 +2046,6 @@ export type V2Event =
| SessionCreated
| SessionAgentSelected
| SessionModelSelected
| SessionMoveAdmitted
| SessionMoved
| SessionRenamed
| SessionUsageUpdated
+3 -2
View File
@@ -9,6 +9,7 @@ import {
spawnServiceContender,
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
export * from "../service.js"
@@ -27,7 +28,7 @@ export async function discover(options: DiscoverOptions = {}) {
async function discoverLocal(options: DiscoverOptions) {
const found = (await registered(options.file)).service
if (found?.state !== "ready") return undefined
if (options.version !== undefined && found.version !== options.version) return undefined
if (!matchesVersion(found.version, options)) return undefined
return found
}
@@ -76,7 +77,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (registration.service !== undefined) {
spawnDelay = timing.spawnDelay
const service = registration.service
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
const compatible = !service.legacy && matchesVersion(service.version, options)
if (compatible && service.state === "ready") return service.endpoint
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
+8
View File
@@ -0,0 +1,8 @@
import type { DiscoverOptions } from "./service.js"
export function matchesVersion(version: string | undefined, options: DiscoverOptions) {
if (options.version === undefined) return true
if (version === undefined) return false
if (typeof options.version === "function") return options.version(version)
return version === options.version
}
+2 -2
View File
@@ -17,8 +17,8 @@ export type Endpoint = {
export type DiscoverOptions = {
/** Absolute registration file path. Defaults to the XDG state directory. */
readonly file?: string
/** Required service version. */
readonly version?: string
/** Required exact service version or compatibility predicate. */
readonly version?: string | ((version: string) => boolean)
}
/** Reason ensuring the service requires a new process. */
+15 -1
View File
@@ -1,6 +1,7 @@
import { Effect } from "effect"
import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect"
import type { Session } from "@opencode-ai/schema/session"
import type { DiscoverOptions } from "../src/service"
type EffectClient = Effect.Success<ReturnType<typeof EffectOpenCode.make>>
type PromiseClient = ReturnType<typeof import("../src/promise").OpenCode.make>
@@ -8,6 +9,9 @@ type PromiseClient = ReturnType<typeof import("../src/promise").OpenCode.make>
declare const effectClient: EffectClient
declare const promiseClient: PromiseClient
const exactVersion: DiscoverOptions = { version: "2.0.0" }
const compatibleVersion: DiscoverOptions = { version: (version) => version.startsWith("2.") }
const effectApi: EffectApi<unknown> = effectClient
const effectSession: Effect.Effect<Session.Info, unknown> = effectClient.session.get({
@@ -42,4 +46,14 @@ const promiseRemove: Promise<void> = promiseClient.session.instructions.entry.re
key: "review-notes",
})
void [effectSession, effectList, effectPut, effectRemove, promiseList, promisePut, promiseRemove]
void [
effectSession,
effectList,
effectPut,
effectRemove,
promiseList,
promisePut,
promiseRemove,
exactVersion,
compatibleVersion,
]
+4 -1
View File
@@ -27,7 +27,10 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
}
let requests = 0
const version = mode === "old" || mode === "reject-stop" ? "old" : "test"
let version = "test"
if (mode === "old" || mode === "reject-stop") version = "old"
if (mode === "incompatible") version = "1.9.0"
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
const id = crypto.randomUUID()
const server = Bun.serve({
port: 0,
@@ -25,6 +25,19 @@ test("discovers a registered service", async () => {
expect(await Service.discover({ file: registration, version: "other" })).toBeUndefined()
})
test("discovers a compatible registered service", async () => {
const registration = await setup("compatible")
expect(await Service.discover({ file: registration, version: "2.1.0" })).toBeUndefined()
expect(await Service.discover({ file: registration, version: "2.1.0-next.1" })).toEqual(
expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
)
expect(await Service.discover({ file: registration, version: (version) => version.startsWith("2.") })).toEqual(
expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
)
expect(await Service.discover({ file: registration, version: (version) => version.startsWith("3.") })).toBeUndefined()
})
test("ensures a missing service with native promises", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+46
View File
@@ -47,6 +47,52 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
})
test("reuses a compatible registered service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "compatible")
await waitForFile(registration)
const starts: EnsureReason[] = []
const endpoint = await run(
ensure({
file: registration,
version: (version) => version.startsWith("2."),
command: [],
onStart: (reason) => starts.push(reason),
}),
)
expect(endpoint.url).toBe((await Bun.file(registration).json()).url)
expect(starts).toEqual([])
expect(existing.exitCode).toBe(null)
})
test("replaces an incompatible registered service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "incompatible")
await waitForFile(registration)
const starts: EnsureReason[] = []
const endpoint = await run(
ensure({
file: registration,
version: (version) => version.startsWith("2."),
command: [process.execPath, fixture, registration, "delayed-compatible", "10"],
onStart: (reason) => starts.push(reason),
}),
)
const replacement = await Bun.file(registration).json()
expect(await existing.exited).toBe(0)
expect(replacement.version).toBe("2.1.0-next.1")
expect(endpoint.url).toBe(replacement.url)
expect(starts).toEqual(["version-mismatch"])
process.kill(replacement.pid, "SIGTERM")
await waitForExit(replacement.pid)
})
test("waits for a registered service to finish starting", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+3
View File
@@ -0,0 +1,3 @@
# @opencode-ai/core
Core runtime services for OpenCode.
+21 -13
View File
@@ -4,24 +4,28 @@
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
"private": true,
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/core"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"scripts": {
"db": "bun drizzle-kit",
"migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"benchmark:location": "bun run script/benchmark-location.ts",
"build": "bun run script/build.ts",
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
"test": "bun test --only-failures",
"typecheck": "tsgo -b tsconfig.json tsconfig.tests.json"
},
"bin": {
"opencode": "./bin/opencode"
},
"exports": {
"./environment": "./src/environment/index.ts",
"./testing/environment-conformance": "./test/lib/environment-conformance.ts",
"./session/runner": "./src/session/runner/index.ts",
"./instructions": "./src/instructions/index.ts",
"./*": "./src/*.ts"
},
"imports": {
@@ -55,9 +59,18 @@
"bun": "./src/util/process-lock-ffi.bun.ts",
"node": "./src/util/process-lock-ffi.node.ts",
"default": "./src/util/process-lock-ffi.bun.ts"
},
"#v1-migration": {
"types": "./src/database/v1-migration.bun.ts",
"bun": "./src/database/v1-migration.bun.ts",
"node": "./src/database/v1-migration.noop.ts",
"workerd": "./src/database/v1-migration.noop.ts",
"default": "./src/database/v1-migration.noop.ts"
}
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
@@ -93,8 +106,6 @@
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@ff-labs/fff-bun": "0.10.1",
@@ -128,8 +139,5 @@
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"zod": "catalog:"
},
"overrides": {
"drizzle-orm": "catalog:"
}
}
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { rm } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await rm("dist", { recursive: true, force: true })
await $`bun tsc -p tsconfig.build.json`
const root = path.resolve("src")
const files = await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: root, absolute: true }))
const result = await Bun.build({
entrypoints: files.filter((file) => !file.endsWith(".d.ts")),
root,
outdir: "dist",
target: "node",
format: "esm",
packages: "external",
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#shell-parser-wasm", "#process-lock-ffi", "#v1-migration"],
splitting: true,
loader: {
".txt": "text",
".md": "text",
},
naming: {
entry: "[dir]/[name].[ext]",
chunk: "chunks/[name]-[hash].[ext]",
asset: "assets/[name]-[hash].[ext]",
},
})
if (!result.success) throw new AggregateError(result.logs, "Failed to build Core")
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
imports: Record<string, Record<string, string>>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
const output = (value: string, types = false) =>
value.replace("./src/", types ? "./dist/types/" : "./dist/").replace(/\.ts$/, types ? ".d.ts" : ".js")
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
try {
await $`bun run typecheck`
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string") return [key, value]
return [key, { import: output(value), types: output(value, true) }]
}),
)
pkg.imports = Object.fromEntries(
Object.entries(pkg.imports).map(([key, conditions]) => [
key,
Object.fromEntries(
Object.entries(conditions).map(([condition, value]) => [condition, output(value, condition === "types")]),
),
]),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
}
+4 -2
View File
@@ -75,7 +75,9 @@ export const create = (
const outputFileParts = outputFiles(content)
if (outputFileParts.length > 0)
yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
return executed.output
if (executed.output !== undefined) return executed.output
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return text === "" ? null : text
}),
{
onToolCallStart: ({ index, name, input }) => {
@@ -155,7 +157,7 @@ function runtime(
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
output: child.outputSchema,
output: child.outputSchema ?? Schema.NullOr(Schema.String),
execute: (input) => executeTool(name, registration, input),
})
}
+1 -11
View File
@@ -1,5 +1,4 @@
import { Database, type SQLQueryBindings } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Context, Effect, Layer } from "effect"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient } from "effect/unstable/sql"
@@ -98,16 +97,7 @@ const nativeLayer = (config: Config) =>
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
Effect.gen(function* () {
return drizzle({ client: (yield* Sqlite.Native) as Database })
}),
)
export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe(Layer.provide(Reactivity.layer))
}
+1 -11
View File
@@ -1,5 +1,4 @@
import { DatabaseSync, type SQLInputValue } from "node:sqlite"
import { drizzle } from "drizzle-orm/node-sqlite"
import { Context, Effect, Layer } from "effect"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient } from "effect/unstable/sql"
@@ -95,16 +94,7 @@ const nativeLayer = (config: Config) =>
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
Effect.gen(function* () {
return drizzle({ client: (yield* Sqlite.Native) as DatabaseSync }) as unknown as Sqlite.DrizzleClient
}),
)
export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe(Layer.provide(Reactivity.layer))
}
-3
View File
@@ -5,11 +5,8 @@ import { identity } from "effect/Function"
import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import type { SqlError } from "effect/unstable/sql/SqlError"
import type { drizzle } from "drizzle-orm/bun-sqlite"
export type DrizzleClient = ReturnType<typeof drizzle>
export class Native extends Context.Service<Native, unknown>()("@opencode-ai/core/database/SqliteNative") {}
export class Drizzle extends Context.Service<Drizzle, DrizzleClient>()("@opencode-ai/core/database/SqliteDrizzle") {}
export interface ClientConfig {
readonly spanAttributes?: Record<string, unknown>
+1 -12
View File
@@ -1,4 +1,3 @@
import { drizzle } from "drizzle-orm/durable-sqlite"
import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function"
import { Reactivity } from "effect/unstable/reactivity"
@@ -238,17 +237,7 @@ const nativeLayer = (config: Config) =>
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
Effect.gen(function* () {
const native = (yield* Sqlite.Native) as DurableObjectStorage
return drizzle(native) as unknown as Sqlite.DrizzleClient
}),
)
export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe(Layer.provide(Reactivity.layer))
}
@@ -0,0 +1,997 @@
export * as V1Migration from "./v1-migration.js"
import { Cause, Effect, Layer, Option, Schema, Semaphore } from "effect"
import { Database } from "./database.js"
import { SessionMessageTable, SessionTable } from "../session/sql.js"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
import { SessionMessage } from "../session/message.js"
import { SessionSchema } from "../session/schema.js"
import { KVTable } from "../kv/sql.js"
import { EventSequenceTable } from "../event/sql.js"
import { eq, sql } from "drizzle-orm"
import { Global } from "@opencode-ai/util/global"
import { existsSync } from "node:fs"
import path from "node:path"
import type { Database as SQLiteDatabase } from "bun:sqlite"
import { Project } from "@opencode-ai/schema/project"
export type SourceMessage = {
readonly id: string
readonly session_id: string
readonly time_created: number
readonly time_updated: number
readonly data: string
}
export type SourcePart = {
readonly id: string
readonly message_id: string
readonly session_id: string
readonly time_created: number
readonly time_updated: number
readonly data: string
}
export type TransformInput = {
readonly session: typeof SessionTable.$inferSelect
readonly messages: ReadonlyArray<SourceMessage>
readonly parts: ReadonlyArray<SourcePart>
}
export type Warning = {
readonly reason: string
readonly sessionID: string
readonly messageID?: string
readonly partID?: string
readonly observedType?: string
}
export type TransformResult = {
readonly messages: ReadonlyArray<{
readonly id: string
readonly session_id: string
readonly type: SessionMessage.Type
readonly seq: number
readonly time_created: number
readonly time_updated: number
readonly data: Record<string, unknown>
}>
readonly session: Pick<
typeof SessionTable.$inferInsert,
| "agent"
| "model"
| "cost"
| "tokens_input"
| "tokens_output"
| "tokens_reasoning"
| "tokens_cache_read"
| "tokens_cache_write"
| "revert"
| "time_compacting"
>
readonly watermark: number
readonly warnings: ReadonlyArray<Warning>
}
type Progress = {
readonly label: string
readonly numerator?: number
readonly denominator?: number
}
export type Status =
| { readonly status: "required" | "completed" }
| { readonly status: "running"; readonly progress: Progress }
| { readonly status: "error"; readonly error: string }
type RunResult = {
readonly status: "completed"
}
type Options = {
readonly nextDatabasePath?: string
}
type MigrationState = { readonly phase: "sessions"; readonly cursor?: string } | { readonly phase: "completed" }
type RuntimeState =
| { readonly status: "idle" }
| { readonly status: "running"; readonly progress: Progress }
| { readonly status: "error"; readonly error: string }
type NextProject = {
readonly id: string
readonly worktree: string
readonly vcs: string | null
readonly name: string | null
readonly icon_url: string | null
readonly icon_url_override: string | null
readonly icon_color: string | null
readonly time_created: number
readonly time_updated: number
readonly time_initialized: number | null
readonly sandboxes: string
readonly commands: string | null
}
type NextSession = {
readonly id: string
readonly project_id: string
readonly workspace_id: string | null
readonly parent_id: string | null
readonly fork_session_id: string | null
readonly fork_boundary: string | null
readonly slug: string
readonly directory: string
readonly path: string | null
readonly title: string | null
readonly version: string
readonly share_url: string | null
readonly summary_additions: number | null
readonly summary_deletions: number | null
readonly summary_files: number | null
readonly summary_diffs: string | null
readonly metadata: string | null
readonly cost: number
readonly tokens_input: number
readonly tokens_output: number
readonly tokens_reasoning: number
readonly tokens_cache_read: number
readonly tokens_cache_write: number
readonly revert: string | null
readonly permission: string | null
readonly agent: string | null
readonly model: string | null
readonly time_created: number
readonly time_updated: number
readonly time_compacting: number | null
readonly time_archived: number | null
readonly time_suspended: number | null
}
type NextMessage = {
readonly id: string
readonly session_id: string
readonly type: string
readonly seq: number
readonly time_created: number
readonly time_updated: number
readonly data: string
}
const lock = Semaphore.makeUnsafe(1)
const MIGRATION_STATE_KEY = "migration.v1-v2"
const EVENT_DELETE_BATCH_SIZE = 1_000
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
let runtimeState: RuntimeState = { status: "idle" }
export function transformSession(input: TransformInput): TransformResult {
const warnings: Warning[] = []
const messages = input.messages
.map((row) => {
const value = Option.getOrUndefined(decodeJson(row.data))
const decoded =
value && typeof value === "object"
? Option.getOrUndefined(decodeMessage({ ...value, id: row.id, sessionID: row.session_id }))
: undefined
if (decoded) return { row, value: decoded }
warnings.push({ reason: "invalid-message", sessionID: input.session.id, messageID: row.id })
return undefined
})
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.sort((a, b) => a.row.time_created - b.row.time_created || a.row.id.localeCompare(b.row.id))
const messageIDs = new Set(input.messages.map((row) => row.id))
const parts = input.parts
.map((row) => {
const value = Option.getOrUndefined(decodeJson(row.data))
const observedType = value && typeof value === "object" && "type" in value ? String(value.type) : undefined
if (!messageIDs.has(row.message_id)) {
warnings.push({
reason: "orphan-part",
sessionID: input.session.id,
messageID: row.message_id,
partID: row.id,
observedType,
})
return undefined
}
const decoded =
value && typeof value === "object"
? Option.getOrUndefined(
decodePart({ ...value, id: row.id, messageID: row.message_id, sessionID: row.session_id }),
)
: undefined
if (decoded) return { row, value: decoded }
warnings.push({
reason: "invalid-part",
sessionID: input.session.id,
messageID: row.message_id,
partID: row.id,
observedType,
})
return undefined
})
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.sort((a, b) => a.row.id.localeCompare(b.row.id))
const byMessage = Map.groupBy(parts, (item) => item.row.message_id)
const paired = new Set<string>()
const used = new Set(messages.map((item) => item.row.id))
const projected = messages
.flatMap((item) => {
if (paired.has(item.row.id)) return []
const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? []
if (item.value.role === "user") {
const compaction = owned.find((part) => part.type === "compaction")
if (compaction?.type === "compaction") {
const pairedSummary = messages.find(
(candidate) =>
candidate.value.role === "assistant" &&
candidate.value.parentID === item.row.id &&
candidate.value.summary,
)
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
paired.add(pairedSummary.row.id)
if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return []
const summary = pairedSummary
const summaryText = (byMessage.get(summary.row.id) ?? [])
.map((part) => part.value)
.filter((part) => part.type === "text" && part.text.length > 0)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")
const tailIndex = compaction.tail_start_id
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
: -1
const compactionIndex = messages.findIndex((candidate) => candidate.row.id === item.row.id)
const tail = tailIndex < 0 ? [] : messages.slice(tailIndex, compactionIndex)
return [
row(
{ ...item.row, time_updated: Math.max(item.row.time_updated, summary.row.time_updated) },
{
id: item.row.id,
type: "compaction",
status: "completed",
reason: compaction.auto ? "auto" : "manual",
summary: summaryText,
recent: serializeRecent(tail, byMessage),
time: { created: item.row.time_created },
},
),
]
}
const subtasks = owned.filter((part) => part.type === "subtask")
const visible = owned.filter((part) => part.type === "text" && !part.ignored)
const files = owned.filter((part) => part.type === "file")
const agents = owned.filter((part) => part.type === "agent")
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic)
const synthetic = visible.filter((part) => part.type === "text" && part.synthetic)
const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : []))
const unavailable = files.flatMap((part) =>
part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [],
)
const text = owned
.flatMap((part) => {
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
if (part.type === "file" && !part.url.startsWith("data:")) return [unavailableFile(part)]
return []
})
.join("\n\n")
const agentAttachments = agents.map((part) =>
part.type === "agent"
? {
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}
: { name: "" },
)
if (
ordinary.length === 0 &&
unavailable.length === 0 &&
synthetic.length > 0 &&
attachments.length === 0 &&
agentAttachments.length === 0
)
return [
row(item.row, {
id: item.row.id,
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
const user = row(item.row, {
id: item.row.id,
type: "user",
text,
...(attachments.length ? { files: attachments } : {}),
...(agentAttachments.length ? { agents: agentAttachments } : {}),
time: { created: item.row.time_created },
})
if (synthetic.length === 0) return [user]
return [
user,
row(item.row, {
id: syntheticID(item.row.id, used),
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
}
if (item.value.role !== "assistant") return []
const assistant = item.value
const parent = messages.find((candidate) => candidate.row.id === assistant.parentID)
const parentParts = parent ? (byMessage.get(parent.row.id)?.map((part) => part.value) ?? []) : []
if (
parentParts.some((part) => part.type === "subtask") &&
owned.some((part) => part.type === "tool" && part.tool === "task")
)
return []
const content = owned.flatMap((part): Array<Record<string, unknown>> => {
if (part.type === "text")
return [{ type: "text", text: part.text, ...(part.metadata ? { state: part.metadata } : {}) }]
if (part.type === "reasoning")
return [
{
type: "reasoning",
text: part.text,
...(part.metadata ? { state: part.metadata } : {}),
time: { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) },
},
]
if (part.type !== "tool") return []
return [migrateTool(part, item.row.time_created)]
})
const start =
owned.flatMap((part) => (part.type === "step-start" && part.snapshot ? [part.snapshot] : []))[0] ??
owned.flatMap((part) => (part.type === "snapshot" ? [part.snapshot] : []))[0] ??
owned.flatMap((part) => (part.type === "patch" ? [part.hash] : []))[0]
const end = owned.flatMap((part) => (part.type === "step-finish" && part.snapshot ? [part.snapshot] : [])).at(-1)
const snapshotFiles = Array.from(new Set(owned.flatMap((part) => (part.type === "patch" ? part.files : []))))
const finish = normalizeFinish(assistant.finish)
return [
row(item.row, {
id: item.row.id,
type: "assistant",
agent: assistant.agent,
model: {
providerID: assistant.providerID,
id: assistant.modelID,
variant: assistant.variant ?? "default",
},
content,
...(start || end || snapshotFiles.length
? {
snapshot: {
...(start ? { start } : {}),
...(end ? { end } : {}),
...(snapshotFiles.length ? { files: snapshotFiles } : {}),
},
}
: {}),
...(finish ? { finish } : {}),
cost: assistant.cost,
tokens: {
input: assistant.tokens.input,
output: assistant.tokens.output,
reasoning: assistant.tokens.reasoning,
cache: assistant.tokens.cache,
},
...(assistant.error ? { error: migrateError(assistant.error) } : {}),
time: {
created: item.row.time_created,
...(assistant.time.completed === undefined ? {} : { completed: item.row.time_updated }),
},
}),
]
})
.map((item, seq) => ({ ...item, seq }))
const assistants = messages
.filter((item) => item.value.role === "assistant")
.map((item) => item.value)
.filter((item): item is SessionV1.Assistant => item.role === "assistant")
const latestUser = messages.findLast((item) => {
if (item.value.role !== "user") return false
const owned = byMessage.get(item.row.id) ?? []
if (owned.some((part) => part.value.type === "compaction")) return false
return !owned.some((part) => part.value.type === "subtask") || !owned.every((part) => part.value.type === "subtask")
})
return {
messages: projected,
session: {
agent: input.session.agent ?? (latestUser?.value.role === "user" ? latestUser.value.agent : null),
model:
input.session.model ??
(latestUser?.value.role === "user"
? {
id: latestUser.value.model.modelID,
providerID: latestUser.value.model.providerID,
variant: latestUser.value.model.variant ?? "default",
}
: null),
cost: assistants.reduce((total, item) => total + item.cost, 0),
tokens_input: assistants.reduce((total, item) => total + item.tokens.input, 0),
tokens_output: assistants.reduce((total, item) => total + item.tokens.output, 0),
tokens_reasoning: assistants.reduce((total, item) => total + item.tokens.reasoning, 0),
tokens_cache_read: assistants.reduce((total, item) => total + item.tokens.cache.read, 0),
tokens_cache_write: assistants.reduce((total, item) => total + item.tokens.cache.write, 0),
revert: null,
time_compacting: null,
},
watermark: projected.length - 1,
warnings,
}
}
export function status(): Effect.Effect<Status, never, Database.Service> {
return Effect.gen(function* () {
const { db } = yield* Database.Service
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const state = yield* readState(db)
if (runtimeState.status === "running") return runtimeState
if (runtimeState.status === "error") return runtimeState
if (state?.phase === "completed") return { status: "completed" as const }
return { status: "required" as const }
}).pipe(Effect.orDie)
}
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
runtimeState = { status: "running", progress: { label: "Clearing old events" } }
yield* run().pipe(
Effect.matchCauseEffect({
onFailure: (cause) =>
Effect.sync(() => {
runtimeState = { status: "error", error: errorText(Cause.squash(cause)) }
}).pipe(Effect.andThen(Effect.logError("V1 migration failed", { cause }))),
onSuccess: () =>
Effect.sync(() => {
runtimeState = { status: "idle" }
}),
}),
Effect.forkScoped({ startImmediately: true }),
)
}),
)
function errorText(input: unknown): string {
if (!(input instanceof Error)) return String(input)
const cause = input.cause
return cause === undefined ? input.message : `${input.message}\nCaused by: ${errorText(cause)}`
}
function updateProgress(progress: Progress) {
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
}
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
return lock.withPermit(
Effect.gen(function* () {
const { db } = yield* Database.Service
const global = yield* Global.Service
const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const migrate = Effect.gen(function* () {
const now = Date.now()
yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
INSERT OR IGNORE INTO session_v2 (
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
)
SELECT
id, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
FROM session
WHERE id = ${nextID.id}
`)
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run(),
)
yield* tx
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
})
return yield* migrate
}).pipe(Effect.orDie),
)
}
function nextPath(options: Options, data: string) {
if (options.nextDatabasePath) return options.nextDatabasePath
if (process.env.OPENCODE_DB === ":memory:") return undefined
return path.join(data, "opencode-next.db")
}
function openNextDatabase(sourcePath: string) {
return Effect.acquireRelease(
Effect.gen(function* () {
const sqlite = yield* Effect.promise(() => import("bun:sqlite"))
return new sqlite.Database(sourcePath, { readonly: true, strict: true })
}),
(source) => Effect.sync(() => source.close()),
)
}
function countNextSessions(sourcePath: string | undefined) {
if (!sourcePath || !existsSync(sourcePath)) return Effect.succeed(0)
return Effect.scoped(
Effect.gen(function* () {
const source = yield* openNextDatabase(sourcePath)
if (!isNextDatabase(source)) return 0
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
}),
).pipe(Effect.orElseSucceed(() => 0))
}
function importNextDatabase(
db: Database.Interface["db"],
sourcePath: string | undefined,
onProgress: (completed: number) => void,
): Effect.Effect<void, unknown> {
if (!sourcePath || !existsSync(sourcePath)) return Effect.void
return Effect.scoped(
Effect.gen(function* () {
const source = yield* openNextDatabase(sourcePath)
if (!isNextDatabase(source)) {
yield* Effect.logWarning("Skipped incompatible opencode-next.db", { path: sourcePath })
return
}
source.run("BEGIN")
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (source.inTransaction) source.run("ROLLBACK")
}),
)
const projects = new Map(
source
.query<NextProject, []>("SELECT * FROM project")
.all()
.map((project) => [project.id, project]),
)
const sessions = source.query<NextSession, []>("SELECT * FROM session ORDER BY id DESC").all()
for (const [index, session] of sessions.entries()) {
const project = projects.get(session.project_id)
const projectID = project ? session.project_id : Project.ID.global
if (!project) {
yield* Effect.logWarning("Reassigned previous V2 session with missing project", {
sessionID: session.id,
projectID: session.project_id,
})
}
const messages = source
.query<
NextMessage,
[string]
>("SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq")
.all(session.id)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
if (project)
yield* tx.run(sql`
INSERT OR IGNORE INTO project (
id, worktree, vcs, name, icon_url, icon_url_override, icon_color,
time_created, time_updated, time_initialized, sandboxes, commands
) VALUES (
${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url},
${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated},
${project.time_initialized}, ${project.sandboxes}, ${project.commands}
)
`)
const existing = yield* tx
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(session.id)))
.get()
if (existing) return
yield* tx.run(sql`
INSERT INTO session_v2 (
id, project_id, workspace_id, parent_id, fork_session_id, fork_boundary, slug, directory,
path, title, version, share_url, summary_additions, summary_deletions, summary_files,
summary_diffs, metadata, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
tokens_cache_write, revert, permission, agent, model, time_created, time_updated, time_compacting,
time_archived, time_suspended
) VALUES (
${session.id}, ${projectID}, ${session.workspace_id}, ${session.parent_id},
${session.fork_session_id}, ${session.fork_boundary}, ${session.slug}, ${session.directory},
${session.path}, ${session.title}, ${session.version}, ${session.share_url},
${session.summary_additions}, ${session.summary_deletions}, ${session.summary_files},
${session.summary_diffs}, ${session.metadata}, ${session.cost}, ${session.tokens_input},
${session.tokens_output}, ${session.tokens_reasoning}, ${session.tokens_cache_read},
${session.tokens_cache_write}, ${session.revert}, ${session.permission}, ${session.agent},
${session.model}, ${session.time_created}, ${session.time_updated}, ${session.time_compacting},
${session.time_archived}, ${session.time_suspended}
)
`)
yield* Effect.forEach(messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type as SessionMessage.Type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${message.data}`,
})
.run(),
)
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: session.id, seq: messages.at(-1)?.seq ?? -1 })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: messages.at(-1)?.seq ?? -1, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
onProgress(index + 1)
yield* Effect.yieldNow
}
source.run("COMMIT")
}),
)
}
function isNextDatabase(source: SQLiteDatabase) {
const tables = new Set(
source
.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'")
.all()
.map((table) => table.name),
)
return tables.has("project") && tables.has("session") && tables.has("session_message")
}
function row(
source: SourceMessage,
message: {
readonly id: string
readonly type: SessionMessage.Type
readonly time: { readonly created: number }
readonly [key: string]: unknown
},
): TransformResult["messages"][number] {
const { id, type, ...data } = message
return {
id,
session_id: source.session_id,
type,
seq: 0,
time_created: source.time_created,
time_updated: source.time_updated,
data,
}
}
function migrateTool(part: typeof SessionV1.ToolPart.Type, fallback: number) {
const base = {
type: "tool" as const,
id: part.callID,
name: part.tool,
...(part.metadata ? { providerState: part.metadata } : {}),
}
if (part.state.status === "completed")
return {
...base,
state: {
status: "completed",
input: part.state.input,
content:
part.state.time.compacted === undefined
? [
{ type: "text", text: part.state.output },
...(part.state.attachments ?? []).map((file) => ({
type: "file" as const,
uri: file.url,
mime: file.mime,
...(file.filename ? { name: file.filename } : {}),
})),
]
: [{ type: "text", text: "[Old tool result content cleared]" }],
metadata: part.state.metadata,
},
time: { created: part.state.time.start, completed: part.state.time.end },
}
if (part.state.status === "error")
return {
...base,
state: {
status: "error",
input: part.state.input,
error: { type: "tool.execution", message: part.state.error },
...(typeof part.state.metadata?.output === "string"
? { content: [{ type: "text", text: part.state.metadata.output }] }
: {}),
...(part.state.metadata ? { metadata: part.state.metadata } : {}),
},
time: { created: part.state.time.start, completed: part.state.time.end },
}
return {
...base,
state: {
status: "error",
input: part.state.input,
error: { type: "tool.interrupted", message: "Tool execution was interrupted before V2 migration" },
...(part.state.status === "running" && part.state.metadata ? { metadata: part.state.metadata } : {}),
},
time: { created: part.state.status === "running" ? part.state.time.start : fallback },
}
}
function migrateError(error: NonNullable<(typeof SessionV1.Assistant.Type)["error"]>) {
const message =
"message" in error.data
? error.data.message
: error.name === "MessageOutputLengthError"
? "The model exceeded its output limit"
: error.name
const type =
error.name === "ProviderAuthError"
? "provider.auth"
: error.name === "ContentFilterError"
? "provider.content-filter"
: error.name === "ContextOverflowError"
? "provider.invalid-request"
: error.name === "StructuredOutputError" || error.name === "MessageOutputLengthError"
? "provider.invalid-output"
: error.name === "MessageAbortedError"
? "aborted"
: error.name === "APIError"
? "provider.error"
: "unknown"
return { type, message }
}
function normalizeFinish(finish: string | undefined) {
if (!finish) return undefined
return (
(["stop", "length", "tool-calls", "content-filter", "error", "unknown"] as const).find(
(value) => value === finish,
) ?? "unknown"
)
}
function migrateFile(part: SessionV1.FilePart) {
if (!part.url.startsWith("data:")) return []
const comma = part.url.indexOf(",")
if (comma < 0) return []
const header = part.url.slice(0, comma)
const payload = part.url.slice(comma + 1)
const data = header.endsWith(";base64")
? Buffer.from(payload, "base64").toString("base64")
: Buffer.from(decodeURIComponent(payload)).toString("base64")
return [
{
data,
mime: part.mime,
source:
part.source?.type === "resource" ? { type: "uri" as const, uri: part.source.uri } : { type: "inline" as const },
...(part.filename ? { name: part.filename } : {}),
...(part.source
? { mention: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end } }
: {}),
},
]
}
function unavailableFile(part: SessionV1.FilePart) {
const label = part.filename ?? (part.source?.type === "resource" ? part.source.uri : part.url)
return `[Attachment unavailable after migration: ${label} (${part.mime})]`
}
function syntheticID(source: string, used: Set<string>) {
const prefix = source.slice(0, 16)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
for (let salt = 0; ; salt++) {
const hex = new Bun.CryptoHasher("sha256").update(`v1-synthetic:${source}${salt ? `:${salt}` : ""}`).digest("hex")
let value = BigInt(`0x${hex}`)
let suffix = ""
while (suffix.length < 14) {
suffix = alphabet[Number(value % 62n)] + suffix
value /= 62n
}
const id = prefix + suffix
if (used.has(id)) continue
used.add(id)
return id
}
}
function serializeRecent(
messages: ReadonlyArray<{ row: SourceMessage; value: typeof SessionV1.Info.Type }>,
parts: Map<string, Array<{ row: SourcePart; value: typeof SessionV1.Part.Type }>>,
) {
return messages
.flatMap((message) => {
const owned = parts.get(message.row.id)?.map((part) => part.value) ?? []
if (message.value.role === "user")
return [
`[User]: ${owned
.filter((part) => part.type === "text" && !part.ignored)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")}`,
]
return owned.flatMap((part) =>
part.type === "text"
? [`[Assistant]: ${part.text}`]
: part.type === "reasoning" && part.text
? [`[Assistant reasoning]: ${part.text}`]
: [],
)
})
.join("\n\n")
}
function readState(db: Database.Interface["db"]): Effect.Effect<MigrationState | undefined> {
return db
.select({ value: KVTable.value })
.from(KVTable)
.where(eq(KVTable.key, MIGRATION_STATE_KEY))
.get()
.pipe(
Effect.map((row) => parseState(row?.value)),
Effect.orDie,
)
}
function parseState(input: unknown): MigrationState | undefined {
if (!input || typeof input !== "object" || !("phase" in input)) return
if (input.phase === "completed") return { phase: "completed" }
if (input.phase !== "sessions") return
if (!("cursor" in input) || input.cursor === undefined) return { phase: "sessions" }
if (typeof input.cursor === "string") return { phase: "sessions", cursor: input.cursor }
}
function hasLegacySessions(db: Database.Interface["db"]) {
return db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`).pipe(
Effect.map((row) => row !== undefined),
Effect.orDie,
)
}
@@ -0,0 +1,11 @@
import { Effect, Layer } from "effect"
export type Status = { readonly status: "completed" }
export const layer = Layer.empty
export function status() {
return Effect.succeed({ status: "completed" } as const)
}
export function run() {
return Effect.succeed({ status: "completed" } as const)
}
+2 -997
View File
@@ -1,997 +1,2 @@
export * as V1Migration from "./v1-migration.js"
import { Cause, Effect, Layer, Option, Schema, Semaphore } from "effect"
import { Database } from "./database.js"
import { SessionMessageTable, SessionTable } from "../session/sql.js"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
import { SessionMessage } from "../session/message.js"
import { SessionSchema } from "../session/schema.js"
import { KVTable } from "../kv/sql.js"
import { EventSequenceTable } from "../event/sql.js"
import { eq, sql } from "drizzle-orm"
import { Global } from "@opencode-ai/util/global"
import { existsSync } from "node:fs"
import path from "node:path"
import type { Database as SQLiteDatabase } from "bun:sqlite"
import { Project } from "@opencode-ai/schema/project"
export type SourceMessage = {
readonly id: string
readonly session_id: string
readonly time_created: number
readonly time_updated: number
readonly data: string
}
export type SourcePart = {
readonly id: string
readonly message_id: string
readonly session_id: string
readonly time_created: number
readonly time_updated: number
readonly data: string
}
export type TransformInput = {
readonly session: typeof SessionTable.$inferSelect
readonly messages: ReadonlyArray<SourceMessage>
readonly parts: ReadonlyArray<SourcePart>
}
export type Warning = {
readonly reason: string
readonly sessionID: string
readonly messageID?: string
readonly partID?: string
readonly observedType?: string
}
export type TransformResult = {
readonly messages: ReadonlyArray<{
readonly id: string
readonly session_id: string
readonly type: SessionMessage.Type
readonly seq: number
readonly time_created: number
readonly time_updated: number
readonly data: Record<string, unknown>
}>
readonly session: Pick<
typeof SessionTable.$inferInsert,
| "agent"
| "model"
| "cost"
| "tokens_input"
| "tokens_output"
| "tokens_reasoning"
| "tokens_cache_read"
| "tokens_cache_write"
| "revert"
| "time_compacting"
>
readonly watermark: number
readonly warnings: ReadonlyArray<Warning>
}
type Progress = {
readonly label: string
readonly numerator?: number
readonly denominator?: number
}
export type Status =
| { readonly status: "required" | "completed" }
| { readonly status: "running"; readonly progress: Progress }
| { readonly status: "error"; readonly error: string }
type RunResult = {
readonly status: "completed"
}
type Options = {
readonly nextDatabasePath?: string
}
type MigrationState = { readonly phase: "sessions"; readonly cursor?: string } | { readonly phase: "completed" }
type RuntimeState =
| { readonly status: "idle" }
| { readonly status: "running"; readonly progress: Progress }
| { readonly status: "error"; readonly error: string }
type NextProject = {
readonly id: string
readonly worktree: string
readonly vcs: string | null
readonly name: string | null
readonly icon_url: string | null
readonly icon_url_override: string | null
readonly icon_color: string | null
readonly time_created: number
readonly time_updated: number
readonly time_initialized: number | null
readonly sandboxes: string
readonly commands: string | null
}
type NextSession = {
readonly id: string
readonly project_id: string
readonly workspace_id: string | null
readonly parent_id: string | null
readonly fork_session_id: string | null
readonly fork_boundary: string | null
readonly slug: string
readonly directory: string
readonly path: string | null
readonly title: string | null
readonly version: string
readonly share_url: string | null
readonly summary_additions: number | null
readonly summary_deletions: number | null
readonly summary_files: number | null
readonly summary_diffs: string | null
readonly metadata: string | null
readonly cost: number
readonly tokens_input: number
readonly tokens_output: number
readonly tokens_reasoning: number
readonly tokens_cache_read: number
readonly tokens_cache_write: number
readonly revert: string | null
readonly permission: string | null
readonly agent: string | null
readonly model: string | null
readonly time_created: number
readonly time_updated: number
readonly time_compacting: number | null
readonly time_archived: number | null
readonly time_suspended: number | null
}
type NextMessage = {
readonly id: string
readonly session_id: string
readonly type: string
readonly seq: number
readonly time_created: number
readonly time_updated: number
readonly data: string
}
const lock = Semaphore.makeUnsafe(1)
const MIGRATION_STATE_KEY = "migration.v1-v2"
const EVENT_DELETE_BATCH_SIZE = 1_000
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
let runtimeState: RuntimeState = { status: "idle" }
export function transformSession(input: TransformInput): TransformResult {
const warnings: Warning[] = []
const messages = input.messages
.map((row) => {
const value = Option.getOrUndefined(decodeJson(row.data))
const decoded =
value && typeof value === "object"
? Option.getOrUndefined(decodeMessage({ ...value, id: row.id, sessionID: row.session_id }))
: undefined
if (decoded) return { row, value: decoded }
warnings.push({ reason: "invalid-message", sessionID: input.session.id, messageID: row.id })
return undefined
})
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.sort((a, b) => a.row.time_created - b.row.time_created || a.row.id.localeCompare(b.row.id))
const messageIDs = new Set(input.messages.map((row) => row.id))
const parts = input.parts
.map((row) => {
const value = Option.getOrUndefined(decodeJson(row.data))
const observedType = value && typeof value === "object" && "type" in value ? String(value.type) : undefined
if (!messageIDs.has(row.message_id)) {
warnings.push({
reason: "orphan-part",
sessionID: input.session.id,
messageID: row.message_id,
partID: row.id,
observedType,
})
return undefined
}
const decoded =
value && typeof value === "object"
? Option.getOrUndefined(
decodePart({ ...value, id: row.id, messageID: row.message_id, sessionID: row.session_id }),
)
: undefined
if (decoded) return { row, value: decoded }
warnings.push({
reason: "invalid-part",
sessionID: input.session.id,
messageID: row.message_id,
partID: row.id,
observedType,
})
return undefined
})
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.sort((a, b) => a.row.id.localeCompare(b.row.id))
const byMessage = Map.groupBy(parts, (item) => item.row.message_id)
const paired = new Set<string>()
const used = new Set(messages.map((item) => item.row.id))
const projected = messages
.flatMap((item) => {
if (paired.has(item.row.id)) return []
const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? []
if (item.value.role === "user") {
const compaction = owned.find((part) => part.type === "compaction")
if (compaction?.type === "compaction") {
const pairedSummary = messages.find(
(candidate) =>
candidate.value.role === "assistant" &&
candidate.value.parentID === item.row.id &&
candidate.value.summary,
)
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
paired.add(pairedSummary.row.id)
if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return []
const summary = pairedSummary
const summaryText = (byMessage.get(summary.row.id) ?? [])
.map((part) => part.value)
.filter((part) => part.type === "text" && part.text.length > 0)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")
const tailIndex = compaction.tail_start_id
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
: -1
const compactionIndex = messages.findIndex((candidate) => candidate.row.id === item.row.id)
const tail = tailIndex < 0 ? [] : messages.slice(tailIndex, compactionIndex)
return [
row(
{ ...item.row, time_updated: Math.max(item.row.time_updated, summary.row.time_updated) },
{
id: item.row.id,
type: "compaction",
status: "completed",
reason: compaction.auto ? "auto" : "manual",
summary: summaryText,
recent: serializeRecent(tail, byMessage),
time: { created: item.row.time_created },
},
),
]
}
const subtasks = owned.filter((part) => part.type === "subtask")
const visible = owned.filter((part) => part.type === "text" && !part.ignored)
const files = owned.filter((part) => part.type === "file")
const agents = owned.filter((part) => part.type === "agent")
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic)
const synthetic = visible.filter((part) => part.type === "text" && part.synthetic)
const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : []))
const unavailable = files.flatMap((part) =>
part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [],
)
const text = owned
.flatMap((part) => {
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
if (part.type === "file" && !part.url.startsWith("data:")) return [unavailableFile(part)]
return []
})
.join("\n\n")
const agentAttachments = agents.map((part) =>
part.type === "agent"
? {
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}
: { name: "" },
)
if (
ordinary.length === 0 &&
unavailable.length === 0 &&
synthetic.length > 0 &&
attachments.length === 0 &&
agentAttachments.length === 0
)
return [
row(item.row, {
id: item.row.id,
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
const user = row(item.row, {
id: item.row.id,
type: "user",
text,
...(attachments.length ? { files: attachments } : {}),
...(agentAttachments.length ? { agents: agentAttachments } : {}),
time: { created: item.row.time_created },
})
if (synthetic.length === 0) return [user]
return [
user,
row(item.row, {
id: syntheticID(item.row.id, used),
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
}
if (item.value.role !== "assistant") return []
const assistant = item.value
const parent = messages.find((candidate) => candidate.row.id === assistant.parentID)
const parentParts = parent ? (byMessage.get(parent.row.id)?.map((part) => part.value) ?? []) : []
if (
parentParts.some((part) => part.type === "subtask") &&
owned.some((part) => part.type === "tool" && part.tool === "task")
)
return []
const content = owned.flatMap((part): Array<Record<string, unknown>> => {
if (part.type === "text")
return [{ type: "text", text: part.text, ...(part.metadata ? { state: part.metadata } : {}) }]
if (part.type === "reasoning")
return [
{
type: "reasoning",
text: part.text,
...(part.metadata ? { state: part.metadata } : {}),
time: { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) },
},
]
if (part.type !== "tool") return []
return [migrateTool(part, item.row.time_created)]
})
const start =
owned.flatMap((part) => (part.type === "step-start" && part.snapshot ? [part.snapshot] : []))[0] ??
owned.flatMap((part) => (part.type === "snapshot" ? [part.snapshot] : []))[0] ??
owned.flatMap((part) => (part.type === "patch" ? [part.hash] : []))[0]
const end = owned.flatMap((part) => (part.type === "step-finish" && part.snapshot ? [part.snapshot] : [])).at(-1)
const snapshotFiles = Array.from(new Set(owned.flatMap((part) => (part.type === "patch" ? part.files : []))))
const finish = normalizeFinish(assistant.finish)
return [
row(item.row, {
id: item.row.id,
type: "assistant",
agent: assistant.agent,
model: {
providerID: assistant.providerID,
id: assistant.modelID,
variant: assistant.variant ?? "default",
},
content,
...(start || end || snapshotFiles.length
? {
snapshot: {
...(start ? { start } : {}),
...(end ? { end } : {}),
...(snapshotFiles.length ? { files: snapshotFiles } : {}),
},
}
: {}),
...(finish ? { finish } : {}),
cost: assistant.cost,
tokens: {
input: assistant.tokens.input,
output: assistant.tokens.output,
reasoning: assistant.tokens.reasoning,
cache: assistant.tokens.cache,
},
...(assistant.error ? { error: migrateError(assistant.error) } : {}),
time: {
created: item.row.time_created,
...(assistant.time.completed === undefined ? {} : { completed: item.row.time_updated }),
},
}),
]
})
.map((item, seq) => ({ ...item, seq }))
const assistants = messages
.filter((item) => item.value.role === "assistant")
.map((item) => item.value)
.filter((item): item is SessionV1.Assistant => item.role === "assistant")
const latestUser = messages.findLast((item) => {
if (item.value.role !== "user") return false
const owned = byMessage.get(item.row.id) ?? []
if (owned.some((part) => part.value.type === "compaction")) return false
return !owned.some((part) => part.value.type === "subtask") || !owned.every((part) => part.value.type === "subtask")
})
return {
messages: projected,
session: {
agent: input.session.agent ?? (latestUser?.value.role === "user" ? latestUser.value.agent : null),
model:
input.session.model ??
(latestUser?.value.role === "user"
? {
id: latestUser.value.model.modelID,
providerID: latestUser.value.model.providerID,
variant: latestUser.value.model.variant ?? "default",
}
: null),
cost: assistants.reduce((total, item) => total + item.cost, 0),
tokens_input: assistants.reduce((total, item) => total + item.tokens.input, 0),
tokens_output: assistants.reduce((total, item) => total + item.tokens.output, 0),
tokens_reasoning: assistants.reduce((total, item) => total + item.tokens.reasoning, 0),
tokens_cache_read: assistants.reduce((total, item) => total + item.tokens.cache.read, 0),
tokens_cache_write: assistants.reduce((total, item) => total + item.tokens.cache.write, 0),
revert: null,
time_compacting: null,
},
watermark: projected.length - 1,
warnings,
}
}
export function status(): Effect.Effect<Status, never, Database.Service> {
return Effect.gen(function* () {
const { db } = yield* Database.Service
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const state = yield* readState(db)
if (runtimeState.status === "running") return runtimeState
if (runtimeState.status === "error") return runtimeState
if (state?.phase === "completed") return { status: "completed" as const }
return { status: "required" as const }
}).pipe(Effect.orDie)
}
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
runtimeState = { status: "running", progress: { label: "Clearing old events" } }
yield* run().pipe(
Effect.matchCauseEffect({
onFailure: (cause) =>
Effect.sync(() => {
runtimeState = { status: "error", error: errorText(Cause.squash(cause)) }
}).pipe(Effect.andThen(Effect.logError("V1 migration failed", { cause }))),
onSuccess: () =>
Effect.sync(() => {
runtimeState = { status: "idle" }
}),
}),
Effect.forkScoped({ startImmediately: true }),
)
}),
)
function errorText(input: unknown): string {
if (!(input instanceof Error)) return String(input)
const cause = input.cause
return cause === undefined ? input.message : `${input.message}\nCaused by: ${errorText(cause)}`
}
function updateProgress(progress: Progress) {
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
}
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
return lock.withPermit(
Effect.gen(function* () {
const { db } = yield* Database.Service
const global = yield* Global.Service
const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const migrate = Effect.gen(function* () {
const now = Date.now()
yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
INSERT OR IGNORE INTO session_v2 (
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
)
SELECT
id, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
FROM session
WHERE id = ${nextID.id}
`)
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run(),
)
yield* tx
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
})
return yield* migrate
}).pipe(Effect.orDie),
)
}
function nextPath(options: Options, data: string) {
if (options.nextDatabasePath) return options.nextDatabasePath
if (process.env.OPENCODE_DB === ":memory:") return undefined
return path.join(data, "opencode-next.db")
}
function openNextDatabase(sourcePath: string) {
return Effect.acquireRelease(
Effect.gen(function* () {
const sqlite = yield* Effect.promise(() => import("bun:sqlite"))
return new sqlite.Database(sourcePath, { readonly: true, strict: true })
}),
(source) => Effect.sync(() => source.close()),
)
}
function countNextSessions(sourcePath: string | undefined) {
if (!sourcePath || !existsSync(sourcePath)) return Effect.succeed(0)
return Effect.scoped(
Effect.gen(function* () {
const source = yield* openNextDatabase(sourcePath)
if (!isNextDatabase(source)) return 0
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
}),
).pipe(Effect.orElseSucceed(() => 0))
}
function importNextDatabase(
db: Database.Interface["db"],
sourcePath: string | undefined,
onProgress: (completed: number) => void,
): Effect.Effect<void, unknown> {
if (!sourcePath || !existsSync(sourcePath)) return Effect.void
return Effect.scoped(
Effect.gen(function* () {
const source = yield* openNextDatabase(sourcePath)
if (!isNextDatabase(source)) {
yield* Effect.logWarning("Skipped incompatible opencode-next.db", { path: sourcePath })
return
}
source.run("BEGIN")
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (source.inTransaction) source.run("ROLLBACK")
}),
)
const projects = new Map(
source
.query<NextProject, []>("SELECT * FROM project")
.all()
.map((project) => [project.id, project]),
)
const sessions = source.query<NextSession, []>("SELECT * FROM session ORDER BY id DESC").all()
for (const [index, session] of sessions.entries()) {
const project = projects.get(session.project_id)
const projectID = project ? session.project_id : Project.ID.global
if (!project) {
yield* Effect.logWarning("Reassigned previous V2 session with missing project", {
sessionID: session.id,
projectID: session.project_id,
})
}
const messages = source
.query<
NextMessage,
[string]
>("SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq")
.all(session.id)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
if (project)
yield* tx.run(sql`
INSERT OR IGNORE INTO project (
id, worktree, vcs, name, icon_url, icon_url_override, icon_color,
time_created, time_updated, time_initialized, sandboxes, commands
) VALUES (
${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url},
${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated},
${project.time_initialized}, ${project.sandboxes}, ${project.commands}
)
`)
const existing = yield* tx
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(session.id)))
.get()
if (existing) return
yield* tx.run(sql`
INSERT INTO session_v2 (
id, project_id, workspace_id, parent_id, fork_session_id, fork_boundary, slug, directory,
path, title, version, share_url, summary_additions, summary_deletions, summary_files,
summary_diffs, metadata, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
tokens_cache_write, revert, permission, agent, model, time_created, time_updated, time_compacting,
time_archived, time_suspended
) VALUES (
${session.id}, ${projectID}, ${session.workspace_id}, ${session.parent_id},
${session.fork_session_id}, ${session.fork_boundary}, ${session.slug}, ${session.directory},
${session.path}, ${session.title}, ${session.version}, ${session.share_url},
${session.summary_additions}, ${session.summary_deletions}, ${session.summary_files},
${session.summary_diffs}, ${session.metadata}, ${session.cost}, ${session.tokens_input},
${session.tokens_output}, ${session.tokens_reasoning}, ${session.tokens_cache_read},
${session.tokens_cache_write}, ${session.revert}, ${session.permission}, ${session.agent},
${session.model}, ${session.time_created}, ${session.time_updated}, ${session.time_compacting},
${session.time_archived}, ${session.time_suspended}
)
`)
yield* Effect.forEach(messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type as SessionMessage.Type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${message.data}`,
})
.run(),
)
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: session.id, seq: messages.at(-1)?.seq ?? -1 })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: messages.at(-1)?.seq ?? -1, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
onProgress(index + 1)
yield* Effect.yieldNow
}
source.run("COMMIT")
}),
)
}
function isNextDatabase(source: SQLiteDatabase) {
const tables = new Set(
source
.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'")
.all()
.map((table) => table.name),
)
return tables.has("project") && tables.has("session") && tables.has("session_message")
}
function row(
source: SourceMessage,
message: {
readonly id: string
readonly type: SessionMessage.Type
readonly time: { readonly created: number }
readonly [key: string]: unknown
},
): TransformResult["messages"][number] {
const { id, type, ...data } = message
return {
id,
session_id: source.session_id,
type,
seq: 0,
time_created: source.time_created,
time_updated: source.time_updated,
data,
}
}
function migrateTool(part: typeof SessionV1.ToolPart.Type, fallback: number) {
const base = {
type: "tool" as const,
id: part.callID,
name: part.tool,
...(part.metadata ? { providerState: part.metadata } : {}),
}
if (part.state.status === "completed")
return {
...base,
state: {
status: "completed",
input: part.state.input,
content:
part.state.time.compacted === undefined
? [
{ type: "text", text: part.state.output },
...(part.state.attachments ?? []).map((file) => ({
type: "file" as const,
uri: file.url,
mime: file.mime,
...(file.filename ? { name: file.filename } : {}),
})),
]
: [{ type: "text", text: "[Old tool result content cleared]" }],
metadata: part.state.metadata,
},
time: { created: part.state.time.start, completed: part.state.time.end },
}
if (part.state.status === "error")
return {
...base,
state: {
status: "error",
input: part.state.input,
error: { type: "tool.execution", message: part.state.error },
...(typeof part.state.metadata?.output === "string"
? { content: [{ type: "text", text: part.state.metadata.output }] }
: {}),
...(part.state.metadata ? { metadata: part.state.metadata } : {}),
},
time: { created: part.state.time.start, completed: part.state.time.end },
}
return {
...base,
state: {
status: "error",
input: part.state.input,
error: { type: "tool.interrupted", message: "Tool execution was interrupted before V2 migration" },
...(part.state.status === "running" && part.state.metadata ? { metadata: part.state.metadata } : {}),
},
time: { created: part.state.status === "running" ? part.state.time.start : fallback },
}
}
function migrateError(error: NonNullable<(typeof SessionV1.Assistant.Type)["error"]>) {
const message =
"message" in error.data
? error.data.message
: error.name === "MessageOutputLengthError"
? "The model exceeded its output limit"
: error.name
const type =
error.name === "ProviderAuthError"
? "provider.auth"
: error.name === "ContentFilterError"
? "provider.content-filter"
: error.name === "ContextOverflowError"
? "provider.invalid-request"
: error.name === "StructuredOutputError" || error.name === "MessageOutputLengthError"
? "provider.invalid-output"
: error.name === "MessageAbortedError"
? "aborted"
: error.name === "APIError"
? "provider.error"
: "unknown"
return { type, message }
}
function normalizeFinish(finish: string | undefined) {
if (!finish) return undefined
return (
(["stop", "length", "tool-calls", "content-filter", "error", "unknown"] as const).find(
(value) => value === finish,
) ?? "unknown"
)
}
function migrateFile(part: SessionV1.FilePart) {
if (!part.url.startsWith("data:")) return []
const comma = part.url.indexOf(",")
if (comma < 0) return []
const header = part.url.slice(0, comma)
const payload = part.url.slice(comma + 1)
const data = header.endsWith(";base64")
? Buffer.from(payload, "base64").toString("base64")
: Buffer.from(decodeURIComponent(payload)).toString("base64")
return [
{
data,
mime: part.mime,
source:
part.source?.type === "resource" ? { type: "uri" as const, uri: part.source.uri } : { type: "inline" as const },
...(part.filename ? { name: part.filename } : {}),
...(part.source
? { mention: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end } }
: {}),
},
]
}
function unavailableFile(part: SessionV1.FilePart) {
const label = part.filename ?? (part.source?.type === "resource" ? part.source.uri : part.url)
return `[Attachment unavailable after migration: ${label} (${part.mime})]`
}
function syntheticID(source: string, used: Set<string>) {
const prefix = source.slice(0, 16)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
for (let salt = 0; ; salt++) {
const hex = new Bun.CryptoHasher("sha256").update(`v1-synthetic:${source}${salt ? `:${salt}` : ""}`).digest("hex")
let value = BigInt(`0x${hex}`)
let suffix = ""
while (suffix.length < 14) {
suffix = alphabet[Number(value % 62n)] + suffix
value /= 62n
}
const id = prefix + suffix
if (used.has(id)) continue
used.add(id)
return id
}
}
function serializeRecent(
messages: ReadonlyArray<{ row: SourceMessage; value: typeof SessionV1.Info.Type }>,
parts: Map<string, Array<{ row: SourcePart; value: typeof SessionV1.Part.Type }>>,
) {
return messages
.flatMap((message) => {
const owned = parts.get(message.row.id)?.map((part) => part.value) ?? []
if (message.value.role === "user")
return [
`[User]: ${owned
.filter((part) => part.type === "text" && !part.ignored)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")}`,
]
return owned.flatMap((part) =>
part.type === "text"
? [`[Assistant]: ${part.text}`]
: part.type === "reasoning" && part.text
? [`[Assistant reasoning]: ${part.text}`]
: [],
)
})
.join("\n\n")
}
function readState(db: Database.Interface["db"]): Effect.Effect<MigrationState | undefined> {
return db
.select({ value: KVTable.value })
.from(KVTable)
.where(eq(KVTable.key, MIGRATION_STATE_KEY))
.get()
.pipe(
Effect.map((row) => parseState(row?.value)),
Effect.orDie,
)
}
function parseState(input: unknown): MigrationState | undefined {
if (!input || typeof input !== "object" || !("phase" in input)) return
if (input.phase === "completed") return { phase: "completed" }
if (input.phase !== "sessions") return
if (!("cursor" in input) || input.cursor === undefined) return { phase: "sessions" }
if (typeof input.cursor === "string") return { phase: "sessions", cursor: input.cursor }
}
function hasLegacySessions(db: Database.Interface["db"]) {
return db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`).pipe(
Effect.map((row) => row !== undefined),
Effect.orDie,
)
}
export * as V1Migration from "#v1-migration"
export * from "#v1-migration"
+5 -1
View File
@@ -23,7 +23,11 @@ export const GooglePlugin = make("google", (id) => (id.includes("gemini-") ? PRO
export const AnthropicPlugin = make("anthropic", (id) => (id.includes("claude") ? PROMPT_ANTHROPIC : undefined))
export const KimiPlugin = make("kimi", (id) => (id.includes("kimi") ? PROMPT_KIMI : undefined))
export const ArceePlugin = make("arcee", (id) => (id.includes("trinity") ? PROMPT_TRINITY : undefined))
export const MetaPlugin = make("meta", (id) => (id.includes("muse-spark") ? PROMPT_META : undefined))
export const MetaPlugin = make("meta", (id) => {
if (!id.includes("muse")) return
const name = id.includes("muse-glimmer") ? "Muse Glimmer" : "Muse Spark"
return PROMPT_META.replaceAll("{{MODEL_NAME}}", name)
})
export const Plugins = [OpenAIPlugin, GooglePlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
@@ -1,4 +1,4 @@
You are OpenCode, a coding agent that helps users with software engineering tasks. You are powered by Muse Spark, a large language model trained by Meta MSL.
You are OpenCode, a coding agent that helps users with software engineering tasks. You are powered by {{MODEL_NAME}}, a large language model trained by Meta MSL.
Use the instructions below and the tools available to assist the user.
@@ -55,5 +55,5 @@ Use the instructions below and the tools available to assist the user.
- NEVER use comments as a place for long-winded chain-of-thought. Long thinking texts must be generated as private reasoning. Comments in code must be appropriately concise.
# User Help & Feedback
- Users can give feedback or report issues at https://github.com/anomalyco/opencode and mention that they are using Meta Muse Spark.
- Users can give feedback or report issues at https://github.com/anomalyco/opencode and mention that they are using Meta {{MODEL_NAME}}.
- When users ask directly about OpenCode (eg. "can OpenCode do...", "are you able to do...") or its features (eg. implement a hook, write a slash command, or install an MCP server), use the `webfetch` tool to gather information to answer the question from the V2 OpenCode docs at https://opencode.ai/v2/docs/.
+13 -11
View File
@@ -185,8 +185,8 @@ export interface Interface {
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
/**
* Durable admitted session work not yet visible in projected history,
* ordered by admission. Includes unpromoted user and synthetic inputs,
* unhandled compaction barriers, and deferred moves.
* ordered by admission. Includes unpromoted user and synthetic inputs and
* unhandled compaction barriers.
*/
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
readonly cancelPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
@@ -738,21 +738,23 @@ const layer = Layer.effect(
const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info) return yield* new DestinationNotFoundError({ directory })
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
const pending = yield* SessionPending.move(db, input.sessionID)
if (!pending && current.location.directory === directory && current.location.workspaceID === input.workspaceID)
return
if (current.location.directory === directory && current.location.workspaceID === input.workspaceID) return
const project = yield* projects.resolve(directory)
yield* persistProject(project)
yield* SessionPending.admitMove(db, bus, {
sessionID: input.sessionID,
source: current.location,
data: {
if ((yield* execution.active).has(input.sessionID)) {
yield* execution.interrupt(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
}
yield* bus.publish(
SessionEvent.Moved,
{
sessionID: input.sessionID,
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id,
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
},
})
yield* execution.wake(input.sessionID)
{ location: current.location },
)
}),
compact: Effect.fn("Session.compact")(function* (input) {
yield* result.get(input.sessionID)
+15 -26
View File
@@ -11,8 +11,6 @@ import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
import { toSessionError } from "./to-session-error.js"
import { UserInterruptedError } from "./error.js"
import { Database } from "../database/database.js"
import { SessionPending } from "./pending.js"
export interface Interface {
/** Snapshots active execution owned by this process. */
@@ -47,7 +45,6 @@ export const layer = Layer.effect(
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
effect.pipe(
Effect.tapCause((cause) =>
@@ -57,6 +54,7 @@ export const layer = Layer.effect(
Effect.annotateLogs({ sessionID }),
),
),
Effect.asVoid,
)
// Write-ahead claim: starting records the durable intent that a turn is in flight, in the same
// transaction as the started event. Terminals release it — except shutdown interruption, which
@@ -74,7 +72,7 @@ export const layer = Layer.effect(
reportLifecycle(
sessionID,
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
).pipe(Effect.asVoid),
),
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
@@ -93,9 +91,11 @@ export const layer = Layer.effect(
sessionID,
Effect.gen(function* () {
const outcome = terminal(exit, reason)
if (outcome.type === "succeeded")
if (outcome.type === "succeeded") {
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, releaseOnCommit(sessionID))
if (outcome.type === "interrupted")
return
}
if (outcome.type === "interrupted") {
// A user cancel (or a superseding execution) releases the claim: the turn must not
// resurrect at the next boot. Shutdown interruption keeps it for restart continuity.
yield* bus.publish(
@@ -103,27 +103,16 @@ export const layer = Layer.effect(
{ sessionID, reason: outcome.reason },
outcome.reason === "shutdown" ? undefined : releaseOnCommit(sessionID),
)
if (outcome.type === "failed")
yield* bus.publish(
SessionEvent.Execution.Failed,
{
sessionID,
error: outcome.error,
},
releaseOnCommit(sessionID),
)
if (outcome.type === "interrupted" && outcome.reason === "shutdown") return false
const pending = yield* SessionPending.move(db, sessionID)
if (!pending) return false
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
return
}
yield* bus.publish(
SessionEvent.Moved,
{ sessionID, moveID: pending.id, ...pending.data },
{ location: session.location },
SessionEvent.Execution.Failed,
{
sessionID,
error: outcome.error,
},
releaseOnCommit(sessionID),
)
return yield* SessionPending.has(db, sessionID, "any")
}),
),
})
@@ -141,7 +130,7 @@ export const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
deps: [SessionStore.node, LocationServiceMap.node, Bus.node],
})
/** Low-level compatibility layer for callers that only need durable Session recording. */
+1 -12
View File
@@ -7,8 +7,6 @@ import { SessionEvent } from "../event.js"
import { SessionExecution } from "../execution.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { Database } from "../../database/database.js"
import { SessionPending } from "../pending.js"
const CONTINUE_AFTER_SERVER_RESTART =
"The server restarted while you were working. Continue from where you left off without repeating completed work."
@@ -64,7 +62,6 @@ export const layer = (options?: Options) =>
const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
const scope = yield* Effect.scope
const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
@@ -106,14 +103,6 @@ export const layer = (options?: Options) =>
// them would only inject a stray continuation into a live turn.
const orphaned = (yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID))
yield* Effect.forEach(orphaned, resumeOne, { concurrency: "unbounded", discard: true })
const claimed = new Set(orphaned)
yield* Effect.forEach(
(yield* SessionPending.moveSessions(db)).filter(
(sessionID) => !active.has(sessionID) && !claimed.has(sessionID),
),
execution.wake,
{ concurrency: "unbounded", discard: true },
)
}),
})
}),
@@ -122,5 +111,5 @@ export const layer = (options?: Options) =>
export const node = makeGlobalNode({
service: Service,
layer: layer(),
deps: [SessionStore.node, SessionExecution.node, Bus.node, Database.node],
deps: [SessionStore.node, SessionExecution.node, Bus.node],
})
@@ -106,7 +106,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
})
},
"session.move.admitted": () => Effect.void,
"session.renamed": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
+12 -118
View File
@@ -7,8 +7,6 @@ import {
Delivery,
Info,
Message,
Move,
MoveData,
Synthetic,
SyntheticData,
User,
@@ -21,11 +19,10 @@ import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable, SessionPendingTable } from "./sql.js"
import { Event } from "@opencode-ai/schema/event"
type DatabaseService = Database.Interface["db"]
export { Compaction, Delivery, Info, Message, Move, MoveData, Synthetic, SyntheticData, User, UserData }
export { Compaction, Delivery, Info, Message, Synthetic, SyntheticData, User, UserData }
/**
* Which pending input `promote` may consume: "steer" promotes steers only (a step
@@ -38,8 +35,6 @@ const decodeUser = Schema.decodeUnknownSync(UserData)
const encodeUser = Schema.encodeSync(UserData)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
const encodeSynthetic = Schema.encodeSync(SyntheticData)
const decodeMove = Schema.decodeUnknownSync(MoveData)
const encodeMove = Schema.encodeSync(MoveData)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
@@ -47,24 +42,21 @@ type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionS
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
"SessionPending.LifecycleConflict",
{
id: Schema.Union([SessionMessage.ID, Event.ID]),
id: SessionMessage.ID,
},
) {}
const fromRow = (row: typeof SessionPendingTable.$inferSelect): Info => {
const base = {
id: SessionMessage.ID.make(row.id),
sessionID: SessionSchema.ID.make(row.session_id),
timeCreated: DateTime.makeUnsafe(row.time_created),
}
if (row.type === "move")
return Move.make({ ...base, id: Event.ID.make(row.id), type: "move", data: decodeMove(row.data) })
const id = SessionMessage.ID.make(row.id)
if (row.type === "compaction") return Compaction.make({ ...base, id, type: "compaction" })
if (!row.delivery) throw new LifecycleConflict({ id })
if (row.type === "compaction") return Compaction.make({ ...base, type: "compaction" })
if (!row.delivery) throw new LifecycleConflict({ id: base.id })
if (row.type === "user")
return User.make({
...base,
id,
type: "user",
data: decodeUser(row.data),
delivery: row.delivery,
@@ -72,12 +64,11 @@ const fromRow = (row: typeof SessionPendingTable.$inferSelect): Info => {
if (row.type === "synthetic")
return Synthetic.make({
...base,
id,
type: "synthetic",
data: decodeSynthetic(row.data),
delivery: row.delivery,
})
throw new LifecycleConflict({ id })
throw new LifecycleConflict({ id: base.id })
}
export const find = Effect.fn("SessionPending.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
@@ -107,44 +98,6 @@ export const compaction = Effect.fn("SessionPending.compaction")(function* (
return entry.type === "compaction" ? entry : undefined
})
export const move = Effect.fn("SessionPending.move")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const row = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.type, "move")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!row) return
const entry = fromRow(row)
return entry.type === "move" ? entry : undefined
})
export const admitMove = Effect.fn("SessionPending.admitMove")(function* (
db: DatabaseService,
bus: Bus.Interface,
input: { readonly sessionID: SessionSchema.ID; readonly data: MoveData; readonly source: MoveData["location"] },
) {
return yield* inboxLocks.withLock(input.sessionID)(
Effect.gen(function* () {
const pending = yield* move(db, input.sessionID)
if (pending && JSON.stringify(encodeMove(pending.data)) === JSON.stringify(encodeMove(input.data))) return pending
const event = yield* bus.publish(
SessionEvent.MoveAdmitted,
{
sessionID: input.sessionID,
move: input.data,
},
{ location: input.source },
)
const stored = yield* move(db, input.sessionID)
if (stored) return stored
return yield* Effect.die(new LifecycleConflict({ id: event.id }))
}),
)
})
const promotedFromMessage = Effect.fn("SessionPending.promotedFromMessage")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
@@ -335,35 +288,6 @@ export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompac
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectMoveAdmitted = Effect.fn("SessionPending.projectMoveAdmitted")(function* (
db: DatabaseService,
input: {
readonly admittedSeq: number
readonly id: Event.ID
readonly sessionID: SessionSchema.ID
readonly data: MoveData
readonly timeCreated: DateTime.Utc
},
) {
yield* db
.delete(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, input.sessionID), eq(SessionPendingTable.type, "move")))
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionPendingTable)
.values({
id: input.id,
session_id: input.sessionID,
type: "move",
data: input.data,
admitted_seq: input.admittedSeq,
time_created: DateTime.toEpochMillis(input.timeCreated),
})
.run()
.pipe(Effect.orDie)
})
/**
* Consume one pending row at promotion. The row's content feeds the projected
* message insert inside the same event transaction; the deleted row is what
@@ -373,8 +297,7 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
db: DatabaseService,
input: PendingRef,
) {
if ((yield* compaction(db, input.sessionID)) || (yield* move(db, input.sessionID)))
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const deleted = yield* db
.delete(SessionPendingTable)
.where(and(eq(SessionPendingTable.id, input.id), eq(SessionPendingTable.session_id, input.sessionID)))
@@ -383,8 +306,7 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const stored = fromRow(deleted)
if (stored.type === "compaction" || stored.type === "move")
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
if (stored.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return stored
})
@@ -452,33 +374,6 @@ export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(fun
return undefined
})
export const settleMove = Effect.fn("SessionPending.settleMove")(function* (
db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID; readonly id: Event.ID },
) {
yield* db
.delete(SessionPendingTable)
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
eq(SessionPendingTable.type, "move"),
),
)
.run()
.pipe(Effect.orDie)
})
export const moveSessions = Effect.fn("SessionPending.moveSessions")(function* (db: DatabaseService) {
const rows = yield* db
.select({ sessionID: SessionPendingTable.session_id })
.from(SessionPendingTable)
.where(eq(SessionPendingTable.type, "move"))
.all()
.pipe(Effect.orDie)
return [...new Set(rows.map((row) => row.sessionID))]
})
export const list = Effect.fn("SessionPending.list")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const rows = yield* db
.select()
@@ -502,7 +397,7 @@ export const has = Effect.fn("SessionPending.has")(function* (
sessionID: SessionSchema.ID,
scope: Scope,
) {
if (scope !== "any" && ((yield* compaction(db, sessionID)) || (yield* move(db, sessionID)))) return false
if (scope !== "any" && (yield* compaction(db, sessionID))) return false
const row = yield* db
.select({ id: SessionPendingTable.id })
.from(SessionPendingTable)
@@ -578,13 +473,12 @@ const publish = Effect.fn("SessionPending.publish")(function* (
sessionID: SessionSchema.ID,
rows: ReadonlyArray<typeof SessionPendingTable.$inferSelect>,
) {
if ((yield* compaction(db, sessionID)) || (yield* move(db, sessionID))) return 0
if (yield* compaction(db, sessionID)) return 0
yield* Effect.forEach(
rows,
(row) => {
const entry = fromRow(row)
if (entry.type === "compaction" || entry.type === "move")
return Effect.die(new LifecycleConflict({ id: entry.id }))
if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id }))
return bus
.publish(SessionEvent.InputPromoted, {
sessionID,
@@ -618,7 +512,7 @@ export const promote = Effect.fn("SessionPending.promote")(function* (
) {
return yield* inboxLocks.withLock(sessionID)(
Effect.gen(function* () {
if ((yield* compaction(db, sessionID)) || (yield* move(db, sessionID))) return 0
if (yield* compaction(db, sessionID)) return 0
const steers = yield* db
.select()
.from(SessionPendingTable)
-15
View File
@@ -433,8 +433,6 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie)
yield* InstructionState.reset(db, event.data.sessionID)
if (event.data.moveID)
yield* SessionPending.settleMove(db, { sessionID: event.data.sessionID, id: event.data.moveID })
}),
)
yield* bus.project(SessionEvent.Deleted, (event) =>
@@ -524,19 +522,6 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.MoveAdmitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
yield* SessionPending.projectMoveAdmitted(db, {
admittedSeq: event.durable.seq,
id: event.id,
sessionID: event.data.sessionID,
data: event.data.move,
timeCreated: event.created,
})
}),
)
yield* bus.project(SessionEvent.InputCancelled, (event) =>
SessionPending.projectCancelled(db, {
id: event.data.inputID,
+3 -24
View File
@@ -1,6 +1,6 @@
export * as SessionRunCoordinator from "./run-coordinator.js"
import { Cause, Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
/** Serializes execution for each key while allowing different keys to run concurrently. */
export interface Coordinator<Key, E, Reason = never> {
@@ -50,17 +50,11 @@ export const make = <Key, E, Reason = never>(options: {
* Runs in the execution fiber for every exit, including interruption, after the final
* drain and before the execution settles (waiters resolve after it completes).
*/
readonly settled?: (key: Key, exit: Exit.Exit<void, E>, reason?: Reason) => Effect.Effect<boolean | void>
readonly settled?: (key: Key, exit: Exit.Exit<void, E>, reason?: Reason) => Effect.Effect<void>
}): Effect.Effect<Coordinator<Key, E, Reason>, never, Scope.Scope> =>
Effect.gen(function* () {
const executions = new Map<Key, Execution<E, Reason>>()
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const closing = { value: false }
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
closing.value = true
}),
)
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
Effect.suspend(() => options.drain(key, force)).pipe(
@@ -91,22 +85,7 @@ export const make = <Key, E, Reason = never>(options: {
Effect.onExit((exit) =>
Effect.sync(() => {
execution.owner = undefined
if (closing.value && Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)) {
execution.stopping = true
execution.pendingWake = false
}
}).pipe(
Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void),
Effect.map(Boolean),
Effect.tap((restart) =>
restart && !execution.stopping
? Effect.sync(() => {
execution.pendingWake = true
})
: Effect.void,
),
Effect.asVoid,
),
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
),
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
Effect.exit,
+3 -7
View File
@@ -113,8 +113,8 @@ const layer = Layer.effect(
const compaction = yield* SessionCompaction.Service
const title = yield* SessionTitle.Service
const toolOutput = yield* ToolOutput.Service
// Title generation is a side effect of a successful step; it must not delay continuation.
// The in-flight set coalesces overlapping steps while title presence records success durably.
// Title generation starts once input is visible and must not delay model execution.
// The in-flight set coalesces overlapping prompts while title presence records success durably.
const titlesRunning = new Set<SessionSchema.ID>()
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
/**
@@ -143,9 +143,7 @@ const layer = Layer.effect(
let promotable: SessionPending.Promotable = "input"
let step = 1
while (true) {
if (yield* SessionPending.move(db, sessionID)) return
const result = yield* runStep(sessionID, promotable, step)
if (step === 1) yield* startTitle(sessionID)
yield* runPendingCompaction(sessionID)
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
promotable = "steer"
@@ -237,8 +235,7 @@ const layer = Layer.effect(
// a blocked first step leaves pending inputs untouched.
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
const promoted = promotable ? yield* SessionPending.promote(db, bus, selected.session.id, promotable) : 0
if (promotable && promoted === 0 && (yield* SessionPending.move(db, sessionID)))
return CallOutcome.Completed({ needsContinuation: false, step })
if (promoted > 0) yield* startTitle(sessionID)
// Promoted input opens a fresh step allowance.
const currentStep = promoted > 0 ? 1 : step
const loaded = yield* context.load(selected)
@@ -485,7 +482,6 @@ const layer = Layer.effect(
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
sessionID: SessionSchema.ID,
) {
if (yield* SessionPending.move(db, sessionID)) return
const pending = yield* SessionPending.compaction(db, sessionID)
if (!pending) return
const session = yield* getSession(sessionID)
+2 -4
View File
@@ -96,15 +96,13 @@ export const SessionMessageTable = sqliteTable(
export const SessionPendingTable = sqliteTable(
"session_pending",
{
id: text().$type<SessionPending.Info["id"]>().primaryKey(),
id: text().$type<SessionMessage.ID>().primaryKey(),
session_id: text()
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
type: text().$type<SessionPending.Info["type"]>().notNull(),
data: text({ mode: "json" })
.$type<UserData | SyntheticData | SessionPending.MoveData | Record<string, never>>()
.notNull(),
data: text({ mode: "json" }).$type<UserData | SyntheticData | Record<string, never>>().notNull(),
delivery: text().$type<SessionPending.Delivery>(),
admitted_seq: integer().notNull(),
time_created: integer()
+1 -1
View File
@@ -5,7 +5,7 @@ import { Deferred, Effect, Fiber, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { AbsolutePath } from "@opencode-ai/core/schema"
+1 -1
View File
@@ -1,5 +1,5 @@
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { Effect, Layer } from "effect"
@@ -7,7 +7,7 @@ import { Bus } from "@opencode-ai/core/bus"
import { ConfigInstructionPlugin } from "@opencode-ai/core/config/plugin/instruction"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { FSUtil } from "@opencode-ai/util/fs-util"
+1 -1
View File
@@ -7,7 +7,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Option, Schema } from "effect"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { it } from "../lib/effect"
const key = (value: string) => Instructions.Key.make(value)
@@ -1,7 +1,6 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Failed, NotFound, WrongKind, type Files } from "../../src/environment/index"
import { it } from "./effect"
import { Failed, NotFound, WrongKind, type Files } from "@opencode-ai/core/environment/index"
export interface EnvironmentHarness {
readonly files: Files
@@ -16,17 +15,21 @@ export const environmentConformance = <E>(
skip = false,
) => {
const check = <A, E2>(title: string, body: (harness: EnvironmentHarness) => Effect.Effect<A, E2>) =>
it.live(title, () =>
Effect.gen(function* () {
const harness = yield* Effect.acquireRelease(makeHarness(), (harness) =>
test(title, () =>
Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* Effect.ignore(harness.files.remove(harness.root))
if (harness.dispose) yield* harness.dispose
const harness = yield* Effect.acquireRelease(makeHarness(), (harness) =>
Effect.gen(function* () {
yield* Effect.ignore(harness.files.remove(harness.root))
if (harness.dispose) yield* harness.dispose
}),
)
yield* harness.files.mkdir(harness.root)
return yield* body(harness)
}),
)
yield* harness.files.mkdir(harness.root)
return yield* body(harness)
}),
),
),
)
const bytes = (value: string) => new TextEncoder().encode(value)
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect, Option, Schema } from "effect"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
export interface State {
readonly values: Readonly<Record<string, Schema.Json>>
+38
View File
@@ -248,6 +248,12 @@ const mcp = Layer.mock(MCP.Service, {
required: ["ok"],
},
}),
new MCP.Tool({
server: MCP.ServerName.make("demo"),
name: "status",
description: "Status",
inputSchema: { type: "object", properties: {} },
}),
new MCP.Tool({
server: MCP.ServerName.make("direct"),
name: "lookup",
@@ -290,6 +296,13 @@ const mcp = Layer.mock(MCP.Service, {
{ type: "media", data: "aGVsbG8=", mimeType: "image/png" },
],
})
if (input.name === "status")
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "hello" }],
})
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
tool: input.name,
@@ -984,6 +997,31 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
}),
)
it.effect("returns content-only MCP results through Code Mode", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.status")
const execution = yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_content_only"),
...toolIdentity,
call: {
type: "tool-call",
id: "call_mcp_content_only",
name: "execute",
input: { code: "return await tools.demo.status({})" },
},
})
expect(execution).toMatchObject({
output: { output: "hello", toolCalls: [{ tool: "demo.status", status: "completed" }] },
content: [{ type: "text", text: "hello" }],
})
}),
)
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
+41
View File
@@ -393,4 +393,45 @@ describe("fromPromise", () => {
expect(progress).toEqual([{ phase: "greeting" }])
}),
)
it.effect("returns content-only plugin results through Code Mode", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const promisePlugin = define({
id: "content-only-tool",
setup: async (ctx) => {
await ctx.tool.transform((tools) => {
tools.add({
name: "demo_status",
description: "Returns a status string",
input: Schema.Struct({}),
execute: async () => ({ content: [{ type: "text", text: "hello" }] }),
options: { codemode: true },
})
})
},
})
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
const toolSet = yield* registry.snapshot()
const throughCodeMode = yield* toolSet.execute({
sessionID: Session.ID.make("ses_content_only_tool"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_content_only_tool"),
call: {
type: "tool-call",
id: "call_content_only_tool",
name: "execute",
input: { code: "return await tools.demo_status({})" },
},
})
expect(throughCodeMode).toMatchObject({
output: { output: "hello", toolCalls: [{ tool: "demo_status", status: "completed" }] },
content: [{ type: "text", text: "hello" }],
})
}),
)
})
@@ -90,6 +90,36 @@ describe("SystemPromptPlugin", () => {
}),
)
it.effect("selects the Meta prompt for Muse family model IDs", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const pluginHost = yield* makeHost
yield* SystemPromptPlugin.MetaPlugin.effect(pluginHost)
yield* Effect.forEach(
[
["meta/muse-spark-preview", "Muse Spark"],
["muse-spark-1.2", "Muse Spark"],
["meta/muse-glimmer-30b", "Muse Glimmer"],
["muse-glimmer-30b", "Muse Glimmer"],
] as const,
([id, name]) => {
const event = context(id)
return hooks.trigger("session", "context", event).pipe(
Effect.tap(() =>
Effect.sync(() => {
expect(event.system[0]?.text).toContain(`powered by ${name},`)
expect(event.system[0]?.text).toContain(`using Meta ${name}.`)
expect(event.system[0]?.text).not.toContain("{{MODEL_NAME}}")
}),
),
)
},
{ discard: true },
)
}),
)
it.effect("preserves an explicit agent system prompt", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
+3 -106
View File
@@ -8,25 +8,20 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { UserInterruptedError } from "@opencode-ai/core/session/error"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { Location } from "@opencode-ai/core/location"
import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "effect"
import { eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node])),
)
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node])))
describe("SessionExecution lifecycle", () => {
test("classifies success and typed failure terminals", () => {
@@ -138,104 +133,6 @@ describe("SessionExecution lifecycle", () => {
}),
)
it.effect("applies a deferred move only after the active execution settles", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_deferred_move")
yield* seedSessions(database, [sessionID])
const draining = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, () =>
Deferred.succeed(draining, undefined).pipe(Effect.andThen(Deferred.await(release))),
)
const execution = Context.get(context, SessionExecution.Service)
yield* execution.resume(sessionID).pipe(Effect.forkIn(scope))
yield* Deferred.await(draining)
yield* bus.publish(SessionEvent.MoveAdmitted, {
sessionID,
move: {
location: Location.Ref.make({ directory: AbsolutePath.make("/destination") }),
projectID: Project.ID.global,
subpath: RelativePath.make(""),
},
})
expect((yield* store.get(sessionID))?.location.directory).toBe(AbsolutePath.make("/project"))
expect((yield* SessionPending.move(database.db, sessionID))?.data.location.directory).toBe(
AbsolutePath.make("/destination"),
)
yield* Deferred.succeed(release, undefined)
yield* execution.awaitIdle(sessionID)
expect((yield* store.get(sessionID))?.location.directory).toBe(AbsolutePath.make("/destination"))
expect(yield* SessionPending.move(database.db, sessionID)).toBeUndefined()
}),
)
it.effect("settling one move preserves a newer admitted destination", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_move_replacement")
yield* seedSessions(database, [sessionID])
const first = {
location: Location.Ref.make({ directory: AbsolutePath.make("/first") }),
projectID: Project.ID.global,
subpath: RelativePath.make("first"),
}
const second = {
location: Location.Ref.make({ directory: AbsolutePath.make("/second") }),
projectID: Project.ID.global,
subpath: RelativePath.make("second"),
}
const admittedFirst = yield* bus.publish(SessionEvent.MoveAdmitted, { sessionID, move: first })
const admittedSecond = yield* bus.publish(SessionEvent.MoveAdmitted, { sessionID, move: second })
yield* bus.publish(SessionEvent.Moved, { sessionID, moveID: admittedFirst.id, ...first })
expect((yield* store.get(sessionID))?.location.directory).toBe(first.location.directory)
expect((yield* SessionPending.move(database.db, sessionID))?.id).toBe(admittedSecond.id)
yield* bus.publish(SessionEvent.Moved, { sessionID, moveID: admittedSecond.id, ...second })
expect((yield* store.get(sessionID))?.location.directory).toBe(second.location.directory)
expect(yield* SessionPending.move(database.db, sessionID)).toBeUndefined()
}),
)
it.effect("recovers an unclaimed deferred move on startup", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_move_recovery")
yield* seedSessions(database, [sessionID])
yield* bus.publish(SessionEvent.MoveAdmitted, {
sessionID,
move: {
location: Location.Ref.make({ directory: AbsolutePath.make("/recovered") }),
projectID: Project.ID.global,
},
})
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, () => Effect.void)
const execution = Context.get(context, SessionExecution.Service)
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
yield* execution.awaitIdle(sessionID)
expect((yield* store.get(sessionID))?.location.directory).toBe(AbsolutePath.make("/recovered"))
expect(yield* SessionPending.move(database.db, sessionID)).toBeUndefined()
}),
)
it.effect("starts every claimed execution without waiting for earlier drains to finish", () =>
Effect.gen(function* () {
const database = yield* Database.Service
+1 -1
View File
@@ -17,7 +17,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { EventTable } from "@opencode-ai/core/event/sql"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { Location } from "@opencode-ai/core/location"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
+17 -20
View File
@@ -1,6 +1,5 @@
import { describe, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { Effect, Layer } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
@@ -35,7 +34,7 @@ const it = testEffect(
)
describe("Session.move", () => {
it.effect("durably admits a move when the source directory no longer exists", () =>
it.effect("moves a session whose source directory no longer exists", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -50,26 +49,24 @@ describe("Session.move", () => {
yield* session.move({ sessionID: created.id, directory: destination })
expect((yield* session.get(created.id)).location.directory).toBe(
AbsolutePath.make(path.join(tmp.path, "deleted")),
)
expect(yield* session.pending(created.id)).toMatchObject([
{
type: "move",
data: { location: { directory: destination }, projectID: Project.ID.global },
},
expect((yield* session.get(created.id)).location.directory).toBe(destination)
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
expect(messages).toEqual([
expect.objectContaining({
type: "location-switched",
location: { directory: destination },
projectID: Project.ID.global,
previous: {
location: { directory: path.join(tmp.path, "deleted") },
projectID: Project.ID.global,
subpath: "",
},
subpath: "",
}),
])
const replacement = AbsolutePath.make(path.join(tmp.path, "replacement"))
yield* Effect.promise(() => fs.mkdir(replacement))
yield* session.move({ sessionID: created.id, directory: replacement })
expect(yield* session.pending(created.id)).toMatchObject([
{
type: "move",
data: { location: { directory: replacement }, projectID: Project.ID.global },
},
])
yield* session.move({ sessionID: created.id, directory: destination })
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toEqual(messages)
}),
),
),
@@ -143,26 +143,19 @@ describe("SessionRunCoordinator", () => {
it.effect("cleans active executions when its scope closes", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* Effect.scoped(
Effect.gen(function* () {
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => runs++).pipe(
Effect.andThen(Deferred.succeed(started, undefined)),
Effect.andThen(Effect.never),
),
drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
yield* coordinator.wake("session")
expect(Array.from(yield* coordinator.active)).toEqual(["session"])
return coordinator
}),
)
expect(Array.from(yield* coordinator.active)).toEqual([])
expect(runs).toBe(1)
}),
)
@@ -524,31 +517,6 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("starts one successor when settlement requests it", () =>
Effect.scoped(
Effect.gen(function* () {
const successor = yield* Deferred.make<void>()
let drains = 0
let settlements = 0
const coordinator = yield* SessionRunCoordinator.make<string, never>({
drain: () =>
Effect.sync(() => {
drains++
if (drains === 2) Deferred.doneUnsafe(successor, Effect.void)
}),
settled: () => Effect.sync(() => ++settlements === 1),
})
yield* coordinator.wake("session")
yield* Deferred.await(successor)
yield* coordinator.awaitIdle("session")
expect(drains).toBe(2)
expect(settlements).toBe(2)
}),
),
)
it.effect("trampolines synchronous self-waking execution", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -22,7 +22,7 @@ import { SessionTitle } from "@opencode-ai/core/session/title"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { Tool } from "@opencode-ai/core/tool"
@@ -31,7 +31,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { Location } from "@opencode-ai/core/location"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
+39 -7
View File
@@ -38,7 +38,7 @@ import { Money } from "@opencode-ai/schema/money"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { PromptCacheDiagnostics } from "@opencode-ai/core/session/prompt-cache-diagnostics"
@@ -61,7 +61,7 @@ import {
} from "@opencode-ai/core/session/sql"
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
@@ -816,7 +816,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
})
describe("SessionRunnerLLM", () => {
it.effect("retries title generation from the first prompt after execution and title failures", () =>
it.effect("generates the title while the first model step is still running", () =>
Effect.gen(function* () {
const session = yield* setup
const agents = yield* Agent.Service
@@ -831,16 +831,48 @@ describe("SessionRunnerLLM", () => {
)
yield* admit(session, "First prompt")
yield* TestLLM.push(Stream.fail(invalidRequest()))
yield* TestLLM.push(TestLLM.text("Generated title", "text-title"), Stream.never)
const bus = yield* Bus.Service
const renamed = yield* bus.subscribe(SessionEvent.Renamed).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.take(1),
Stream.runDrain,
Effect.forkScoped({ startImmediately: true }),
)
const runner = yield* SessionRunner.Service
const fiber = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild)
yield* Fiber.join(renamed)
expect((yield* session.get(sessionID)).title).toBe("Generated title")
yield* Fiber.interrupt(fiber)
}),
)
it.effect("retries title generation from the first prompt after title and execution failures", () =>
Effect.gen(function* () {
const session = yield* setup
const agents = yield* Agent.Service
const { db } = yield* Database.Service
yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "Generate a title."
}),
)
yield* admit(session, "First prompt")
yield* TestLLM.push(Stream.fail(invalidRequest()), Stream.fail(invalidRequest()))
expect((yield* session.resume(sessionID).pipe(Effect.exit))._tag).toBe("Failure")
yield* admit(session, "Second prompt")
const titleFailed = yield* Deferred.make<void>()
yield* TestLLM.push(
TestLLM.text("Recovered", "text-recovered"),
Stream.make(LLMEvent.providerError({ message: "Title provider unavailable" })).pipe(
Stream.ensuring(Deferred.succeed(titleFailed, undefined)),
),
TestLLM.text("Recovered", "text-recovered"),
)
yield* session.resume(sessionID)
yield* Deferred.await(titleFailed)
@@ -856,13 +888,13 @@ describe("SessionRunnerLLM", () => {
)
yield* admit(session, "Third prompt")
yield* TestLLM.push(
TestLLM.text("Recovered again", "text-recovered-again"),
TestLLM.text("Generated title", "text-title"),
TestLLM.text("Recovered again", "text-recovered-again"),
)
yield* session.resume(sessionID)
yield* Fiber.join(renamed)
expect(requests).toHaveLength(5)
expect(requests).toHaveLength(6)
expect(requests[2]?.messages).toContainEqual(Message.user("First prompt"))
expect(requests[4]?.messages).toContainEqual(Message.user("First prompt"))
expect((yield* session.get(sessionID)).title).toBe("Generated title")
+39 -36
View File
@@ -143,44 +143,47 @@ describe("Snapshot", () => {
),
)
testEffect(Layer.empty).live("isolates snapshot indexes by canonical Git worktree", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const linked = path.join(tmp.path, "linked")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
await initGit(project, true)
await $`git -c core.fsmonitor=false worktree add --detach ${linked} HEAD`.cwd(project).quiet()
})
testEffect(Layer.empty).live(
"isolates snapshot indexes by canonical Git worktree",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const linked = path.join(tmp.path, "linked")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
await initGit(project, true)
await $`git -c core.fsmonitor=false worktree add --detach ${linked} HEAD`.cwd(project).quiet()
})
const capture = (directory: string) =>
Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
return yield* snapshot.capture()
}).pipe(Effect.provide(snapshotLayer(tmp.path, directory)))
expect(yield* capture(project)).toBeDefined()
expect(yield* capture(linked)).toBeDefined()
const capture = (directory: string) =>
Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
return yield* snapshot.capture()
}).pipe(Effect.provide(snapshotLayer(tmp.path, directory)))
expect(yield* capture(project)).toBeDefined()
expect(yield* capture(linked)).toBeDefined()
const projectID = yield* Effect.gen(function* () {
return (yield* Location.Service).project.id
}).pipe(
Effect.provide(
AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
),
)
expect(
yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))),
).toBeDefined()
expect(
yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))),
).toBeDefined()
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
const projectID = yield* Effect.gen(function* () {
return (yield* Location.Service).project.id
}).pipe(
Effect.provide(
AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
),
)
expect(
yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))),
).toBeDefined()
expect(
yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))),
).toBeDefined()
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
{ timeout: 15_000 },
)
})
+1 -1
View File
@@ -4,7 +4,7 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { Location } from "@opencode-ai/core/location"
+1 -1
View File
@@ -4,7 +4,7 @@ import { describe, expect } from "bun:test"
import { Effect, Exit, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "@opencode-ai/core/formatter"
import { FileMutation } from "@opencode-ai/core/file-mutation"
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
+1 -1
View File
@@ -21,7 +21,7 @@ import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
+1 -1
View File
@@ -5,7 +5,7 @@ import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
+80 -71
View File
@@ -12,7 +12,7 @@ import { filesystem } from "@opencode-ai/util/effect/app-node-platform"
import { Database } from "@opencode-ai/core/database/database"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "@opencode-ai/core/location"
@@ -387,57 +387,63 @@ describe("ShellTool", () => {
),
)
it.live("approves an explicit external workdir before shell execution", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
return withSession(active.path, (registry) =>
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
expect(assertions[0]).toMatchObject({
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
})
}),
it.live(
"approves an explicit external workdir before shell execution",
() =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
return withSession(active.path, (registry) =>
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
expect(assertions[0]).toMatchObject({
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
})
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
),
{ timeout: 15_000 },
)
it.live("approves an external directory used by a directory-change command", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
const command = isWindows
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
: `cd '${outside.path}' && pwd`
return withSession(active.path, (registry) =>
executeTool(registry, call({ command }, "call-external-cd")),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
expect(assertions[0]).toMatchObject({
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
})
}),
it.live(
"approves an external directory used by a directory-change command",
() =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
const command = isWindows
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
: `cd '${outside.path}' && pwd`
return withSession(active.path, (registry) =>
executeTool(registry, call({ command }, "call-external-cd")),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
expect(assertions[0]).toMatchObject({
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
})
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
),
{ timeout: 15_000 },
)
it.live("approves an expanded external home directory", () =>
@@ -459,28 +465,31 @@ describe("ShellTool", () => {
),
)
it.live("does not execute after external-directory or shell denial", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) =>
Effect.gen(function* () {
reset()
denyAction = "external_directory"
yield* withSession(active.path, (registry) =>
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
)
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
it.live(
"does not execute after external-directory or shell denial",
() =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) =>
Effect.gen(function* () {
reset()
denyAction = "external_directory"
yield* withSession(active.path, (registry) =>
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
)
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
reset()
denyAction = "shell"
yield* withSession(active.path, (registry) => executeTool(registry, call({ command: cwdCommand })))
expect(assertions.map((item) => item.action)).toEqual(["shell"])
}),
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
reset()
denyAction = "shell"
yield* withSession(active.path, (registry) => executeTool(registry, call({ command: cwdCommand })))
expect(assertions.map((item) => item.action)).toEqual(["shell"])
}),
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
{ timeout: 15_000 },
)
it.live("keeps non-zero exits useful", () =>
@@ -619,7 +628,7 @@ describe("ShellTool", () => {
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 10_000 },
{ timeout: 15_000 },
)
it.live(
@@ -630,7 +639,7 @@ describe("ShellTool", () => {
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 50 })),
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 500 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
+1 -1
View File
@@ -6,7 +6,7 @@ import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
+1 -1
View File
@@ -1,7 +1,7 @@
import { beforeEach, expect } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Database } from "@opencode-ai/core/database/database"
import { makeMemoryDriver } from "@opencode-ai/core/environment"
import { makeMemoryDriver } from "@opencode-ai/core/environment/index"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { WorkspaceTable } from "@opencode-ai/core/workspace/sql"
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"incremental": false,
"outDir": "dist/types",
"tsBuildInfoFile": null
},
"include": ["src"]
}
+1 -1
View File
@@ -308,7 +308,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
OpenApi.annotations({
identifier: "v2.session.move",
summary: "Move session",
description: "Move a session to another project directory after any active execution settles.",
description: "Move a session to another project directory, optionally transferring local changes.",
}),
),
)
-12
View File
@@ -90,7 +90,6 @@ export const Moved = Event.durable({
...options,
schema: {
...Base,
moveID: Event.ID.pipe(optional),
location: Location.Ref,
projectID: Project.ID.pipe(optional),
subpath: RelativePath.pipe(optional),
@@ -98,16 +97,6 @@ export const Moved = Event.durable({
})
export type Moved = typeof Moved.Type
export const MoveAdmitted = Event.durable({
type: "session.move.admitted",
...options,
schema: {
...Base,
move: SessionPending.MoveData,
},
})
export type MoveAdmitted = typeof MoveAdmitted.Type
export const Renamed = Event.durable({
type: "session.renamed",
...options,
@@ -608,7 +597,6 @@ export const Definitions = Event.inventory(
Created,
AgentSelected,
ModelSelected,
MoveAdmitted,
Moved,
Renamed,
UsageUpdated,
+1 -21
View File
@@ -7,10 +7,6 @@ import { DateTimeUtcFromMillis } from "./schema.js"
import { SessionDelivery } from "./session-delivery.js"
import { SessionID } from "./session-id.js"
import { SessionMessage } from "./session-message.js"
import { Event } from "./event.js"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { RelativePath } from "./schema.js"
export const Delivery = SessionDelivery.Delivery
export type Delivery = SessionDelivery.Delivery
@@ -72,23 +68,7 @@ export const Compaction = Schema.Struct({
type: Schema.tag("compaction"),
}).annotate({ identifier: "SessionPending.Compaction" })
export interface MoveData extends Schema.Schema.Type<typeof MoveData> {}
export const MoveData = Schema.Struct({
location: Location.Ref,
projectID: Project.ID,
subpath: RelativePath.pipe(optional),
}).annotate({ identifier: "SessionPending.MoveData" })
export interface Move extends Schema.Schema.Type<typeof Move> {}
export const Move = Schema.Struct({
id: Event.ID,
sessionID: SessionID,
timeCreated: DateTimeUtcFromMillis,
type: Schema.tag("move"),
data: MoveData,
}).annotate({ identifier: "SessionPending.Move" })
export const Info = Schema.Union([User, Synthetic, Compaction, Move]).pipe(
export const Info = Schema.Union([User, Synthetic, Compaction]).pipe(
Schema.toTaggedUnion("type"),
Schema.annotate({ identifier: "SessionPending.Info" }),
)
@@ -90,16 +90,6 @@ describe("contract hygiene", () => {
})
})
test("pending moves omit absent placement details", () => {
expect(
Schema.encodeSync(SessionPending.MoveData)({
location: { directory: AbsolutePath.make("/project"), workspaceID: undefined },
projectID: Project.ID.global,
subpath: undefined,
}),
).toEqual({ location: { directory: "/project" }, projectID: "global" })
})
test("forms require at least one field", () => {
expect(() =>
Schema.decodeUnknownSync(Form.Info)({
@@ -78,7 +78,6 @@ describe("public event manifest", () => {
"session.deleted.2",
"session.agent.selected.1",
"session.model.selected.1",
"session.move.admitted.1",
"session.moved.1",
"session.renamed.1",
"session.usage.recorded.1",
+1 -1
View File
@@ -2,7 +2,7 @@ import { Effect, Sink, Stream } from "effect"
import { systemError } from "effect/PlatformError"
import type { Command, KillOptions } from "effect/unstable/process/ChildProcess"
import { ExitCode, make, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
import type { Driver } from "@opencode-ai/core/environment"
import type { Driver } from "@opencode-ai/core/environment/index"
import type { App, Image, ModalClient, ModalClientParams, Sandbox, SandboxCreateParams } from "modal"
const INNER_WRAPPER = `
@@ -4,8 +4,8 @@ import path from "node:path"
import { afterAll, expect, test } from "bun:test"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Failed, makeFiles } from "@opencode-ai/core/environment"
import { environmentConformance } from "@opencode-ai/core/testing/environment-conformance"
import { Failed, makeFiles } from "@opencode-ai/core/environment/index"
import { environmentConformance } from "../../core/test/lib/environment-conformance.js"
import { createModalSandbox } from "../src/workspace/modal"
const enabled =
+1 -1
View File
@@ -3,7 +3,7 @@ import os from "node:os"
import path from "node:path"
import { expect, test } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeFiles } from "@opencode-ai/core/environment"
import { makeFiles } from "@opencode-ai/core/environment/index"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { Effect, Layer } from "effect"
+1 -1
View File
@@ -501,7 +501,7 @@ function App(props: { pair?: DialogPairCredentials }) {
toast.show({
variant: "error",
title: `MCP server failed: ${server.name}`,
message: "Open MCP servers to view details.",
message: "Run /mcps to view details.",
})
}
})
@@ -0,0 +1,115 @@
import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { useConfig } from "../config"
import { useClipboard } from "../context/clipboard"
import { Keymap } from "../context/keymap"
import { getScrollAcceleration } from "../util/scroll"
import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme"
import { useToast } from "../ui/toast"
export function DialogErrorDetails(props: { title: string; error: string; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const [scrollable, setScrollable] = createSignal(false)
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
let measure: (() => void) | undefined
onMount(() => dialog.setSize("large"))
createEffect(() => {
dimensions()
props.error
if (measure) renderer.off(CliRenderEvents.FRAME, measure)
measure = () => {
measure = undefined
setScrollable(Boolean(scroll && scroll.scrollHeight > scroll.viewport.height))
}
renderer.once(CliRenderEvents.FRAME, measure)
renderer.requestRender()
})
onCleanup(() => {
if (measure) renderer.off(CliRenderEvents.FRAME, measure)
})
const copy = () => {
void clipboard
.write(props.error)
.then(() => setCopied(true))
.catch(toast.error)
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{ bind: "escape", title: "Back", group: "Dialog", run: props.onBack },
{ bind: "c", title: "Copy details", group: "Dialog", run: copy },
],
}))
useKeyboard((event) => {
if (!scrollable()) return
if (event.name === "up") return scroll?.scrollBy(-1)
if (event.name === "down") return scroll?.scrollBy(1)
if (event.name === "pageup") return scroll?.scrollBy(-height())
if (event.name === "pagedown") return scroll?.scrollBy(height())
if (event.name === "home") return scroll?.scrollTo(0)
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
})
return (
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
esc
</text>
</box>
<text fg={theme.text.feedback.error.default}> Failed</text>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
<text fg={overlayTheme.text.default} wrapMode="word">
{props.error}
</text>
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between">
<text>
<span style={{ fg: theme.text.default }}>
<b>{scrollable() ? "↑/↓" : ""}</b>
</span>
<span style={{ fg: theme.text.subdued }}>{scrollable() ? " scroll" : ""}</span>
</text>
<text onMouseUp={copy}>
<span style={{ fg: copied() ? theme.text.feedback.success.default : theme.text.default }}>
<b>{copied() ? "✓ copied" : "c"}</b>
</span>
<span style={{ fg: theme.text.subdued }}>{copied() ? "" : " copy details"}</span>
</text>
</box>
</box>
)
}
+6 -84
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
import { createEffect, createMemo, createSignal, Show } from "solid-js"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
@@ -6,13 +6,10 @@ import { pipe, sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { TextAttributes } from "@opentui/core"
import type { McpServer } from "@opencode-ai/client"
import { useClipboard } from "../context/clipboard"
import { useToast } from "../ui/toast"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll"
import { DialogErrorDetails } from "./dialog-error-details"
function statusError(status: McpServer["status"]) {
if (status.status === "failed") return status.error
@@ -143,8 +140,9 @@ export function DialogMcp() {
}
>
{(server) => (
<DialogMcpError
server={server()}
<DialogErrorDetails
title={`MCP server: ${server().name}`}
error={statusError(server().status) ?? "Unknown MCP connection error"}
onBack={() => {
setDetail()
dialog.setSize("medium")
@@ -155,79 +153,3 @@ export function DialogMcp() {
</box>
)
}
function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const error = () => statusError(props.server.status) ?? "Unknown MCP connection error"
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
onMount(() => dialog.setSize("large"))
const copy = () => {
void clipboard
.write(error())
.then(() => setCopied(true))
.catch(toast.error)
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [{ bind: "escape", title: "Back to MCP servers", group: "Dialog", run: props.onBack }],
}))
useKeyboard((event) => {
if (event.name === "c") return copy()
if (event.name === "up") return scroll?.scrollBy(-1)
if (event.name === "down") return scroll?.scrollBy(1)
if (event.name === "pageup") return scroll?.scrollBy(-height())
if (event.name === "pagedown") return scroll?.scrollBy(height())
if (event.name === "home") return scroll?.scrollTo(0)
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
})
return (
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
MCP server: {props.server.name}
</text>
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
esc back
</text>
</box>
<text fg={theme.text.feedback.error.default}> Failed</text>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
<text fg={overlayTheme.text.default} wrapMode="word">
{error()}
</text>
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued}> scroll</text>
<text fg={theme.text.subdued} onMouseUp={copy}>
{copied() ? "✓ copied" : "c copy details"}
</text>
</box>
</box>
)
}
@@ -151,6 +151,7 @@ export function DialogOpen() {
options={options()}
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
focusCurrent={false}
sectionNavigation={true}
preserveSelection={selectionMoved()}
onMove={() => setSelectionMoved(true)}
onFilter={setFilter}
+102 -23
View File
@@ -23,7 +23,7 @@ import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
import { tint } from "../theme/color"
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { projectName } from "../util/project"
import { marqueeText } from "../util/marquee"
import { marqueeCycleWidth, marqueeText } from "../util/marquee"
// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
@@ -60,27 +60,98 @@ function fadeTitleColor(color: RGBA, background: RGBA, index: number, length: nu
return opacity === 0 ? color : tint(color, background, opacity)
}
function createMarquee(hovered: () => string | undefined, animations: () => boolean) {
function createMarquee(animations: () => boolean) {
const [offset, setOffset] = createSignal(0)
const [active, setActive] = createSignal<string>()
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
let delay: ReturnType<typeof setTimeout> | undefined
let interval: ReturnType<typeof setInterval> | undefined
let cycleWidth = 0
let returning = false
createEffect(() => {
const clear = () => {
if (delay) clearTimeout(delay)
if (interval) clearInterval(interval)
delay = undefined
interval = undefined
}
const scroll = () => {
interval = setInterval(() => setOffset((value) => (value + 1) % cycleWidth), MARQUEE_INTERVAL)
}
const enter = (sessionID: string, title: string, width: number) => {
if (active() === sessionID && !returning) return
clear()
if (active() === sessionID) {
returning = false
return scroll()
}
if (stringWidth(title) <= width) return
cycleWidth = marqueeCycleWidth(title)
setActive(sessionID)
setOffset(0)
returning = false
leading.jump({ opacity: 0 })
if (!hovered()) return
let interval: ReturnType<typeof setInterval> | undefined
const delay = setTimeout(() => {
delay = setTimeout(() => {
setOffset(1)
leading.animate({ opacity: 1 })
interval = setInterval(() => setOffset((value) => value + 1), MARQUEE_INTERVAL)
scroll()
}, MARQUEE_DELAY)
onCleanup(() => {
clearTimeout(delay)
if (interval) clearInterval(interval)
}
const leave = (sessionID: string) => {
if (active() !== sessionID) return
clear()
if (offset() === 0) {
setActive(undefined)
return
}
returning = true
interval = setInterval(() => {
setOffset((value) => {
const next = (value + 1) % cycleWidth
if (next !== 0) return next
clear()
returning = false
setActive(undefined)
leading.animate({ opacity: 0 })
return 0
})
}, MARQUEE_INTERVAL)
}
const reset = () => {
clear()
returning = false
setActive(undefined)
setOffset(0)
leading.jump({ opacity: 0 })
}
onCleanup(clear)
return { offset, active, enter, leave, reset, leading: () => leading.value().opacity }
}
function createTabMarquee(animations: () => boolean) {
const [hovered, setHovered] = createSignal<string>()
const marquee = createMarquee(animations)
let hoverClear: ReturnType<typeof setTimeout> | undefined
const enter = (sessionID: string, title: string, width: number) => {
if (hoverClear) clearTimeout(hoverClear)
setHovered(sessionID)
marquee.enter(sessionID, title, width)
}
const leave = (sessionID: string) => {
if (hoverClear) clearTimeout(hoverClear)
hoverClear = setTimeout(() => {
if (hovered() !== sessionID) return
setHovered(undefined)
marquee.leave(sessionID)
})
}
onCleanup(() => {
if (hoverClear) clearTimeout(hoverClear)
})
return { offset, leading: () => leading.value().opacity }
return { ...marquee, hovered, enter, leave }
}
export function SessionTabs(
@@ -105,9 +176,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
const [hovered, setHovered] = createSignal<string>()
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createMarquee(hovered, animations)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
const [dragging, setDragging] = createSignal<string>()
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
const newTab = () => tabs.newTab?.() ?? false
@@ -118,6 +189,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
})
const items = ordered
createEffect(() => {
const active = marquee.active()
if (active && !items().some((tab) => tab.sessionID === active)) marquee.reset()
})
const statuses = createMemo(
() =>
new Map(
@@ -185,7 +260,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const numberWidth = () => 2
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
const title = () => tab.title ?? "Untitled session"
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0
const visibleTitle = createMemo(() =>
scrolling()
? marqueeText(title(), titleWidth(), marquee.offset())
@@ -274,10 +349,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
position="relative"
flexDirection="column"
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseOver={() => marquee.enter(tab.sessionID, title(), titleWidth())}
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={() => {
setHovered(tab.sessionID)
marquee.enter(tab.sessionID, title(), titleWidth())
setDragging(tab.sessionID)
}}
onMouseUp={release}
@@ -492,9 +567,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const { mode } = useThemes()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const [hovered, setHovered] = createSignal<string>()
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createMarquee(hovered, animations)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
const [dragging, setDragging] = createSignal<string>()
// A drag reorders a local preview and persists one move on release instead of writing
// per slot crossing; the preview holds after release until the store reflects the move,
@@ -530,6 +605,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
previous?.start,
),
)
createEffect(() => {
const active = marquee.active()
if (active && !layout().tabs.some((tab) => tab.sessionID === active)) marquee.reset()
})
const statuses = createMemo(
() =>
new Map(
@@ -682,7 +761,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
// Hovering reveals the close mark, so the title's right bound shifts left of it.
const availableTitleWidth = () =>
Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0))
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0
const visibleTitle = createMemo(() =>
scrolling()
? marqueeText(title(), availableTitleWidth(), marquee.offset())
@@ -741,10 +820,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
position="relative"
flexDirection="row"
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseOver={() => marquee.enter(tab.sessionID, title(), availableTitleWidth())}
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={() => {
setHovered(tab.sessionID)
marquee.enter(tab.sessionID, title(), availableTitleWidth())
setDragging(tab.sessionID)
}}
onMouseUp={release}
+15 -11
View File
@@ -96,13 +96,15 @@ export const Definitions = {
"session.move": keybind("none", "Move session"),
"session.new": keybind("<leader>n", "Create a new session"),
"session.list": keybind("<leader>l", "List all sessions"),
"session.tab.next": keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
"session.tab.previous": keybind("ctrl+shift+tab,<leader>left", "Switch to previous open session tab"),
"session.tab.history.back": keybind("ctrl+o", "Go back in session tab history"),
"open.menu": keybind("ctrl+o", "Open recent sessions and projects"),
"session.tab.next": keybind("ctrl+tab,alt+down", "Switch to next open session tab"),
"session.tab.previous": keybind("ctrl+shift+tab,alt+up", "Switch to previous open session tab"),
"session.tab.history.back": keybind("none", "Go back in session tab history"),
"session.tab.history.forward": keybind("ctrl+i", "Go forward in session tab history"),
"session.tab.next_unread": keybind("<leader>down", "Switch to next unread session tab"),
"session.tab.previous_unread": keybind("<leader>up", "Switch to previous unread session tab"),
"session.tab.next_unread": keybind("alt+shift+down", "Switch to next unread session tab"),
"session.tab.previous_unread": keybind("alt+shift+up", "Switch to previous unread session tab"),
"session.tab.close": keybind("<leader>w", "Close current session tab"),
"session.tab.reopen": keybind("ctrl+shift+t", "Reopen last closed session tab"),
"session.timeline": keybind("<leader>g", "Show session timeline"),
"session.fork": keybind("none", "Fork session from message"),
"session.rename": keybind("ctrl+r", "Rename session"),
@@ -139,6 +141,7 @@ export const Definitions = {
"session.tab.select.7": keybind("<leader>7,ctrl+7", "Switch to session tab 7"),
"session.tab.select.8": keybind("<leader>8,ctrl+8", "Switch to session tab 8"),
"session.tab.select.9": keybind("<leader>9,ctrl+9", "Switch to session tab 9"),
"session.tab.select.10": keybind("<leader>0,ctrl+0", "Switch to session tab 10"),
"stash.delete": keybind("ctrl+d", "Delete stash entry"),
"model.dialog.provider": keybind("ctrl+a", "Open provider list from model dialog"),
@@ -164,10 +167,10 @@ export const Definitions = {
"session.half.page.down": keybind("ctrl+alt+d", "Scroll messages down by half page"),
"session.first": keybind("ctrl+g,home,alt+home", "Navigate to first message"),
"session.last": keybind("ctrl+alt+g,end", "Navigate to last message"),
"session.message.next": keybind("alt+down", "Navigate to next message"),
"session.message.previous": keybind("alt+up", "Navigate to previous message"),
"session.message.user.next": keybind("alt+shift+down", "Navigate to next user message"),
"session.message.user.previous": keybind("alt+shift+up", "Navigate to previous user message"),
"session.message.next": keybind("none", "Navigate to next message"),
"session.message.previous": keybind("none", "Navigate to previous message"),
"session.message.user.next": keybind("none", "Navigate to next user message"),
"session.message.user.previous": keybind("none", "Navigate to previous user message"),
"session.messages_last_user": keybind("alt+end", "Navigate to last user message"),
"messages.copy": keybind("<leader>y", "Copy message"),
"session.undo": keybind("<leader>u", "Undo message"),
@@ -177,6 +180,7 @@ export const Definitions = {
"prompt.submit": keybind("none", "Submit prompt"),
"prompt.queue": keybind("alt+return", "Queue prompt"),
"prompt.editor_context.clear": keybind("none", "Clear editor context"),
"prompt.images.view": keybind("<leader>i", "View image attachments"),
"prompt.skills": keybind("none", "Open skill selector"),
"prompt.stash": keybind("none", "Stash prompt"),
"prompt.stash.pop": keybind("none", "Pop stashed prompt"),
@@ -202,8 +206,8 @@ export const Definitions = {
"input.visual.line.end": keybind("alt+e", "Move to end of visual line in input"),
"input.select.visual.line.home": keybind("alt+shift+a", "Select to start of visual line in input"),
"input.select.visual.line.end": keybind("alt+shift+e", "Select to end of visual line in input"),
"input.buffer.home": keybind("home", "Move to start of buffer in input"),
"input.buffer.end": keybind("end", "Move to end of buffer in input"),
"input.buffer.home": keybind("none", "Move to start of buffer in input"),
"input.buffer.end": keybind("none", "Move to end of buffer in input"),
"input.select.buffer.home": keybind("shift+home", "Select to start of buffer in input"),
"input.select.buffer.end": keybind("shift+end", "Select to end of buffer in input"),
"input.delete.line": keybind("ctrl+shift+d", "Delete line in input"),
+1 -2
View File
@@ -201,8 +201,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
function updatePending(sessionID: string, inputID: string, delivery: SessionPending.Delivery) {
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inputID) ?? -1
const item = store.session.pending[sessionID]?.[index]
if (index < 0 || !item || (item.type !== "user" && item.type !== "synthetic") || item.delivery === delivery)
return
if (index < 0 || !item || item.type === "compaction" || item.delivery === delivery) return
setStore("session", "pending", sessionID, index, { ...item, delivery })
}
@@ -1,10 +1,21 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { usePlugin } from "../../plugin/context"
export function homeFooterVisibility(width: number) {
return {
mcpCommand: width >= 64,
pluginCommand: width >= 80,
version: width >= 64,
}
}
function Mcp(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const visibility = createMemo(() => homeFooterVisibility(dimensions().width))
const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? [])
const failed = createMemo(() => list().some((item) => item.status.status === "failed"))
const failed = createMemo(() => list().filter((item) => item.status.status === "failed").length)
const count = createMemo(() => list().filter((item) => item.status.status === "connected").length)
return (
@@ -14,6 +25,7 @@ function Mcp(props: { context: Plugin.Context }) {
<Switch>
<Match when={failed()}>
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span>
{failed()} MCP failed
</Match>
<Match when={true}>
<span
@@ -24,11 +36,34 @@ function Mcp(props: { context: Plugin.Context }) {
>
{" "}
</span>
{count()} MCP
</Match>
</Switch>
{count()} MCP
</text>
<text fg={props.context.theme.text.subdued}>/status</text>
<Show when={visibility().mcpCommand}>
<text fg={props.context.theme.text.subdued}>/mcps</text>
</Show>
</box>
</Show>
)
}
function Plugins(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const visibility = createMemo(() => homeFooterVisibility(dimensions().width))
const plugins = usePlugin()
const failed = createMemo(() => plugins.list().filter((item) => item.status === "failed").length)
return (
<Show when={failed()}>
<box gap={1} flexDirection="row" flexShrink={0}>
<text fg={props.context.theme.text.default}>
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span>
{failed()} plugin{failed() === 1 ? "" : "s"} failed
</text>
<Show when={visibility().pluginCommand}>
<text fg={props.context.theme.text.subdued}>/plugins</text>
</Show>
</box>
</Show>
)
@@ -36,6 +71,7 @@ function Mcp(props: { context: Plugin.Context }) {
function View(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const visibility = createMemo(() => homeFooterVisibility(dimensions().width))
return (
<Show when={dimensions().height >= 12 && dimensions().width >= 44}>
@@ -50,10 +86,13 @@ function View(props: { context: Plugin.Context }) {
gap={2}
>
<Mcp context={props.context} />
<Plugins context={props.context} />
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
</box>
<Show when={visibility().version}>
<box flexShrink={0}>
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
</box>
</Show>
</box>
</Show>
)
@@ -1,22 +1,26 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, createSignal } from "solid-js"
import { createEffect, createMemo, createSignal, Show } from "solid-js"
import { usePlugin } from "../../plugin/context"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
import { DialogErrorDetails } from "../../component/dialog-error-details"
const id = "opencode.plugins"
function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePlugin> }) {
const [locked, setLocked] = createSignal(false)
const options = createMemo(() =>
props.plugins
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<{ title: string; error: string }>()
const dialog = useDialog()
const options = createMemo(() => {
const builtins = props.plugins
.registered()
.filter((plugin) => plugin.id !== id)
.sort((a, b) => a.id.localeCompare(b.id))
.filter((plugin) => plugin.id !== id && plugin.source === "builtin")
.map(
(plugin): DialogSelectOption<string> => ({
title: plugin.id,
value: plugin.id,
category: plugin.source === "builtin" ? "Built-in" : "External",
category: "Built-in",
footer: (
<span
style={{
@@ -29,8 +33,46 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
</span>
),
}),
),
)
)
const external = props.plugins
.list()
.filter((plugin) => plugin.status !== "unsupported")
.map(
(plugin): DialogSelectOption<string> => ({
title: plugin.id ?? plugin.target,
value: plugin.id ?? plugin.target,
category: "External",
searchText: plugin.target,
footer: (
<span
style={{
fg:
plugin.status === "active"
? props.context.theme.text.feedback.success.default
: plugin.status === "failed"
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued,
}}
>
{plugin.status}
</span>
),
}),
)
return [...builtins, ...external].sort((a, b) => a.title.localeCompare(b.title))
})
const failure = (value: string | undefined) =>
props.plugins.list().find((plugin) => {
if (plugin.status !== "failed") return false
return (plugin.id ?? plugin.target) === value
})
createEffect(() => {
if (focused()) return
const first = options()[0]
if (first) setFocused(first.value)
})
const toggle = (plugin: DialogSelectOption<string>) => {
if (locked()) return
@@ -51,15 +93,56 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
.finally(() => setLocked(false))
}
const select = (plugin: DialogSelectOption<string>) => {
const failed = failure(plugin.value)
if (!failed || failed.status !== "failed") return toggle(plugin)
setDetail({ title: failed.target, error: failed.error })
}
return (
<DialogSelect
title="Plugins"
options={options()}
locked={locked()}
preserveSelection={true}
actions={[{ title: "toggle", command: "plugins.toggle", onTrigger: toggle }]}
onSelect={toggle}
/>
<box>
<Show
when={detail()}
fallback={
<DialogSelect
title="Plugins"
options={options()}
current={focused()}
locked={locked()}
preserveSelection={true}
onMove={(option) => setFocused(option.value)}
actions={[
{
title: "toggle",
command: "plugins.toggle",
disabled: (option) => {
const failed = failure(option?.value)
return Boolean(failed && !("id" in failed && failed.id))
},
onTrigger: toggle,
},
]}
onSelect={select}
footer={
<Show when={failure(focused())}>
<text fg={props.context.theme.text.subdued}>enter to view error</text>
</Show>
}
/>
}
>
{(item) => (
<DialogErrorDetails
title={`Plugin: ${item().title}`}
error={item().error}
onBack={() => {
setDetail()
dialog.setSize("medium")
}}
/>
)}
</Show>
</box>
)
}
@@ -72,6 +155,7 @@ function Commands(props: { context: Plugin.Context }) {
id: "plugins.list",
title: "Plugins",
group: "System",
slash: { name: "plugins" },
palette: true,
run() {
props.context.ui.dialog.show(() => <View context={props.context} plugins={plugins} />)
+8 -3
View File
@@ -34,7 +34,7 @@ export interface PackageResolver {
type State =
| { readonly target: string; readonly id: string; readonly status: "active" | "inactive" }
| { readonly target: string; readonly status: "unsupported" }
| { readonly target: string; readonly status: "failed"; readonly error: string }
| { readonly target: string; readonly id?: string; readonly status: "failed"; readonly error: string }
type RegisteredPlugin = {
readonly id: string
@@ -271,6 +271,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
if (!local && !previous) npmFailures.set(target, resolved.error)
failures.push({
target,
id: previous?.plugin.id,
status: "failed",
error: previous?.active ? `${resolved.error} (previous version still active)` : resolved.error,
})
@@ -376,7 +377,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
// A failed reload keeps this item running; the failure entry covers it.
if (failedTargets.has(item.target)) return []
const error = errors.get(item.plugin.id)
if (error) return [{ target: item.target, status: "failed", error }]
if (error) return [{ target: item.target, id: item.plugin.id, status: "failed", error }]
const status = store.registrations[item.plugin.id]?.active ? "active" : "inactive"
return [{ target: item.target, id: item.plugin.id, status }]
}),
@@ -390,7 +391,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
(prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error,
)
)
host.toast.show({ variant: "error", title: "Plugin", message: `${state.target}: ${state.error}` })
host.toast.show({
variant: "error",
title: `Plugin failed: ${state.target}`,
message: "Run /plugins to view details.",
})
setStore("states", reconcileStore(states))
}
const slotItems = new WeakMap<SlotRender, Claim<SlotRender>>()
@@ -1983,6 +1983,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
border={["left"]}
borderColor={delivery() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars}
backgroundColor={theme.background.default}
>
<SessionImages images={images()} paddingLeft={2} />
<box
+26
View File
@@ -40,6 +40,7 @@ export interface DialogSelectProps<T> {
bindings?: readonly KeymapCommand[]
current?: T
focusCurrent?: boolean
sectionNavigation?: boolean
}
type DialogSelectActionBase<T> = {
@@ -327,6 +328,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
moveTo(moveSelection(store.selected, { count: flat().length, delta: direction, policy: "wrap" }), true)
}
function moveSection(direction: 1 | -1) {
if (props.locked) return
const sections = grouped().filter(([_, options]) => options.length > 0)
if (sections.length === 0) return
const current = sections.findIndex(([category]) => category === selected()?.category)
const section = sections[(current + direction + sections.length) % sections.length]
moveTo(flat().indexOf(section[1][0]), true)
}
function moveTo(next: number, center = false, preserve = true) {
setFocusedAction(undefined)
setStore("selected", next)
@@ -488,6 +498,22 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
]
: []),
...(props.bindings ?? []),
...(props.sectionNavigation
? [
{
bind: "alt+up",
title: "Previous section",
group: "Dialog",
run: () => moveSection(-1),
},
{
bind: "alt+down",
title: "Next section",
group: "Dialog",
run: () => moveSection(1),
},
]
: []),
],
}
})
+6 -2
View File
@@ -1,14 +1,18 @@
import { Locale } from "./locale"
import { stringWidth } from "./string-width"
const GAP = " "
const GAP = " · "
export function marqueeCycleWidth(value: string) {
return stringWidth(value + GAP)
}
export function marqueeText(value: string, width: number, offset: number) {
if (width <= 0) return ""
if (stringWidth(value) <= width || offset <= 0) return Locale.takeWidth(value, width)
const loop = value + GAP
const cursor = offset % stringWidth(loop)
const cursor = offset % marqueeCycleWidth(value)
const segments = Locale.graphemes(loop + loop)
const start = segments.reduce(
(state, segment, index) =>
+2 -12
View File
@@ -982,12 +982,7 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
await wait(() =>
data.session.pending
.list(sessionID)
.some(
(item) =>
item.id === "message-queued" &&
(item.type === "user" || item.type === "synthetic") &&
item.delivery === "steer",
),
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "steer"),
)
expect(rows).toContainEqual({ type: "message", messageID: "message-queued" })
@@ -1001,12 +996,7 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
await wait(() =>
data.session.pending
.list(sessionID)
.some(
(item) =>
item.id === "message-queued" &&
(item.type === "user" || item.type === "synthetic") &&
item.delivery === "queue",
),
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "queue"),
)
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
@@ -186,6 +186,94 @@ test("preserves a moved project when sessions arrive", async () => {
}
})
test("option arrows jump between sections", async () => {
const handler: FetchHandler = (url) => {
if (url.pathname === "/api/session")
return json({
data: [
{
id: "ses_recent",
projectID: "proj_recent",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: "Recent session",
location: { directory: "/tmp/opencode/recent" },
},
],
cursor: {},
})
if (url.pathname === "/api/project")
return json([
{
id: "proj_recent",
canonical: "/tmp/opencode/recent",
name: "Recent project",
time: { created: 1, updated: 2 },
sandboxes: [],
},
])
return undefined
}
const next = await renderOpen(handler)
try {
await next.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Recent project"))
next.app.mockInput.pressArrow("down", { meta: true })
next.app.mockInput.pressEnter()
await next.app.waitFor(() => next.route.data.type === "home")
expect(next.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/recent" } })
} finally {
await next.dispose()
}
const previous = await renderOpen(handler)
try {
await previous.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Recent project"))
previous.app.mockInput.pressArrow("up", { meta: true })
previous.app.mockInput.pressEnter()
await previous.app.waitFor(() => previous.route.data.type === "home")
expect(previous.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/recent" } })
} finally {
await previous.dispose()
}
})
test("option arrows stay in the only visible section", async () => {
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname !== "/api/project") return undefined
return json([
{
id: "proj_effect",
canonical: "/tmp/effect",
name: "Effect",
time: { created: 1, updated: 2 },
sandboxes: [],
},
{
id: "proj_opencode",
canonical: "/tmp/opencode",
name: "OpenCode",
time: { created: 1, updated: 1 },
sandboxes: [],
},
])
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Effect") && frame.includes("OpenCode"))
await fixture.app.mockInput.typeText("Effect")
await fixture.app.waitForFrame((frame) => frame.includes("Effect") && !frame.includes("OpenCode"))
fixture.app.mockInput.pressArrow("down", { meta: true })
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/effect" } })
} finally {
await fixture.dispose()
}
})
async function renderOpen(
handler: FetchHandler,
beforeOpen?: (contexts: {
@@ -1,10 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { createSignal } from "solid-js"
import {
TabPulse,
blendTabPulseColor,
completionPulseOpacity,
glowIgnitionLevel,
@@ -12,50 +8,6 @@ import {
} from "../../src/component/tab-pulse"
import { tint } from "../../src/theme/color"
test("a prompt pulse restarts the neutral edge flash while the tab remains busy", async () => {
const background = RGBA.fromHex("#101010")
const flash = RGBA.fromHex("#f0f0f0")
const [promptPulse, setPromptPulse] = createSignal(0)
const app = await testRender(
() => (
<box width={8} height={1} backgroundColor={background}>
<TabPulse
active={true}
promptPulse={promptPulse()}
color={background}
flashColor={flash}
backgroundColor={background}
/>
</box>
),
{ width: 8, height: 1 },
)
const firstBackground = () => app.captureSpans().lines[0]?.spans[0]?.bg
try {
await app.renderOnce()
expect(firstBackground()?.equals(background)).toBeTrue()
setPromptPulse(1)
await Bun.sleep(80)
await app.renderOnce()
expect(firstBackground()?.equals(background)).toBeFalse()
expect(firstBackground()?.r ?? 0).toBeGreaterThan(0.17)
await Bun.sleep(800)
await app.renderOnce()
expect(firstBackground()?.equals(background)).toBeTrue()
setPromptPulse(2)
await Bun.sleep(80)
await app.renderOnce()
expect(firstBackground()?.equals(background)).toBeFalse()
} finally {
app.renderer.destroy()
}
})
test("completion pulse rises quickly and fades over the remaining duration", () => {
expect(completionPulseOpacity(0)).toBe(0)
expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5)
+19
View File
@@ -74,6 +74,25 @@ test("uses command IDs as keybind keys", () => {
).toBe(true)
})
test("preserves current navigation defaults", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("open.menu")).toMatchObject([{ key: "ctrl+o" }])
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,alt+down" }])
expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,alt+up" }])
expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "alt+shift+down" }])
expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "alt+shift+up" }])
expect(config.keybinds.get("session.tab.reopen")).toMatchObject([{ key: "ctrl+shift+t" }])
expect(config.keybinds.get("session.tab.select.10")).toMatchObject([{ key: "<leader>0,ctrl+0" }])
expect(config.keybinds.get("session.message.next")).toEqual([])
expect(config.keybinds.get("session.message.previous")).toEqual([])
expect(config.keybinds.get("session.message.user.next")).toEqual([])
expect(config.keybinds.get("session.message.user.previous")).toEqual([])
expect(config.keybinds.get("input.buffer.home")).toEqual([])
expect(config.keybinds.get("input.buffer.end")).toEqual([])
expect(config.keybinds.get("prompt.images.view")).toMatchObject([{ key: "<leader>i" }])
})
test("preserves migrated v1 keybind defaults", () => {
const pairs = [
["app.exit", "app_exit"],
@@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test"
import { homeFooterVisibility } from "../../src/feature-plugins/home/footer"
describe("home footer visibility", () => {
test("keeps failure labels readable at the minimum supported width", () => {
expect(homeFooterVisibility(44)).toEqual({ mcpCommand: false, pluginCommand: false, version: false })
})
test("adds secondary hints as space becomes available", () => {
expect(homeFooterVisibility(64)).toEqual({ mcpCommand: true, pluginCommand: false, version: true })
expect(homeFooterVisibility(80)).toEqual({ mcpCommand: true, pluginCommand: true, version: true })
})
})
+9 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { marqueeText } from "../../src/util/marquee"
import { marqueeCycleWidth, marqueeText } from "../../src/util/marquee"
import { stringWidth } from "../../src/util/string-width"
describe("marquee text", () => {
@@ -10,8 +10,14 @@ describe("marquee text", () => {
test("starts clipped and scrolls through a long title", () => {
expect(marqueeText("A long session title", 8, 0)).toBe("A long s")
expect(marqueeText("A long session title", 8, 2)).toBe("long ses")
expect(marqueeText("A long session title", 8, 15)).toBe("title ")
expect(marqueeText("A long session title", 8, 20)).toBe(" A lo")
expect(marqueeText("A long session title", 8, 15)).toBe("title · ")
expect(marqueeText("A long session title", 8, 20)).toBe(" · A lon")
})
test("loops after one spaced dot separator", () => {
const title = "A long session title"
expect(marqueeText(title, 8, marqueeCycleWidth(title) - 3)).toBe(" · A lon")
expect(marqueeText(title, 8, marqueeCycleWidth(title))).toBe("A long s")
})
test("clips wide graphemes to terminal cells", () => {
+4 -3
View File
@@ -94,13 +94,14 @@ const client = OpenCode.make({
const health = await client.health.get()
```
`Service.ensure()` accepts an optional registration file, required version,
service command, and `onStart` callback:
`Service.ensure()` accepts an optional registration file, version, service
command, and `onStart` callback. `version` accepts either an exact value or a
compatibility predicate:
```ts
const endpoint = await Service.ensure({
file: "/var/run/opencode/service.json",
version: "2.0.0",
version: (version) => version.startsWith("2."),
command: ["opencode", "serve", "--service"],
onStart(reason, previousVersion) {
console.log(reason, previousVersion)
+3
View File
@@ -63,6 +63,9 @@ if (Script.channel !== "beta") {
console.log("\n=== plugin ===\n")
await $`bun ./packages/plugin/script/publish.ts`
console.log("\n=== core ===\n")
await $`bun ./packages/core/script/publish.ts`
console.log("\n=== ui ===\n")
await $`bun ./packages/ui/script/publish.ts`
}