Merge branch 'dev' into adjust-perm-array-logic

This commit is contained in:
Aiden Cline
2026-05-12 20:25:55 -05:00
committed by GitHub
31 changed files with 1226 additions and 853 deletions

View File

@@ -33,8 +33,9 @@ runs:
shell: bash
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
- name: Cache Bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
- name: Restore Bun dependencies
id: bun-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.cache.outputs.dir }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
@@ -56,3 +57,10 @@ runs:
bun install ${{ inputs.install-flags }}
fi
shell: bash
- name: Save Bun dependencies
if: steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.cache.outputs.dir }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}

View File

@@ -418,7 +418,6 @@
"bonjour-service": "1.3.0",
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"cli-sound": "1.1.3",
"clipboardy": "4.0.0",
"cross-spawn": "catalog:",
"decimal.js": "10.5.0",
@@ -2811,8 +2810,6 @@
"cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
"cli-sound": ["cli-sound@1.1.3", "", { "dependencies": { "find-exec": "^1.0.3" }, "bin": { "cli-sound": "dist/esm/cli.js" } }, "sha512-dpdF3KS3wjo1fobKG5iU9KyKqzQWAqueymHzZ9epus/dZ40487gAvS6aXFeBul+GiQAQYUTAtUWgQvw6Jftbyg=="],
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
@@ -3237,8 +3234,6 @@
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
"find-exec": ["find-exec@1.0.3", "", { "dependencies": { "shell-quote": "^1.8.1" } }, "sha512-gnG38zW90mS8hm5smNcrBnakPEt+cGJoiMkJwCU0IYnEb0H2NQk0NIljhNW+48oniCriFek/PH6QXbwsJo/qug=="],
"find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="],
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
@@ -4567,8 +4562,6 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
"shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-MUHog06sZEi6bXR1m8exdkjSNW9bHEv9bPQXACJ7SFw=",
"aarch64-linux": "sha256-3dwdZ3It++OsdGT8xMOQ10Arz8eeODp/LXOrI4DLEhY=",
"aarch64-darwin": "sha256-TmUPGDCewjsrT13npVH6B55J43NKKut67p/HgPJpQNM=",
"x86_64-darwin": "sha256-j8I7t3MZoUQUMFRWyaFO75TRbAw5TauSZAa4yKOHFMA="
"x86_64-linux": "sha256-UCX1xw1DHJC/qM7dk2lD4gQbrtzg3LhwtRNQuwDVGH0=",
"aarch64-linux": "sha256-IgQmkCL+R48B15Kc+Fc9HzcKZeQgluEePwGNKL+QLqk=",
"aarch64-darwin": "sha256-7Kr4U/ueWVzSpOae4CsuVq5EwVfvc3bxOVkbdEkW0EU=",
"x86_64-darwin": "sha256-HyLQUa0D1gSSoMLO2gXraU3WTXEBnvHmjqY5B54Eg3w="
}
}

View File

@@ -166,18 +166,9 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
})
const isWorking = createMemo(() => {
if (hasPermissions()) return false
const pending = (sessionStore.message[props.session.id] ?? []).findLast(
(message) =>
message.role === "assistant" &&
typeof (message as { time?: { completed?: unknown } }).time?.completed !== "number",
)
// This matches how the TUI does it
const status = sessionStore.session_status[props.session.id]
return (
pending !== undefined ||
status?.type === "busy" ||
status?.type === "retry" ||
(status !== undefined && status.type !== "idle")
)
return status?.type === "busy" || status?.type === "retry"
})
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent))

View File

@@ -1497,10 +1497,8 @@ export default function Page() {
})
const busy = (sessionID: string) => {
if ((sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle") return true
return (sync.data.message[sessionID] ?? []).some(
(item) => item.role === "assistant" && typeof item.time.completed !== "number",
)
// This matches how the TUI does it
return (sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle"
}
const queuedFollowups = createMemo(() => {

View File

@@ -0,0 +1,207 @@
import { Context, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
command: Schema.String,
exitCode: Schema.optional(Schema.Number),
stderr: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect),
}) {}
export interface RunOptions {
readonly maxOutputBytes?: number
readonly maxErrorBytes?: number
readonly signal?: AbortSignal
readonly timeout?: Duration.Input
}
export interface RunStreamOptions {
readonly signal?: AbortSignal
readonly includeStderr?: boolean
readonly okExitCodes?: ReadonlyArray<number>
readonly maxErrorBytes?: number
}
export interface RunResult {
readonly command: string
readonly exitCode: number
readonly stdout: Buffer
readonly stderr: Buffer
readonly truncated: boolean
}
export type Interface = ChildProcessSpawner["Service"] & {
readonly run: (command: ChildProcess.Command, options?: RunOptions) => Effect.Effect<RunResult, AppProcessError>
readonly runStream: (
command: ChildProcess.Command,
options?: RunStreamOptions,
) => Stream.Stream<string, AppProcessError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/AppProcess") {}
export const requireSuccess = (result: RunResult): Effect.Effect<RunResult, AppProcessError> =>
result.exitCode === 0
? Effect.succeed(result)
: Effect.fail(
new AppProcessError({
command: result.command,
exitCode: result.exitCode,
stderr: result.stderr.toString("utf8"),
}),
)
export const requireExitIn =
(codes: ReadonlyArray<number>) =>
(result: RunResult): Effect.Effect<RunResult, AppProcessError> =>
codes.includes(result.exitCode)
? Effect.succeed(result)
: Effect.fail(
new AppProcessError({
command: result.command,
exitCode: result.exitCode,
stderr: result.stderr.toString("utf8"),
}),
)
const describeCommand = (command: ChildProcess.Command): string => {
if (command._tag === "StandardCommand") {
return command.args.length ? `${command.command} ${command.args.join(" ")}` : command.command
}
return `${describeCommand(command.left)} | ${describeCommand(command.right)}`
}
const wrapError = (description: string, cause: unknown): AppProcessError =>
cause instanceof AppProcessError ? cause : new AppProcessError({ command: description, cause })
const abortError = (signal: AbortSignal): Error => {
const reason = signal.reason
if (reason instanceof Error) return reason
const err = new Error("Aborted")
err.name = "AbortError"
return err
}
const waitForAbort = (signal: AbortSignal) =>
Effect.callback<never, Error>((resume) => {
if (signal.aborted) {
resume(Effect.fail(abortError(signal)))
return
}
const onabort = () => resume(Effect.fail(abortError(signal)))
signal.addEventListener("abort", onabort, { once: true })
return Effect.sync(() => signal.removeEventListener("abort", onabort))
})
const collectStream = (stream: Stream.Stream<Uint8Array, PlatformError>, maxOutputBytes: number | undefined) =>
Stream.runFold(
stream,
() => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }),
(acc, chunk) => {
if (maxOutputBytes === undefined) {
acc.chunks.push(chunk)
acc.bytes += chunk.length
return acc
}
const remaining = maxOutputBytes - acc.bytes
if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining))
acc.bytes += chunk.length
acc.truncated = acc.truncated || acc.bytes > maxOutputBytes
return acc
},
).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated })))
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) {
const description = describeCommand(command)
const collect = Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(command)
const [stdout, stderr, exitCode] = yield* Effect.all(
[
collectStream(handle.stdout, options?.maxOutputBytes),
collectStream(handle.stderr, options?.maxErrorBytes),
handle.exitCode,
],
{ concurrency: "unbounded" },
)
return {
command: description,
exitCode,
stdout: stdout.buffer,
stderr: stderr.buffer,
truncated: stdout.truncated,
} satisfies RunResult
}),
)
const timed = options?.timeout
? Effect.timeoutOrElse(collect, {
duration: options.timeout,
orElse: () =>
Effect.fail(new AppProcessError({ command: description, cause: new Error("Timed out") })),
})
: collect
const aborted = options?.signal
? timed.pipe(
Effect.raceFirst(
waitForAbort(options.signal).pipe(Effect.mapError((cause) => wrapError(description, cause))),
),
)
: timed
return yield* aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause))))
})
const runStream = (command: ChildProcess.Command, options?: RunStreamOptions): Stream.Stream<string, AppProcessError> => {
const description = describeCommand(command)
const okExitCodes = options?.okExitCodes
const built: Stream.Stream<string, AppProcessError | PlatformError> = Stream.unwrap(
Effect.gen(function* () {
const handle = yield* spawner.spawn(command)
const stderrFiber = yield* Effect.forkScoped(
collectStream(handle.stderr, options?.maxErrorBytes).pipe(
Effect.map((x) => x.buffer.toString("utf8")),
),
)
const source = options?.includeStderr === true ? handle.all : handle.stdout
const lines = source.pipe(
Stream.decodeText,
Stream.splitLines,
Stream.filter((line) => line.length > 0),
)
const tail = Stream.unwrap(
Effect.gen(function* () {
const code = yield* handle.exitCode
if (okExitCodes && okExitCodes.length > 0 && !okExitCodes.includes(code)) {
const stderr = yield* Fiber.join(stderrFiber)
return Stream.fail(new AppProcessError({ command: description, exitCode: code, stderr }))
}
return Stream.empty
}),
)
return Stream.concat(lines, tail) as Stream.Stream<string, AppProcessError | PlatformError>
}),
)
const mapped = built.pipe(
Stream.catch((cause): Stream.Stream<string, AppProcessError> => Stream.fail(wrapError(description, cause))),
)
if (!options?.signal) return mapped
const signal = options.signal
return mapped.pipe(
Stream.interruptWhen(waitForAbort(signal).pipe(Effect.mapError((cause) => wrapError(description, cause)))),
)
}
return Service.of({ ...spawner, run, runStream })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
export * as AppProcess from "./process"

