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
commit 6fbcad4020
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1213 additions and 840 deletions

View file

@ -33,8 +33,9 @@ runs:
shell: bash shell: bash
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT" run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
- name: Cache Bun dependencies - name: Restore Bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 id: bun-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with: with:
path: ${{ steps.cache.outputs.dir }} path: ${{ steps.cache.outputs.dir }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
@ -56,3 +57,10 @@ runs:
bun install ${{ inputs.install-flags }} bun install ${{ inputs.install-flags }}
fi fi
shell: bash 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", "bonjour-service": "1.3.0",
"bun-pty": "0.4.8", "bun-pty": "0.4.8",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"cli-sound": "1.1.3",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
"decimal.js": "10.5.0", "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-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-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=="], "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-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": ["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=="], "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=="], "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=="], "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=="], "shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="],

View file

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

View file

@ -166,18 +166,9 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
}) })
const isWorking = createMemo(() => { const isWorking = createMemo(() => {
if (hasPermissions()) return false if (hasPermissions()) return false
const pending = (sessionStore.message[props.session.id] ?? []).findLast( // This matches how the TUI does it
(message) =>
message.role === "assistant" &&
typeof (message as { time?: { completed?: unknown } }).time?.completed !== "number",
)
const status = sessionStore.session_status[props.session.id] const status = sessionStore.session_status[props.session.id]
return ( return status?.type === "busy" || status?.type === "retry"
pending !== undefined ||
status?.type === "busy" ||
status?.type === "retry" ||
(status !== undefined && status.type !== "idle")
)
}) })
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)) 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) => { const busy = (sessionID: string) => {
if ((sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle") return true // This matches how the TUI does it
return (sync.data.message[sessionID] ?? []).some( return (sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle"
(item) => item.role === "assistant" && typeof item.time.completed !== "number",
)
} }
const queuedFollowups = createMemo(() => { 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", "bonjour-service": "1.3.0",
"bun-pty": "0.4.8", "bun-pty": "0.4.8",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"cli-sound": "1.1.3",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
"decimal.js": "10.5.0", "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 { useRenderer } from "@opentui/solid"
import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js" import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
import { useTheme, tint } from "@tui/context/theme" import { useTheme, tint } from "@tui/context/theme"
import * as Sound from "@tui/util/sound"
import { go, logo } from "@/cli/logo" import { go, logo } from "@/cli/logo"
export type LogoShape = { export type LogoShape = {
@ -563,7 +562,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
const [now, setNow] = createSignal(0) const [now, setNow] = createSignal(0)
let box: BoxRenderable | undefined let box: BoxRenderable | undefined
let timer: ReturnType<typeof setInterval> | undefined let timer: ReturnType<typeof setInterval> | undefined
let hum = false
const stop = () => { const stop = () => {
if (!timer) return if (!timer) return
@ -575,10 +573,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
const t = performance.now() const t = performance.now()
setNow(t) setNow(t)
const item = hold() const item = hold()
if (item && !hum && t - item.at >= HOLD) {
hum = true
Sound.start()
}
if (item && t - item.at >= CHARGE) { if (item && t - item.at >= CHARGE) {
burst(item.x, item.y) burst(item.x, item.y)
} }
@ -605,8 +599,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
onCleanup(() => { onCleanup(() => {
stop() stop()
hum = false
Sound.dispose()
}) })
onMount(() => { onMount(() => {
@ -626,14 +618,12 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
setNow(t) setNow(t)
if (!last) setRelease(undefined) if (!last) setRelease(undefined)
setHold({ x, y, at: t, glyph: select(x, y, ctx) }) setHold({ x, y, at: t, glyph: select(x, y, ctx) })
hum = false
start() start()
} }
const burst = (x: number, y: number) => { const burst = (x: number, y: number) => {
const item = hold() const item = hold()
if (!item) return if (!item) return
hum = false
const t = performance.now() const t = performance.now()
const age = t - item.at const age = t - item.at
const rise = ramp(age, HOLD, CHARGE) const rise = ramp(age, HOLD, CHARGE)
@ -655,7 +645,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
]) ])
setNow(t) setNow(t)
start() start()
Sound.pulse(lerp(0.8, 1, level))
} }
const frame = createMemo(() => { 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 { Bus } from "../bus"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { createOpencodeClient } from "@opencode-ai/sdk" import { createOpencodeClient } from "@opencode-ai/sdk"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ServerAuth } from "@/server/auth" import { ServerAuth } from "@/server/auth"
import { CodexAuthPlugin } from "./codex" import { CodexAuthPlugin } from "./codex"
import { Session } from "@/session/session" import { Session } from "@/session/session"
@ -28,6 +27,7 @@ import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
import { registerAdapter } from "@/control-plane/adapters" import { registerAdapter } from "@/control-plane/adapters"
import type { WorkspaceAdapter } from "@/control-plane/types" import type { WorkspaceAdapter } from "@/control-plane/types"
import { RuntimeFlags } from "@/effect/runtime-flags"
const log = Log.create({ service: "plugin" }) const log = Log.create({ service: "plugin" })
@ -112,6 +112,7 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const config = yield* Config.Service const config = yield* Config.Service
const flags = yield* RuntimeFlags.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Plugin.state")(function* (ctx) { Effect.fn("Plugin.state")(function* (ctx) {
@ -148,7 +149,7 @@ export const layer = Layer.effect(
$: typeof Bun === "undefined" ? undefined : Bun.$, $: 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 }) log.info("loading internal plugin", { name: plugin.name })
const init = yield* Effect.tryPromise({ const init = yield* Effect.tryPromise({
try: () => plugin(input), try: () => plugin(input),
@ -159,8 +160,8 @@ export const layer = Layer.effect(
if (init._tag === "Some") hooks.push(init.value) if (init._tag === "Some") hooks.push(init.value)
} }
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin_origins ?? []) const plugins = flags.pure ? [] : (cfg.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && cfg.plugin_origins?.length) { if (flags.pure && cfg.plugin_origins?.length) {
log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length }) log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length })
} }
if (plugins.length) yield* config.waitForDependencies() 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 "." export * as Plugin from "."

View file

@ -7,6 +7,7 @@ import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config" import { Config } from "../../src/config/config"
import { Env } from "../../src/env" import { Env } from "../../src/env"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Plugin } from "../../src/plugin" import { Plugin } from "../../src/plugin"
import { AccountTest } from "../fake/account" import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth" import { AuthTest } from "../fake/auth"
@ -29,7 +30,11 @@ const configLayer = Config.layer.pipe(
Layer.provide(AccountTest.empty), Layer.provide(AccountTest.empty),
Layer.provide(NpmTest.noop), 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( const agentLayer = Agent.layer.pipe(
Layer.provide(configLayer), Layer.provide(configLayer),
Layer.provide(AuthTest.empty), 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 { 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 { 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 () => { afterEach(async () => {
await disposeAllInstances() await disposeAllInstances()
@ -14,35 +19,40 @@ afterEach(async () => {
// has lost the outer InstanceRef. Services that read `InstanceState.context` // has lost the outer InstanceRef. Services that read `InstanceState.context`
// then fall back to `Instance.current` ALS, which must be installed at the JS // 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 // 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 + // 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 // so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't
// fail it. This grep guards the actual production callsite. // fail it. This grep guards the actual production callsite.
test("effect-cmd.ts wraps the handler body in Instance.restore", async () => { it.live("effect-cmd.ts wraps the handler body in Instance.restore", () =>
const source = await fs.readFile(new URL("../../src/cli/effect-cmd.ts", import.meta.url), "utf8") Effect.gen(function* () {
expect(source).toContain("Instance.restore(ctx") 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 () => { it.instance(
await using dir = await tmpdir({ git: true }) "Instance.current reachable after await inside restored Effect.promise(async)",
await provideTestInstance({ () =>
directory: dir.path, Effect.gen(function* () {
fn: () => const test = yield* TestInstance
Effect.runPromise( const ctx = yield* InstanceRef
Effect.promise(async () => { if (!ctx) throw new Error("InstanceRef not provided")
await new Promise((r) => setTimeout(r, 5))
const current = await Effect.runPromise( const current = yield* Effect.promise(() =>
Effect.sync(() => { Instance.restore(ctx, async () => {
try { await Promise.resolve()
return Instance.current try {
} catch { return Instance.current
return undefined } catch {
} return undefined
}), }
)
expect(current?.directory).toBe(dir.path)
}), }),
), )
})
}) expect(current?.directory).toBe(test.directory)
}),
{ git: true },
)

View file

@ -1,12 +1,13 @@
import { expect } from "bun:test" 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 { 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 { EffectBridge } from "@/effect/bridge"
import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceRef } from "../../src/effect/instance-ref"
import * as EffectLogger from "@opencode-ai/core/effect/logger" import * as EffectLogger from "@opencode-ai/core/effect/logger"
import { makeRuntime } from "../../src/effect/run-service" import * as Observability from "@opencode-ai/core/effect/observability"
import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { attach } from "../../src/effect/run-service"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const it = testEffect(CrossSpawnSpawner.defaultLayer) 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())) const current = yield* Dummy.use((svc) => svc.current()).pipe(
Effect.provide(Layer.provideMerge(layer, Observability.layer)),
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)),
) )
expect(current.effectLogger).toBe(true) 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* () { Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true }) const current = yield* Effect.map(Effect.service(Logger.CurrentLoggers), check).pipe(Effect.provide(AppLayer))
const current = yield* Effect.promise(() =>
AppRuntime.runPromise( 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* () { Effect.gen(function* () {
return (yield* InstanceRef)?.directory 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", () => it.instance(
Effect.gen(function* () { "EffectBridge preserves logger and instance context across async boundaries",
const dir = yield* tmpdirScoped({ git: true }) () =>
const result = yield* Effect.promise(() => Effect.gen(function* () {
AppRuntime.runPromise( const test = yield* TestInstance
Effect.gen(function* () { const bridge = yield* EffectBridge.make()
const bridge = yield* EffectBridge.make() const started = yield* Deferred.make<void>()
return yield* Effect.promise(() =>
Promise.resolve().then(() => const fiber = yield* Effect.gen(function* () {
bridge.promise( yield* Deferred.succeed(started, undefined)
Effect.gen(function* () { return yield* Effect.promise(() =>
return { Promise.resolve().then(() =>
directory: (yield* InstanceRef)?.directory, bridge.promise(
...check(yield* Effect.service(Logger.CurrentLoggers)), Effect.gen(function* () {
} return {
}), directory: (yield* InstanceRef)?.directory,
), ...check(yield* Effect.service(Logger.CurrentLoggers)),
}
}),
), ),
) ),
}), )
), }).pipe(Effect.forkScoped)
).pipe(provideInstance(dir))
expect(result.directory).toBe(dir) yield* Deferred.await(started)
expect(result.effectLogger).toBe(true) const result = yield* Fiber.join(fiber)
expect(result.defaultLogger).toBe(false)
}), 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 { ProviderAuth } from "@/provider/auth"
import { ProviderID } from "../../src/provider/schema" import { ProviderID } from "../../src/provider/schema"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Auth } from "@/auth" import { Auth } from "@/auth"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { TestConfig } from "../fixture/config" import { TestConfig } from "../fixture/config"
@ -21,6 +22,7 @@ function layer(directory: string, plugins: string[]) {
Layer.provide( Layer.provide(
Plugin.layer.pipe( Plugin.layer.pipe(
Layer.provide(Bus.layer), Layer.provide(Bus.layer),
Layer.provide(RuntimeFlags.layer()),
Layer.provide( Layer.provide(
TestConfig.layer({ TestConfig.layer({
get: () => 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 { Effect, Layer } from "effect"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
@ -8,23 +8,13 @@ import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/f
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { Filesystem } from "@/util/filesystem" 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 { Plugin } = await import("../../src/plugin/index")
const { PluginLoader } = await import("../../src/plugin/loader") const { PluginLoader } = await import("../../src/plugin/loader")
const { readPackageThemes } = await import("../../src/plugin/shared") const { readPackageThemes } = await import("../../src/plugin/shared")
const { Bus } = await import("../../src/bus") const { Bus } = await import("../../src/bus")
const { Npm } = await import("@opencode-ai/core/npm") const { Npm } = await import("@opencode-ai/core/npm")
const { TestConfig } = await import("../fixture/config") const { TestConfig } = await import("../fixture/config")
const { RuntimeFlags } = await import("../../src/effect/runtime-flags")
afterAll(() => {
if (disableDefault === undefined) {
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
return
}
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
})
afterEach(async () => { afterEach(async () => {
await disposeAllInstances() 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") const source = path.join(dir, "opencode.json")
return Effect.gen(function* () { return Effect.gen(function* () {
const config = yield* Effect.promise( const config = yield* Effect.promise(
@ -57,6 +47,7 @@ function load(dir: string) {
Effect.provide( Effect.provide(
Plugin.layer.pipe( Plugin.layer.pipe(
Layer.provide(Bus.layer), Layer.provide(Bus.layer),
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })),
Layer.provide( Layer.provide(
TestConfig.layer({ TestConfig.layer({
get: () => get: () =>
@ -934,25 +925,14 @@ export default {
}, },
(tmp) => (tmp) =>
Effect.gen(function* () { Effect.gen(function* () {
const pure = process.env.OPENCODE_PURE yield* load(tmp.path, { pure: true })
process.env.OPENCODE_PURE = "1" const called = yield* Effect.promise(() =>
fs
try { .readFile(tmp.extra.mark, "utf8")
yield* load(tmp.path) .then(() => true)
const called = yield* Effect.promise(() => .catch(() => false),
fs )
.readFile(tmp.extra.mark, "utf8") expect(called).toBe(false)
.then(() => true)
.catch(() => false),
)
expect(called).toBe(false)
} finally {
if (pure === undefined) {
delete process.env.OPENCODE_PURE
} else {
process.env.OPENCODE_PURE = pure
}
}
}), }),
), ),
) )

View file

@ -1,26 +1,48 @@
import { afterAll, describe, expect } from "bun:test" import { 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 { 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 path from "path"
import { pathToFileURL } from "url" 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 { ModelID, ProviderID } from "../../src/provider/schema"
import { provideTmpdirInstance } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { NpmTest } from "../fake/npm"
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS const emptyAccount = Layer.mock(Account.Service)({
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" active: () => Effect.succeed(Option.none()),
activeOrg: () => Effect.succeed(Option.none()),
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 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>) { function withProject<A, E, R>(source: string, self: Effect.Effect<A, E, R>) {
return provideTmpdirInstance((dir) => return provideTmpdirInstance((dir) =>

View file

@ -1,25 +1,51 @@
import { afterAll, afterEach, describe, expect } from "bun:test" 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 { 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 path from "path"
import { pathToFileURL } from "url" 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 { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { NpmTest } from "../fake/npm"
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS const emptyAccount = Layer.mock(Account.Service)({
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" active: () => Effect.succeed(Option.none()),
activeOrg: () => Effect.succeed(Option.none()),
const { Flag } = await import("@opencode-ai/core/flag/flag") })
const { Plugin } = await import("../../src/plugin/index") const emptyAuth = Layer.mock(Auth.Service)({
const { Workspace } = await import("../../src/control-plane/workspace") all: () => Effect.succeed({}),
const { InstanceBootstrap } = await import("../../src/project/bootstrap") })
const { Instance } = await import("../../src/project/instance") const configLayer = Config.layer.pipe(
const { InstanceStore } = await import("../../src/project/instance-store") Layer.provide(EffectFlock.defaultLayer),
const workspaceLayer = Workspace.defaultLayer.pipe( Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(InstanceStore.defaultLayer), Layer.provide(Env.defaultLayer),
Layer.provide(InstanceBootstrap.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 const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
@ -30,12 +56,6 @@ afterEach(async () => {
}) })
afterAll(() => { afterAll(() => {
if (disableDefault === undefined) {
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
} else {
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
}
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = experimental 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 // Set test managed config directory to isolate tests from system managed settings
const testManagedConfigDir = path.join(dir, "managed") const testManagedConfigDir = path.join(dir, "managed")
process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir 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 // Write the cache version file to prevent global/index.ts from clearing the cache
const cacheDir = path.join(dir, "cache", "opencode") const cacheDir = path.join(dir, "cache", "opencode")

View file

@ -1,302 +1,300 @@
import { $ } from "bun"
import { afterEach, describe, expect } from "bun:test" import { afterEach, describe, expect } from "bun:test"
import * as fs from "fs/promises"
import path from "path" 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 { 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 { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { InstanceRuntime } from "../../src/project/instance-runtime" import { InstanceRuntime } from "../../src/project/instance-runtime"
import { Worktree } from "../../src/worktree" import { Worktree } from "../../src/worktree"
import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture" import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer)) const it = testEffect(
const wintest = process.platform !== "win32" ? it.live : it.live.skip Layer.mergeAll(Worktree.defaultLayer, AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer),
)
const wintest = process.platform !== "win32" ? it.instance : it.instance.skip
function normalize(input: string) { function normalize(input: string) {
return input.replace(/\\/g, "/").toLowerCase() return input.replace(/\\/g, "/").toLowerCase()
} }
async function waitReady() { const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () {
const { GlobalBus } = await import("../../src/bus/global") 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) => { GlobalBus.on("event", on)
const timer = setTimeout(() => { yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
GlobalBus.off("event", on)
reject(new Error("timed out waiting for worktree.ready"))
}, 10_000)
function on(evt: { directory?: string; payload: { type: string; properties: { name: string; branch?: string } } }) { return yield* Deferred.await(ready).pipe(
if (evt.payload.type !== Worktree.Event.Ready.type) return Effect.timeoutOrElse({
clearTimeout(timer) duration: "10 seconds",
GlobalBus.off("event", on) orElse: () => Effect.fail(new Error("timed out waiting for worktree.ready")),
resolve(evt.payload.properties) }),
} )
})
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", () => { describe("Worktree", () => {
afterEach(() => disposeAllInstances()) afterEach(() => disposeAllInstances())
describe("makeWorktreeInfo", () => { describe("makeWorktreeInfo", () => {
it.live("returns info with name, branch, and directory", () => it.instance(
provideTmpdirInstance( "returns info with name, branch, and directory",
() => () =>
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(() =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service const svc = yield* Worktree.Service
const exit = yield* Effect.exit(svc.makeWorktreeInfo()) const info = yield* svc.makeWorktreeInfo()
expect(Exit.isFailure(exit)).toBe(true) expect(info.name).toBeDefined()
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError) 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", () => it.instance(
provideTmpdirInstance( "uses provided name as base",
(dir) => () =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo({ name: "detached-test", detached: true }) const info = yield* svc.makeWorktreeInfo({ name: "my-feature" })
const ready = waitReady()
yield* svc.createFromInfo(info)
const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text()) expect(info.name).toBe("my-feature")
const normalizedList = normalize(list) expect(info.branch).toBe("opencode/my-feature")
const normalizedDir = normalize(info.directory) }),
expect(normalizedList).toContain(normalizedDir) { git: true },
)
const branch = yield* Effect.promise(() => it.instance(
$`git symbolic-ref -q --short HEAD`.cwd(info.directory).quiet().nothrow(), "slugifies the provided name",
) () =>
expect(branch.exitCode).not.toBe(0) Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo({ name: "My Feature Branch!" })
const props = yield* Effect.promise(() => ready) expect(info.name).toBe("my-feature-branch")
expect(props.name).toBe(info.name) }),
expect(props.branch).toBeUndefined() { git: true },
)
yield* svc.remove({ directory: info.directory }) it.instance(
}), "omits branch for detached info",
{ git: true }, () =>
), 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", () => { describe("create + remove lifecycle", () => {
it.live("create returns worktree info and remove cleans up", () => it.instance(
provideTmpdirInstance( "create returns worktree info and remove cleans up",
() => () =>
withCreatedWorktree(undefined, ({ info }) =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.create()
expect(info.name).toBeDefined() expect(info.name).toBeDefined()
expect(info.branch ?? "").toStartWith("opencode/") expect(info.branch ?? "").toStartWith("opencode/")
expect(info.directory).toBeDefined() 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", () => it.instance(
provideTmpdirInstance( "create returns after setup and fires Event.Ready after bootstrap",
(dir) => () =>
withCreatedWorktree(undefined, ({ info, ready }) =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service const svc = yield* Worktree.Service
const ready = waitReady()
const info = yield* svc.create()
expect(info.name).toBeDefined() expect(info.name).toBeDefined()
expect(info.branch ?? "").toStartWith("opencode/") expect(info.branch ?? "").toStartWith("opencode/")
const text = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text()) expect(ready.name).toBe(info.name)
const next = yield* Effect.promise(() => fs.realpath(info.directory).catch(() => info.directory)) expect(ready.branch).toBe(info.branch)
expect(normalize(text)).toContain(normalize(next))
const props = yield* Effect.promise(() => ready) const list = yield* svc.list()
expect(props.name).toBe(info.name) expect(list).toContainEqual(expect.objectContaining({ name: info.name, branch: info.branch }))
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 })
}), }),
{ git: true }, ),
), { git: true },
) )
it.live("create with custom name", () => it.instance(
provideTmpdirInstance( "create with custom name",
() => () =>
withCreatedWorktree({ name: "test-workspace" }, ({ info }) =>
Effect.gen(function* () { 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.name).toBe("test-workspace")
expect(info.branch).toBe("opencode/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", () => { describe("createFromInfo", () => {
wintest("creates git worktree and boots asynchronously", () => wintest(
provideTmpdirInstance( "creates git worktree and boots asynchronously",
(dir) => () =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service const test = yield* TestInstance
const info = yield* svc.makeWorktreeInfo({ name: "from-info-test" }) const svc = yield* Worktree.Service
const ready = waitReady() const info = yield* svc.makeWorktreeInfo({ name: "from-info-test" })
yield* svc.createFromInfo(info) 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 list = yield* git(test.directory, ["worktree", "list", "--porcelain"])
const normalizedList = list.replace(/\\/g, "/") const normalizedList = list.replace(/\\/g, "/")
const normalizedDir = info.directory.replace(/\\/g, "/") const normalizedDir = info.directory.replace(/\\/g, "/")
expect(normalizedList).toContain(normalizedDir) expect(normalizedList).toContain(normalizedDir)
yield* Effect.promise(() => ready) yield* Fiber.join(ready)
yield* svc.remove({ directory: info.directory }) yield* removeCreatedWorktree(info.directory)
}), }),
{ git: true }, { git: true },
),
) )
}) })
describe("list", () => { describe("list", () => {
it.live("uses parent folder name when worktree basename matches the primary worktree", () => it.instance(
provideTmpdirInstance( "uses parent folder name when worktree basename matches the primary worktree",
(dir) => () =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service const test = yield* TestInstance
const parent = path.join(path.dirname(dir), `${path.basename(dir)}-parent`) const fs = yield* AppFileSystem.Service
const target = path.join(parent, path.basename(dir)) const svc = yield* Worktree.Service
const branch = `same-basename-list-${Date.now()}` 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* fs.ensureDir(parent)
yield* Effect.promise(() => $`git worktree add -b ${branch} ${target}`.cwd(dir).quiet()) yield* git(test.directory, ["worktree", "add", "-b", branch, target])
const list = yield* svc.list() const list = yield* svc.list()
const directory = yield* Effect.promise(() => fs.realpath(target).catch(() => target)) const directory = yield* fs.realPath(target).pipe(Effect.catch(() => Effect.succeed(target)))
expect(list.map((item) => ({ ...item, directory: normalize(item.directory) }))).toContainEqual({ expect(list.map((item) => ({ ...item, directory: normalize(item.directory) }))).toContainEqual({
name: path.basename(parent), name: path.basename(parent),
branch, branch,
directory: normalize(directory), directory: normalize(directory),
}) })
yield* svc.remove({ directory: target }) yield* svc.remove({ directory: target })
}), }),
{ git: true }, { git: true },
),
) )
}) })
describe("remove edge cases", () => { describe("remove edge cases", () => {
it.live("remove non-existent directory succeeds silently", () => it.instance(
provideTmpdirInstance( "remove non-existent directory succeeds silently",
(dir) => () =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service const test = yield* TestInstance
const ok = yield* svc.remove({ directory: path.join(dir, "does-not-exist") }) const svc = yield* Worktree.Service
expect(ok).toBe(true) const ok = yield* svc.remove({ directory: path.join(test.directory, "does-not-exist") })
}), expect(ok).toBe(true)
{ git: true }, }),
), { git: true },
) )
it.live("throws NotGitError for non-git directories", () => it.instance("throws NotGitError for non-git directories", () =>
provideTmpdirInstance(() => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
const svc = yield* Worktree.Service const svc = yield* Worktree.Service
const exit = yield* Effect.exit(svc.remove({ directory: "/tmp/fake" })) const exit = yield* Effect.exit(svc.remove({ directory: path.join(test.directory, "fake") }))
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError) 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 { eq } from "drizzle-orm"
import * as Database from "@/storage/db" import * as Database from "@/storage/db"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/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 { MessageID, PartID } from "../../src/session/schema"
import { PartTable } from "@/session/session.sql" import { PartTable } from "@/session/session.sql"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { it } from "../lib/effect" import { testEffect } from "../lib/effect"
const it = testEffect(Session.defaultLayer)
afterEach(async () => { afterEach(async () => {
await disposeAllInstances() await disposeAllInstances()
await resetDatabase() await resetDatabase()
}) })
const withTmp = <A, E, R>( const seedCorruptStepFinishPart = Effect.gen(function* () {
options: Parameters<typeof tmpdir>[0], const session = yield* Session.Service
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>, const info = yield* session.create({})
) => const message = yield* session.updateMessage({
Effect.acquireRelease( id: MessageID.ascending(),
Effect.promise(() => tmpdir(options)), role: "user",
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), sessionID: info.id,
).pipe(Effect.flatMap(fn)) agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
async function seedCorruptStepFinishPart(directory: string) { time: { created: Date.now() },
return WithInstance.provide({ })
directory, const partID = PartID.ascending()
fn: () => yield* session.updatePart({
Effect.runPromise( id: partID,
Effect.gen(function* () { sessionID: info.id,
const session = yield* Session.Service messageID: message.id,
const info = yield* session.create({}) type: "step-finish",
const message = yield* session.updateMessage({ reason: "stop",
id: MessageID.ascending(), cost: 0,
role: "user", tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
sessionID: info.id, })
agent: "build", // Schema.Finite still rejects NaN at encode: exact mirror of the corrupt row
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, // that broke the user's session in the OMO/Windows bug.
time: { created: Date.now() }, yield* Effect.sync(() =>
}) Database.use((db) =>
const partID = PartID.ascending() db
yield* session.updatePart({ .update(PartTable)
id: partID, .set({
sessionID: info.id, data: {
messageID: message.id,
type: "step-finish", type: "step-finish",
reason: "stop", reason: "stop",
cost: 0, cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
}) } as never, // drizzle's .set() can't narrow the discriminated union
// Schema.Finite still rejects NaN at encode — exact mirror of the })
// corrupt row that broke the user's session in the OMO/Windows bug. .where(eq(PartTable.id, partID))
Database.use((db) => .run(),
db ),
.update(PartTable) )
.set({ return info.id
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)),
),
})
}
describe("schema-rejection wire shape", () => { describe("schema-rejection wire shape", () => {
it.live( it.instance(
"Payload schema rejection returns NamedError-shaped JSON, not empty", "Payload schema rejection returns NamedError-shaped JSON, not empty",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance
const res = yield* Effect.promise(async () => const res = yield* Effect.promise(async () =>
Server.Default().app.request(SyncPaths.history, { Server.Default().app.request(SyncPaths.history, {
method: "POST", 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 }), 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).toEqual(expect.any(String))
expect(parsed.data.message.length).toBeGreaterThan(0) 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", "Query schema rejection returns NamedError-shaped JSON",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance
// /find/file?limit=999999 violates the limit constraint check. // /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 res = yield* Effect.promise(async () => Server.Default().app.request(url))
const body = yield* Effect.promise(async () => res.text()) const body = yield* Effect.promise(async () => res.text())
expect(res.status).toBe(400) expect(res.status).toBe(400)
const parsed = JSON.parse(body) const parsed = JSON.parse(body)
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } }) 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", "rejected request body never echoes back unbounded — message is capped",
// Defense against DoS-amplification + secret-echo: Effect's Issue formatter // Defense against DoS-amplification + secret-echo: Effect's Issue formatter
// dumps the rejected `actual` verbatim. A multi-MB invalid array would // 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. // 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* () { Effect.gen(function* () {
const test = yield* TestInstance
const huge = "X".repeat(50_000) const huge = "X".repeat(50_000)
const res = yield* Effect.promise(async () => const res = yield* Effect.promise(async () =>
Server.Default().app.request(SyncPaths.history, { Server.Default().app.request(SyncPaths.history, {
method: "POST", 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 }), body: JSON.stringify({ aggregate: huge }),
}), }),
) )
@ -139,15 +128,16 @@ describe("schema-rejection wire shape", () => {
const parsed = JSON.parse(body) const parsed = JSON.parse(body)
expect(parsed.data.message).not.toContain(huge) 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", "response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) => () =>
Effect.gen(function* () { Effect.gen(function* () {
const sessionID = yield* Effect.promise(() => seedCorruptStepFinishPart(tmp.path)) const test = yield* TestInstance
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}` 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 res = yield* Effect.promise(async () => Server.Default().app.request(url))
const body = yield* Effect.promise(async () => res.text()) const body = yield* Effect.promise(async () => res.text())
expect(res.status).toBe(400) 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. // Field path in data.message — what made this PR worth shipping.
expect(parsed.data.message).toMatch(/output/) 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 { Context, Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag" 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 { Server } from "../../src/server/server"
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db" 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 }) void Log.init({ print: false })
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const context = Context.empty() as Context.Context<unknown> const context = Context.empty() as Context.Context<unknown>
const it = testEffect(Session.defaultLayer)
function app() { function app() {
return Server.Default().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 () => { afterEach(async () => {
mock.restore() mock.restore()
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
@ -32,111 +28,138 @@ afterEach(async () => {
}) })
describe("sync HttpApi", () => { describe("sync HttpApi", () => {
test("serves sync routes", async () => { it.instance(
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true "serves sync routes",
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) () =>
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } Effect.gen(function* () {
const info = spyOn(Log.create({ service: "server.sync" }), "info") 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({ const started = yield* Effect.promise(() =>
directory: tmp.path, Promise.resolve(app().request(SyncPaths.start, { method: "POST", headers })),
fn: async () => runSession(Session.Service.use((svc) => svc.create({ title: "sync" }))), )
}) expect(started.status).toBe(200)
expect(yield* Effect.promise(() => started.json())).toBe(true)
const started = await app().request(SyncPaths.start, { method: "POST", headers }) const history = yield* Effect.promise(() =>
expect(started.status).toBe(200) Promise.resolve(
expect(await started.json()).toBe(true) 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, { const replayed = yield* Effect.promise(() =>
method: "POST", Promise.resolve(
headers, app().request(SyncPaths.replay, {
body: JSON.stringify({}), method: "POST",
}) headers,
expect(history.status).toBe(200) body: JSON.stringify({
const rows = (await history.json()) as Array<{ directory: tmp.directory,
id: string events: rows
aggregate_id: string .filter((row) => row.aggregate_id === session.id)
seq: number .map((row) => ({
type: string id: row.id,
data: Record<string, unknown> aggregateID: row.aggregate_id,
}> seq: row.seq,
expect(rows.map((row) => row.aggregate_id)).toContain(session.id) type: row.type,
data: row.data,
const replayed = await app().request(SyncPaths.replay, { })),
method: "POST", }),
headers, }),
body: JSON.stringify({ ),
directory: tmp.path, )
events: rows expect(replayed.status).toBe(200)
.filter((row) => row.aggregate_id === session.id) expect(yield* Effect.promise(() => replayed.json())).toEqual({ sessionID: session.id })
.map((row) => ({ expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true)
id: row.id, expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true)
aggregateID: row.aggregate_id,
seq: row.seq,
type: row.type,
data: row.data,
})),
}), }),
}) { git: true, config: { formatter: false, lsp: false } },
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)
})
test("validates seq values", async () => { it.instance(
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) "validates seq values",
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } () =>
const cases = [ Effect.gen(function* () {
{ const tmp = yield* TestInstance
path: SyncPaths.history, const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" }
body: { aggregate: -1 }, const cases = [
}, {
{ path: SyncPaths.history,
path: SyncPaths.history, body: { aggregate: -1 },
body: { aggregate: 1.5 }, },
}, {
{ path: SyncPaths.history,
path: SyncPaths.replay, body: { aggregate: 1.5 },
body: { },
directory: tmp.path, {
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, type: "session.created", data: {} }],
path: SyncPaths.replay, },
body: { },
directory: tmp.path, {
events: [{ id: "event", aggregateID: "session", seq: 1.5, 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) { for (const item of cases) {
const response = await app().request(item.path, { const response = yield* Effect.promise(() =>
method: "POST", Promise.resolve(
headers, app().request(item.path, {
body: JSON.stringify(item.body), method: "POST",
}) headers,
expect(response.status).toBe(400) body: JSON.stringify(item.body),
} }),
}) ),
)
test.todo("returns structured validation errors", async () => { expect(response.status).toBe(400)
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 }),
}), }),
context, { git: true, config: { formatter: false, lsp: false } },
) )
expect(response.status).toBe(400) it.instance.skip(
expect(response.headers.get("content-type") ?? "").toContain("application/json") "returns structured validation errors",
const body = (await response.json()) as Record<string, unknown> () =>
expect(body.success).toBe(false) Effect.gen(function* () {
expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true) 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 { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { Session as SessionNs } from "@/session/session" import { Session as SessionNs } from "@/session/session"
import type { SessionID } from "../../src/session/schema"
import * as Log from "@opencode-ai/core/util/log" 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 }) void Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) { const it = testEffect(SessionNs.defaultLayer)
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)))
},
}
afterEach(async () => { afterEach(async () => {
mock.restore() mock.restore()
@ -30,21 +16,28 @@ afterEach(async () => {
}) })
describe("session action routes", () => { describe("session action routes", () => {
test("abort route returns success", async () => { it.instance(
await using tmp = await tmpdir({ git: true }) "abort route returns success",
await WithInstance.provide({ () =>
directory: tmp.path, Effect.gen(function* () {
fn: async () => { const test = yield* TestInstance
const session = await svc.create({}) const session = yield* Effect.acquireRelease(
const app = Server.Default().app 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(res.status).toBe(200)
expect(await res.json()).toBe(true) expect(yield* Effect.promise(() => res.json())).toBe(true)
}),
await svc.remove(session.id) { git: true },
}, )
})
})
}) })

View file

@ -1,15 +1,14 @@
import { describe, expect, test } from "bun:test" import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http" import { FetchHttpClient } from "effect/unstable/http"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { Truncate } from "@/tool/truncate" 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 { WebFetchTool } from "../../src/tool/webfetch"
import { SessionID, MessageID } from "../../src/session/schema" 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 = { const ctx = {
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
@ -22,30 +21,31 @@ const ctx = {
ask: () => Effect.void, ask: () => Effect.void,
} }
async function withFetch(fetch: (req: Request) => Response | Promise<Response>, fn: (url: URL) => Promise<void>) { const withFetch = <A, E, R>(
using server = Bun.serve({ port: 0, fetch }) fetch: (req: Request) => Response | Promise<Response>,
await fn(server.url) fn: (url: URL) => Effect.Effect<A, E, R>,
} ) =>
Effect.acquireUseRelease(
function exec(args: { url: string; format: "text" | "markdown" | "html" }) { Effect.sync(() => Bun.serve({ port: 0, fetch })),
return WebFetchTool.pipe( (server) => fn(server.url),
Effect.flatMap((info) => info.init()), (server) => Effect.sync(() => server.stop(true)),
Effect.flatMap((tool) => tool.execute(args, ctx)),
Effect.provide(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)),
Effect.runPromise,
) )
}
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", () => { describe("tool.webfetch", () => {
test("returns image responses as file attachments", async () => { it.instance("returns image responses as file attachments", () =>
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]) Effect.gen(function* () {
await withFetch( const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
() => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }), yield* withFetch(
async (url) => { () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
await WithInstance.provide({ (url) =>
directory: projectRoot, Effect.gen(function* () {
fn: async () => { const result = yield* exec({ url: new URL("/image.png", url).toString(), format: "markdown" })
const result = await exec({ url: new URL("/image.png", url).toString(), format: "markdown" })
expect(result.output).toBe("Image fetched successfully") expect(result.output).toBe("Image fetched successfully")
expect(result.attachments).toBeDefined() expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1) 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("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID") expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID") expect(result.attachments?.[0]).not.toHaveProperty("messageID")
}, }),
}) )
}, }),
) )
})
test("keeps svg as text output", async () => { it.instance("keeps svg as text output", () =>
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>hello</text></svg>' withFetch(
await withFetch(
() => () =>
new Response(svg, { new Response('<svg xmlns="http://www.w3.org/2000/svg"><text>hello</text></svg>', {
status: 200, status: 200,
headers: { "content-type": "image/svg+xml; charset=UTF-8" }, headers: { "content-type": "image/svg+xml; charset=UTF-8" },
}), }),
async (url) => { (url) =>
await WithInstance.provide({ Effect.gen(function* () {
directory: projectRoot, const result = yield* exec({ url: new URL("/image.svg", url).toString(), format: "html" })
fn: async () => { expect(result.output).toContain("<svg")
const result = await exec({ url: new URL("/image.svg", url).toString(), format: "html" }) expect(result.attachments).toBeUndefined()
expect(result.output).toContain("<svg") }),
expect(result.attachments).toBeUndefined() ),
}, )
})
},
)
})
test("keeps text responses as text output", async () => { it.instance("keeps text responses as text output", () =>
await withFetch( withFetch(
() => () =>
new Response("hello from webfetch", { new Response("hello from webfetch", {
status: 200, status: 200,
headers: { "content-type": "text/plain; charset=utf-8" }, headers: { "content-type": "text/plain; charset=utf-8" },
}), }),
async (url) => { (url) =>
await WithInstance.provide({ Effect.gen(function* () {
directory: projectRoot, const result = yield* exec({ url: new URL("/file.txt", url).toString(), format: "text" })
fn: async () => { expect(result.output).toBe("hello from webfetch")
const result = await exec({ url: new URL("/file.txt", url).toString(), format: "text" }) expect(result.attachments).toBeUndefined()
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 { selectWebSearchProvider, webSearchModelName, webSearchProviderLabel } from "../../src/tool/websearch"
import { ProviderID } from "../../src/provider/schema" import { ProviderID } from "../../src/provider/schema"
import { webSearchEnabled } from "../../src/tool/registry" import { webSearchEnabled } from "../../src/tool/registry"
import { it } from "../lib/effect"
const SESSION_ID = "ses_0196aabbccddeeff001122334455" const SESSION_ID = "ses_0196aabbccddeeff001122334455"
@ -74,17 +75,24 @@ describe("websearch MCP response parser", () => {
}, },
}) })
test("parses plain JSON-RPC responses", async () => { it.effect("parses plain JSON-RPC responses", () =>
await expect(Effect.runPromise(parseResponse(payload))).resolves.toBe("search results") Effect.gen(function* () {
}) const result = yield* parseResponse(payload)
expect(result).toBe("search results")
}),
)
test("parses SSE JSON-RPC responses", async () => { it.effect("parses SSE JSON-RPC responses", () =>
await expect(Effect.runPromise(parseResponse(`event: message\ndata: ${payload}\n\n`))).resolves.toBe( Effect.gen(function* () {
"search results", const result = yield* parseResponse(`event: message\ndata: ${payload}\n\n`)
) expect(result).toBe("search results")
}) }),
)
test("ignores non-JSON SSE data frames", async () => { it.effect("ignores non-JSON SSE data frames", () =>
await expect(Effect.runPromise(parseResponse(`data: [DONE]\ndata: ${payload}\n\n`))).resolves.toBe("search results") Effect.gen(function* () {
}) const result = yield* parseResponse(`data: [DONE]\ndata: ${payload}\n\n`)
expect(result).toBe("search results")
}),
)
}) })