View File

@@ -0,0 +1,210 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process"
import { testEffect } from "../lib/effect"
const it = testEffect(AppProcess.defaultLayer)
const NODE = process.execPath
const cmd = (...args: string[]) => ChildProcess.make(NODE, args)
describe("AppProcess", () => {
describe("run", () => {
it.effect(
"captures stdout and exit code zero",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc.run(cmd("-e", "process.stdout.write('hi\\n')"))
expect(result.exitCode).toBe(0)
expect(result.stdout.toString("utf8")).toBe("hi\n")
expect(result.truncated).toBe(false)
}),
)
it.effect(
"non-zero exit returns RunResult; caller can require success",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc.run(cmd("-e", "process.exit(1)"))
expect(result.exitCode).toBe(1)
}),
)
it.effect(
"requireSuccess fails on non-zero exit",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess)))
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1)
} else {
throw new Error("expected fail reason")
}
}
}),
)
it.effect(
"requireSuccess succeeds on exit 0",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(AppProcess.requireSuccess))
expect(result.exitCode).toBe(0)
}),
)
it.effect(
"requireExitIn allowlists multiple exit codes",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const requireZeroOrOne = AppProcess.requireExitIn([0, 1])
const okZero = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(requireZeroOrOne))
expect(okZero.exitCode).toBe(0)
const okOne = yield* svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(requireZeroOrOne))
expect(okOne.exitCode).toBe(1)
const exit = yield* Effect.exit(
svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne)),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2)
}
}
}),
)
it.effect(
"truncates output when maxOutputBytes is set",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc.run(cmd("-e", "process.stdout.write('0123456789')"), { maxOutputBytes: 5 })
expect(result.exitCode).toBe(0)
expect(result.truncated).toBe(true)
expect(result.stdout.length).toBe(5)
expect(result.stdout.toString("utf8")).toBe("01234")
}),
)
it.effect(
"result includes command description",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc.run(cmd("-e", "process.stdout.write('hi')"))
expect(result.command).toBe(`${NODE} -e process.stdout.write('hi')`)
}),
)
})
describe("inherited platform methods", () => {
it.effect(
"string returns stdout as string",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const out = yield* svc.string(cmd("-e", "process.stdout.write('hi\\n')"))
expect(out).toBe("hi\n")
}),
)
it.effect(
"lines returns the platform's array of lines",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const out = yield* svc.lines(cmd("-e", "process.stdout.write('a\\nb\\n')"))
expect(Array.from(out)).toEqual(["a", "b"])
}),
)
})
describe("runStream", () => {
it.live(
"emits lines incrementally and ends cleanly on exit 0",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc
.runStream(cmd("-e", "console.log('one'); console.log('two'); console.log('three')"))
.pipe(Stream.runCollect)
expect(Array.from(result)).toEqual(["one", "two", "three"])
}),
)
it.live(
"fails with AppProcessError when exit not in okExitCodes",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const exit = yield* Effect.exit(
svc
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0] })
.pipe(Stream.runCollect),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
}
}
}),
)
it.live(
"okExitCodes allowlist treats non-zero as success",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc
.runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
.pipe(Stream.runCollect)
expect(Array.from(result)).toEqual(["only"])
}),
)
it.live(
"without okExitCodes, never fails on exit code",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc
.runStream(cmd("-e", "console.log('only'); process.exit(7)"))
.pipe(Stream.runCollect)
expect(Array.from(result)).toEqual(["only"])
}),
)
it.live(
"AbortSignal interrupts the stream",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const controller = new AbortController()
setTimeout(() => controller.abort(), 50)
const exit = yield* Effect.exit(
svc
.runStream(cmd("-e", "setInterval(() => console.log('tick'), 100); setTimeout(() => {}, 60_000)"), {
signal: controller.signal,
})
.pipe(Stream.runCollect),
)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
})
describe("spawn (inherited)", () => {
it.live(
"returns the platform ChildProcessHandle for advanced use",
Effect.scoped(
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const handle = yield* svc.spawn(cmd("-e", "setInterval(() => {}, 1_000)"))
expect(yield* handle.isRunning).toBe(true)
yield* handle.kill()
}),
),
)
})
})

View File

@@ -129,7 +129,6 @@
"bonjour-service": "1.3.0",
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"cli-sound": "1.1.3",
"clipboardy": "4.0.0",
"cross-spawn": "catalog:",
"decimal.js": "10.5.0",

View File

@@ -2,7 +2,6 @@ import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@o
import { useRenderer } from "@opentui/solid"
import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
import { useTheme, tint } from "@tui/context/theme"
import * as Sound from "@tui/util/sound"
import { go, logo } from "@/cli/logo"
export type LogoShape = {
@@ -563,7 +562,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
const [now, setNow] = createSignal(0)
let box: BoxRenderable | undefined
let timer: ReturnType<typeof setInterval> | undefined
let hum = false
const stop = () => {
if (!timer) return
@@ -575,10 +573,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
const t = performance.now()
setNow(t)
const item = hold()
if (item && !hum && t - item.at >= HOLD) {
hum = true
Sound.start()
}
if (item && t - item.at >= CHARGE) {
burst(item.x, item.y)
}
@@ -605,8 +599,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
onCleanup(() => {
stop()
hum = false
Sound.dispose()
})
onMount(() => {
@@ -626,14 +618,12 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
setNow(t)
if (!last) setRelease(undefined)
setHold({ x, y, at: t, glyph: select(x, y, ctx) })
hum = false
start()
}
const burst = (x: number, y: number) => {
const item = hold()
if (!item) return
hum = false
const t = performance.now()
const age = t - item.at
const rise = ramp(age, HOLD, CHARGE)
@@ -655,7 +645,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
])
setNow(t)
start()
Sound.pulse(lerp(0.8, 1, level))
}
const frame = createMemo(() => {

View File

@@ -1,156 +0,0 @@
import { Player } from "cli-sound"
import { mkdirSync } from "node:fs"
import { tmpdir } from "node:os"
import { basename, join } from "node:path"
import { Process } from "@/util/process"
import { which } from "@/util/which"
import pulseA from "../asset/pulse-a.wav" with { type: "file" }
import pulseB from "../asset/pulse-b.wav" with { type: "file" }
import pulseC from "../asset/pulse-c.wav" with { type: "file" }
import charge from "../asset/charge.wav" with { type: "file" }
const FILE = [pulseA, pulseB, pulseC]
const HUM = charge
const DIR = join(tmpdir(), "opencode-sfx")
const LIST = [
"ffplay",
"mpv",
"mpg123",
"mpg321",
"mplayer",
"afplay",
"play",
"omxplayer",
"aplay",
"cmdmp3",
"cvlc",
"powershell.exe",
] as const
type Kind = (typeof LIST)[number]
function args(kind: Kind, file: string, volume: number) {
if (kind === "ffplay") return [kind, "-autoexit", "-nodisp", "-af", `volume=${volume}`, file]
if (kind === "mpv")
return [kind, "--no-video", "--audio-display=no", "--volume", String(Math.round(volume * 100)), file]
if (kind === "mpg123" || kind === "mpg321") return [kind, "-g", String(Math.round(volume * 100)), file]
if (kind === "mplayer") return [kind, "-vo", "null", "-volume", String(Math.round(volume * 100)), file]
if (kind === "afplay" || kind === "omxplayer" || kind === "aplay" || kind === "cmdmp3") return [kind, file]
if (kind === "play") return [kind, "-v", String(volume), file]
if (kind === "cvlc") return [kind, `--gain=${volume}`, "--play-and-exit", file]
return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`]
}
let item: Player | null | undefined
let kind: Kind | null | undefined
let proc: Process.Child | undefined
let tail: ReturnType<typeof setTimeout> | undefined
let cache: Promise<{ hum: string; pulse: string[] }> | undefined
let seq = 0
let shot = 0
function load() {
if (item !== undefined) return item
try {
item = new Player({ volume: 0.35 })
} catch {
item = null
}
return item
}
async function file(path: string) {
mkdirSync(DIR, { recursive: true })
const next = join(DIR, basename(path))
const out = Bun.file(next)
if (await out.exists()) return next
await Bun.write(out, Bun.file(path))
return next
}
function asset() {
cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse }))
return cache
}
function pick() {
if (kind !== undefined) return kind
kind = LIST.find((item) => which(item)) ?? null
return kind
}
function run(file: string, volume: number) {
const kind = pick()
if (!kind) return
return Process.spawn(args(kind, file, volume), {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
})
}
function clear() {
if (!tail) return
clearTimeout(tail)
tail = undefined
}
function play(file: string, volume: number) {
const item = load()
if (!item) return run(file, volume)?.exited
return item.play(file, { volume }).catch(() => run(file, volume)?.exited)
}
export function start() {
stop()
const id = ++seq
void asset().then(({ hum }) => {
if (id !== seq) return
const next = run(hum, 0.24)
if (!next) return
proc = next
void next.exited.then(
() => {
if (id !== seq) return
if (proc === next) proc = undefined
},
() => {
if (id !== seq) return
if (proc === next) proc = undefined
},
)
})
}
export function stop(delay = 0) {
seq++
clear()
if (!proc) return
const next = proc
if (delay <= 0) {
proc = undefined
void Process.stop(next).catch(() => undefined)
return
}
tail = setTimeout(() => {
tail = undefined
if (proc === next) proc = undefined
void Process.stop(next).catch(() => undefined)
}, delay)
}
export function pulse(scale = 1) {
stop(140)
const index = shot++ % FILE.length
void asset()
.then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale))
.catch(() => undefined)
}
export function dispose() {
stop()
}
export * as Sound from "./sound"

View File

@@ -0,0 +1,27 @@
import { Config, ConfigProvider, Context, Effect, Layer } from "effect"
import { ConfigService } from "@/effect/config-service"
export class Service extends ConfigService.Service<Service>()("@opencode/RuntimeFlags", {
pure: Config.boolean("OPENCODE_PURE").pipe(Config.withDefault(false)),
disableDefaultPlugins: Config.boolean("OPENCODE_DISABLE_DEFAULT_PLUGINS").pipe(Config.withDefault(false)),
}) {}
export type Info = Context.Service.Shape<typeof Service>
const emptyConfigLayer = Service.defaultLayer.pipe(
Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({}))),
Layer.orDie,
)
export const layer = (overrides: Partial<Info> = {}) =>
Layer.effect(
Service,
Effect.gen(function* () {
const flags = yield* Service
return Service.of({ ...flags, ...overrides })
}),
).pipe(Layer.provide(emptyConfigLayer))
export const defaultLayer = Service.defaultLayer.pipe(Layer.orDie)
export * as RuntimeFlags from "./runtime-flags"

View File

@@ -9,7 +9,6 @@ import { Config } from "@/config/config"
import { Bus } from "../bus"
import * as Log from "@opencode-ai/core/util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ServerAuth } from "@/server/auth"
import { CodexAuthPlugin } from "./codex"
import { Session } from "@/session/session"
@@ -28,6 +27,7 @@ import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
import { registerAdapter } from "@/control-plane/adapters"
import type { WorkspaceAdapter } from "@/control-plane/types"
import { RuntimeFlags } from "@/effect/runtime-flags"
const log = Log.create({ service: "plugin" })
@@ -112,6 +112,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const config = yield* Config.Service
const flags = yield* RuntimeFlags.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Plugin.state")(function* (ctx) {
@@ -148,7 +149,7 @@ export const layer = Layer.effect(
$: typeof Bun === "undefined" ? undefined : Bun.$,
}
for (const plugin of INTERNAL_PLUGINS) {
for (const plugin of flags.disableDefaultPlugins ? [] : INTERNAL_PLUGINS) {
log.info("loading internal plugin", { name: plugin.name })
const init = yield* Effect.tryPromise({
try: () => plugin(input),
@@ -159,8 +160,8 @@ export const layer = Layer.effect(
if (init._tag === "Some") hooks.push(init.value)
}
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && cfg.plugin_origins?.length) {
const plugins = flags.pure ? [] : (cfg.plugin_origins ?? [])
if (flags.pure && cfg.plugin_origins?.length) {
log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length })
}
if (plugins.length) yield* config.waitForDependencies()
@@ -285,6 +286,10 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
export const defaultLayer = layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
export * as Plugin from "."

View File

@@ -7,6 +7,7 @@ import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config"
import { Env } from "../../src/env"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Plugin } from "../../src/plugin"
import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
@@ -29,7 +30,11 @@ const configLayer = Config.layer.pipe(
Layer.provide(AccountTest.empty),
Layer.provide(NpmTest.noop),
)
const pluginLayer = Plugin.layer.pipe(Layer.provide(Bus.layer), Layer.provide(configLayer))
const pluginLayer = Plugin.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(configLayer),
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
)
const agentLayer = Agent.layer.pipe(
Layer.provide(configLayer),
Layer.provide(AuthTest.empty),

View File

@@ -1,8 +1,13 @@
import { afterEach, expect, test } from "bun:test"
import { afterEach, expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect } from "effect"
import fs from "fs/promises"
import { fileURLToPath } from "url"
import { InstanceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, provideTestInstance, tmpdir } from "../fixture/fixture"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(AppFileSystem.defaultLayer)
afterEach(async () => {
await disposeAllInstances()
@@ -14,35 +19,40 @@ afterEach(async () => {
// has lost the outer InstanceRef. Services that read `InstanceState.context`
// then fall back to `Instance.current` ALS, which must be installed at the JS
// callback boundary (Node ALS persists across awaits, Effect's fiber context
// does not). `provideTestInstance` mirrors effectCmd's load + ALS-restore wrap.
// does not). `it.instance` provides the loaded InstanceRef; the explicit
// Instance.restore mirrors effectCmd's load + ALS-restore wrap.
// Pins effect-cmd.ts directly: the pattern test below exercises the load +
// Instance.restore + dispose triple via the shared `provideTestInstance` fixture,
// Instance.restore boundary via the shared `it.instance` fixture,
// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't
// fail it. This grep guards the actual production callsite.
test("effect-cmd.ts wraps the handler body in Instance.restore", async () => {
const source = await fs.readFile(new URL("../../src/cli/effect-cmd.ts", import.meta.url), "utf8")
expect(source).toContain("Instance.restore(ctx")
})
it.live("effect-cmd.ts wraps the handler body in Instance.restore", () =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const source = yield* fs.readFileString(fileURLToPath(new URL("../../src/cli/effect-cmd.ts", import.meta.url)))
expect(source).toContain("Instance.restore(ctx")
}),
)
test("Instance.current reachable from inner runPromise inside Effect.promise(async)", async () => {
await using dir = await tmpdir({ git: true })
await provideTestInstance({
directory: dir.path,
fn: () =>
Effect.runPromise(
Effect.promise(async () => {
await new Promise((r) => setTimeout(r, 5))
const current = await Effect.runPromise(
Effect.sync(() => {
try {
return Instance.current
} catch {
return undefined
}
}),
)
expect(current?.directory).toBe(dir.path)
it.instance(
"Instance.current reachable after await inside restored Effect.promise(async)",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const ctx = yield* InstanceRef
if (!ctx) throw new Error("InstanceRef not provided")
const current = yield* Effect.promise(() =>
Instance.restore(ctx, async () => {
await Promise.resolve()
try {
return Instance.current
} catch {
return undefined
}
}),
),
})
})
)
expect(current?.directory).toBe(test.directory)
}),
{ git: true },
)

View File

@@ -1,12 +1,13 @@
import { expect } from "bun:test"
import { Context, Effect, Layer, Logger } from "effect"
import { Context, Deferred, Effect, Fiber, Layer, Logger } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppRuntime } from "../../src/effect/app-runtime"
import { AppLayer } from "../../src/effect/app-runtime"
import { EffectBridge } from "@/effect/bridge"
import { InstanceRef } from "../../src/effect/instance-ref"
import * as EffectLogger from "@opencode-ai/core/effect/logger"
import { makeRuntime } from "../../src/effect/run-service"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import * as Observability from "@opencode-ai/core/effect/observability"
import { attach } from "../../src/effect/run-service"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(CrossSpawnSpawner.defaultLayer)
@@ -35,17 +36,8 @@ it.live("makeRuntime installs EffectLogger through Observability.layer", () =>
}),
)
const current = yield* Effect.promise(() => makeRuntime(Dummy, layer).runPromise((svc) => svc.current()))
expect(current.effectLogger).toBe(true)
expect(current.defaultLogger).toBe(false)
}),
)
it.live("AppRuntime also installs EffectLogger through Observability.layer", () =>
Effect.gen(function* () {
const current = yield* Effect.promise(() =>
AppRuntime.runPromise(Effect.map(Effect.service(Logger.CurrentLoggers), check)),
const current = yield* Dummy.use((svc) => svc.current()).pipe(
Effect.provide(Layer.provideMerge(layer, Observability.layer)),
)
expect(current.effectLogger).toBe(true)
@@ -53,46 +45,61 @@ it.live("AppRuntime also installs EffectLogger through Observability.layer", ()
}),
)
it.live("AppRuntime attaches InstanceRef from ALS", () =>
it.live("AppLayer also installs EffectLogger through Observability.layer", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const current = yield* Effect.promise(() =>
AppRuntime.runPromise(
const current = yield* Effect.map(Effect.service(Logger.CurrentLoggers), check).pipe(Effect.provide(AppLayer))
expect(current.effectLogger).toBe(true)
expect(current.defaultLogger).toBe(false)
}),
)
it.instance(
"attach preserves InstanceRef from the current fiber context",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const current = yield* attach(
Effect.gen(function* () {
return (yield* InstanceRef)?.directory
}),
),
).pipe(provideInstance(dir))
)
expect(current).toBe(dir)
}),
expect(current).toBe(test.directory)
}),
{ git: true },
)
it.live("EffectBridge preserves logger and instance context across async boundaries", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const result = yield* Effect.promise(() =>
AppRuntime.runPromise(
Effect.gen(function* () {
const bridge = yield* EffectBridge.make()
return yield* Effect.promise(() =>
Promise.resolve().then(() =>
bridge.promise(
Effect.gen(function* () {
return {
directory: (yield* InstanceRef)?.directory,
...check(yield* Effect.service(Logger.CurrentLoggers)),
}
}),
),
it.instance(
"EffectBridge preserves logger and instance context across async boundaries",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const bridge = yield* EffectBridge.make()
const started = yield* Deferred.make<void>()
const fiber = yield* Effect.gen(function* () {
yield* Deferred.succeed(started, undefined)
return yield* Effect.promise(() =>
Promise.resolve().then(() =>
bridge.promise(
Effect.gen(function* () {
return {
directory: (yield* InstanceRef)?.directory,
...check(yield* Effect.service(Logger.CurrentLoggers)),
}
}),
),
)
}),
),
).pipe(provideInstance(dir))
),
)
}).pipe(Effect.forkScoped)
expect(result.directory).toBe(dir)
expect(result.effectLogger).toBe(true)
expect(result.defaultLogger).toBe(false)
}),
yield* Deferred.await(started)
const result = yield* Fiber.join(fiber)
expect(result.directory).toBe(test.directory)
expect(result.effectLogger).toBe(true)
expect(result.defaultLogger).toBe(false)
}).pipe(Effect.provide(Observability.layer)),
{ git: true },
)

View File

@@ -0,0 +1,55 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer } from "effect"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { it } from "../lib/effect"
const fromConfig = (input: Record<string, unknown>) =>
RuntimeFlags.defaultLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input))))
const readFlags = RuntimeFlags.Service.useSync((flags) => flags)
describe("RuntimeFlags", () => {
it.effect("defaultLayer parses plugin flags from the active ConfigProvider", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(
Effect.provide(
fromConfig({
OPENCODE_PURE: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
}),
),
)
expect(flags.pure).toBe(true)
expect(flags.disableDefaultPlugins).toBe(true)
}),
)
it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })))
expect(flags.pure).toBe(false)
expect(flags.disableDefaultPlugins).toBe(true)
}),
)
it.effect("layer ignores the active ConfigProvider for omitted test overrides", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(
Effect.provide(RuntimeFlags.layer()),
Effect.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_PURE: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
}),
),
),
)
expect(flags.pure).toBe(false)
expect(flags.disableDefaultPlugins).toBe(false)
}),
)
})

View File

@@ -7,6 +7,7 @@ import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { ProviderAuth } from "@/provider/auth"
import { ProviderID } from "../../src/provider/schema"
import { Plugin } from "@/plugin"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Auth } from "@/auth"
import { Bus } from "@/bus"
import { TestConfig } from "../fixture/config"
@@ -21,6 +22,7 @@ function layer(directory: string, plugins: string[]) {
Layer.provide(
Plugin.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(RuntimeFlags.layer()),
Layer.provide(
TestConfig.layer({
get: () =>

View File

@@ -1,4 +1,4 @@
import { afterAll, afterEach, describe, expect, spyOn } from "bun:test"
import { afterEach, describe, expect, spyOn } from "bun:test"
import { Effect, Layer } from "effect"
import fs from "fs/promises"
import path from "path"
@@ -8,23 +8,13 @@ import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/f
import { testEffect } from "../lib/effect"
import { Filesystem } from "@/util/filesystem"
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
const { Plugin } = await import("../../src/plugin/index")
const { PluginLoader } = await import("../../src/plugin/loader")
const { readPackageThemes } = await import("../../src/plugin/shared")
const { Bus } = await import("../../src/bus")
const { Npm } = await import("@opencode-ai/core/npm")
const { TestConfig } = await import("../fixture/config")
afterAll(() => {
if (disableDefault === undefined) {
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
return
}
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
})
const { RuntimeFlags } = await import("../../src/effect/runtime-flags")
afterEach(async () => {
await disposeAllInstances()
@@ -43,7 +33,7 @@ function withTmp<T, A, E, R>(
})
}
function load(dir: string) {
function load(dir: string, flags?: Parameters<typeof RuntimeFlags.layer>[0]) {
const source = path.join(dir, "opencode.json")
return Effect.gen(function* () {
const config = yield* Effect.promise(
@@ -57,6 +47,7 @@ function load(dir: string) {
Effect.provide(
Plugin.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })),
Layer.provide(
TestConfig.layer({
get: () =>
@@ -934,25 +925,14 @@ export default {
},
(tmp) =>
Effect.gen(function* () {
const pure = process.env.OPENCODE_PURE
process.env.OPENCODE_PURE = "1"
try {
yield* load(tmp.path)
const called = yield* Effect.promise(() =>
fs
.readFile(tmp.extra.mark, "utf8")
.then(() => true)
.catch(() => false),
)
expect(called).toBe(false)
} finally {
if (pure === undefined) {
delete process.env.OPENCODE_PURE
} else {
process.env.OPENCODE_PURE = pure
}
}
yield* load(tmp.path, { pure: true })
const called = yield* Effect.promise(() =>
fs
.readFile(tmp.extra.mark, "utf8")
.then(() => true)
.catch(() => false),
)
expect(called).toBe(false)
}),
),
)

View File

@@ -1,26 +1,48 @@
import { afterAll, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { describe, expect } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import path from "path"
import { pathToFileURL } from "url"
import { Account } from "../../src/account/account"
import { Auth } from "../../src/auth"
import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config"
import { Env } from "../../src/env"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Plugin } from "../../src/plugin/index"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { NpmTest } from "../fake/npm"
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
const { Plugin } = await import("../../src/plugin/index")
const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer))
const systemHook = "experimental.chat.system.transform"
afterAll(() => {
if (disableDefault === undefined) {
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
return
}
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()),
activeOrg: () => Effect.succeed(Option.none()),
})
const emptyAuth = Layer.mock(Auth.Service)({
all: () => Effect.succeed({}),
})
const configLayer = Config.layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
Layer.provide(NpmTest.noop),
)
const it = testEffect(
Layer.mergeAll(
Plugin.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(configLayer),
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
),
CrossSpawnSpawner.defaultLayer,
),
)
const systemHook = "experimental.chat.system.transform"
function withProject<A, E, R>(source: string, self: Effect.Effect<A, E, R>) {
return provideTmpdirInstance((dir) =>

View File

@@ -1,25 +1,51 @@
import { afterAll, afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Effect, Layer, Option } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Flag } from "@opencode-ai/core/flag/flag"
import path from "path"
import { pathToFileURL } from "url"
import { Account } from "../../src/account/account"
import { Auth } from "../../src/auth"
import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config"
import { Env } from "../../src/env"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Workspace } from "../../src/control-plane/workspace"
import { Plugin } from "../../src/plugin/index"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { NpmTest } from "../fake/npm"
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
const { Flag } = await import("@opencode-ai/core/flag/flag")
const { Plugin } = await import("../../src/plugin/index")
const { Workspace } = await import("../../src/control-plane/workspace")
const { InstanceBootstrap } = await import("../../src/project/bootstrap")
const { Instance } = await import("../../src/project/instance")
const { InstanceStore } = await import("../../src/project/instance-store")
const workspaceLayer = Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()),
activeOrg: () => Effect.succeed(Option.none()),
})
const emptyAuth = Layer.mock(Auth.Service)({
all: () => Effect.succeed({}),
})
const configLayer = Config.layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
Layer.provide(NpmTest.noop),
)
const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer))
const pluginLayer = Plugin.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(configLayer),
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
)
const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const workspaceLayer = Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))),
)
const it = testEffect(Layer.mergeAll(pluginLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer))
const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
@@ -30,12 +56,6 @@ afterEach(async () => {
})
afterAll(() => {
if (disableDefault === undefined) {
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
} else {
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
}
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = experimental
})

View File

@@ -45,7 +45,6 @@ process.env["OPENCODE_TEST_HOME"] = testHome
// Set test managed config directory to isolate tests from system managed settings
const testManagedConfigDir = path.join(dir, "managed")
process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir
process.env["OPENCODE_DISABLE_DEFAULT_PLUGINS"] = "true"
// Write the cache version file to prevent global/index.ts from clearing the cache
const cacheDir = path.join(dir, "cache", "opencode")

View File

@@ -1,302 +1,300 @@
import { $ } from "bun"
import { afterEach, describe, expect } from "bun:test"
import * as fs from "fs/promises"
import path from "path"
import { Cause, Effect, Exit, Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import { Git } from "../../src/git"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { Worktree } from "../../src/worktree"
import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture"
import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer))
const wintest = process.platform !== "win32" ? it.live : it.live.skip
const it = testEffect(
Layer.mergeAll(Worktree.defaultLayer, AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer),
)
const wintest = process.platform !== "win32" ? it.instance : it.instance.skip
function normalize(input: string) {
return input.replace(/\\/g, "/").toLowerCase()
}
async function waitReady() {
const { GlobalBus } = await import("../../src/bus/global")
const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () {
const ready = yield* Deferred.make<{ name: string; branch?: string }>()
const on = (evt: GlobalEvent) => {
if (evt.payload.type !== Worktree.Event.Ready.type) return
Deferred.doneUnsafe(ready, Effect.succeed(evt.payload.properties))
}
return await new Promise<{ name: string; branch?: string }>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", on)
reject(new Error("timed out waiting for worktree.ready"))
}, 10_000)
GlobalBus.on("event", on)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
function on(evt: { directory?: string; payload: { type: string; properties: { name: string; branch?: string } } }) {
if (evt.payload.type !== Worktree.Event.Ready.type) return
clearTimeout(timer)
GlobalBus.off("event", on)
resolve(evt.payload.properties)
}
return yield* Deferred.await(ready).pipe(
Effect.timeoutOrElse({
duration: "10 seconds",
orElse: () => Effect.fail(new Error("timed out waiting for worktree.ready")),
}),
)
})
GlobalBus.on("event", on)
const removeCreatedWorktree = (directory: string) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const ctx = yield* Effect.sync(() => Instance.current).pipe(provideInstance(directory))
yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx))
const ok = yield* svc.remove({ directory })
if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${directory}`))
})
}
const withCreatedWorktree = <A, E, R>(
input: Parameters<Worktree.Interface["create"]>[0],
use: (created: { info: Worktree.Info; ready: { name: string; branch?: string } }) => Effect.Effect<A, E, R>,
) =>
Effect.acquireUseRelease(
Effect.gen(function* () {
const svc = yield* Worktree.Service
const ready = yield* waitReady().pipe(Effect.forkScoped)
const info = yield* svc.create(input)
const props = yield* Fiber.join(ready)
return { info, ready: props }
}),
use,
({ info }) => removeCreatedWorktree(info.directory),
)
const git = Effect.fn("WorktreeTest.git")(function* (cwd: string, args: string[]) {
const service = yield* Git.Service
const result = yield* service.run(args, { cwd })
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString("utf8")}`)
return result.text()
})
const gitResult = Effect.fn("WorktreeTest.gitResult")(function* (cwd: string, args: string[]) {
const service = yield* Git.Service
return yield* service.run(args, { cwd })
})
describe("Worktree", () => {
afterEach(() => disposeAllInstances())
describe("makeWorktreeInfo", () => {
it.live("returns info with name, branch, and directory", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo()
expect(info.name).toBeDefined()
expect(typeof info.name).toBe("string")
expect(info.branch).toBe(`opencode/${info.name}`)
expect(info.directory).toContain(info.name)
}),
{ git: true },
),
)
it.live("uses provided name as base", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo({ name: "my-feature" })
expect(info.name).toBe("my-feature")
expect(info.branch).toBe("opencode/my-feature")
}),
{ git: true },
),
)
it.live("slugifies the provided name", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo({ name: "My Feature Branch!" })
expect(info.name).toBe("my-feature-branch")
}),
{ git: true },
),
)
it.live("omits branch for detached info", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
yield* Effect.promise(() => $`git branch opencode/my-feature`.cwd(dir).quiet())
const info = yield* svc.makeWorktreeInfo({ name: "my-feature", detached: true })
expect(info.name).toBe("my-feature")
expect(info.branch).toBeUndefined()
}),
{ git: true },
),
)
it.live("throws NotGitError for non-git directories", () =>
provideTmpdirInstance(() =>
it.instance(
"returns info with name, branch, and directory",
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const exit = yield* Effect.exit(svc.makeWorktreeInfo())
const info = yield* svc.makeWorktreeInfo()
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
expect(info.name).toBeDefined()
expect(typeof info.name).toBe("string")
expect(info.branch).toBe(`opencode/${info.name}`)
expect(info.directory).toContain(info.name)
}),
),
{ git: true },
)
wintest("creates detached git worktree when info has no branch", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo({ name: "detached-test", detached: true })
const ready = waitReady()
yield* svc.createFromInfo(info)
it.instance(
"uses provided name as base",
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo({ name: "my-feature" })
const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
const normalizedList = normalize(list)
const normalizedDir = normalize(info.directory)
expect(normalizedList).toContain(normalizedDir)
expect(info.name).toBe("my-feature")
expect(info.branch).toBe("opencode/my-feature")
}),
{ git: true },
)
const branch = yield* Effect.promise(() =>
$`git symbolic-ref -q --short HEAD`.cwd(info.directory).quiet().nothrow(),
)
expect(branch.exitCode).not.toBe(0)
it.instance(
"slugifies the provided name",
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo({ name: "My Feature Branch!" })
const props = yield* Effect.promise(() => ready)
expect(props.name).toBe(info.name)
expect(props.branch).toBeUndefined()
expect(info.name).toBe("my-feature-branch")
}),
{ git: true },
)
yield* svc.remove({ directory: info.directory })
}),
{ git: true },
),
it.instance(
"omits branch for detached info",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const svc = yield* Worktree.Service
yield* git(test.directory, ["branch", "opencode/my-feature"])
const info = yield* svc.makeWorktreeInfo({ name: "my-feature", detached: true })
expect(info.name).toBe("my-feature")
expect(info.branch).toBeUndefined()
}),
{ git: true },
)
it.instance("throws NotGitError for non-git directories", () =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const exit = yield* Effect.exit(svc.makeWorktreeInfo())
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
}),
)
wintest(
"creates detached git worktree when info has no branch",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo({ name: "detached-test", detached: true })
const ready = yield* waitReady().pipe(Effect.forkScoped)
yield* svc.createFromInfo(info)
const list = yield* git(test.directory, ["worktree", "list", "--porcelain"])
const normalizedList = normalize(list)
const normalizedDir = normalize(info.directory)
expect(normalizedList).toContain(normalizedDir)
const branch = yield* gitResult(info.directory, ["symbolic-ref", "-q", "--short", "HEAD"])
expect(branch.exitCode).not.toBe(0)
const props = yield* Fiber.join(ready)
expect(props.name).toBe(info.name)
expect(props.branch).toBeUndefined()
yield* svc.remove({ directory: info.directory })
}),
{ git: true },
)
})
describe("create + remove lifecycle", () => {
it.live("create returns worktree info and remove cleans up", () =>
provideTmpdirInstance(
() =>
it.instance(
"create returns worktree info and remove cleans up",
() =>
withCreatedWorktree(undefined, ({ info }) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.create()
expect(info.name).toBeDefined()
expect(info.branch ?? "").toStartWith("opencode/")
expect(info.directory).toBeDefined()
yield* Effect.promise(() => Bun.sleep(1000))
const ok = yield* svc.remove({ directory: info.directory })
expect(ok).toBe(true)
}),
{ git: true },
),
),
{ git: true },
)
it.live("create returns after setup and fires Event.Ready after bootstrap", () =>
provideTmpdirInstance(
(dir) =>
it.instance(
"create returns after setup and fires Event.Ready after bootstrap",
() =>
withCreatedWorktree(undefined, ({ info, ready }) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const ready = waitReady()
const info = yield* svc.create()
expect(info.name).toBeDefined()
expect(info.branch ?? "").toStartWith("opencode/")
const text = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
const next = yield* Effect.promise(() => fs.realpath(info.directory).catch(() => info.directory))
expect(normalize(text)).toContain(normalize(next))
expect(ready.name).toBe(info.name)
expect(ready.branch).toBe(info.branch)
const props = yield* Effect.promise(() => ready)
expect(props.name).toBe(info.name)
expect(props.branch).toBe(info.branch)
yield* Effect.promise(() =>
WithInstance.provide({
directory: info.directory,
fn: () => InstanceRuntime.disposeInstance(Instance.current),
}),
)
yield* Effect.promise(() => Bun.sleep(100))
yield* svc.remove({ directory: info.directory })
const list = yield* svc.list()
expect(list).toContainEqual(expect.objectContaining({ name: info.name, branch: info.branch }))
}),
{ git: true },
),
),
{ git: true },
)
it.live("create with custom name", () =>
provideTmpdirInstance(
() =>
it.instance(
"create with custom name",
() =>
withCreatedWorktree({ name: "test-workspace" }, ({ info }) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const ready = waitReady()
const info = yield* svc.create({ name: "test-workspace" })
expect(info.name).toBe("test-workspace")
expect(info.branch).toBe("opencode/test-workspace")
yield* Effect.promise(() => ready)
yield* Effect.promise(() =>
WithInstance.provide({
directory: info.directory,
fn: () => InstanceRuntime.disposeInstance(Instance.current),
}),
)
yield* Effect.promise(() => Bun.sleep(100))
yield* svc.remove({ directory: info.directory })
}),
{ git: true },
),
),
{ git: true },
)
})
describe("createFromInfo", () => {
wintest("creates git worktree and boots asynchronously", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo({ name: "from-info-test" })
const ready = waitReady()
yield* svc.createFromInfo(info)
wintest(
"creates git worktree and boots asynchronously",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo({ name: "from-info-test" })
const ready = yield* waitReady().pipe(Effect.forkScoped)
yield* svc.createFromInfo(info)
const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
const normalizedList = list.replace(/\\/g, "/")
const normalizedDir = info.directory.replace(/\\/g, "/")
expect(normalizedList).toContain(normalizedDir)
const list = yield* git(test.directory, ["worktree", "list", "--porcelain"])
const normalizedList = list.replace(/\\/g, "/")
const normalizedDir = info.directory.replace(/\\/g, "/")
expect(normalizedList).toContain(normalizedDir)
yield* Effect.promise(() => ready)
yield* svc.remove({ directory: info.directory })
}),
{ git: true },
),
yield* Fiber.join(ready)
yield* removeCreatedWorktree(info.directory)
}),
{ git: true },
)
})
describe("list", () => {
it.live("uses parent folder name when worktree basename matches the primary worktree", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const parent = path.join(path.dirname(dir), `${path.basename(dir)}-parent`)
const target = path.join(parent, path.basename(dir))
const branch = `same-basename-list-${Date.now()}`
it.instance(
"uses parent folder name when worktree basename matches the primary worktree",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const fs = yield* AppFileSystem.Service
const svc = yield* Worktree.Service
const parent = path.join(path.dirname(test.directory), `${path.basename(test.directory)}-parent`)
const target = path.join(parent, path.basename(test.directory))
const branch = `same-basename-list-${Date.now()}`
yield* Effect.promise(() => fs.mkdir(parent, { recursive: true }))
yield* Effect.promise(() => $`git worktree add -b ${branch} ${target}`.cwd(dir).quiet())
yield* fs.ensureDir(parent)
yield* git(test.directory, ["worktree", "add", "-b", branch, target])
const list = yield* svc.list()
const directory = yield* Effect.promise(() => fs.realpath(target).catch(() => target))
const list = yield* svc.list()
const directory = yield* fs.realPath(target).pipe(Effect.catch(() => Effect.succeed(target)))
expect(list.map((item) => ({ ...item, directory: normalize(item.directory) }))).toContainEqual({
name: path.basename(parent),
branch,
directory: normalize(directory),
})
expect(list.map((item) => ({ ...item, directory: normalize(item.directory) }))).toContainEqual({
name: path.basename(parent),
branch,
directory: normalize(directory),
})
yield* svc.remove({ directory: target })
}),
{ git: true },
),
yield* svc.remove({ directory: target })
}),
{ git: true },
)
})
describe("remove edge cases", () => {
it.live("remove non-existent directory succeeds silently", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const ok = yield* svc.remove({ directory: path.join(dir, "does-not-exist") })
expect(ok).toBe(true)
}),
{ git: true },
),
it.instance(
"remove non-existent directory succeeds silently",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const svc = yield* Worktree.Service
const ok = yield* svc.remove({ directory: path.join(test.directory, "does-not-exist") })
expect(ok).toBe(true)
}),
{ git: true },
)
it.live("throws NotGitError for non-git directories", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const exit = yield* Effect.exit(svc.remove({ directory: "/tmp/fake" }))
it.instance("throws NotGitError for non-git directories", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const svc = yield* Worktree.Service
const exit = yield* Effect.exit(svc.remove({ directory: path.join(test.directory, "fake") }))
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
}),
),
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
}),
)
})
})

View File

@@ -3,7 +3,6 @@ import { Effect } from "effect"
import { eq } from "drizzle-orm"
import * as Database from "@/storage/db"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { Session } from "@/session/session"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
@@ -11,80 +10,68 @@ import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
import { MessageID, PartID } from "../../src/session/schema"
import { PartTable } from "@/session/session.sql"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Session.defaultLayer)
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
const withTmp = <A, E, R>(
options: Parameters<typeof tmpdir>[0],
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
) =>
Effect.acquireRelease(
Effect.promise(() => tmpdir(options)),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap(fn))
async function seedCorruptStepFinishPart(directory: string) {
return WithInstance.provide({
directory,
fn: () =>
Effect.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const info = yield* session.create({})
const message = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()
yield* session.updatePart({
id: partID,
sessionID: info.id,
messageID: message.id,
const seedCorruptStepFinishPart = Effect.gen(function* () {
const session = yield* Session.Service
const info = yield* session.create({})
const message = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()
yield* session.updatePart({
id: partID,
sessionID: info.id,
messageID: message.id,
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
// Schema.Finite still rejects NaN at encode: exact mirror of the corrupt row
// that broke the user's session in the OMO/Windows bug.
yield* Effect.sync(() =>
Database.use((db) =>
db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
// Schema.Finite still rejects NaN at encode — exact mirror of the
// corrupt row that broke the user's session in the OMO/Windows bug.
Database.use((db) =>
db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
} as never, // drizzle's .set() can't narrow the discriminated union
})
.where(eq(PartTable.id, partID))
.run(),
)
return info.id
}).pipe(Effect.provide(Session.defaultLayer)),
),
})
}
tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
} as never, // drizzle's .set() can't narrow the discriminated union
})
.where(eq(PartTable.id, partID))
.run(),
),
)
return info.id
})
describe("schema-rejection wire shape", () => {
it.live(
it.instance(
"Payload schema rejection returns NamedError-shaped JSON, not empty",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const res = yield* Effect.promise(async () =>
Server.Default().app.request(SyncPaths.history, {
method: "POST",
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
headers: { "x-opencode-directory": test.directory, "content-type": "application/json" },
body: JSON.stringify({ aggregate: -1 }),
}),
)
@@ -99,36 +86,38 @@ describe("schema-rejection wire shape", () => {
expect(parsed.data.message).toEqual(expect.any(String))
expect(parsed.data.message.length).toBeGreaterThan(0)
}),
),
{ git: true, config: { formatter: false, lsp: false } },
)
it.live(
it.instance(
"Query schema rejection returns NamedError-shaped JSON",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
() =>
Effect.gen(function* () {
const test = yield* TestInstance
// /find/file?limit=999999 violates the limit constraint check.
const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(tmp.path)}`
const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(test.directory)}`
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
const body = yield* Effect.promise(async () => res.text())
expect(res.status).toBe(400)
const parsed = JSON.parse(body)
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } })
}),
),
{ git: true, config: { formatter: false, lsp: false } },
)
it.live(
it.instance(
"rejected request body never echoes back unbounded — message is capped",
// Defense against DoS-amplification + secret-echo: Effect's Issue formatter
// dumps the rejected `actual` verbatim. A multi-MB invalid array would
// become a multi-MB 400 response and log line. Cap kicks in around 1KB.
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const huge = "X".repeat(50_000)
const res = yield* Effect.promise(async () =>
Server.Default().app.request(SyncPaths.history, {
method: "POST",
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
headers: { "x-opencode-directory": test.directory, "content-type": "application/json" },
body: JSON.stringify({ aggregate: huge }),
}),
)
@@ -139,15 +128,16 @@ describe("schema-rejection wire shape", () => {
const parsed = JSON.parse(body)
expect(parsed.data.message).not.toContain(huge)
}),
),
{ git: true, config: { formatter: false, lsp: false } },
)
it.live(
it.instance(
"response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
() =>
Effect.gen(function* () {
const sessionID = yield* Effect.promise(() => seedCorruptStepFinishPart(tmp.path))
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}`
const test = yield* TestInstance
const sessionID = yield* seedCorruptStepFinishPart
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
const body = yield* Effect.promise(async () => res.text())
expect(res.status).toBe(400)
@@ -157,6 +147,6 @@ describe("schema-rejection wire shape", () => {
// Field path in data.message — what made this PR worth shipping.
expect(parsed.data.message).toMatch(/output/)
}),
),
{ config: { formatter: false, lsp: false } },
)
})

View File

@@ -1,29 +1,25 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { afterEach, describe, expect, mock, spyOn } from "bun:test"
import { Context, Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const context = Context.empty() as Context.Context<unknown>
const it = testEffect(Session.defaultLayer)
function app() {
return Server.Default().app
}
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
}
afterEach(async () => {
mock.restore()
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
@@ -32,111 +28,138 @@ afterEach(async () => {
})
describe("sync HttpApi", () => {
test("serves sync routes", async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
const info = spyOn(Log.create({ service: "server.sync" }), "info")
it.instance(
"serves sync routes",
() =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const tmp = yield* TestInstance
const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" }
const info = spyOn(Log.create({ service: "server.sync" }), "info")
const session = yield* Session.Service.use((svc) => svc.create({ title: "sync" }))
const session = await WithInstance.provide({
directory: tmp.path,
fn: async () => runSession(Session.Service.use((svc) => svc.create({ title: "sync" }))),
})
const started = yield* Effect.promise(() =>
Promise.resolve(app().request(SyncPaths.start, { method: "POST", headers })),
)
expect(started.status).toBe(200)
expect(yield* Effect.promise(() => started.json())).toBe(true)
const started = await app().request(SyncPaths.start, { method: "POST", headers })
expect(started.status).toBe(200)
expect(await started.json()).toBe(true)
const history = yield* Effect.promise(() =>
Promise.resolve(
app().request(SyncPaths.history, {
method: "POST",
headers,
body: JSON.stringify({}),
}),
),
)
expect(history.status).toBe(200)
const rows = (yield* Effect.promise(() => history.json())) as Array<{
id: string
aggregate_id: string
seq: number
type: string
data: Record<string, unknown>
}>
expect(rows.map((row) => row.aggregate_id)).toContain(session.id)
const history = await app().request(SyncPaths.history, {
method: "POST",
headers,
body: JSON.stringify({}),
})
expect(history.status).toBe(200)
const rows = (await history.json()) as Array<{
id: string
aggregate_id: string
seq: number
type: string
data: Record<string, unknown>
}>
expect(rows.map((row) => row.aggregate_id)).toContain(session.id)
const replayed = await app().request(SyncPaths.replay, {
method: "POST",
headers,
body: JSON.stringify({
directory: tmp.path,
events: rows
.filter((row) => row.aggregate_id === session.id)
.map((row) => ({
id: row.id,
aggregateID: row.aggregate_id,
seq: row.seq,
type: row.type,
data: row.data,
})),
const replayed = yield* Effect.promise(() =>
Promise.resolve(
app().request(SyncPaths.replay, {
method: "POST",
headers,
body: JSON.stringify({
directory: tmp.directory,
events: rows
.filter((row) => row.aggregate_id === session.id)
.map((row) => ({
id: row.id,
aggregateID: row.aggregate_id,
seq: row.seq,
type: row.type,
data: row.data,
})),
}),
}),
),
)
expect(replayed.status).toBe(200)
expect(yield* Effect.promise(() => replayed.json())).toEqual({ sessionID: session.id })
expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true)
expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true)
}),
})
expect(replayed.status).toBe(200)
expect(await replayed.json()).toEqual({ sessionID: session.id })
expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true)
expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true)
})
{ git: true, config: { formatter: false, lsp: false } },
)
test("validates seq values", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
const cases = [
{
path: SyncPaths.history,
body: { aggregate: -1 },
},
{
path: SyncPaths.history,
body: { aggregate: 1.5 },
},
{
path: SyncPaths.replay,
body: {
directory: tmp.path,
events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }],
},
},
{
path: SyncPaths.replay,
body: {
directory: tmp.path,
events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
},
},
]
it.instance(
"validates seq values",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" }
const cases = [
{
path: SyncPaths.history,
body: { aggregate: -1 },
},
{
path: SyncPaths.history,
body: { aggregate: 1.5 },
},
{
path: SyncPaths.replay,
body: {
directory: tmp.directory,
events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }],
},
},
{
path: SyncPaths.replay,
body: {
directory: tmp.directory,
events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
},
},
]
for (const item of cases) {
const response = await app().request(item.path, {
method: "POST",
headers,
body: JSON.stringify(item.body),
})
expect(response.status).toBe(400)
}
})
test.todo("returns structured validation errors", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await ExperimentalHttpApiServer.webHandler().handler(
new Request(`http://localhost${SyncPaths.history}`, {
method: "POST",
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
body: JSON.stringify({ aggregate: -1 }),
for (const item of cases) {
const response = yield* Effect.promise(() =>
Promise.resolve(
app().request(item.path, {
method: "POST",
headers,
body: JSON.stringify(item.body),
}),
),
)
expect(response.status).toBe(400)
}
}),
context,
)
{ git: true, config: { formatter: false, lsp: false } },
)
expect(response.status).toBe(400)
expect(response.headers.get("content-type") ?? "").toContain("application/json")
const body = (await response.json()) as Record<string, unknown>
expect(body.success).toBe(false)
expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true)
})
it.instance.skip(
"returns structured validation errors",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const response = yield* Effect.promise(() =>
ExperimentalHttpApiServer.webHandler().handler(
new Request(`http://localhost${SyncPaths.history}`, {
method: "POST",
headers: { "x-opencode-directory": tmp.directory, "content-type": "application/json" },
body: JSON.stringify({ aggregate: -1 }),
}),
context,
),
)
expect(response.status).toBe(400)
expect(response.headers.get("content-type") ?? "").toContain("application/json")
const body = (yield* Effect.promise(() => response.json())) as Record<string, unknown>
expect(body.success).toBe(false)
expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true)
}),
{ git: true, config: { formatter: false, lsp: false } },
)
})

View File

@@ -1,28 +1,14 @@
import { afterEach, describe, expect, mock, test } from "bun:test"
import { afterEach, describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { Session as SessionNs } from "@/session/session"
import type { SessionID } from "../../src/session/schema"
import * as Log from "@opencode-ai/core/util/log"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
remove(id: SessionID) {
return run(SessionNs.Service.use((svc) => svc.remove(id)))
},
}
const it = testEffect(SessionNs.defaultLayer)
afterEach(async () => {
mock.restore()
@@ -30,21 +16,28 @@ afterEach(async () => {
})
describe("session action routes", () => {
test("abort route returns success", async () => {
await using tmp = await tmpdir({ git: true })
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const app = Server.Default().app
it.instance(
"abort route returns success",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const session = yield* Effect.acquireRelease(
SessionNs.Service.use((svc) => svc.create({})),
(created) => SessionNs.Service.use((svc) => svc.remove(created.id)).pipe(Effect.ignore),
)
const res = await app.request(`/session/${session.id}/abort`, { method: "POST" })
const res = yield* Effect.promise(() =>
Promise.resolve(
Server.Default().app.request(`/session/${session.id}/abort`, {
method: "POST",
headers: { "x-opencode-directory": test.directory },
}),
),
)
expect(res.status).toBe(200)
expect(await res.json()).toBe(true)
await svc.remove(session.id)
},
})
})
expect(yield* Effect.promise(() => res.json())).toBe(true)
}),
{ git: true },
)
})

View File

@@ -1,15 +1,14 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Agent } from "../../src/agent/agent"
import { Truncate } from "@/tool/truncate"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { WebFetchTool } from "../../src/tool/webfetch"
import { SessionID, MessageID } from "../../src/session/schema"
import { Tool } from "@/tool/tool"
import { testEffect } from "../lib/effect"
const projectRoot = path.join(import.meta.dir, "../..")
const it = testEffect(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer))
const ctx = {
sessionID: SessionID.make("ses_test"),
@@ -22,30 +21,31 @@ const ctx = {
ask: () => Effect.void,
}
async function withFetch(fetch: (req: Request) => Response | Promise<Response>, fn: (url: URL) => Promise<void>) {
using server = Bun.serve({ port: 0, fetch })
await fn(server.url)
}
function exec(args: { url: string; format: "text" | "markdown" | "html" }) {
return WebFetchTool.pipe(
Effect.flatMap((info) => info.init()),
Effect.flatMap((tool) => tool.execute(args, ctx)),
Effect.provide(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)),
Effect.runPromise,
const withFetch = <A, E, R>(
fetch: (req: Request) => Response | Promise<Response>,
fn: (url: URL) => Effect.Effect<A, E, R>,
) =>
Effect.acquireUseRelease(
Effect.sync(() => Bun.serve({ port: 0, fetch })),
(server) => fn(server.url),
(server) => Effect.sync(() => server.stop(true)),
)
}
const exec = Effect.fn("WebFetchToolTest.exec")(function* (args: Tool.InferParameters<typeof WebFetchTool>) {
const info = yield* WebFetchTool
const tool = yield* info.init()
return yield* tool.execute(args, ctx)
})
describe("tool.webfetch", () => {
test("returns image responses as file attachments", async () => {
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
await withFetch(
() => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
async (url) => {
await WithInstance.provide({
directory: projectRoot,
fn: async () => {
const result = await exec({ url: new URL("/image.png", url).toString(), format: "markdown" })
it.instance("returns image responses as file attachments", () =>
Effect.gen(function* () {
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
yield* withFetch(
() => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
(url) =>
Effect.gen(function* () {
const result = yield* exec({ url: new URL("/image.png", url).toString(), format: "markdown" })
expect(result.output).toBe("Image fetched successfully")
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
@@ -55,50 +55,40 @@ describe("tool.webfetch", () => {
expect(result.attachments?.[0]).not.toHaveProperty("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID")
},
})
},
)
})
}),
)
}),
)
test("keeps svg as text output", async () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>hello</text></svg>'
await withFetch(
it.instance("keeps svg as text output", () =>
withFetch(
() =>
new Response(svg, {
new Response('<svg xmlns="http://www.w3.org/2000/svg"><text>hello</text></svg>', {
status: 200,
headers: { "content-type": "image/svg+xml; charset=UTF-8" },
}),
async (url) => {
await WithInstance.provide({
directory: projectRoot,
fn: async () => {
const result = await exec({ url: new URL("/image.svg", url).toString(), format: "html" })
expect(result.output).toContain("<svg")
expect(result.attachments).toBeUndefined()
},
})
},
)
})
(url) =>
Effect.gen(function* () {
const result = yield* exec({ url: new URL("/image.svg", url).toString(), format: "html" })
expect(result.output).toContain("<svg")
expect(result.attachments).toBeUndefined()
}),
),
)
test("keeps text responses as text output", async () => {
await withFetch(
it.instance("keeps text responses as text output", () =>
withFetch(
() =>
new Response("hello from webfetch", {
status: 200,
headers: { "content-type": "text/plain; charset=utf-8" },
}),
async (url) => {
await WithInstance.provide({
directory: projectRoot,
fn: async () => {
const result = await exec({ url: new URL("/file.txt", url).toString(), format: "text" })
expect(result.output).toBe("hello from webfetch")
expect(result.attachments).toBeUndefined()
},
})
},
)
})
(url) =>
Effect.gen(function* () {
const result = yield* exec({ url: new URL("/file.txt", url).toString(), format: "text" })
expect(result.output).toBe("hello from webfetch")
expect(result.attachments).toBeUndefined()
}),
),
)
})

View File

@@ -4,6 +4,7 @@ import { parseResponse } from "../../src/tool/mcp-websearch"
import { selectWebSearchProvider, webSearchModelName, webSearchProviderLabel } from "../../src/tool/websearch"
import { ProviderID } from "../../src/provider/schema"
import { webSearchEnabled } from "../../src/tool/registry"
import { it } from "../lib/effect"
const SESSION_ID = "ses_0196aabbccddeeff001122334455"
@@ -74,17 +75,24 @@ describe("websearch MCP response parser", () => {
},
})
test("parses plain JSON-RPC responses", async () => {
await expect(Effect.runPromise(parseResponse(payload))).resolves.toBe("search results")
})
it.effect("parses plain JSON-RPC responses", () =>
Effect.gen(function* () {
const result = yield* parseResponse(payload)
expect(result).toBe("search results")
}),
)
test("parses SSE JSON-RPC responses", async () => {
await expect(Effect.runPromise(parseResponse(`event: message\ndata: ${payload}\n\n`))).resolves.toBe(
"search results",
)
})
it.effect("parses SSE JSON-RPC responses", () =>
Effect.gen(function* () {
const result = yield* parseResponse(`event: message\ndata: ${payload}\n\n`)
expect(result).toBe("search results")
}),
)
test("ignores non-JSON SSE data frames", async () => {
await expect(Effect.runPromise(parseResponse(`data: [DONE]\ndata: ${payload}\n\n`))).resolves.toBe("search results")
})
it.effect("ignores non-JSON SSE data frames", () =>
Effect.gen(function* () {
const result = yield* parseResponse(`data: [DONE]\ndata: ${payload}\n\n`)
expect(result).toBe("search results")
}),
)
})