Compare commits

..
82 changed files with 3086 additions and 3287 deletions
@@ -1367,7 +1367,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
successStatus: 200,
declaredStatuses: [404, 401, 400],
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
@@ -1496,7 +1496,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
successStatus: 200,
declaredStatuses: [404, 401, 400],
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
-2
View File
@@ -1,7 +1,5 @@
/// <reference types="@solidjs/start/env" />
import "@solidjs/start"
export declare module "@solidjs/start/server" {
export type APIEvent = { request: Request }
}
+1 -1
View File
@@ -87,7 +87,7 @@ const layer = Layer.effect(
draft.agents.delete(id)
},
}),
notify: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
})
const selectable = (agent: Info | undefined) =>
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
+5 -1
View File
@@ -859,12 +859,16 @@ export function configured(options?: Options) {
aggregateID: input.aggregateID,
...(target >= 0 ? { seq: Event.Seq.make(target) } : {}),
}
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(Stream.concat(Stream.make(marker)))
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(
Stream.map((event): LogItem => event),
Stream.concat(Stream.make(marker)),
)
if (!wakes) return replay
const live: Stream.Stream<LogItem> = Stream.fromSubscription(wakes).pipe(
Stream.mapEffect(() => latestSequence(db, input.aggregateID)),
Stream.filter((target) => target > sequence),
Stream.flatMap((target) => readThrough(target)),
Stream.map((event): LogItem => event),
)
return Stream.concat(replay, live)
}),
+1 -1
View File
@@ -134,7 +134,7 @@ const layer = Layer.effect(
}
return result
},
notify: Effect.fn("Catalog.notify")(function* () {
finalize: Effect.fn("Catalog.finalize")(function* () {
yield* bus.publish(Catalog.Event.Updated, {})
}),
})
+1 -1
View File
@@ -60,7 +60,7 @@ export const layer = Layer.effect(
draft: (draft) => ({
add: (definition) => draft.set(definition.name, definition),
}),
notify: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
})
const info = (definition: Definition) =>
Info.make({
+10 -9
View File
@@ -316,15 +316,16 @@ export const layer = (options?: Options) =>
}
})
const reload = Effect.fn("Config.reload")(
function* () {
const next = yield* discover()
yield* reconcile(next)
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* bus.publish(Event.Updated, {})
},
(effect) => reloadLock.withPermit(effect),
const reload = Effect.fn("Config.reload")(() =>
reloadLock.withPermit(
Effect.gen(function* () {
const next = yield* discover()
yield* reconcile(next)
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* bus.publish(Event.Updated, {})
}),
),
)
yield* Stream.fromPubSub(updates).pipe(
+8 -3
View File
@@ -292,13 +292,18 @@ function normalizeMcpTimeout(
invalid(path, diagnostics)
return
}
const recognized = Object.entries(ConfigMCP.Timeout.fields).filter(([key]) => own(value, key))
const recognized = ["startup", "catalog", "execution"].filter((key) => own(value, key))
if (Object.keys(value).length && !recognized.length) {
invalid(path, diagnostics)
return
}
recognized.forEach(([key, field]) => {
const leaf = decodeEncoded(field, value[key], [...path, key], diagnostics)
recognized.forEach((key) => {
const leaf = decodeEncoded(
ConfigMCP.Timeout.fields[key as keyof typeof ConfigMCP.Timeout.fields],
value[key],
[...path, key],
diagnostics,
)
if (leaf === undefined) return
overlay(timeout, key, leaf, [...path, key], diagnostics)
})
+13 -1
View File
@@ -32,7 +32,19 @@ type PathAction =
| typeof ReadTool.name
| typeof EditTool.name
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
const agentKeys = new Set(["variant", ...Object.keys(ConfigAgent.Info.fields)])
const agentKeys = new Set([
"model",
"variant",
"request",
"system",
"description",
"mode",
"hidden",
"color",
"steps",
"disabled",
"permissions",
])
export const Plugin = define({
id: "opencode.config.agent",
+20 -19
View File
@@ -83,25 +83,26 @@ export const Plugin = define({
),
)
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(
function* (file?: string) {
const sources = yield* Effect.all({
global: isolate("global", globalSource()),
project: isolate("project", projectSource()),
})
loaded.current =
Array.isArray(sources.global) && Array.isArray(sources.project)
? { type: "available", files: [...sources.global, ...sources.project] }
: { type: "unavailable" }
if (!file) return
yield* Effect.logDebug("instructions rescanned", {
file,
instructions:
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
})
},
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
)
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(function* (file?: string) {
yield* lock.withPermit(
Effect.gen(function* () {
const sources = yield* Effect.all({
global: isolate("global", globalSource()),
project: isolate("project", projectSource()),
})
loaded.current =
Array.isArray(sources.global) && Array.isArray(sources.project)
? { type: "available", files: [...sources.global, ...sources.project] }
: { type: "unavailable" }
if (!file) return
yield* Effect.logDebug("instructions rescanned", {
file,
instructions:
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
})
}),
)
})
yield* Stream.fromPubSub(changes).pipe(
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
+20 -19
View File
@@ -151,25 +151,26 @@ export const Plugin = define({
return skills
})
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(
function* (file?: string) {
yield* FiberMap.clear(watches)
const skills = new Map<Skill.ID, Skill.Info>()
const current = sources()
for (const source of current) {
for (const skill of yield* load(source)) skills.set(skill.id, skill)
}
loaded.skills = Array.from(skills.values())
if (file) {
yield* Effect.logInfo("skills rescanned", {
file,
sources: current.map(Skill.Source.key),
skills: loaded.skills.map((skill) => skill.id),
})
}
},
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
)
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(function* (file?: string) {
yield* lock.withPermit(
Effect.gen(function* () {
yield* FiberMap.clear(watches)
const skills = new Map<Skill.ID, Skill.Info>()
const current = sources()
for (const source of current) {
for (const skill of yield* load(source)) skills.set(skill.id, skill)
}
loaded.skills = Array.from(skills.values())
if (file) {
yield* Effect.logInfo("skills rescanned", {
file,
sources: current.map(Skill.Source.key),
skills: loaded.skills.map((skill) => skill.id),
})
}
}),
)
})
yield* Stream.fromPubSub(changes).pipe(
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))),
@@ -279,7 +279,7 @@ export class SQLiteEffectUpdateBase<
: undefined
on = on(
new Proxy(
getTableColumnsRuntime(this.config.table),
this.config.table._.columns,
new SelectionProxyHandler({ sqlAliasedBehavior: "sql", sqlBehavior: "sql" }),
) as any,
from &&
+236
View File
@@ -0,0 +1,236 @@
export * as StateMachine from "./state-machine.js"
import { Cause, Effect, Exit, Fiber, Queue, type Scope } from "effect"
export type Command<Operation> =
| {
readonly _tag: "Invoke"
readonly id: string
readonly operation: Operation
}
| {
readonly _tag: "Stop"
readonly id: string
}
| {
readonly _tag: "StopAndJoin"
readonly id: string
readonly ids: ReadonlyArray<string>
readonly waitFor: ReadonlyArray<string>
}
export type InvocationExited<Event, Operation, Error> = {
readonly _tag: "InvocationExited"
readonly id: string
readonly generation: number
readonly operation: Operation
readonly exit: Exit.Exit<Event, Error>
}
export type RuntimeEvent<Event, Operation, Error> =
| {
readonly _tag: "Input"
readonly input: Event
readonly cause?: Cause.Cause<never>
}
| InvocationExited<Event, Operation, Error>
| {
readonly _tag: "InvocationsStopped"
readonly id: string
readonly exits: ReadonlyArray<InvocationExited<Event, Operation, Error>>
}
export type Continue<State, Operation> = {
readonly _tag: "Continue"
readonly state: State
readonly commands: ReadonlyArray<Command<Operation>>
}
export type Decision<State, Operation, Output> =
| Continue<State, Operation>
| {
readonly _tag: "Done"
readonly output: Output
}
export type Definition<State, Event, Operation, Error, Output> = {
readonly initial: Continue<State, Operation>
readonly transition: (
state: State,
event: RuntimeEvent<Event, Operation, Error>,
) => Decision<State, Operation, Output>
readonly interruption?: Event
}
export type Executor<Event, Operation, Error, Requirements> = (
operation: Operation,
) => Effect.Effect<Event, Error, Requirements>
export function define<State, Event, Operation, Error, Output>(
definition: Definition<State, Event, Operation, Error, Output>,
) {
return definition
}
export function next<State, Operation = never>(state: State, ...commands: ReadonlyArray<Command<Operation>>) {
return { _tag: "Continue", state, commands } as const
}
export function done<Output>(output: Output) {
return { _tag: "Done", output } as const
}
export function invoke<Operation>(id: string, operation: Operation): Command<Operation> {
return { _tag: "Invoke", id, operation }
}
export function stop(id: string): Command<never> {
return { _tag: "Stop", id }
}
/** Stops `ids`, awaits `waitFor` without interruption, and delivers their exits as one batch. */
export function stopAndJoin(
id: string,
ids: ReadonlyArray<string>,
waitFor: ReadonlyArray<string> = [],
): Command<never> {
return { _tag: "StopAndJoin", id, ids, waitFor }
}
export const run = Effect.fn("StateMachine.run")(function* <State, Event, Operation, Error, Output, Requirements>(
definition: Definition<State, Event, Operation, Error, Output>,
execute: Executor<Event, Operation, Error, Requirements>,
) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.scoped(
Effect.gen(function* () {
const queue = yield* Queue.unbounded<RuntimeEvent<Event, Operation, Error>>()
const invocations = new Map<
string,
{
readonly generation: number
readonly operation: Operation
readonly fiber: Fiber.Fiber<Event, Error>
}
>()
let generation = 0
const executeCommands = Effect.fnUntraced(function* (
commands: ReadonlyArray<Command<Operation>>,
interruptibleExecution: boolean,
) {
yield* Effect.forEach(
commands,
(command) =>
Effect.gen(function* () {
if (command._tag === "Stop") {
const invocation = invocations.get(command.id)
yield* invocation
? Fiber.interrupt(invocation.fiber)
: Effect.die(new Error(`Unknown state machine invocation: ${command.id}`))
return
}
if (command._tag === "StopAndJoin") {
const captured = [...command.ids, ...command.waitFor].flatMap((id) => {
const invocation = invocations.get(id)
return invocation ? [{ id, ...invocation }] : []
})
if (captured.length !== command.ids.length + command.waitFor.length)
yield* Effect.die(new Error("Unknown state machine invocation in StopAndJoin"))
// Invalidate individual exits, including ones already queued, before interrupting.
captured.forEach((invocation) => invocations.delete(invocation.id))
yield* Fiber.interruptAll(captured.slice(0, command.ids.length).map((invocation) => invocation.fiber))
const exits = yield* Effect.forEach(captured, (invocation) =>
Fiber.await(invocation.fiber).pipe(
Effect.map((exit) => ({
_tag: "InvocationExited" as const,
id: invocation.id,
generation: invocation.generation,
operation: invocation.operation,
exit,
})),
),
)
yield* Queue.offer(queue, { _tag: "InvocationsStopped", id: command.id, exits })
return
}
const previous = invocations.get(command.id)
if (previous) yield* Fiber.interrupt(previous.fiber)
generation += 1
const current = generation
const execution = interruptibleExecution
? restore(execute(command.operation))
: execute(command.operation)
const fiber = yield* execution.pipe(Effect.forkScoped({ startImmediately: false }))
invocations.set(command.id, { generation: current, operation: command.operation, fiber })
// A deferred child may be interrupted before an Effect.onExit observer starts.
fiber.addObserver((exit) => {
Queue.offerUnsafe(queue, {
_tag: "InvocationExited",
id: command.id,
generation: current,
operation: command.operation,
exit,
})
})
}),
{ discard: true },
)
})
const handleInterruption = (
state: State,
cause: Cause.Cause<never>,
): Effect.Effect<Output, never, Requirements | Scope.Scope> =>
Effect.gen(function* () {
if (!Cause.hasInterruptsOnly(cause) || definition.interruption === undefined)
return yield* Effect.failCause(cause)
return yield* dispatch(
definition.transition(state, {
_tag: "Input",
input: definition.interruption,
cause,
}),
true,
)
})
const dispatch = (
decision: Decision<State, Operation, Output>,
interrupted: boolean,
): Effect.Effect<Output, never, Requirements | Scope.Scope> =>
Effect.gen(function* () {
if (decision._tag === "Done") return decision.output
yield* executeCommands(decision.commands, !interrupted)
if (interrupted) return yield* Effect.suspend(() => loop(decision.state, true))
const boundary = yield* restore(Effect.void).pipe(Effect.exit)
if (Exit.isFailure(boundary)) return yield* handleInterruption(decision.state, boundary.cause)
return yield* Effect.suspend(() => loop(decision.state, false))
})
const loop = (state: State, interrupted: boolean): Effect.Effect<Output, never, Requirements | Scope.Scope> =>
Effect.gen(function* () {
const received = yield* (interrupted ? Queue.take(queue) : restore(Queue.take(queue))).pipe(Effect.exit)
if (Exit.isFailure(received)) return yield* handleInterruption(state, received.cause)
if (received.value._tag === "InvocationExited") {
const invocation = invocations.get(received.value.id)
if (!invocation || invocation.generation !== received.value.generation) {
return yield* Effect.suspend(() => loop(state, interrupted))
}
invocations.delete(received.value.id)
}
return yield* dispatch(definition.transition(state, received.value), interrupted)
})
return yield* dispatch(definition.initial, false)
}),
),
)
})
@@ -25,15 +25,19 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
const layer = Layer.effect(
Service,
Effect.gen(function* () {
let current: readonly string[] = []
const listeners = new Set<(ignore: readonly string[]) => Effect.Effect<void>>()
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
const state = State.create<Data, Draft>({
name: "location-watcher-policy",
initial: () => ({ ignore: [] }),
draft: (draft) => ({
add: (ignore) => draft.ignore.push(...ignore),
list: () => draft.ignore,
}),
notify: () => Effect.forEach(listeners, (listener) => listener(state.get().ignore), { discard: true }),
finalize: (draft) =>
Effect.sync(() => {
current = [...draft.list()]
}).pipe(Effect.andThen(Effect.forEach(listeners, (listener) => listener(current), { discard: true }))),
})
const observe = Effect.fn("LocationWatcherPolicy.observe")(function* (
listener: (ignore: readonly string[]) => Effect.Effect<void>,
@@ -52,7 +56,7 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
current: () => state.get().ignore,
current: () => current,
observe,
})
}),
@@ -653,11 +653,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
}
if (isActiveText) {
controller.enqueue({
type: "text-end",
id: "txt-0",
providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
})
controller.enqueue({ type: "text-end", id: "txt-0" })
}
// go through all tool calls and send the ones that are not finished
+1 -1
View File
@@ -74,7 +74,7 @@ export const layer = (options?: Options) =>
draft.available = false
},
}),
notify: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
+107 -101
View File
@@ -328,7 +328,7 @@ const layer = Layer.effect(
},
},
}),
notify: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
})
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
@@ -378,111 +378,117 @@ const layer = Layer.effect(
}
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.OAuth, unknown>) {
const now = yield* Clock.currentTimeMillis
const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
const match = current.get(attemptID)
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
const next = Exit.isSuccess(exit)
? { ...match, persisting: true }
: {
status: "failed" as const,
integrationID: match.integrationID,
message: message(exit.cause),
time: match.time,
removeAt: now + terminalRetention,
}
return [match, new Map(current).set(attemptID, next)]
})
if (!attempt) return
if (Exit.isFailure(exit)) {
yield* close(attempt.scope)
return
}
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis
const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
const match = current.get(attemptID)
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
const next = Exit.isSuccess(exit)
? { ...match, persisting: true }
: {
status: "failed" as const,
integrationID: match.integrationID,
message: message(exit.cause),
time: match.time,
removeAt: now + terminalRetention,
}
return [match, new Map(current).set(attemptID, next)]
})
if (!attempt) return
if (Exit.isFailure(exit)) {
yield* close(attempt.scope)
return
}
yield* Effect.gen(function* () {
const persistence = yield* Effect.sync(() => {
const implementation = state
.get()
.integrations.get(attempt.integrationID)
?.implementations.get(attempt.methodID)
return attempt.label ?? implementation?.label?.(exit.value)
}).pipe(
Effect.flatMap((label) =>
createCredential({
integrationID: attempt.integrationID,
label,
value: exit.value,
}),
),
Effect.asVoid,
Effect.exit,
)
const settledAt = yield* Clock.currentTimeMillis
const terminal: TerminalAttempt = Exit.isSuccess(persistence)
? {
status: "complete",
integrationID: attempt.integrationID,
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
: {
status: "failed",
integrationID: attempt.integrationID,
message: message(persistence.cause),
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
// Persisting attempts cannot be cancelled, expired, or claimed again.
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
}).pipe(Effect.ensuring(close(attempt.scope)))
}, Effect.uninterruptible)
yield* Effect.gen(function* () {
const implementation = state
.get()
.integrations.get(attempt.integrationID)
?.implementations.get(attempt.methodID)
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
Effect.flatMap((label) =>
createCredential({
integrationID: attempt.integrationID,
label,
value: exit.value,
}),
),
Effect.asVoid,
Effect.exit,
)
const settledAt = yield* Clock.currentTimeMillis
const terminal: TerminalAttempt = Exit.isSuccess(persistence)
? {
status: "complete",
integrationID: attempt.integrationID,
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
: {
status: "failed",
integrationID: attempt.integrationID,
message: message(persistence.cause),
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
// Persisting attempts cannot be cancelled, expired, or claimed again.
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
}).pipe(Effect.ensuring(close(attempt.scope)))
}),
)
})
const settleCommand = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<string, unknown>) {
const now = yield* Clock.currentTimeMillis
const attempt = yield* SynchronizedRef.modify(commandAttempts, (current) => {
const match = current.get(attemptID)
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
const next = Exit.isSuccess(exit)
? { ...match, persisting: true }
: {
status: "failed" as const,
integrationID: match.integrationID,
message: message(exit.cause),
time: match.time,
removeAt: now + terminalRetention,
}
return [match, new Map(current).set(attemptID, next)]
})
if (!attempt) return
if (Exit.isFailure(exit)) {
yield* close(attempt.scope)
return
}
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis
const attempt = yield* SynchronizedRef.modify(commandAttempts, (current) => {
const match = current.get(attemptID)
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
const next = Exit.isSuccess(exit)
? { ...match, persisting: true }
: {
status: "failed" as const,
integrationID: match.integrationID,
message: message(exit.cause),
time: match.time,
removeAt: now + terminalRetention,
}
return [match, new Map(current).set(attemptID, next)]
})
if (!attempt) return
if (Exit.isFailure(exit)) {
yield* close(attempt.scope)
return
}
const persistence = yield* createCredential({
integrationID: attempt.integrationID,
label: attempt.label,
value: Credential.Key.make({ type: "key", key: exit.value }),
}).pipe(Effect.asVoid, Effect.exit)
const settledAt = yield* Clock.currentTimeMillis
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
? {
status: "complete",
const persistence = yield* createCredential({
integrationID: attempt.integrationID,
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
: {
status: "failed",
integrationID: attempt.integrationID,
message: message(persistence.cause),
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
yield* close(attempt.scope)
}, Effect.uninterruptible)
label: attempt.label,
value: Credential.Key.make({ type: "key", key: exit.value }),
}).pipe(Effect.asVoid, Effect.exit)
const settledAt = yield* Clock.currentTimeMillis
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
? {
status: "complete",
integrationID: attempt.integrationID,
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
: {
status: "failed",
integrationID: attempt.integrationID,
message: message(persistence.cause),
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
yield* close(attempt.scope)
}),
)
})
const scrub = Effect.fnUntraced(function* () {
const now = yield* Clock.currentTimeMillis
-1
View File
@@ -37,7 +37,6 @@ export function buildLocationServiceMap(
...inner,
get: (ref: Location.Ref) => inner.get(canonical(ref)),
contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)),
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(canonical(ref)),
invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)),
}),
),
+5 -25
View File
@@ -6,21 +6,7 @@ import { ephemeral } from "@opencode-ai/schema/event"
import type { Session } from "@opencode-ai/schema/session"
import { createHash } from "node:crypto"
import { isDeepStrictEqual } from "node:util"
import {
Cause,
Context,
Effect,
Exit,
Fiber,
FiberSet,
Latch,
Layer,
Schema,
Scope,
Semaphore,
Stream,
Types,
} from "effect"
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Credential } from "../credential.js"
import { Bus } from "../bus.js"
@@ -629,9 +615,8 @@ export const layer = (options?: Options) =>
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
const overrides = new Map<ServerName, Mcp.ServerConfig | false>()
const reconcileLock = Semaphore.makeUnsafe(1)
const reconcile = Effect.fnUntraced(function* () {
const servers = state.get().servers
const reconcile = Effect.fnUntraced(function* (next: Draft) {
const servers = new Map(next.list())
if (!applied && entries.size === 0) {
for (const [name, server] of servers) {
entries.set(name, {
@@ -692,7 +677,7 @@ export const layer = (options?: Options) =>
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
),
)
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
const state = State.create<Data, Draft>({
name: "mcp",
initial: () => ({
servers: new Map(
@@ -717,12 +702,7 @@ export const layer = (options?: Options) =>
},
remove: (server) => draft.servers.delete(ServerName.make(server)),
}),
notify: () =>
Effect.gen(function* () {
const exit = yield* Fiber.await(fork(reconcileLock.withPermit(reconcile())))
if (Exit.isFailure(exit) && root.state._tag === "Closed" && Cause.hasInterruptsOnly(exit.cause)) return
yield* exit
}),
finalize: reconcile,
})
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
+1 -3
View File
@@ -84,9 +84,7 @@ export const Plugin = define({
})
function append(template: string, input: string) {
const value = input.trim()
if (template.includes("$ARGUMENTS")) return template.replaceAll("$ARGUMENTS", () => value)
return [template, value].filter(Boolean).join("\n\n")
return [template, input.trim()].filter(Boolean).join("\n\n")
}
function parseArguments(input: string) {
+42 -53
View File
@@ -26,7 +26,6 @@ export type Info = Reference.Info
type Data = {
sources: Map<string, Types.DeepMutable<Source>>
materialized: Map<string, Info>
}
type Draft = {
@@ -48,71 +47,61 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const cache = yield* RepositoryCache.Service
const scope = yield* Scope.Scope
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
const materialized = new Map<string, Info>()
const state = State.create<Data, Draft>({
name: "reference",
initial: () => ({ sources: new Map(), materialized: new Map() }),
initial: () => ({ sources: new Map() }),
draft: (draft) => ({
add: (name, source) => draft.sources.set(name, source as Types.DeepMutable<Source>),
remove: (name) => draft.sources.delete(name),
list: () => Array.from(draft.sources.entries()) as [string, Source][],
}),
prepare: (data) => {
for (const [name, source] of data.sources) {
if (source.type === "local") {
data.materialized.set(
finalize: (draft) =>
Effect.gen(function* () {
materialized.clear()
for (const [name, source] of draft.list()) {
if (source.type === "local") {
materialized.set(
name,
Info.make({
name,
path: source.path,
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
}),
)
continue
}
const repository = Repository.parse(source.repository)
if (!repository || !Repository.isRemote(repository)) continue
if (source.branch) {
try {
Repository.validateBranch(source.branch)
} catch {
continue
}
}
materialized.set(
name,
Info.make({
name,
path: source.path,
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
}),
)
continue
}
const repository = Repository.parse(source.repository)
if (!repository || !Repository.isRemote(repository)) continue
if (source.branch) {
try {
Repository.validateBranch(source.branch)
} catch {
continue
}
}
data.materialized.set(
name,
Info.make({
name,
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
}),
)
}
},
notify: () =>
Effect.gen(function* () {
for (const info of state.get().materialized.values()) {
const source = info.source
if (source.type !== "git") continue
yield* cache
.ensure({
reference: Repository.parseRemote(source.repository),
branch: source.branch,
refresh: true,
})
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", {
name: info.name,
repository: source.repository,
cause,
}),
),
Effect.forkIn(scope),
)
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", {
name,
repository: source.repository,
cause,
}),
),
Effect.forkIn(scope),
)
}
yield* bus.publish(Reference.Event.Updated, {})
}),
@@ -122,7 +111,7 @@ const layer = Layer.effect(
transform: state.transform,
reload: state.reload,
list: Effect.fn("Reference.list")(function* () {
return Array.from(state.get().materialized.values())
return Array.from(materialized.values())
}),
})
}),
+9 -5
View File
@@ -134,9 +134,10 @@ const layer = Layer.effect(
}),
Stream.take(input.limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const truncated = rows.length > input.limit
if (truncated) return rows.slice(0, input.limit)
if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
const code = yield* handle.exitCode
const stderr = yield* Fiber.join(stderrFiber)
@@ -146,7 +147,7 @@ const layer = Layer.effect(
if (code !== 0 && code !== 1 && code !== 2) {
return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`)
}
return code === 1 ? [] : rows
return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 }
}),
)
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
@@ -177,7 +178,7 @@ const layer = Layer.effect(
parse: (line) => Effect.succeed(normalizePath(line)),
}).pipe(
Effect.map((result) =>
result.map((relative) =>
result.items.map((relative) =>
Entry.make({
path: RelativePath.make(relative),
type: "file",
@@ -211,7 +212,10 @@ const layer = Layer.effect(
)
},
onItem: input.onEntry,
}).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))),
}).pipe(
Effect.map((result) => result.items),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
grep: (input) =>
run<RawMatchData>({
...input,
@@ -244,7 +248,7 @@ const layer = Layer.effect(
),
}).pipe(
Effect.map((result) =>
result.map((match) =>
result.items.map((match) =>
Match.make({
entry: Entry.make({
path: RelativePath.make(match.path.text),
@@ -48,7 +48,6 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
export interface Prepared {
readonly request: LLMRequest
readonly options: StreamOptions
readonly retry: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void>
/**
* One request-scoped execution operation. Unknown and hook-removed calls
* fail individually through the same seam.
@@ -365,11 +364,9 @@ export const layer = Layer.effect(
tools
.execute({ ...input, definitions: hooked })
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
const retry: Prepared["retry"] = (event) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid)
return {
request,
options,
retry,
executeTool,
}
})
+73 -88
View File
@@ -1,7 +1,7 @@
export * as SessionRunnerLLM from "./llm.js"
import { Message } from "@opencode-ai/ai"
import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
import { Cause, Effect, Exit, FiberMap, Layer, Pull, Schedule } from "effect"
import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js"
import { InstructionState } from "../instruction-state.js"
@@ -22,6 +22,7 @@ import { llmClient } from "../../effect/app-node-platform.js"
import { StepFailedError } from "../error.js"
import { SessionRunnerRetry } from "./retry.js"
import { SessionStep } from "./step.js"
import { SessionStepMachine } from "./step-machine.js"
import { ToolOutput } from "../../tool-output.js"
import { PluginSupervisor } from "../../plugin/supervisor.js"
import { MAX_STEPS_PROMPT } from "./max-steps.js"
@@ -167,99 +168,83 @@ const layer = Layer.effect(
return selected
})
/** Owns logical Step policy; each attempt owns its streaming, tools, and durable settlement. */
/** Owns logical Step policy; each attempt owns provider observation, tools, and durable settlement. */
const runStep = Effect.fn("SessionRunner.runStep")(function* (first: SessionContext.Loaded, step: number) {
const sessionID = first.session.id
let assistantMessageID = SessionMessage.ID.create()
const retry = yield* SessionRunnerRetry.make(bus, sessionID)
const retry = yield* Schedule.toStepWithSleep(SessionRunnerRetry.schedule(bus, sessionID))
let initial: SessionContext.Loaded | undefined = first
let recoverOverflow = true
let recoverContinuation = true
while (true) {
// Reuse boundary preparation once; retries refresh context without delivering more input.
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
initial = undefined
const compactionInput = {
session: loaded.session,
messages: loaded.messages,
resolved: loaded.model,
prepare: context.prepare,
}
if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
assistantMessageID = SessionMessage.ID.create()
continue
}
const stepLimitReached = loaded.agent.info.steps !== undefined && step >= loaded.agent.info.steps
const transcript = SessionModelRequest.baseTranscript({
agent: loaded.agent.info,
model: loaded.model,
tools: loaded.tools,
initial: loaded.initial,
messages: loaded.messages,
})
const prepared = yield* context.prepare({
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
transcript: {
system: transcript.system,
messages: stepLimitReached
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
: transcript.messages,
},
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
})
const outcome = yield* steps.attempt({
sessionID,
assistantMessageID,
agent: loaded.agent.id,
model: loaded.model,
prepared,
retry: (cause, error, proposed) =>
retry.decide({
cause,
error,
return yield* SessionStepMachine.run(SessionMessage.ID.create(), {
prepare: Effect.fnUntraced(function* (state) {
// Reuse boundary preparation once; retries refresh context without delivering more input.
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
initial = undefined
const compactionInput = {
session: loaded.session,
messages: loaded.messages,
resolved: loaded.model,
prepare: context.prepare,
}
if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
return SessionStepMachine.Preparation.Rebuilt()
}
const stepLimitReached = loaded.agent.info.steps !== undefined && step >= loaded.agent.info.steps
const transcript = SessionModelRequest.baseTranscript({
agent: loaded.agent.info,
model: loaded.model,
tools: loaded.tools,
initial: loaded.initial,
messages: loaded.messages,
})
const prepared = yield* context.prepare({
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
transcript: {
system: transcript.system,
messages: stepLimitReached
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
: transcript.messages,
},
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
})
return SessionStepMachine.Preparation.Ready({
attempt: yield* steps.open({
sessionID,
assistantMessageID: state.assistantMessageID,
agent: loaded.agent.id,
model: loaded.model.ref,
hook: prepared.retry,
retry: proposed,
model: loaded.model,
prepared,
recoverContinuation: state.recoverContinuation,
recoverOverflow: Effect.suspend(() =>
compaction.enabled()
? compaction.compact(compactionInput).pipe(Effect.map((result) => result.status === "completed"))
: Effect.succeed(false),
),
}),
recoverContinuation,
recoverOverflow: Effect.suspend(() =>
recoverOverflow && compaction.enabled()
? compaction.compact(compactionInput).pipe(Effect.map((result) => result.status === "completed"))
: Effect.succeed(false),
})
}),
retry: (state, outcome) =>
retry({ cause: outcome.cause, error: outcome.error, assistantMessageID: state.assistantMessageID }).pipe(
Pull.catchDone(() =>
outcome._tag === "Retry"
? bus
.publish(SessionEvent.Step.Failed, {
sessionID,
assistantMessageID: state.assistantMessageID,
error: outcome.error,
})
.pipe(Effect.andThen(outcome.cause))
: outcome.cause,
),
Effect.asVoid,
),
})
const completed = yield* SessionStep.Outcome.$match(outcome, {
Completed: (outcome) => Effect.succeed(outcome.needsContinuation),
Retry: (outcome) =>
retry.wait({
decision: outcome.decision,
error: outcome.error,
assistantMessageID,
}),
Continue: Effect.fnUntraced(function* (outcome) {
yield* retry.wait({
decision: outcome.decision,
error: outcome.error,
assistantMessageID,
})
yield* bus.publish(SessionEvent.Synthetic, { sessionID, text: CONTINUE_AFTER_INCOMPLETE_STREAM })
assistantMessageID = SessionMessage.ID.create()
}),
Compacted: Effect.fnUntraced(function* () {
recoverOverflow = false
assistantMessageID = SessionMessage.ID.create()
}),
RecoverFull: Effect.fnUntraced(function* () {
recoverContinuation = false
}),
})
if (completed !== undefined) return completed
}
publishSynthetic: bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_INCOMPLETE_STREAM,
}),
})
})
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
@@ -1,6 +1,5 @@
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Clock, Effect, Iterable } from "effect"
import { isArrayNonEmpty, isReadonlyArrayNonEmpty } from "effect/Array"
import { Bus } from "../../bus.js"
import { Model } from "../../model.js"
import { SessionEvent } from "../event.js"
@@ -46,6 +45,9 @@ export interface StepRecord {
/** Derives canonical model content from a provider-hosted tool result. */
type NonEmptyContent = readonly [Tool.Content, ...Tool.Content[]]
const nonEmpty = (content: ReadonlyArray<Tool.Content>): NonEmptyContent | undefined =>
content.length > 0 ? (content as NonEmptyContent) : undefined
const stringify = (value: unknown) => {
if (typeof value === "string") return value
try {
@@ -56,7 +58,10 @@ const stringify = (value: unknown) => {
}
const hostedContent = (result: ToolResultValue): NonEmptyContent => {
if (result.type === "content" && isReadonlyArrayNonEmpty(result.value)) return result.value
if (result.type === "content") {
const content = nonEmpty(result.value)
if (content !== undefined) return content
}
return [{ type: "text", text: stringify(result.value) }]
}
@@ -558,12 +563,12 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
: result.content === undefined
? []
: [...result.content]
if (!isArrayNonEmpty(content)) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID,
id,
content,
content: [content[0], ...content.slice(1)],
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
executed: tool.providerExecuted,
})
+22 -70
View File
@@ -1,29 +1,17 @@
export * as SessionRunnerRetry from "./retry.js"
import { AIError } from "@opencode-ai/ai"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Clock, Duration, Effect, Pull, Schedule } from "effect"
import { Duration, Effect, Schedule } from "effect"
import { Bus } from "../../bus.js"
import type { PluginHooks } from "../../plugin/hooks.js"
import { SessionEvent } from "../event.js"
import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
interface Input {
export interface Input {
readonly cause: AIError
readonly error: SessionError.Error
readonly agent: Agent.ID
readonly model: Model.Ref
readonly hook: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void>
readonly retry: boolean
}
export interface Decision {
readonly retry: true
readonly attempt: number
readonly delay: number
readonly assistantMessageID: SessionMessage.ID
}
export function isRetryable(error: AIError) {
@@ -67,58 +55,22 @@ const retryAfter = (input: Input) => {
return undefined
}
const schedule = Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
Schedule.jittered,
Schedule.setInputType<Input>(),
Schedule.modifyDelay(({ input, duration: delay }) => {
const minimum = retryAfter(input)
const duration = minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum))
return Effect.succeed(Duration.millis(Math.ceil(Duration.toMillis(duration))))
}),
)
export const make = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
Effect.gen(function* () {
const step = yield* Schedule.toStep(schedule)
let attempt = 1
const decide = (input: Input) =>
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis
const next = yield* step(now, input).pipe(Pull.catchDone(() => Effect.succeed(undefined)))
if (!next) return { retry: false as const }
const [, duration] = next
attempt++
const delay = Math.ceil(Duration.toMillis(duration))
const event: PluginHooks.Domains["session"]["retry"] = {
sessionID,
agent: input.agent,
model: input.model,
error: input.error,
attempt,
decision: input.retry ? { retry: true, delay } : { retry: false },
}
yield* input.hook(event)
if (!event.decision.retry) return event.decision
const normalized =
Number.isFinite(event.decision.delay) && event.decision.delay >= 0 ? Math.ceil(event.decision.delay) : delay
return { retry: true as const, attempt, delay: normalized }
})
const wait = (input: {
readonly decision: Decision
readonly assistantMessageID: SessionMessage.ID
readonly error: SessionError.Error
}) =>
Effect.gen(function* () {
const scheduled = yield* Clock.currentTimeMillis
yield* bus.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: input.assistantMessageID,
attempt: input.decision.attempt,
at: scheduled + input.decision.delay,
error: input.error,
})
const remaining = Math.max(0, scheduled + input.decision.delay - (yield* Clock.currentTimeMillis))
yield* Effect.sleep(Duration.millis(remaining))
})
return { decide, wait }
})
export const schedule = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
Schedule.jittered,
Schedule.setInputType<Input>(),
Schedule.modifyDelay(({ input, duration: delay }) => {
const minimum = retryAfter(input)
const duration = minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum))
return Effect.succeed(Duration.millis(Math.ceil(Duration.toMillis(duration))))
}),
Schedule.tap((metadata) =>
bus.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: metadata.input.assistantMessageID,
attempt: metadata.attempt + 1,
at: metadata.now + Duration.toMillis(metadata.duration),
error: metadata.input.error,
}),
),
)
@@ -0,0 +1,402 @@
export * as SessionStepMachine from "./step-machine.js"
import { AIError, type ToolCall } from "@opencode-ai/ai"
import { Cause, Data, Effect, Exit } from "effect"
import { StateMachine } from "../../effect/state-machine.js"
import { StepFailedError } from "../error.js"
import { SessionMessage } from "../message.js"
import { SessionStep } from "./step.js"
const PREPARATION = "preparation"
const PROVIDER = "provider"
const COMPACTION = "compaction"
const SETTLEMENT = "settlement"
const RETRY = "retry"
export type Context = {
readonly assistantMessageID: SessionMessage.ID
readonly recoverOverflow: boolean
readonly recoverContinuation: boolean
}
export type Preparation = Data.TaggedEnum<{
Rebuilt: {}
Ready: { readonly attempt: SessionStep.Attempt }
}>
export const Preparation = Data.taggedEnum<Preparation>()
type AttemptFailure = AIError | StepFailedError
type BackoffOutcome = Data.TaggedEnum.Value<SessionStep.Outcome, "Retry" | "Continue">
type ToolRun = {
readonly call: ToolCall
readonly exit?: SessionStep.ToolExit
}
type ActiveAttempt = {
readonly context: Context
readonly attempt: SessionStep.Attempt
readonly tools: ReadonlyMap<string, ToolRun>
}
type AttemptState = Data.TaggedEnum<{
ObservingProvider: { readonly active: ActiveAttempt }
FinalizingProvider: {
readonly active: ActiveAttempt
readonly stream: Exit.Exit<void, AIError>
readonly stopping?: Cause.Cause<never>
}
AwaitingTools: { readonly active: ActiveAttempt; readonly stream: Exit.Exit<void, AIError> }
RecoveringOverflow: { readonly active: ActiveAttempt; readonly stream: Exit.Exit<void, AIError> }
}>
export type State =
| AttemptState
| Data.TaggedEnum<{
PreparingAttempt: { readonly context: Context }
SettlingAttempt: { readonly active: ActiveAttempt; readonly stopping?: Cause.Cause<never> }
BackingOff: {
readonly context: Context
readonly outcome: BackoffOutcome
}
Stopping: { readonly from?: AttemptState; readonly cause: Cause.Cause<never> }
}>
export const State = Data.taggedEnum<State>()
export type Event<Failure> = Data.TaggedEnum<{
Prepared: { readonly exit: Exit.Exit<{ readonly context: Context; readonly preparation: Preparation }, Failure> }
ProviderObserved: { readonly exit: Exit.Exit<SessionStep.ProviderObservation, AIError> }
ToolFinished: { readonly call: ToolCall; readonly exit: SessionStep.ToolExit }
ProviderFinished: { readonly exit: Exit.Exit<void> }
OverflowRecovered: { readonly exit: Exit.Exit<boolean> }
AttemptSettled: { readonly exit: Exit.Exit<SessionStep.Outcome, AttemptFailure> }
RetryFinished: { readonly exit: Exit.Exit<void, Failure> }
CancelRequested: {}
}>
interface EventDefinition extends Data.TaggedEnum.WithGenerics<1> {
readonly taggedEnum: Event<this["A"]>
}
export const Event = Data.taggedEnum<EventDefinition>()
export type Operation = Data.TaggedEnum<{
PrepareAttempt: { readonly context: Context; readonly freshAssistant: boolean }
ObserveProvider: { readonly attempt: SessionStep.Attempt }
RunTool: { readonly attempt: SessionStep.Attempt; readonly call: ToolCall }
FinishProvider: { readonly attempt: SessionStep.Attempt; readonly stream: Exit.Exit<void, AIError> }
RecoverOverflow: { readonly attempt: SessionStep.Attempt; readonly settlement: SessionStep.Settlement }
SettleAttempt: { readonly attempt: SessionStep.Attempt; readonly settlement: SessionStep.Settlement }
Retry: {
readonly context: Context
readonly outcome: BackoffOutcome
}
}>
export const Operation = Data.taggedEnum<Operation>()
export type Capabilities<Failure, RetryFailure, Requirements> = {
readonly prepare: (context: Context) => Effect.Effect<Preparation, Failure, Requirements>
readonly retry: (context: Context, outcome: BackoffOutcome) => Effect.Effect<void, RetryFailure, Requirements>
readonly publishSynthetic: Effect.Effect<void, Failure, Requirements>
}
export const run = Effect.fn("SessionStepMachine.run")(function* <Failure, RetryFailure, Requirements>(
assistantMessageID: SessionMessage.ID,
capabilities: Capabilities<Failure, RetryFailure, Requirements>,
) {
const execute = Operation.$match({
PrepareAttempt: (operation) =>
Effect.suspend(() => {
const context = operation.freshAssistant
? { ...operation.context, assistantMessageID: SessionMessage.ID.create() }
: operation.context
return capabilities.prepare(context).pipe(Effect.map((preparation) => ({ context, preparation })))
}).pipe(
Effect.exit,
Effect.map((exit) => Event.Prepared({ exit })),
),
ObserveProvider: (operation) =>
operation.attempt.observeUntilBoundary().pipe(
Effect.exit,
Effect.map((exit) => Event.ProviderObserved({ exit })),
),
RunTool: (operation) =>
operation.attempt.runTool(operation.call).pipe(
Effect.exit,
Effect.map((exit) => Event.ToolFinished({ call: operation.call, exit })),
),
FinishProvider: (operation) =>
operation.attempt.finishProvider(operation.stream).pipe(
Effect.exit,
Effect.map((exit) => Event.ProviderFinished({ exit })),
),
RecoverOverflow: (operation) =>
operation.attempt.recoverOverflow(operation.settlement).pipe(
Effect.exit,
Effect.map((exit) => Event.OverflowRecovered({ exit })),
),
SettleAttempt: (operation) =>
operation.attempt.settle(operation.settlement).pipe(
Effect.exit,
Effect.map((exit) => Event.AttemptSettled({ exit })),
),
Retry: (operation) =>
capabilities.retry(operation.context, operation.outcome).pipe(
Effect.andThen(operation.outcome._tag === "Continue" ? capabilities.publishSynthetic : Effect.void),
Effect.exit,
Effect.map((exit) => Event.RetryFinished({ exit })),
),
})
const result = yield* StateMachine.run(definition<Failure, RetryFailure>(assistantMessageID), execute)
return yield* result
})
export const definition = <Failure, RetryFailure>(assistantMessageID: SessionMessage.ID) => {
const context = {
assistantMessageID,
recoverOverflow: true,
recoverContinuation: true,
}
type MachineFailure = Failure | RetryFailure | AttemptFailure
type Decision = StateMachine.Decision<State, Operation, Exit.Exit<boolean, MachineFailure>>
const prepare = (context: Context, freshAssistant = false): StateMachine.Continue<State, Operation> =>
StateMachine.next(
State.PreparingAttempt({ context }),
StateMachine.invoke(PREPARATION, Operation.PrepareAttempt({ context, freshAssistant })),
)
const pull = (active: ActiveAttempt): Decision =>
StateMachine.next(
State.ObservingProvider({ active }),
StateMachine.invoke(PROVIDER, Operation.ObserveProvider({ attempt: active.attempt })),
)
const settlement = (active: ActiveAttempt, stream: Exit.Exit<void, AIError>): SessionStep.Settlement => ({
stream,
tools: Array.from(active.tools.values()).flatMap((tool) =>
tool.exit ? [{ call: tool.call, exit: tool.exit }] : [],
),
})
const settle = (active: ActiveAttempt, stream: Exit.Exit<void, AIError>, stopping?: Cause.Cause<never>): Decision =>
StateMachine.next(
State.SettlingAttempt({ active, stopping }),
StateMachine.invoke(
SETTLEMENT,
Operation.SettleAttempt({
attempt: active.attempt,
settlement: settlement(active, stream),
}),
),
)
const afterProvider = (active: ActiveAttempt, stream: Exit.Exit<void, AIError>): Decision => {
if (Array.from(active.tools.values()).some((tool) => tool.exit === undefined))
return StateMachine.next(State.AwaitingTools({ active, stream }))
if (!active.context.recoverOverflow) return settle(active, stream)
return StateMachine.next(
State.RecoveringOverflow({ active, stream }),
StateMachine.invoke(
COMPACTION,
Operation.RecoverOverflow({
attempt: active.attempt,
settlement: settlement(active, stream),
}),
),
)
}
const finishProvider = (
active: ActiveAttempt,
stream: Exit.Exit<void, AIError>,
stopping?: Cause.Cause<never>,
): Decision =>
StateMachine.next(
State.FinalizingProvider({ active, stream, stopping }),
StateMachine.invoke(
PROVIDER,
Operation.FinishProvider({
attempt: active.attempt,
stream,
}),
),
)
const stop = (cause: Cause.Cause<never>, ids: ReadonlyArray<string>, from?: AttemptState): Decision => {
return StateMachine.next(
State.Stopping({ cause, from }),
StateMachine.stopAndJoin("step", ids, from?._tag === "FinalizingProvider" ? [PROVIDER] : []),
)
}
const interrupt = (state: State, cause: Cause.Cause<never>): Decision => {
const stopAttempt = (state: Exclude<AttemptState, { readonly _tag: "RecoveringOverflow" }>) =>
stop(
cause,
[
...(state._tag === "ObservingProvider" ? [PROVIDER] : []),
...Array.from(state.active.tools.values()).flatMap((tool) =>
tool.exit === undefined ? [toolID(tool.call)] : [],
),
],
state,
)
return State.$match(state, {
PreparingAttempt: () => stop(cause, [PREPARATION]),
ObservingProvider: stopAttempt,
FinalizingProvider: stopAttempt,
AwaitingTools: stopAttempt,
SettlingAttempt: (state) => StateMachine.next(State.SettlingAttempt({ active: state.active, stopping: cause })),
RecoveringOverflow: (state) => stop(cause, [COMPACTION], state),
BackingOff: () => stop(cause, [RETRY]),
Stopping: (state) => StateMachine.next(state),
})
}
return StateMachine.define<
State,
Event<Failure | RetryFailure>,
Operation,
never,
Exit.Exit<boolean, MachineFailure>
>({
initial: prepare(context),
interruption: Event.CancelRequested(),
transition: (state, runtimeEvent): Decision => {
if (runtimeEvent._tag === "Input") return interrupt(state, runtimeEvent.cause ?? Cause.interrupt(undefined))
if (runtimeEvent._tag === "InvocationsStopped") {
if (state._tag !== "Stopping") return unexpected(state, runtimeEvent)
if (!state.from) return StateMachine.done(Exit.failCause(state.cause))
const finished = runtimeEvent.exits.map(completed)
if (state.from._tag === "RecoveringOverflow") {
const recovered = finished.some(
(event) => event._tag === "OverflowRecovered" && Exit.isSuccess(event.exit) && event.exit.value,
)
return recovered
? StateMachine.done(Exit.failCause(state.cause))
: settle(state.from.active, Exit.failCause(state.cause), state.cause)
}
const tools = new Map(state.from.active.tools)
finished.forEach((event) => {
if (event._tag === "ToolFinished") tools.set(event.call.id, { call: event.call, exit: event.exit })
})
const active = { ...state.from.active, tools }
if (state.from._tag === "ObservingProvider")
return finishProvider(active, Exit.failCause(state.cause), state.cause)
const provider = finished.find((event) => event._tag === "ProviderFinished")
const stream =
provider && Exit.isFailure(provider.exit) ? Exit.failCause(provider.exit.cause) : state.from.stream
return settle(active, stream, state.cause)
}
const event = completed(runtimeEvent)
if (event._tag === "ToolFinished") {
if (
state._tag === "ObservingProvider" ||
state._tag === "FinalizingProvider" ||
state._tag === "AwaitingTools"
) {
const tools = new Map(state.active.tools)
tools.set(event.call.id, { call: event.call, exit: event.exit })
const active = { ...state.active, tools }
return state._tag === "AwaitingTools"
? afterProvider(active, state.stream)
: StateMachine.next({ ...state, active })
}
return unexpected(state, event)
}
return State.$match(state, {
PreparingAttempt: (state) => {
if (event._tag !== "Prepared") return unexpected(state, event)
if (Exit.isFailure(event.exit)) return StateMachine.done(Exit.failCause(event.exit.cause))
if (event.exit.value.preparation._tag === "Rebuilt") return prepare(event.exit.value.context, true)
const active = {
context: event.exit.value.context,
attempt: event.exit.value.preparation.attempt,
tools: new Map<string, ToolRun>(),
}
return pull(active)
},
ObservingProvider: (state) => {
if (event._tag !== "ProviderObserved") return unexpected(state, event)
if (Exit.isFailure(event.exit)) return finishProvider(state.active, Exit.failCause(event.exit.cause))
const observed = event.exit.value
if (observed._tag === "ProviderEnd") return finishProvider(state.active, Exit.succeed(undefined))
const tools = new Map(state.active.tools)
tools.set(observed.call.id, { call: observed.call })
const next = { ...state.active, tools }
return StateMachine.next(
State.ObservingProvider({ active: next }),
StateMachine.invoke<Operation>(
toolID(observed.call),
Operation.RunTool({
attempt: next.attempt,
call: observed.call,
}),
),
StateMachine.invoke<Operation>(PROVIDER, Operation.ObserveProvider({ attempt: next.attempt })),
)
},
FinalizingProvider: (state) => {
if (event._tag !== "ProviderFinished") return unexpected(state, event)
const stream = Exit.isFailure(event.exit) ? Exit.failCause(event.exit.cause) : state.stream
return state.stopping ? settle(state.active, stream, state.stopping) : afterProvider(state.active, stream)
},
RecoveringOverflow: (state) => {
if (event._tag !== "OverflowRecovered") return unexpected(state, event)
if (Exit.isFailure(event.exit)) return StateMachine.done(Exit.failCause(event.exit.cause))
if (!event.exit.value) return settle(state.active, state.stream)
const context = { ...state.active.context, recoverOverflow: false }
return prepare(context, true)
},
SettlingAttempt: (state) => {
if (event._tag !== "AttemptSettled") return unexpected(state, event)
if (state.stopping) return StateMachine.done(Exit.failCause(state.stopping))
if (Exit.isFailure(event.exit)) return StateMachine.done(Exit.failCause(event.exit.cause))
const backoff = (outcome: BackoffOutcome) =>
StateMachine.next(
State.BackingOff({ context: state.active.context, outcome }),
StateMachine.invoke(RETRY, Operation.Retry({ context: state.active.context, outcome })),
)
return SessionStep.Outcome.$match(event.exit.value, {
Completed: (outcome) => StateMachine.done(Exit.succeed(outcome.needsContinuation)),
Retry: backoff,
Continue: backoff,
RecoverFull: () => prepare({ ...state.active.context, recoverContinuation: false }),
})
},
BackingOff: (state) => {
if (event._tag !== "RetryFinished") return unexpected(state, event)
if (Exit.isFailure(event.exit)) return StateMachine.done(Exit.failCause(event.exit.cause))
return prepare(state.context, state.outcome._tag === "Continue")
},
AwaitingTools: (state) => unexpected(state, event),
Stopping: (state) => unexpected(state, event),
})
},
})
}
const toolID = (call: ToolCall) => `tool:${call.id}`
// Pre-start interruption can bypass the interpreter's Effect.exit.
// Normalize outer failures once without erasing operation-specific error types.
function completed<Failure>(
invocation: StateMachine.InvocationExited<Event<Failure>, Operation, never>,
): Event<Failure> {
if (Exit.isSuccess(invocation.exit)) return invocation.exit.value
const exit = Exit.failCause(invocation.exit.cause)
return Operation.$match(invocation.operation, {
PrepareAttempt: () => Event.Prepared({ exit }),
ObserveProvider: () => Event.ProviderObserved({ exit }),
RunTool: (operation) => Event.ToolFinished({ call: operation.call, exit }),
FinishProvider: () => Event.ProviderFinished({ exit }),
RecoverOverflow: () => Event.OverflowRecovered({ exit }),
SettleAttempt: () => Event.AttemptSettled({ exit }),
Retry: () => Event.RetryFinished({ exit }),
})
}
function unexpected(state: State, event: { readonly _tag: string }): never {
throw new Error(`Unexpected ${event._tag} event while Session Step machine is ${state._tag}`)
}
+210 -194
View File
@@ -9,13 +9,12 @@ import {
type ProviderErrorEvent,
type ToolCall,
} from "@opencode-ai/ai"
import { Cause, Data, Effect, Exit, Fiber, Option, Stream } from "effect"
import { Cause, Data, Effect, Exit, Option, Pull, Scope, Stream } from "effect"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Agent } from "../../agent.js"
import { Bus } from "../../bus.js"
import { Permission } from "../../permission.js"
import { Snapshot } from "../../snapshot.js"
import { Tool } from "../../tool.js"
import { ToolOutput } from "../../tool-output.js"
import { QuestionTool } from "../../tool/plugin/question.js"
import { StepFailedError } from "../error.js"
@@ -31,32 +30,44 @@ import { SessionRunnerRetry } from "./retry.js"
export type Outcome = Data.TaggedEnum<{
Completed: { readonly needsContinuation: boolean }
Retry: { readonly error: SessionError.Error; readonly decision: SessionRunnerRetry.Decision }
Continue: {
readonly error: SessionError.Error
readonly decision: SessionRunnerRetry.Decision
}
Retry: { readonly cause: AIError; readonly error: SessionError.Error }
Continue: { readonly cause: AIError; readonly error: SessionError.Error }
RecoverFull: {}
Compacted: {}
}>
export const Outcome = Data.taggedEnum<Outcome>()
interface Input {
export interface Input {
readonly sessionID: SessionSchema.ID
readonly assistantMessageID: SessionMessage.ID
readonly agent: Agent.ID
readonly model: SessionRunnerModel.Resolved
readonly prepared: SessionModelRequest.Prepared
readonly retry: (
cause: AIError,
error: SessionError.Error,
retry: boolean,
) => Effect.Effect<{ readonly retry: false } | SessionRunnerRetry.Decision>
readonly recoverContinuation: boolean
/** The runner owns compaction policy; the attempt invokes it only before durable output. */
readonly recoverOverflow: Effect.Effect<boolean>
}
export type ProviderObservation = Data.TaggedEnum<{
ToolCall: { readonly call: ToolCall }
ProviderEnd: {}
}>
export const ProviderObservation = Data.taggedEnum<ProviderObservation>()
export type ToolExit = Exit.Exit<void, Permission.DeclinedError | QuestionTool.CancelledError>
export interface Settlement {
readonly stream: Exit.Exit<void, AIError>
readonly tools: ReadonlyArray<{ readonly call: ToolCall; readonly exit: ToolExit }>
}
export interface Attempt {
readonly observeUntilBoundary: () => Effect.Effect<ProviderObservation, AIError>
readonly runTool: (call: ToolCall) => Effect.Effect<void, Permission.DeclinedError | QuestionTool.CancelledError>
readonly finishProvider: (stream: Exit.Exit<void, AIError>) => Effect.Effect<void>
readonly recoverOverflow: (settlement: Settlement) => Effect.Effect<boolean>
readonly settle: (settlement: Settlement) => Effect.Effect<Outcome, AIError | StepFailedError>
}
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
@@ -68,7 +79,7 @@ export const make = Effect.gen(function* () {
const snapshots = yield* Snapshot.Service
const toolOutput = yield* ToolOutput.Service
const attempt = Effect.fn("SessionStep.attempt")(function* (input: Input) {
const open = Effect.fn("SessionStep.open")(function* (input: Input) {
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(bus, {
sessionID: input.sessionID,
@@ -78,191 +89,197 @@ export const make = Effect.gen(function* () {
providerMetadataKey: input.model.model.route.providerMetadataKey ?? input.model.model.provider,
snapshot: startSnapshot,
})
const toolRuns: Array<{
readonly call: ToolCall
readonly fiber: Fiber.Fiber<void, Permission.DeclinedError | QuestionTool.CancelledError>
}> = []
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
const executeTool = (call: ToolCall) => {
if (input.prepared.request.toolChoice?.type === "none")
return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
return input.prepared.executeTool({
sessionID: input.sessionID,
agent: input.agent,
messageID: input.assistantMessageID,
call,
progress: (update) => publisher.progress(call.id, update),
})
}
// Provider and tool fibers retain per-source order without a shared writer queue.
// A local execution starts only after its Tool.Called publication completes.
const scope = yield* Scope.Scope
const providerScope = yield* Scope.fork(scope)
const pull = yield* llm
.stream(input.prepared.request, input.prepared.options)
.pipe(Stream.ensuring(publisher.flush()), Stream.toPull, Scope.provide(providerScope))
let buffered: ReadonlyArray<LLMEvent> = []
let offset = 0
let overflowFailure: ProviderErrorEvent | undefined
// Read to the end, not just the finish event, so the next request can reuse this response.
const providerStream = llm.stream(input.prepared.request, input.prepared.options).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
const observeUntilBoundary = Effect.fnUntraced(function* (): Effect.fn.Return<ProviderObservation, AIError> {
while (true) {
const event = buffered[offset]
if (event) {
offset += 1
if (overflowFailure || publisher.hasProviderError()) continue
if (
LLMEvent.is.providerError(event) &&
isContextOverflowFailure(event) &&
!publisher.record().outputStarted
) {
overflowFailure = event
return
continue
}
yield* publisher.publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
toolRuns.push({
call: event,
fiber: yield* Effect.uninterruptibleMask((restore) =>
restore(executeTool(event)).pipe(
Effect.flatMap(toolOutput.truncate),
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
Effect.catchTag("Tool.Error", (error) =>
publisher.failTool(event.id, toSessionError(error), error.metadata).pipe(Effect.asVoid),
),
),
).pipe(Effect.forkScoped),
})
}),
),
Effect.ensuring(publisher.flush()),
)
// Keep the final tool and Step events uninterruptible, even when the work itself is cancelled.
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const stream = yield* restore(providerStream).pipe(Effect.exit)
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream))
const streamInterrupted = Exit.hasInterrupts(stream)
if (!overflowFailure && publisher.hasStarted()) yield* publisher.streamed()
if (streamInterrupted) yield* interruptTools
const joined = yield* restore(Fiber.awaitAll(toolRuns.map((run) => run.fiber))).pipe(Effect.exit)
if (Exit.isFailure(joined)) yield* interruptTools
const tools = classifyToolExits(joined, toolRuns)
if (
!publisher.record().outputStarted &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(input.recoverOverflow))
)
return Outcome.Compacted()
if (overflowFailure) yield* publisher.publish(overflowFailure)
const recorded = publisher.record()
const unknownFinish =
Exit.isSuccess(stream) && recorded.finish?.finish === "unknown"
? new AIError({
reason: new InvalidProviderOutputError({
message: "The provider response ended with an unknown finish reason.",
classification: "incomplete-stream",
}),
})
: undefined
const llmFailure = streamFailure instanceof AIError ? streamFailure : unknownFinish
const llmError = llmFailure && !recorded.providerFailed ? toSessionError(llmFailure) : undefined
if (
input.recoverContinuation &&
llmFailure?.reason._tag === "Transport" &&
(llmFailure.reason.recovery === "retry-full" || llmFailure.reason.recovery === "rotate-and-retry-full") &&
!recorded.outputStarted
)
return Outcome.RecoverFull()
const retry =
llmFailure && llmError && !isContextOverflowFailure(llmFailure)
? yield* restore(
input.retry(
llmFailure,
llmError,
SessionRunnerRetry.isRetryable(llmFailure) ||
(recorded.outputStarted && isInterruptedStream(llmFailure)),
),
)
: undefined
if (llmFailure && llmError && retry?.retry && !recorded.outputStarted) {
// Retry state projects onto the existing assistant, even before it has produced output.
yield* publisher.startAssistant()
return Outcome.Retry({ error: llmError, decision: retry })
// Keep the publisher's in-memory mark and durable write indivisible under cancellation.
yield* publisher.publish(event).pipe(Effect.uninterruptible)
if (event.type === "tool-call" && !event.providerExecuted)
return ProviderObservation.ToolCall({ call: event })
continue
}
if (llmError) yield* publisher.failAssistant(llmError)
const chunk = yield* pull.pipe(Pull.catchDone(() => Effect.succeed(undefined)))
if (!chunk) return ProviderObservation.ProviderEnd()
buffered = chunk
offset = 0
}
})
for (const decline of tools.declines)
yield* publisher.failTool(decline.call.id, {
type: "aborted",
message:
decline.reason._tag === "QuestionTool.CancelledError"
? decline.reason.message
: "The user declined this tool call",
})
const interrupted = tools.declines.length > 0 || streamInterrupted || tools.interrupted
const toolFailure = interrupted
? TOOLS_INTERRUPTED
: tools.failure !== undefined
? toSessionError(Cause.squash(tools.failure))
: recorded.providerFailed
? TOOLS_INTERRUPTED
: undefined
if (toolFailure) yield* publisher.failUnsettledTools(toolFailure)
if (interrupted) yield* publisher.failAssistant(STEP_INTERRUPTED)
const runTool = Effect.fnUntraced(function* (call: ToolCall) {
return yield* Effect.uninterruptibleMask((restore) => {
if (input.prepared.request.toolChoice?.type === "none")
return publisher
.failTool(call.id, { type: "tool.execution", message: "Tools are disabled after the maximum agent steps" })
.pipe(Effect.asVoid)
return restore(
input.prepared.executeTool({
sessionID: input.sessionID,
agent: input.agent,
messageID: input.assistantMessageID,
call,
progress: (update) => publisher.progress(call.id, update),
}),
).pipe(
Effect.flatMap(toolOutput.truncate),
Effect.flatMap((outcome) => publisher.toolExecution(call.id, call.name, outcome)),
Effect.catchTag("Tool.Error", (error) =>
publisher.failTool(call.id, toSessionError(error), error.metadata).pipe(Effect.asVoid),
),
)
})
})
// All local fibers have joined; only provider-hosted results can still be missing.
if (llmError || (Exit.isSuccess(stream) && !recorded.providerFailed)) {
const missing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
if (missing && !llmError && !recorded.finish) yield* publisher.failAssistant(RESULT_MISSING)
}
const finishProvider = Effect.fnUntraced(function* (stream: Exit.Exit<void, AIError>) {
yield* Scope.close(providerScope, stream)
if (!overflowFailure && publisher.hasStarted()) yield* publisher.streamed()
}, Effect.uninterruptible)
const record = publisher.record()
if (record.finish || record.failure) {
const snapshot = yield* snapshots.capture()
const files =
startSnapshot && snapshot
? startSnapshot === snapshot
? []
: yield* snapshots
.files({ from: startSnapshot, to: snapshot })
.pipe(Effect.orElseSucceed(() => undefined))
: undefined
const usage = record.finish
? { cost: SessionUsage.calculateCost(input.model.cost, record.finish.tokens), tokens: record.finish.tokens }
: undefined
if (record.failure) yield* publisher.publishStepFailure({ ...usage, snapshot, files })
if (record.finish && usage && !record.failure)
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* publisher.startAssistant(),
finish: record.finish.finish,
rawFinish: record.finish.rawFinish,
providerState: record.finish.providerState,
...usage,
snapshot,
files,
const recoverOverflow = (settlement: Settlement) => {
if (publisher.record().outputStarted) return Effect.succeed(false)
const failure = overflowFailure ?? Option.getOrUndefined(Exit.findErrorOption(settlement.stream))
return isContextOverflowFailure(failure) ? input.recoverOverflow : Effect.succeed(false)
}
const settle = Effect.fn("SessionStep.settle")(function* (settlement: Settlement) {
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(settlement.stream))
const streamInterrupted = Exit.hasInterrupts(settlement.stream)
const tools = classifyToolExits(settlement.tools)
if (overflowFailure) yield* publisher.publish(overflowFailure)
const recorded = publisher.record()
const unknownFinish =
Exit.isSuccess(settlement.stream) && recorded.finish?.finish === "unknown"
? new AIError({
reason: new InvalidProviderOutputError({
message: "The provider response ended with an unknown finish reason.",
classification: "incomplete-stream",
}),
})
}
: undefined
const llmFailure = streamFailure instanceof AIError ? streamFailure : unknownFinish
const llmError = llmFailure && !recorded.providerFailed ? toSessionError(llmFailure) : undefined
if (
input.recoverContinuation &&
llmFailure?.reason._tag === "Transport" &&
(llmFailure.reason.recovery === "retry-full" || llmFailure.reason.recovery === "rotate-and-retry-full") &&
!recorded.outputStarted
)
return Outcome.RecoverFull()
if (llmFailure && llmError && SessionRunnerRetry.isRetryable(llmFailure) && !recorded.outputStarted) {
yield* publisher.startAssistant()
return Outcome.Retry({ cause: llmFailure, error: llmError })
}
if (llmError) yield* publisher.failAssistant(llmError)
if (
llmFailure &&
llmError &&
retry?.retry &&
record.outputStarted &&
tools.declines.length === 0 &&
!tools.interrupted
)
return Outcome.Continue({ error: llmError, decision: retry })
if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause)
if (tools.declines.length > 0) return yield* Effect.interrupt
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
if (tools.interrupted && Exit.isFailure(joined)) return yield* Effect.failCause(joined.cause)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
return Outcome.Completed({
needsContinuation: input.prepared.request.toolChoice?.type !== "none" && record.needsContinuation,
for (const decline of tools.declines)
yield* publisher.failTool(decline.call.id, {
type: "aborted",
message:
decline.reason._tag === "QuestionTool.CancelledError"
? decline.reason.message
: "The user declined this tool call",
})
}),
)
}, Effect.scoped)
const interrupted = tools.declines.length > 0 || streamInterrupted || tools.interrupted
const toolFailure = interrupted
? TOOLS_INTERRUPTED
: tools.failure !== undefined
? toSessionError(Cause.squash(tools.failure))
: recorded.providerFailed
? TOOLS_INTERRUPTED
: undefined
if (toolFailure) yield* publisher.failUnsettledTools(toolFailure)
if (interrupted) yield* publisher.failAssistant(STEP_INTERRUPTED)
return { attempt }
if (llmError || (Exit.isSuccess(settlement.stream) && !recorded.providerFailed)) {
const missing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
if (missing && !llmError && !recorded.finish) yield* publisher.failAssistant(RESULT_MISSING)
}
const record = publisher.record()
if (record.finish || record.failure) {
const snapshot = yield* snapshots.capture()
const files =
startSnapshot && snapshot
? startSnapshot === snapshot
? []
: yield* snapshots
.files({ from: startSnapshot, to: snapshot })
.pipe(Effect.orElseSucceed(() => undefined))
: undefined
const usage = record.finish
? {
cost: SessionUsage.calculateCost(input.model.cost, record.finish.tokens),
tokens: record.finish.tokens,
}
: undefined
if (record.failure) yield* publisher.publishStepFailure({ ...usage, snapshot, files })
if (record.finish && usage && !record.failure)
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* publisher.startAssistant(),
finish: record.finish.finish,
rawFinish: record.finish.rawFinish,
providerState: record.finish.providerState,
...usage,
snapshot,
files,
})
}
// After durable output, recovery continues instead of replaying: the
// partial assistant message is already persisted history. Any failure
// the pre-output gate would retry is continued here, plus interrupted
// streams, whose read failures may carry delivery states the retry
// policy rejects for full resends.
if (
llmFailure &&
llmError &&
(isInterruptedStream(llmFailure) || SessionRunnerRetry.isRetryable(llmFailure)) &&
record.outputStarted &&
tools.declines.length === 0 &&
!tools.interrupted
)
return Outcome.Continue({ cause: llmFailure, error: llmError })
if (Exit.isFailure(settlement.stream)) return yield* Effect.failCause(settlement.stream.cause)
if (tools.declines.length > 0) return yield* Effect.interrupt
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
return Outcome.Completed({
needsContinuation: input.prepared.request.toolChoice?.type !== "none" && record.needsContinuation,
})
}, Effect.uninterruptible)
return {
observeUntilBoundary,
runTool,
finishProvider,
recoverOverflow,
settle,
} satisfies Attempt
})
return { open }
})
const isInterruptedStream = (failure: AIError) => {
@@ -273,20 +290,19 @@ const isInterruptedStream = (failure: AIError) => {
/** Tool.Error settles in each fiber; only user declines remain in the typed error channel. */
const classifyToolExits = (
settled: Exit.Exit<Array<Exit.Exit<void, Permission.DeclinedError | QuestionTool.CancelledError>>>,
runs: ReadonlyArray<{ readonly call: ToolCall }>,
runs: ReadonlyArray<{
readonly call: ToolCall
readonly exit: ToolExit
}>,
) => {
const exits = Exit.isSuccess(settled) ? settled.value : []
const declines = exits.flatMap((exit, index) =>
Exit.isFailure(exit)
? exit.cause.reasons.flatMap((reason) =>
Cause.isFailReason(reason) ? [{ call: runs[index].call, reason: reason.error }] : [],
const declines = runs.flatMap((run) =>
Exit.isFailure(run.exit)
? run.exit.cause.reasons.flatMap((reason) =>
Cause.isFailReason(reason) ? [{ call: run.call, reason: reason.error }] : [],
)
: [],
)
const causes = Exit.isFailure(settled)
? [settled.cause]
: exits.flatMap((exit) => (Exit.isFailure(exit) ? [exit.cause] : []))
const causes = runs.flatMap((run) => (Exit.isFailure(run.exit) ? [run.exit.cause] : []))
const failure = causes
.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
+1 -1
View File
@@ -287,7 +287,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
batches(ids.map((row) => row.id)),
(batch) =>
db
.select({ data: EventTable.data })
.select({ created: EventTable.created, data: EventTable.data })
.from(EventTable)
.where(
and(
+10 -3
View File
@@ -5,7 +5,6 @@ import { Tool } from "@opencode-ai/schema/tool"
import { Skill } from "@opencode-ai/schema/skill"
import { eq } from "drizzle-orm"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { map } from "effect/Array"
import path from "path"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app.js"
@@ -310,13 +309,21 @@ function sanitizeToolState(id: string, state: SessionMessage.ToolState): Session
return {
...state,
input: { redacted: `tool-input:${id}` },
content: map(state.content, (item) => sanitizeToolContent(id, item)),
content: [
sanitizeToolContent(id, state.content[0]),
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
],
metadata: meta,
}
return {
...state,
input: { redacted: `tool-input:${id}` },
content: state.content ? map(state.content, (item) => sanitizeToolContent(id, item)) : undefined,
content: state.content
? [
sanitizeToolContent(id, state.content[0]),
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
]
: undefined,
metadata: meta,
}
}
+1 -1
View File
@@ -109,7 +109,7 @@ const layer = Layer.effect(
draft.skills.delete(ID.make(id))
},
}),
notify: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
})
return Service.of({
+95 -89
View File
@@ -1,9 +1,9 @@
export * as State from "./state.js"
import { Clock, Context, Deferred, Effect, Exit, Scope } from "effect"
import { Clock, Context, Deferred, Effect, Scope, Semaphore } from "effect"
/**
* A replayable transform applied to a draft while deriving state.
* A replayable transform applied to a draft during reload.
*
* Domain drafts expose readable and writable state while preserving concise
* plugin/config code. Transforms synchronously rebuild derived state.
@@ -16,14 +16,13 @@ export interface Registration {
}
/**
* Registers a scoped transform and invalidates the derived state. Closing the
* owning Scope removes the transform. Reads synchronously replay pending changes.
* Registers and applies a scoped transform. Closing the owning Scope removes
* the transform and reloads the materialized state.
*/
export type Transform<DraftApi> = (
transform: TransformCallback<DraftApi>,
) => Effect.Effect<Registration, never, Scope.Scope>
/** Invalidates the snapshot after captured inputs change and coalesces notifications. */
export type Reload = () => Effect.Effect<void>
export interface Transformable<DraftApi> {
@@ -34,7 +33,7 @@ export interface Transformable<DraftApi> {
type Batch = {
active: boolean
readonly flush: boolean
readonly notifications: Set<Reload>
readonly reloads: Set<Reload>
}
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
@@ -42,24 +41,17 @@ const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/Curre
})
const reloadDebounce = 500
/** Batches notifications, not read visibility. flush: false is terminal teardown. */
/** flush: false is terminal teardown: states whose transforms are removed stop rebuilding, including pending reloads. */
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>, options: { readonly flush?: boolean } = {}) {
return Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const current = yield* CurrentBatch
if (current?.active && options.flush !== false) return yield* restore(effect)
const batch: Batch = { active: true, flush: options.flush !== false, notifications: new Set() }
const exit = yield* restore(effect.pipe(Effect.provideService(CurrentBatch, batch))).pipe(Effect.exit)
batch.active = false
const notifications = batch.flush
? yield* Effect.forEach(batch.notifications, (notify) => restore(notify()).pipe(Effect.exit))
: []
// Accepted writes are not rolled back: one failed observer must not hide
// the other states' changes, or replace the batch body's failure.
yield* Exit.asVoidAll([exit, ...notifications])
return yield* exit
}),
)
return Effect.gen(function* () {
const current = yield* CurrentBatch
if (current?.active && options.flush !== false) return yield* effect
const batch: Batch = { active: true, flush: options.flush !== false, reloads: new Set() }
const exit = yield* effect.pipe(Effect.provideService(CurrentBatch, batch), Effect.exit)
batch.active = false
if (batch.flush) yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true })
return yield* exit
})
}
export const inherit = Effect.fnUntraced(function* () {
@@ -73,110 +65,124 @@ export interface Options<State, DraftApi> {
readonly initial: () => State
/** Wraps mutable state in a domain-specific draft API. */
readonly draft: MakeDraft<State, DraftApi>
/** Synchronously completes derived data after ordered transform replay. */
readonly prepare?: (state: State) => void
/**
* Observes accepted changes outside the read path. Batched writes notify at
* batch completion; reloads debounce notifications. Reads never run this hook.
* Resource reconciliation owns its execution scope and coordination.
* Runs after the rebuilt state becomes visible. Update events published here
* act as read barriers: subscribers refetching on the event observe the
* committed state.
*/
readonly notify?: () => Effect.Effect<void>
readonly finalize?: (draft: DraftApi) => Effect.Effect<void>
}
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
/** Returns the latest accepted state, replaying stale inputs synchronously. */
readonly get: () => State
}
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
let state = options.initial()
const transforms = new Set<{ run: TransformCallback<DraftApi> }>()
let dirty = false
let transforms: { run: TransformCallback<DraftApi> }[] = []
let generation = 0
let requestedAt = 0
let running = false
let closed = false
let pending: Deferred.Deferred<void> | undefined
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
const get = () => {
if (!dirty || closed) return state
const next = options.initial()
const api = options.draft(next)
transforms.forEach((transform) => transform.run(api))
options.prepare?.(next)
const commit = Effect.fn("State.commit")(function* (next: State) {
state = next
dirty = false
return state
}
const notify = Effect.fn("State.notify")(function* () {
if (closed) return
get()
if (options.notify) yield* options.notify()
if (options.finalize) yield* options.finalize(options.draft(next))
})
const publish = (done: Deferred.Deferred<void>): Effect.Effect<void> =>
const materialize = Effect.fnUntraced(function* () {
if (closed) return
const next = options.initial()
const api = options.draft(next)
for (const transform of transforms) {
yield* Effect.sync(() => {
transform.run(api)
})
}
yield* commit(next)
})
const materializeReload = () => semaphore.withPermit(materialize())
const rebuild = (): Effect.Effect<void> =>
Effect.gen(function* () {
const clock = yield* Clock.Clock
const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe()
if (remaining > 0) yield* Effect.sleep(remaining)
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* publish(done)
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* rebuild()
// Release scheduling ownership before observers run: an observer may
// request and await another reload without joining this notification.
pending = undefined
yield* notify().pipe(Deferred.into(done))
const target = generation
const exit = yield* materializeReload().pipe(Effect.exit)
const completed = waiters.filter((waiter) => waiter.generation <= target)
waiters = waiters.filter((waiter) => waiter.generation > target)
yield* Effect.forEach(completed, (waiter) => Deferred.done(waiter.done, exit), {
concurrency: "unbounded",
discard: true,
})
if (generation > target) return yield* rebuild()
running = false
})
const changed = (debounce: boolean) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (closed) return
if (debounce) dirty = true
const batch = yield* CurrentBatch
if (batch?.active) {
if (!batch.flush) {
closed = true
return
}
batch.notifications.add(notify)
return
}
if (!debounce) return yield* restore(notify())
const clock = yield* Clock.Clock
requestedAt = clock.currentTimeMillisUnsafe()
// No yields between choosing the burst's completion and claiming it.
const done = pending ?? Deferred.makeUnsafe<void>()
if (!pending) {
pending = done
yield* publish(done).pipe(Effect.forkDetach)
}
yield* restore(Deferred.await(done))
}),
)
const reload = Effect.fnUntraced(function* () {
if (closed) return
const done = Deferred.makeUnsafe<void>()
const clock = yield* Clock.Clock
generation++
requestedAt = clock.currentTimeMillisUnsafe()
waiters.push({ generation, done })
if (!running) {
running = true
yield* rebuild().pipe(Effect.forkDetach)
}
yield* Deferred.await(done)
})
return {
get,
get: () => state,
transform: Effect.fn("State.transform")(function* (update) {
yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous")
const scope = yield* Scope.Scope
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const transform = { run: update }
let active = true
const dispose = Effect.uninterruptible(
Effect.suspend(() => {
if (!transforms.delete(transform)) return Effect.void
dirty = true
return changed(false)
semaphore.withPermit(
Effect.suspend(() => {
if (!active) return Effect.void
active = false
transforms = transforms.filter((item) => item !== transform)
return Effect.gen(function* () {
const batch = yield* CurrentBatch
if (batch?.active) {
// Detached debounced reloads must also stay quiet after teardown.
if (!batch.flush) {
closed = true
return
}
batch.reloads.add(materializeReload)
return
}
yield* materialize()
})
}),
),
)
yield* semaphore.withPermit(
Effect.sync(() => {
transforms = [...transforms, transform]
}),
)
transforms.add(transform)
dirty = true
yield* Scope.addFinalizer(scope, dispose)
yield* changed(false)
const batch = yield* CurrentBatch
if (batch?.active) batch.reloads.add(materializeReload)
else yield* materializeReload()
return { dispose }
}),
)
}),
reload: () => changed(true),
reload,
}
}
+1 -1
View File
@@ -185,7 +185,7 @@ const layer = Layer.effect(
draft.tools.delete(id)
},
}),
notify: () =>
finalize: () =>
Effect.forEach(
state.get().errors,
({ tool, error }) =>
+18 -1
View File
@@ -40,7 +40,24 @@ const AgentSchema = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Any)],
)
const KNOWN_KEYS = new Set(["name", ...Object.keys(AgentSchema.schema.fields)])
const KNOWN_KEYS = new Set([
"name",
"model",
"variant",
"prompt",
"description",
"temperature",
"top_p",
"mode",
"hidden",
"color",
"steps",
"maxSteps",
"options",
"permission",
"disable",
"tools",
])
const normalize = (agent: Schema.Schema.Type<typeof AgentSchema>): Schema.Schema.Type<typeof AgentSchema> => {
const options: Record<string, unknown> = { ...agent.options }
+10 -34
View File
@@ -1,7 +1,7 @@
export * as Vcs from "./vcs.js"
import path from "path"
import { Cause, Context, Effect, Exit, Fiber, FiberSet, Layer, Schema, Semaphore, Stream } from "effect"
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
import type { VcsDefinition, VcsDraft } from "@opencode-ai/plugin/effect/vcs"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileSystem } from "@opencode-ai/schema/filesystem"
@@ -47,11 +47,8 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const bus = yield* Bus.Service
const root = yield* Effect.scope
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const vcs = location.vcs
const current: { info: Info } = { info: { branch: {} } }
const refreshLock = Semaphore.makeUnsafe(1)
const scope = {
directory: location.directory,
worktree: location.project.directory,
@@ -72,12 +69,7 @@ const layer = Layer.effect(
set: (selection) => (draft.selection = selection),
},
}),
notify: () =>
Effect.gen(function* () {
const exit = yield* Fiber.await(fork(refresh()))
if (Exit.isFailure(exit) && root.state._tag === "Closed" && Cause.hasInterruptsOnly(exit.cause)) return
yield* exit
}),
finalize: () => refresh(),
})
const selected = () => {
const value = state.get()
@@ -95,23 +87,13 @@ const layer = Layer.effect(
),
)
const refresh = Effect.fn("Vcs.refresh")(function* () {
const changed = yield* Effect.gen(function* () {
const provider = selected()
const next: Info = provider
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
: { branch: {} }
const changed = current.info.branch.current !== next.branch.current
current.info = next
return changed
}).pipe(refreshLock.withPermit)
if (!changed) return
// Legacy listeners can publish nested updates before streams and SSE receive
// this event. Re-announce the latest branch if publication was overtaken.
while (true) {
const branch = current.info.branch.current
yield* bus.publish(VcsEvent.BranchUpdated, { branch })
if (branch === current.info.branch.current) return
}
const provider = selected()
const next: Info = provider
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
: { branch: {} }
const changed = current.info.branch.current !== next.branch.current
current.info = next
if (changed) yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
})
if (vcs) {
@@ -123,13 +105,7 @@ const layer = Layer.effect(
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.filter((event) => isBranchMetadata(event.data.file)),
Stream.runForEach((event) =>
refresh().pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
(cause) => Effect.logWarning("vcs refresh failed", { file: event.data.file, cause }),
),
Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } }),
),
refresh().pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
),
Effect.forkScoped({ startImmediately: true }),
)
+1 -1
View File
@@ -88,7 +88,7 @@ const layer = Layer.effect(
set: (selection) => (draft.selection = selection),
},
}),
notify: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
})
const requireProvider = (providers: Map<ID, ProviderImplementation>, providerID: ID) => {
+51 -47
View File
@@ -124,58 +124,62 @@ const layer = Layer.effect(
return entries
})
const refresh = Effect.fn("WellKnown.refresh")(
function* () {
const value = yield* kv.get(sourcesKey)
const origins = Schema.is(Sources)(value) ? value : []
if (!origins.length) return false
const entries = yield* Effect.forEach(origins, loadEntry)
const next = new Map(entries.map((entry) => [entry.origin, entry]))
const changed = !isDeepStrictEqual(Ref.getUnsafe(cache), next)
if (!changed) return false
yield* Ref.set(cache, next)
yield* bus.publish(Event.Updated, {})
return true
},
(effect) => lock.withPermit(effect),
)
const refresh = Effect.fn("WellKnown.refresh")(function* () {
return yield* lock.withPermit(
Effect.gen(function* () {
const value = yield* kv.get(sourcesKey)
const origins = Schema.is(Sources)(value) ? value : []
if (!origins.length) return false
const entries = yield* Effect.forEach(origins, loadEntry)
const next = new Map(entries.map((entry) => [entry.origin, entry]))
const changed = !isDeepStrictEqual(Ref.getUnsafe(cache), next)
if (!changed) return false
yield* Ref.set(cache, next)
yield* bus.publish(Event.Updated, {})
return true
}),
)
})
return Service.of({
entries: load,
snapshot: () => Array.from(Ref.getUnsafe(cache).values()),
refresh,
add: Effect.fn("WellKnown.add")(
function* (value) {
const origin = value.replace(/\/+$/, "")
const entry = yield* loadEntry(origin)
if (!entry.manifest.auth) return yield* Effect.fail(new Error(`No authentication method found at ${origin}`))
const sources = yield* kv.get(sourcesKey)
const origins = Schema.is(Sources)(sources) ? sources : []
yield* kv.set(sourcesKey, Array.from(new Set([...origins, origin])))
yield* Ref.update(cache, (current) => new Map(current).set(origin, entry))
yield* bus.publish(Event.Updated, {})
return entry
},
(effect, _value) => lock.withPermit(effect),
),
remove: Effect.fn("WellKnown.remove")(
function* (value) {
const origin = value.replace(/\/+$/, "")
const sources = yield* kv.get(sourcesKey)
const origins = Schema.is(Sources)(sources) ? sources : []
yield* kv.set(
sourcesKey,
origins.filter((item) => item !== origin),
)
yield* Ref.update(cache, (current) => {
const next = new Map(current)
next.delete(origin)
return next
})
yield* bus.publish(Event.Updated, {})
},
(effect, _value) => lock.withPermit(effect),
),
add: Effect.fn("WellKnown.add")(function* (value) {
return yield* lock.withPermit(
Effect.gen(function* () {
const origin = value.replace(/\/+$/, "")
const entry = yield* loadEntry(origin)
if (!entry.manifest.auth)
return yield* Effect.fail(new Error(`No authentication method found at ${origin}`))
const sources = yield* kv.get(sourcesKey)
const origins = Schema.is(Sources)(sources) ? sources : []
yield* kv.set(sourcesKey, Array.from(new Set([...origins, origin])))
yield* Ref.update(cache, (current) => new Map(current).set(origin, entry))
yield* bus.publish(Event.Updated, {})
return entry
}),
)
}),
remove: Effect.fn("WellKnown.remove")(function* (value) {
yield* lock.withPermit(
Effect.gen(function* () {
const origin = value.replace(/\/+$/, "")
const sources = yield* kv.get(sourcesKey)
const origins = Schema.is(Sources)(sources) ? sources : []
yield* kv.set(
sourcesKey,
origins.filter((item) => item !== origin),
)
yield* Ref.update(cache, (current) => {
const next = new Map(current)
next.delete(origin)
return next
})
yield* bus.publish(Event.Updated, {})
}),
)
}),
resolve: Effect.fn("WellKnown.resolveEntry")((entry, variables) =>
resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http)),
),
+3 -59
View File
@@ -11,7 +11,6 @@ import { Location } from "@opencode-ai/core/location"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { State } from "@opencode-ai/core/state"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
@@ -31,57 +30,6 @@ const catalogLayer = AppNodeBuilder.build(
const it = testEffect(catalogLayer)
describe("Catalog", () => {
it.effect("reads available and default models inside a batch before publishing", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const bus = yield* Bus.Service
const observed: string[] = []
const unsubscribe = yield* bus.listen((event) =>
event.type === Catalog.Event.Updated.type
? catalog.model.default().pipe(
Effect.map((model) => {
observed.push(model?.id ?? "none")
}),
)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const providerID = Provider.ID.make("test")
const old = Model.ID.make("old")
const newest = Model.ID.make("new")
yield* State.batch(
Effect.gen(function* () {
yield* catalog.transform((draft) => {
draft.provider.update(providerID, () => {})
draft.model.update(providerID, old, (model) => {
model.time.released = 1000
})
draft.model.update(providerID, newest, (model) => {
model.time.released = 2000
})
draft.model.default.set(providerID, old)
})
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([newest, old])
expect((yield* catalog.model.default())?.id).toBe(old)
const overlay = yield* catalog.transform((draft) =>
draft.model.update(providerID, old, (model) => {
model.enabled = false
}),
)
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([newest])
expect((yield* catalog.model.default())?.id).toBe(newest)
yield* overlay.dispose
expect((yield* catalog.model.default())?.id).toBe(old)
expect(observed).toEqual([])
}),
)
expect(observed).toEqual([old])
}),
)
it.effect("publishes an updated event after catalog changes", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -136,8 +84,7 @@ describe("Catalog", () => {
return Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
yield* catalog.transform((editor) =>
editor.provider.update(providerID, (provider) => {
provider.integrationID = integrationID
@@ -145,8 +92,7 @@ describe("Catalog", () => {
)
expect(yield* catalog.provider.available()).toEqual([])
const credentials = yield* Credential.Service
yield* credentials.create({
yield* (yield* Credential.Service).create({
integrationID,
value: Credential.Key.make({ type: "key", key: "secret" }),
})
@@ -166,8 +112,7 @@ describe("Catalog", () => {
return Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
yield* catalog.transform((editor) =>
editor.provider.update(providerID, (provider) => {
provider.integrationID = integrationID
@@ -346,7 +291,6 @@ describe("Catalog", () => {
configured = false
const reload = yield* catalog.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect((yield* catalog.model.default())?.id).toBe(newest)
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect((yield* catalog.model.default())?.id).toBe(newest)
-26
View File
@@ -15,7 +15,6 @@ import { Permission } from "@opencode-ai/core/permission"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { ConfigAgentV1 } from "@opencode-ai/core/v1/config/agent"
import { advance, drain } from "../lib/clock"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
@@ -35,30 +34,6 @@ test("rejects named agent color tokens", () => {
expect(() => decode({ agents: { reviewer: { color: "warning" } } })).toThrow()
})
test("keeps schema fields and name out of legacy agent options", () => {
const agent = Schema.decodeUnknownSync(ConfigAgentV1.Info)({
name: "reviewer",
model: "test/model",
variant: "high",
temperature: 0.5,
top_p: 0.9,
prompt: "Review carefully.",
tools: { edit: false },
disable: false,
description: "Reviews changes",
mode: "subagent",
hidden: true,
options: { existing: true },
color: "#112233",
steps: 10,
maxSteps: 20,
permission: { read: "allow" },
custom: "preserved",
})
expect(agent.options).toEqual({ existing: true, custom: "preserved" })
})
describe("ConfigAgentPlugin.Plugin", () => {
it.effect("matches POSIX paths against home-relative permissions", () =>
Effect.gen(function* () {
@@ -376,7 +351,6 @@ Review carefully.`,
await fs.writeFile(
path.join(tmp.path, "agents", "native.md"),
`---
variant: high
request:
headers:
x-agent: native
@@ -362,20 +362,6 @@ describe("ConfigNormalize", () => {
])
})
test("normalizes MCP timeout fields in schema order with per-leaf recovery", () => {
const result = normalized({ mcp: { timeout: { execution: 3000, startup: "invalid", catalog: 2000 } } })
expect(result.encoded.mcp).toEqual({ timeout: { catalog: 2000, execution: 3000 } })
expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([
["invalid", ["mcp", "timeout", "startup"]],
])
expect(normalized({ mcp: { timeout: {} } }).encoded.mcp).toBeUndefined()
const unknown = normalized({ mcp: { timeout: { unknown: 1000 } } })
expect(unknown.encoded.mcp).toBeUndefined()
expect(unknown.diagnostics.map((item) => [item.kind, item.path])).toEqual([["invalid", ["mcp", "timeout"]]])
})
test("merges bounded compaction leaves and omits unsupported leaves", () => {
const result = normalized({
compaction: {
@@ -15,14 +15,6 @@ const users = sqliteTable("users", {
id: integer().primaryKey({ autoIncrement: true }),
name: text().notNull(),
})
const teams = sqliteTable("teams", {
id: integer().primaryKey(),
name: text().notNull(),
})
const memberships = sqliteTable("memberships", {
user_id: integer().notNull(),
team_id: integer().notNull(),
})
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
Effect.runPromise(
@@ -171,41 +163,3 @@ test("supports returning and rejects empty update sets", async () => {
}),
)
})
test("supports function-valued update joins with runtime table columns", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
const query = db
.update(users)
.set({ name: "Grace" })
.from(teams)
.innerJoin(memberships, (update) => eq(update.id, memberships.user_id))
.where(eq(teams.name, "Core"))
expect(query.toSQL()).toEqual({
sql: 'update "users" set "name" = ? from "teams" inner join "memberships" on "users"."id" = "memberships"."user_id" where "teams"."name" = ?',
params: ["Grace", "Core"],
})
}),
)
})
test("supports SQL-valued update joins", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
const query = db
.update(users)
.set({ name: "Lin" })
.from(teams)
.innerJoin(memberships, eq(users.id, memberships.user_id))
.where(eq(teams.name, "Core"))
expect(query.toSQL()).toEqual({
sql: 'update "users" set "name" = ? from "teams" inner join "memberships" on "users"."id" = "memberships"."user_id" where "teams"."name" = ?',
params: ["Lin", "Core"],
})
}),
)
})
@@ -201,8 +201,7 @@ describe("layer node", () => {
Layer.provide(LayerNode.compile(result.hoisted)),
) as unknown as Layer.Layer<App>
const program = Effect.gen(function* () {
const app = yield* App
return yield* app.run
return yield* (yield* App).run
}).pipe(Effect.provide(layer))
expect(await Effect.runPromise(program)).toEqual(["Alice"])
@@ -0,0 +1,415 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Fiber, Option, Ref, Scheduler } from "effect"
import { StateMachine } from "@opencode-ai/core/effect/state-machine"
import { it } from "../lib/effect"
describe("StateMachine", () => {
it.effect("runs invoked operations through pure transitions", () => {
type Event = { readonly _tag: "Completed"; readonly value: number }
type Operation = { readonly _tag: "Work" }
const definition = StateMachine.define<"running", Event, Operation, never, number>({
initial: StateMachine.next("running", StateMachine.invoke("work", { _tag: "Work" })),
transition: (state, event) => {
expect(state).toBe("running")
expect(event._tag).toBe("InvocationExited")
if (event._tag !== "InvocationExited" || Exit.isFailure(event.exit)) return StateMachine.done(-1)
return StateMachine.done(event.exit.value.value)
},
})
return StateMachine.run(definition, () => Effect.succeed({ _tag: "Completed", value: 42 })).pipe(
Effect.map((output) => expect(output).toBe(42)),
)
})
it.effect("preserves the operation Cause", () => {
type Operation = { readonly _tag: "Work" }
const definition = StateMachine.define<"running", never, Operation, string, Cause.Cause<string>>({
initial: StateMachine.next("running", StateMachine.invoke("work", { _tag: "Work" })),
transition: (_, event) => {
if (event._tag === "InvocationExited" && Exit.isFailure(event.exit)) return StateMachine.done(event.exit.cause)
throw new Error("Expected the invocation to fail")
},
})
return StateMachine.run(definition, () => Effect.fail("boom")).pipe(
Effect.map((cause) => {
expect(Option.getOrUndefined(Cause.findErrorOption(cause))).toBe("boom")
}),
)
})
it.effect("settles owned work before propagating interruption", () =>
Effect.gen(function* () {
const finalized = yield* Deferred.make<void>()
type State = "running" | "stopping"
type Event = { readonly _tag: "Cancel" }
type Operation = { readonly _tag: "Work" }
const definition = StateMachine.define<State, Event, Operation, never, "cancelled">({
initial: StateMachine.next("running", StateMachine.invoke("work", { _tag: "Work" })),
interruption: { _tag: "Cancel" } as const,
transition: (state, event) => {
if (event._tag === "Input") {
expect(state).toBe("running")
return StateMachine.next("stopping" as const, StateMachine.stop("work"))
}
expect(state).toBe("stopping")
if (event._tag !== "InvocationExited") throw new Error("Expected the invocation to stop")
expect(Exit.hasInterrupts(event.exit)).toBe(true)
return StateMachine.done("cancelled" as const)
},
})
const machine = yield* StateMachine.run(definition, () =>
Effect.never.pipe(Effect.ensuring(Deferred.succeed(finalized, undefined))),
).pipe(Effect.forkChild({ startImmediately: true }))
yield* Effect.yieldNow
yield* Fiber.interrupt(machine)
const exit = yield* Fiber.await(machine)
expect(Exit.hasInterrupts(exit)).toBe(true)
expect(yield* Deferred.isDone(finalized)).toBe(true)
}),
)
it.effect("runs cleanup invocations after interruption", () =>
Effect.gen(function* () {
const workStarted = yield* Deferred.make<void>()
const cleanupRan = yield* Deferred.make<void>()
type State = "running" | "stopping" | "cleaning"
type Event = { readonly _tag: "Cancel" } | { readonly _tag: "WorkDone" } | { readonly _tag: "CleanupDone" }
type Operation = { readonly _tag: "Work" } | { readonly _tag: "Cleanup" }
const definition = StateMachine.define<State, Event, Operation, never, void>({
initial: StateMachine.next("running", StateMachine.invoke("phase", { _tag: "Work" })),
interruption: { _tag: "Cancel" },
transition: (state, event) => {
if (event._tag === "Input")
return StateMachine.next("stopping", StateMachine.stopAndJoin("interruption", ["phase"]))
if (state === "stopping") {
if (event._tag !== "InvocationsStopped") throw new Error("Expected the aggregate stop result")
expect(event.id).toBe("interruption")
expect(event.exits).toMatchObject([{ id: "phase", operation: { _tag: "Work" } }])
expect(Exit.hasInterrupts(event.exits[0].exit)).toBe(true)
return StateMachine.next("cleaning", StateMachine.invoke("cleanup", { _tag: "Cleanup" }))
}
if (state === "cleaning") return StateMachine.done(undefined)
throw new Error("Unexpected state machine transition")
},
})
const machine = yield* StateMachine.run(definition, (operation) => {
if (operation._tag === "Cleanup")
return Deferred.succeed(cleanupRan, undefined).pipe(Effect.as({ _tag: "CleanupDone" } as const))
return Deferred.succeed(workStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.as({ _tag: "WorkDone" } as const),
)
}).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(workStarted)
yield* Fiber.interrupt(machine)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Deferred.isDone(cleanupRan)).toBe(true)
}),
)
it.effect("stops invocations together and joins cross-dependent finalizers", () =>
Effect.gen(function* () {
const started = { left: yield* Deferred.make<void>(), right: yield* Deferred.make<void>() }
const finalizing = { left: yield* Deferred.make<void>(), right: yield* Deferred.make<void>() }
const finalized = yield* Ref.make<ReadonlyArray<string>>([])
type State = "running" | "stopping" | "verifying"
type Event = "ready" | "verified"
type Operation = "left" | "right" | "trigger" | "verify"
const definition = StateMachine.define<State, Event, Operation, never, boolean>({
initial: StateMachine.next(
"running",
StateMachine.invoke<Operation>("left", "left"),
StateMachine.invoke<Operation>("right", "right"),
StateMachine.invoke<Operation>("trigger", "trigger"),
),
transition: (state, event) => {
if (event._tag === "InvocationExited" && event.operation === "trigger")
return StateMachine.next("stopping", StateMachine.stopAndJoin("workers", ["left", "right"]))
if (event._tag === "InvocationsStopped") {
expect(state).toBe("stopping")
expect(event.id).toBe("workers")
expect(event.exits).toMatchObject([
{ _tag: "InvocationExited", id: "left", generation: 1, operation: "left" },
{ _tag: "InvocationExited", id: "right", generation: 2, operation: "right" },
])
expect(event.exits.every((invocation) => Exit.hasInterrupts(invocation.exit))).toBe(true)
return StateMachine.next("verifying", StateMachine.invoke("verify", "verify"))
}
if (event._tag === "InvocationExited" && event.operation === "verify") {
expect(state).toBe("verifying")
expect(event.exit).toEqual(Exit.succeed("verified"))
return StateMachine.done(true)
}
throw new Error("Unexpected state machine transition")
},
})
const output = yield* StateMachine.run(definition, (operation) => {
if (operation === "trigger")
return Deferred.await(started.left).pipe(Effect.andThen(Deferred.await(started.right)), Effect.as("ready"))
if (operation === "verify")
return Ref.get(finalized).pipe(
Effect.map((value) => {
expect(value.toSorted()).toEqual(["left", "right"])
return "verified" as const
}),
)
return Deferred.succeed(started[operation], undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(
Deferred.succeed(finalizing[operation], undefined).pipe(
Effect.andThen(Deferred.await(finalizing[operation === "left" ? "right" : "left"])),
Effect.andThen(Ref.update(finalized, (value) => [...value, operation])),
),
),
)
})
expect(output).toBe(true)
}),
)
it.effect("aggregates queued and never-started exits once without affecting reused keys", () =>
Effect.gen(function* () {
const completed = yield* Deferred.make<Fiber.Fiber<unknown, unknown>>()
const releaseCompleted = yield* Deferred.make<void>()
const gateStarted = yield* Deferred.make<void>()
const childStarted = yield* Deferred.make<void>()
type Event = "completed" | "triggered" | "replaced"
type Operation = "complete" | "gate" | "trigger" | "never-started" | "replacement"
type Seen = ReadonlyArray<StateMachine.RuntimeEvent<Event, Operation, never>>
const definition = StateMachine.define<Seen, Event, Operation, never, Seen>({
initial: StateMachine.next(
[],
StateMachine.invoke<Operation>("completed", "complete"),
StateMachine.invoke<Operation>("gate", "gate"),
StateMachine.invoke<Operation>("trigger", "trigger"),
),
transition: (state, event) => {
const seen = [...state, event]
if (event._tag === "InvocationExited" && event.operation === "trigger")
return StateMachine.next(
seen,
StateMachine.stop("gate"),
StateMachine.invoke<Operation>("child", "never-started"),
StateMachine.stopAndJoin("batch", ["completed", "gate", "child"]),
StateMachine.invoke<Operation>("completed", "replacement"),
StateMachine.invoke<Operation>("child", "replacement"),
)
return seen.length === 4 ? StateMachine.done(seen) : StateMachine.next(seen)
},
})
const seen = yield* StateMachine.run(definition, (operation) => {
if (operation === "complete")
return Effect.withFiber((fiber) => Deferred.succeed(completed, fiber)).pipe(
Effect.andThen(Deferred.await(releaseCompleted)),
Effect.as("completed"),
)
if (operation === "gate")
return Deferred.succeed(gateStarted, undefined).pipe(
Effect.andThen(Effect.never),
// Hold the command loop until the completed child's exit is queued.
Effect.ensuring(
Deferred.succeed(releaseCompleted, undefined).pipe(
Effect.andThen(Deferred.await(completed)),
Effect.flatMap(Fiber.await),
),
),
)
if (operation === "trigger")
return Deferred.await(completed).pipe(Effect.andThen(Deferred.await(gateStarted)), Effect.as("triggered"))
if (operation === "never-started")
return Deferred.succeed(childStarted, undefined).pipe(Effect.andThen(Effect.never))
return Effect.succeed("replaced")
}).pipe(
// Keep the adjacent invoke/stop commands in one scheduler slice.
Effect.provideService(Scheduler.PreventSchedulerYield, true),
)
expect(seen.map((event) => (event._tag === "InvocationExited" ? event.operation : event._tag))).toEqual([
"trigger",
"InvocationsStopped",
"replacement",
"replacement",
])
const stopped = seen[1]
if (stopped._tag !== "InvocationsStopped") throw new Error("Expected the aggregate stop result")
expect(stopped.id).toBe("batch")
expect(stopped.exits).toMatchObject([
{
_tag: "InvocationExited",
id: "completed",
generation: 1,
operation: "complete",
exit: Exit.succeed("completed"),
},
{ _tag: "InvocationExited", id: "gate", generation: 2, operation: "gate" },
{ _tag: "InvocationExited", id: "child", generation: 4, operation: "never-started" },
])
expect(stopped.exits.slice(1).every((invocation) => Exit.hasInterrupts(invocation.exit))).toBe(true)
expect(seen.slice(2)).toMatchObject([
{ _tag: "InvocationExited", id: "completed", generation: 5, exit: Exit.succeed("replaced") },
{ _tag: "InvocationExited", id: "child", generation: 6, exit: Exit.succeed("replaced") },
])
expect(yield* Deferred.isDone(childStarted)).toBe(false)
}),
)
it.effect("emits an empty aggregate for an empty stop batch", () => {
const definition = StateMachine.define<"stopping", never, never, never, boolean>({
initial: StateMachine.next("stopping", StateMachine.stopAndJoin("empty", [])),
transition: (_, event) => {
expect(event).toEqual({ _tag: "InvocationsStopped", id: "empty", exits: [] })
return StateMachine.done(true)
},
})
return StateMachine.run(definition, () => Effect.die("Unexpected operation")).pipe(
Effect.map((output) => expect(output).toBe(true)),
)
})
it.effect("awaits a never-started finalizer without interrupting it", () =>
Effect.gen(function* () {
const finalized = yield* Ref.make(0)
type Operation = "work" | "finalize"
const definition = StateMachine.define<"stopping", "finalized", Operation, never, boolean>({
initial: StateMachine.next(
"stopping",
StateMachine.invoke<Operation>("work", "work"),
StateMachine.invoke<Operation>("finalizer", "finalize"),
StateMachine.stopAndJoin("batch", ["work"], ["finalizer"]),
),
transition: (_, event) => {
if (event._tag !== "InvocationsStopped") throw new Error("Expected only the joined batch")
expect(event.exits).toHaveLength(2)
expect(event.exits[0].id).toBe("work")
expect(Exit.hasInterrupts(event.exits[0].exit)).toBe(true)
expect(event.exits[1]).toMatchObject({ id: "finalizer", exit: Exit.succeed("finalized") })
return StateMachine.done(true)
},
})
expect(
yield* StateMachine.run(definition, (operation) =>
operation === "work"
? Effect.never
: Ref.update(finalized, (count) => count + 1).pipe(Effect.as("finalized" as const)),
).pipe(Effect.provideService(Scheduler.PreventSchedulerYield, true)),
).toBe(true)
expect(yield* Ref.get(finalized)).toBe(1)
}),
)
it.effect("defects when a stop batch contains an unknown invocation", () =>
Effect.gen(function* () {
const definition = StateMachine.define<"stopping", never, "work", never, never>({
initial: StateMachine.next(
"stopping",
StateMachine.invoke("known", "work"),
StateMachine.stopAndJoin("batch", ["known", "unknown"]),
),
transition: () => {
throw new Error("Unexpected state machine transition")
},
})
const exit = yield* StateMachine.run(definition, () => Effect.never).pipe(Effect.exit)
if (Exit.isSuccess(exit)) throw new Error("Expected an unknown invocation defect")
expect(Cause.hasDies(exit.cause)).toBe(true)
expect(Cause.prettyErrors(exit.cause).map((error) => error.message)).toEqual([
"Unknown state machine invocation in StopAndJoin",
])
}),
)
it.effect("observes an individual exit when a deferred child is stopped before starting", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const definition = StateMachine.define<"stopping", never, "work", never, boolean>({
initial: StateMachine.next("stopping", StateMachine.invoke("work", "work"), StateMachine.stop("work")),
transition: (_, event) => {
if (event._tag !== "InvocationExited") throw new Error("Expected the invocation to stop")
expect(event.id).toBe("work")
expect(Exit.hasInterrupts(event.exit)).toBe(true)
return StateMachine.done(true)
},
})
const output = yield* StateMachine.run(definition, () =>
Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
).pipe(Effect.provideService(Scheduler.PreventSchedulerYield, true))
expect(output).toBe(true)
expect(yield* Deferred.isDone(started)).toBe(false)
}),
)
it.effect("waits for replaced invocation cleanup and ignores its stale exit", () =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseTrigger = yield* Deferred.make<void>()
const events = yield* Ref.make<ReadonlyArray<string>>([])
type State = "first" | "second"
type Event = { readonly _tag: "Triggered" } | { readonly _tag: "SecondDone" }
type Operation = { readonly _tag: "First" } | { readonly _tag: "Trigger" } | { readonly _tag: "Second" }
const definition = StateMachine.define<State, Event, Operation, never, string>({
initial: StateMachine.next(
"first",
StateMachine.invoke<Operation>("work", { _tag: "First" }),
StateMachine.invoke<Operation>("trigger", { _tag: "Trigger" }),
),
transition: (state, event) => {
if (event._tag !== "InvocationExited" || Exit.isFailure(event.exit)) return StateMachine.done("unexpected")
if (event.operation._tag === "Trigger") {
return StateMachine.next("second" as const, StateMachine.invoke("work", { _tag: "Second" } as const))
}
if (state === "second") return StateMachine.done(event.exit.value._tag)
return StateMachine.next(state)
},
})
const output = yield* StateMachine.run(definition, (operation) => {
if (operation._tag === "Trigger")
return Deferred.await(releaseTrigger).pipe(Effect.as({ _tag: "Triggered" } as const))
if (operation._tag === "Second") {
return Ref.update(events, (value) => [...value, "second started"]).pipe(
Effect.as({ _tag: "SecondDone" } as const),
)
}
return Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(Ref.update(events, (value) => [...value, "first finalized"])),
)
}).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(firstStarted)
yield* Deferred.succeed(releaseTrigger, undefined)
expect(yield* Fiber.join(output)).toBe("SecondDone")
expect(yield* Ref.get(events)).toEqual(["first finalized", "second started"])
}),
)
it.effect("does not start the next invocation when interruption is pending at the transition boundary", () =>
Effect.gen(function* () {
const releaseFirst = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
type State = "first" | "second"
type Event = { readonly _tag: "FirstDone" } | { readonly _tag: "SecondDone" }
type Operation = { readonly _tag: "First" } | { readonly _tag: "Second" }
let machine: Fiber.Fiber<string> | undefined
const definition = StateMachine.define<State, Event, Operation, never, string>({
initial: StateMachine.next("first", StateMachine.invoke("work", { _tag: "First" })),
transition: (state, event) => {
if (event._tag !== "InvocationExited" || Exit.isFailure(event.exit)) return StateMachine.done("unexpected")
if (state === "second") return StateMachine.done("completed")
machine?.interruptUnsafe(123)
return StateMachine.next("second", StateMachine.invoke("work", { _tag: "Second" }))
},
})
machine = yield* StateMachine.run(definition, (operation) =>
operation._tag === "First"
? Deferred.await(releaseFirst).pipe(Effect.as({ _tag: "FirstDone" } as const))
: Deferred.succeed(secondStarted, undefined).pipe(Effect.as({ _tag: "SecondDone" } as const)),
).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.succeed(releaseFirst, undefined)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
}),
)
})
@@ -1,127 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Fiber, Scope } from "effect"
import { TestClock } from "effect/testing"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LocationWatcherPolicy } from "@opencode-ai/core/filesystem/location-watcher-policy"
import { State } from "@opencode-ai/core/state"
import { testEffect } from "../lib/effect"
const it = testEffect(AppNodeBuilder.build(LocationWatcherPolicy.node))
describe("LocationWatcherPolicy", () => {
it.effect("reads batched registrations and disposals without notifying observers", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const observed: string[][] = []
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
yield* State.batch(
Effect.gen(function* () {
yield* policy.transform((draft) => draft.add(["base"]))
const overlay = yield* policy.transform((draft) => draft.add(["overlay"]))
const snapshot = policy.current()
expect(snapshot).toEqual(["base", "overlay"])
expect(observed).toEqual([])
yield* overlay.dispose
expect(policy.current()).toEqual(["base"])
expect(snapshot).toEqual(["base", "overlay"])
expect(observed).toEqual([])
}),
)
expect(observed).toEqual([["base"]])
}),
)
it.effect("reads reloaded patterns before debounced observer reconciliation", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const observed: string[][] = []
let ignore = ["first"]
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
yield* policy.transform((draft) => draft.add(ignore))
const snapshot = policy.current()
observed.length = 0
ignore = ["second"]
const reload = yield* policy.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect(policy.current()).toEqual(["second"])
expect(snapshot).toEqual(["first"])
expect(observed).toEqual([])
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(observed).toEqual([["second"]])
}),
)
it.effect("passes the latest policy to later observers after a reentrant registration", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const scope = yield* Scope.Scope
const observed: string[][] = []
let reentered = false
yield* policy.observe(() =>
Effect.gen(function* () {
if (reentered) return
reentered = true
yield* policy.transform((draft) => draft.add(["inner"])).pipe(Scope.provide(scope))
}),
)
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
yield* policy.transform((draft) => draft.add(["outer"]))
expect(policy.current()).toEqual(["outer", "inner"])
expect(observed).toEqual([
["outer", "inner"],
["outer", "inner"],
])
}),
)
it.effect("allows an observer to await a reload and keeps later observers current", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const observed: string[][] = []
let ignore = ["first"]
let reentered = false
yield* policy.observe(() =>
Effect.gen(function* () {
if (reentered) return
reentered = true
ignore = ["second"]
yield* policy.reload()
}),
)
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
const writer = yield* policy
.transform((draft) => draft.add(ignore))
.pipe(Effect.forkChild({ startImmediately: true }))
expect(policy.current()).toEqual(["second"])
expect(observed).toEqual([])
yield* TestClock.adjust("500 millis")
yield* Fiber.join(writer)
expect(observed).toEqual([["second"], ["second"]])
}),
)
})
+1 -5
View File
@@ -14,10 +14,6 @@ export function location(ref: Location.Ref, input: { projectDirectory?: Absolute
} satisfies Location.Interface
}
export function locationLayer(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
return Layer.succeed(Location.Service, Location.Service.of(location(ref, input)))
}
export const tempLocationLayer = Layer.unwrap(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -25,7 +21,7 @@ export const tempLocationLayer = Layer.unwrap(
).pipe(
Effect.map((tmp) => {
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
return locationLayer(ref)
return Layer.succeed(Location.Service, Location.Service.of(location(ref)))
}),
),
)
@@ -244,25 +244,14 @@ describe("doStream", () => {
expect(reasoningEndIndex).toBeLessThan(textStartIndex)
// In this fixture, reasoning_opaque comes AFTER content has started (in chunk 4)
// So it arrives too late to be attached to reasoning-end. It should still be
// captured on the completed text part and the finish event.
// So it arrives too late to be attached to reasoning-end. But it should still
// be captured and included in the finish event's providerMetadata.
const reasoningEnd = parts.find((p) => p.type === "reasoning-end")
expect(reasoningEnd).toMatchObject({
type: "reasoning-end",
id: "reasoning-0",
})
const textEnd = parts.find((p) => p.type === "text-end")
expect(textEnd).toEqual({
type: "text-end",
id: "txt-0",
providerMetadata: {
copilot: {
reasoningOpaque: "/PMlTqxqSJZnUBDHgnnJKLVI4eZQ",
},
},
})
// reasoning_opaque should be in the finish event's providerMetadata
const finish = parts.find((p) => p.type === "finish")
expect(finish).toMatchObject({
@@ -316,17 +305,6 @@ describe("doStream", () => {
},
})
const textEnd = parts.find((p) => p.type === "text-end")
expect(textEnd).toEqual({
type: "text-end",
id: "txt-0",
providerMetadata: {
copilot: {
reasoningOpaque: "ExXaGwW7jBo39OXRe9EPoFGN1rOtLJBx",
},
},
})
// Check text deltas
const textDeltas = parts.filter((p) => p.type === "text-delta")
expect(textDeltas).toHaveLength(2)
@@ -1,73 +0,0 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Fiber } from "effect"
import { TestClock } from "effect/testing"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Integration } from "@opencode-ai/core/integration"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node])))
describe("Integration replay", () => {
it.effect("fails and closes an OAuth attempt when fresh implementation replay throws", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("replay-test")
const methodID = Integration.MethodID.make("code")
const source = { fail: false, closed: false }
const failure = new Error("integration transform replay failed")
yield* integrations.transform((editor) => {
if (source.fail) throw failure
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Fixture" },
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (source.closed = true))).pipe(
Effect.as({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Enter the fixture code",
callback: () =>
Effect.succeed(
Credential.OAuth.make({
type: "oauth",
methodID,
access: "dummy-access",
refresh: "dummy-refresh",
expires: Number.MAX_SAFE_INTEGER,
}),
),
}),
),
})
})
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, label: "Fixture" })
source.fail = true
const reload = yield* integrations.reload().pipe(Effect.exit, Effect.forkChild({ startImmediately: true }))
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
source.fail = false
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
}),
)
const exit = yield* integrations.oauth
.complete({ integrationID, attemptID: attempt.attemptID, code: "dummy-code" })
.pipe(Effect.exit)
expect(exit).toMatchObject(Exit.die(failure))
expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBe(failure)
expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({
status: "failed",
message: failure.message,
time: attempt.time,
})
expect(source.closed).toBe(true)
expect(yield* credentials.list(integrationID)).toEqual([])
}),
)
})
-97
View File
@@ -7,7 +7,6 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Integration } from "@opencode-ai/core/integration"
import { State } from "@opencode-ai/core/state"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node])))
@@ -263,102 +262,6 @@ describe("Integration", () => {
}),
)
it.effect("resolves stored OAuth with refresh registrations made inside a batch", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("acme")
const method = Integration.OAuthMethod.make({
id: Integration.MethodID.make("browser"),
type: "oauth",
label: "Browser",
})
const expired = Credential.OAuth.make({
type: "oauth",
methodID: method.id,
access: "expired",
refresh: "refresh",
expires: 0,
})
const fresh = Credential.OAuth.make({
...expired,
access: "fresh",
refresh: "fresh-refresh",
expires: (yield* Clock.currentTimeMillis) + Duration.toMillis(Duration.hours(1)),
})
const stored = yield* credentials.create({ integrationID, label: "Personal", value: expired })
const connection = { type: "credential" as const, id: stored.id, label: stored.label }
const calls: string[] = []
const implementation = {
integrationID,
method,
authorize: () => Effect.die("unexpected authorization"),
refresh: (value: Credential.OAuth) =>
Effect.sync(() => {
expect(value).toEqual(expired)
calls.push("original")
return fresh
}),
}
yield* State.batch(
Effect.gen(function* () {
yield* integrations.transform((editor) => editor.method.update(implementation))
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
expect(calls).toEqual(["original"])
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
expect(calls).toEqual(["original"])
yield* credentials.update(stored.id, { value: expired })
const overridden = Credential.OAuth.make({ ...fresh, access: "override" })
const override = yield* integrations.transform((editor) =>
editor.method.update({
...implementation,
refresh: (value) =>
Effect.sync(() => {
expect(value).toEqual(expired)
calls.push("override")
return overridden
}),
}),
)
expect(yield* integrations.connection.resolve(connection)).toEqual(overridden)
expect((yield* credentials.get(stored.id))?.value).toEqual(overridden)
yield* override.dispose
yield* credentials.update(stored.id, { value: expired })
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
expect(calls).toEqual(["original", "override", "original"])
yield* credentials.update(stored.id, { value: expired })
const removal = yield* integrations.transform((editor) => editor.method.remove(integrationID, method))
expect(yield* integrations.connection.resolve(connection)).toEqual(expired)
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
expect(calls).toEqual(["original", "override", "original"])
yield* removal.dispose
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
yield* credentials.update(stored.id, { value: expired })
yield* integrations.transform((editor) => editor.method.update({ ...implementation, refresh: undefined }))
expect(yield* integrations.connection.resolve(connection)).toEqual(expired)
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
expect(calls).toEqual(["original", "override", "original", "original"])
const failure = new Error("refresh failed")
yield* integrations.transform((editor) =>
editor.method.update({ ...implementation, refresh: () => Effect.fail(failure) }),
)
expect(yield* integrations.connection.resolve(connection).pipe(Effect.flip)).toEqual(
new Integration.AuthorizationError({ cause: failure }),
)
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
}),
)
}),
)
it.effect("completes code OAuth once and stores the credential", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
@@ -51,8 +51,7 @@ describe("FileSystem", () => {
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
const filesystem = yield* FileSystem.Service
const entries = yield* filesystem.list()
const entries = yield* (yield* FileSystem.Service).list()
expect(entries.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
{ path: RelativePath.make("src" + path.sep), type: "directory" },
{ path: RelativePath.make("README.md"), type: "file" },
@@ -104,8 +103,9 @@ describe("FileSystem", () => {
it.live("rejects lexical escapes", () =>
withTmp((directory) =>
Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
const result = yield* (yield* FileSystem.Service)
.read({ path: RelativePath.make("../outside.txt") })
.pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
}).pipe(provide(directory)),
),
+2 -12
View File
@@ -13,7 +13,6 @@ import {
Hash,
Layer,
LayerMap,
Option,
RcMap,
Schema,
Stream,
@@ -518,8 +517,7 @@ describe("LocationServiceMap", () => {
)
const plugins = yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* (yield* PluginSupervisor.Service).flush
return yield* plugins.list()
}).pipe(
Effect.scoped,
@@ -676,21 +674,14 @@ describe("LocationServiceMap", () => {
expect(Equal.equals(absent, present)).toBe(false)
if (process.platform === "win32") expect(absent.directory).not.toBe(present.directory)
expect(yield* locations.contextEffectOption(absent)).toEqual(Option.none())
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
const first = yield* locations.contextEffect(absent)
expect(yield* locations.contextEffect(present)).toBe(first)
expect(Option.getOrThrow(yield* locations.contextEffectOption(absent))).toBe(first)
expect(Option.getOrThrow(yield* locations.contextEffectOption(present))).toBe(first)
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([
Location.Ref.make({ directory, workspaceID: undefined }),
])
// Invalidating with the shape opposite to the one that booted must evict.
yield* locations.invalidate(present)
expect(yield* locations.contextEffectOption(absent)).toEqual(Option.none())
expect(yield* locations.contextEffectOption(present)).toEqual(Option.none())
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
}),
),
@@ -943,8 +934,7 @@ describe("LocationServiceMap", () => {
})
yield* plugins.activate([{ ...reviewer, version: "1" }])
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
expect(yield* (yield* Agent.Service).get(Agent.ID.make("reviewer"))).toMatchObject({
description: "Reviews code",
mode: "subagent",
})
+12 -24
View File
@@ -43,8 +43,7 @@ describe("LocationMutation", () => {
Effect.gen(function* () {
const targetPath = path.join(directory, "hello.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "hello.txt" })
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
expect(target).toMatchObject({
absolute: targetPath,
@@ -59,8 +58,7 @@ describe("LocationMutation", () => {
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: path.join("src", "new.txt") })
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "src", "new.txt"),
resource: "src/new.txt",
@@ -72,8 +70,7 @@ describe("LocationMutation", () => {
it.live("requires external-directory authorization for a relative lexical escape", () =>
withTmp((directory) =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "../outside.txt" })
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
const root = path.dirname(directory)
expect(target).toMatchObject({
absolute: path.join(root, "outside.txt"),
@@ -120,8 +117,7 @@ describe("LocationMutation", () => {
await fs.mkdir(outside)
await fs.symlink(outside, path.join(directory, "escape"))
})
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: path.join("escape", "new.txt") })
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "escape", "new.txt"),
resource: "escape/new.txt",
@@ -141,8 +137,7 @@ describe("LocationMutation", () => {
await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
})
const mutation = yield* LocationMutation.Service
expect(yield* mutation.resolve({ path: "linked/new.txt" })).toMatchObject({
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
absolute: path.join(directory, "linked", "new.txt"),
resource: "linked/new.txt",
})
@@ -154,8 +149,7 @@ describe("LocationMutation", () => {
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "new.txt")
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target).toMatchObject({
absolute: targetPath,
resource: "new.txt",
@@ -170,8 +164,7 @@ describe("LocationMutation", () => {
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new.txt")
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const root = outside
expect(target).toMatchObject({
absolute: path.join(root, "new.txt"),
@@ -192,8 +185,7 @@ describe("LocationMutation", () => {
Effect.gen(function* () {
const targetPath = path.join(outside, "existing.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target).toMatchObject({ absolute: targetPath })
expect(target.externalDirectory?.directory).toBe(outside)
}).pipe(provide(directory)),
@@ -205,8 +197,7 @@ describe("LocationMutation", () => {
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: outside, kind: "file" })
const target = yield* (yield* LocationMutation.Service).resolve({ path: outside, kind: "file" })
expect(target.externalDirectory).toMatchObject({
directory: path.dirname(outside),
resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
@@ -221,8 +212,7 @@ describe("LocationMutation", () => {
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new", "nested", "file.txt")
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const parent = path.dirname(targetPath)
expect(target.externalDirectory).toMatchObject({
directory: parent,
@@ -264,8 +254,7 @@ describe("LocationMutation", () => {
it.live("resolves a tilde path as an external home target", () =>
withTmp((directory) =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "~/notes.md" })
const target = yield* (yield* LocationMutation.Service).resolve({ path: "~/notes.md" })
const absolute = path.resolve(Global.Path.home, "notes.md")
expect(target).toMatchObject({
absolute,
@@ -281,8 +270,7 @@ describe("LocationMutation", () => {
it.live("treats a tilde path as in-location when the location is home", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "~/notes.md" })
const target = yield* (yield* LocationMutation.Service).resolve({ path: "~/notes.md" })
expect(target).toMatchObject({
absolute: path.resolve(Global.Path.home, "notes.md"),
resource: "notes.md",
+4 -139
View File
@@ -33,31 +33,16 @@ import { McpStdio } from "@opencode-ai/core/mcp/stdio"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { State } from "@opencode-ai/core/state"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { Tool } from "@opencode-ai/core/tool"
import {
Context,
Deferred,
Effect,
Exit,
Fiber,
Layer,
PubSub,
Ref,
Schedule,
Schema,
Scope,
Sink,
Stream,
} from "effect"
import { Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
import { TestClock } from "effect/testing"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { location, locationLayer } from "./fixture/location"
import { location } from "./fixture/location"
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
@@ -82,7 +67,7 @@ function resourceServer(
listChanged?: boolean
emptyElicitation?: boolean
urlElicitation?: boolean
respond?: (request: Request) => Response | undefined | Promise<Response | undefined>
respond?: (request: Request) => Response | undefined
} = {},
) {
return Effect.acquireRelease(
@@ -191,7 +176,7 @@ function resourceServer(
if (typeof body === "object" && body !== null && "method" in body && body.method === "initialize") {
state.initializations += 1
}
return (await input.respond?.(request)) ?? transport.handleRequest(request)
return input.respond?.(request) ?? transport.handleRequest(request)
},
})
return {
@@ -1426,126 +1411,6 @@ test("reconciles only changed MCP server config", async () => {
)
})
testEffect(Layer.empty).live("serializes MCP config restoration behind an in-flight replacement", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const accepted = yield* Deferred.make<void>()
const server = yield* resourceServer({
respond: (request) =>
request.method !== "POST"
? undefined
: Effect.runPromise(
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as(undefined)),
),
})
yield* Effect.gen(function* () {
const service = yield* Mcp.Service
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
const replacing = yield* service
.transform((draft) => draft.update("resources", (config) => (config.disabled = false)))
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(started)
const restoring = yield* State.batch(
Effect.gen(function* () {
yield* service.transform((draft) => draft.update("resources", (config) => (config.disabled = true)))
yield* Deferred.succeed(accepted, undefined)
}),
).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(accepted)
expect((yield* service.servers())[0]?.status).toEqual({ status: "pending" })
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(replacing)
yield* Fiber.join(restoring)
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
expect(yield* service.tools()).toEqual([])
expect(server.state.initializations).toBe(1)
}).pipe(
Effect.ensuring(Deferred.succeed(release, undefined)),
Effect.provide(
resourceMcpLayer(new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, disabled: true })),
),
)
}),
)
const shutdownIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Bus.node, Integration.node, Credential.node, Form.node, Environment.node, Location.node]),
[
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
[Environment.node, hostEnvironmentLayer],
],
),
)
;["active", "queued"].forEach((phase) =>
shutdownIt.effect(`discards ${phase} MCP notifications after its layer closes`, () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const root = yield* Scope.make()
yield* Effect.addFinalizer(() =>
Deferred.succeed(release, undefined).pipe(
Effect.andThen(State.batch(Scope.close(root, Exit.void), { flush: false })),
Effect.andThen(TestClock.adjust("500 millis")),
),
)
const context = yield* Layer.buildWithScope(Mcp.layer(), root)
const service = Context.get(context, Mcp.Service)
const observed: string[] = []
let block = false
const unsubscribe = yield* bus.listen((event) =>
Effect.gen(function* () {
if (event.type !== McpEvent.StatusChanged.type) return
observed.push(Schema.decodeUnknownSync(McpEvent.StatusChanged.data)(event.data).server)
if (!block) return
block = false
yield* Deferred.succeed(entered, undefined)
yield* Deferred.await(release)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
const source = { url: "https://example.com/initial", added: false }
yield* service
.transform((draft) => {
draft.set("fixture", { type: "remote", url: source.url, oauth: false, disabled: true })
if (source.added) draft.set("queued", { type: "local", command: ["unused"], disabled: true })
})
.pipe(Scope.provide(root))
block = true
source.url = "https://example.com/first"
source.added = phase === "active"
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Deferred.await(entered)
source.url = "https://example.com/second"
source.added = true
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
const shutdown = yield* State.batch(Scope.close(root, Exit.void), { flush: false }).pipe(
Effect.forkChild({ startImmediately: true }),
)
yield* TestClock.adjust("1 millis")
expect(shutdown.pollUnsafe()).toBeDefined()
expect(first.pollUnsafe()).toBeDefined()
expect(second.pollUnsafe()).toBeDefined()
expect(yield* Deferred.isDone(release)).toBe(false)
yield* Fiber.join(shutdown)
observed.length = 0
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(observed).toEqual([])
expect((yield* service.servers()).map((server) => server.name)).toEqual([Mcp.ServerName.make("fixture")])
}),
),
)
test("serializes concurrent MCP lifecycle operations", async () => {
await Effect.runPromise(
Effect.scoped(
+1 -61
View File
@@ -1,12 +1,10 @@
import { describe, expect } from "bun:test"
import { ToolFailure } from "@opencode-ai/ai"
import { Clock, Context, Duration, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
@@ -105,64 +103,6 @@ describe("Plugin", () => {
}),
)
it.live("refreshes its own stored OAuth connection during plugin activation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("acme")
const methodID = Integration.MethodID.make("browser")
const expired = Credential.OAuth.make({
type: "oauth",
methodID,
access: "expired",
refresh: "refresh",
expires: 0,
})
const fresh = Credential.OAuth.make({
...expired,
access: "fresh",
refresh: "fresh-refresh",
expires: (yield* Clock.currentTimeMillis) + Duration.toMillis(Duration.hours(1)),
})
const stored = yield* credentials.create({ integrationID, label: "Personal", value: expired })
const resolved: (Credential.Value | undefined)[] = []
const refreshed: Credential.OAuth[] = []
yield* plugins.activate([
versioned(
EffectPlugin.define({
id: "oauth-refresh",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.integration.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () => Effect.die("unexpected authorization"),
refresh: (value) =>
Effect.sync(() => {
refreshed.push(value)
return fresh
}),
}),
)
const connection = yield* ctx.integration.connection.active(integrationID)
if (!connection) return yield* Effect.die("stored connection missing")
resolved.push(yield* ctx.integration.connection.resolve(connection).pipe(Effect.orDie))
}),
}),
),
])
expect(resolved).toEqual([fresh])
expect(refreshed).toEqual([expired])
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("oauth-refresh"), source: { type: "builtin" }, status: "active", tui: false },
])
}),
)
it.live("exposes public events through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
+3 -48
View File
@@ -16,7 +16,6 @@ import { emptyMcpLayer } from "../fixture/mcp"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { host } from "./host"
import PROMPT_INITIALIZE from "../../src/plugin/command/initialize.txt"
import PROMPT_REVIEW from "../../src/plugin/command/review.txt"
const directory = AbsolutePath.make("/repo/packages/app")
@@ -41,11 +40,7 @@ describe("CommandPlugin.Plugin", () => {
it.effect("registers built-in init and review commands", () =>
Effect.gen(function* () {
const command = yield* Command.Service
const prompts: {
text: string
files?: readonly { readonly uri: string }[]
delivery?: "steer" | "queue"
}[] = []
const prompts: { text: string; files?: readonly { readonly uri: string }[] }[] = []
yield* CommandPlugin.Plugin.effect(
host({
command: {
@@ -56,7 +51,7 @@ describe("CommandPlugin.Plugin", () => {
session: {
prompt: (input) =>
Effect.sync(() => {
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
prompts.push({ text: input.text, files: input.files })
return SessionInbox.User.make({
id: SessionMessage.ID.make("msg_test"),
sessionID: input.sessionID,
@@ -91,50 +86,10 @@ describe("CommandPlugin.Plugin", () => {
delivery: "queue",
},
})
yield* command.execute({
name: "review",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: " branch $& $$ $` $' " },
delivery: "steer",
},
})
yield* command.execute({
name: "init",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: "" },
delivery: "steer",
},
})
yield* command.execute({
name: "review",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: " " },
delivery: "steer",
},
})
expect(prompts).toEqual([
{
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", "extra context"),
text: expect.stringContaining("extra context"),
files: [{ uri: "file:///tmp/context.md" }],
delivery: "queue",
},
{
text: PROMPT_REVIEW.replace("${path}", project).replaceAll("$ARGUMENTS", () => "branch $& $$ $` $'"),
files: undefined,
delivery: "steer",
},
{
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", ""),
files: undefined,
delivery: "steer",
},
{
text: PROMPT_REVIEW.replace("${path}", project).replaceAll("$ARGUMENTS", ""),
files: undefined,
delivery: "steer",
},
])
}),
-55
View File
@@ -3,8 +3,6 @@ import { Message, SystemPart } from "@opencode-ai/ai"
import { DateTime, Effect, Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin"
@@ -112,59 +110,6 @@ describe("fromPromise", () => {
}),
)
it.live("refreshes its own stored OAuth connection during plugin activation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("acme")
const methodID = Integration.MethodID.make("browser")
const expired = Credential.OAuth.make({
type: "oauth",
methodID,
access: "expired",
refresh: "dummy",
expires: 0,
})
const fresh = Credential.OAuth.make({ ...expired, access: "fresh", expires: Number.MAX_SAFE_INTEGER })
const stored = yield* credentials.create({ integrationID, label: "Fixture", value: expired })
const resolved: string[] = []
const refreshed: string[] = []
const adapted = PluginPromise.fromPromise(
define({
id: "promise-oauth-refresh",
setup: async (ctx) => {
await ctx.integration.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: async () => {
throw new Error("unexpected authorization")
},
refresh: async (value) => {
refreshed.push(value.access)
return fresh
},
}),
)
const connection = await ctx.integration.connection.active(integrationID)
if (!connection) throw new Error("stored connection missing")
const value = await ctx.integration.connection.resolve(connection)
resolved.push(value?.type === "oauth" ? value.access : "missing")
},
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(resolved).toEqual(["fresh"])
expect(refreshed).toEqual(["expired"])
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("promise-oauth-refresh"), source: { type: "builtin" }, status: "active", tui: false },
])
}),
)
it.effect("exposes the host location including workspace and project metadata", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -306,34 +306,6 @@ describe("AppProcess", () => {
}),
)
it.live(
"includes stderr in output while retaining capped failure diagnostics",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const lines: string[] = []
const exit = yield* Effect.exit(
svc
.runStream(
cmd(
"-e",
"console.log('stdout-line'); console.error('stderr-line'); console.error('diagnostic-tail'); process.exit(2)",
),
{ includeStderr: true, maxErrorBytes: 20, okExitCodes: [0] },
)
.pipe(Stream.runForEach((line) => Effect.sync(() => lines.push(line)))),
)
expect(lines.toSorted()).toEqual(["diagnostic-tail", "stderr-line", "stdout-line"])
expect(Exit.isFailure(exit)).toBe(true)
if (!Exit.isFailure(exit)) return
const reason = exit.cause.reasons[0]
expect(reason?._tag).toBe("Fail")
if (!reason || reason._tag !== "Fail") return
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
expect(reason.error.stderr).toBe("stderr-line\ndiagnost")
}),
)
it.live(
"without okExitCodes, never fails on exit code",
Effect.gen(function* () {
+9 -133
View File
@@ -1,147 +1,23 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer, Scope } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { State } from "@opencode-ai/core/state"
import { Global } from "@opencode-ai/util/global"
import { Reference } from "@opencode-ai/core/reference"
import { Repository } from "@opencode-ai/core/repository"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { it, testEffect } from "./lib/effect"
import { it } from "./lib/effect"
const cache = Layer.mock(RepositoryCache.Service, {
ensure: () => Effect.die("unexpected Git materialization"),
})
const referenceLayer = AppNodeBuilder.build(LayerNode.group([Reference.node, Bus.node]), [
[RepositoryCache.node, cache],
])
const referenceIt = testEffect(referenceLayer)
const referenceLayer = AppNodeBuilder.build(Reference.node, [[RepositoryCache.node, cache]])
describe("Reference", () => {
it.effect("prepares batched references before cache work or update events", () => {
const operations: RepositoryCache.EnsureInput[] = []
const cache = Layer.mock(RepositoryCache.Service, {
ensure: (input) =>
Effect.sync(() => {
operations.push(input)
return {
repository: input.reference.label,
host: input.reference.host,
remote: input.reference.remote,
localPath: Repository.cachePath(Global.Path.repos, input.reference, input.branch),
status: "cached",
} satisfies RepositoryCache.Result
}),
})
const referenceLayer = AppNodeBuilder.build(LayerNode.group([Reference.node, Bus.node]), [
[RepositoryCache.node, cache],
])
return Effect.gen(function* () {
const references = yield* Reference.Service
const bus = yield* Bus.Service
const observed: string[][] = []
const unsubscribe = yield* bus.listen((event) =>
event.type === Reference.Event.Updated.type
? references.list().pipe(
Effect.map((infos) => {
observed.push(infos.map((info) => info.name))
}),
)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
yield* State.batch(
Effect.gen(function* () {
yield* references.transform((draft) => {
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }))
draft.add(
"sdk",
Reference.GitSource.make({
type: "git",
repository: "owner/repo",
branch: "feature/docs",
description: "SDK documentation",
hidden: true,
}),
)
draft.add("invalid", Reference.GitSource.make({ type: "git", repository: "invalid" }))
draft.add(
"invalid-branch",
Reference.GitSource.make({ type: "git", repository: "owner/repo", branch: "../escape" }),
)
draft.add("file", Reference.GitSource.make({ type: "git", repository: "file:///docs" }))
})
const infos = yield* references.list()
expect(infos.map((info) => info.name)).toEqual(["docs", "sdk"])
expect(infos[1]).toMatchObject({
path: Repository.cachePath(Global.Path.repos, Repository.parseRemote("owner/repo"), "feature/docs"),
description: "SDK documentation",
hidden: true,
})
expect(operations).toEqual([])
expect(observed).toEqual([])
}),
)
expect(observed).toEqual([["docs", "sdk"]])
yield* Effect.yieldNow
expect(
operations.map((input) => ({
repository: input.reference.label,
branch: input.branch,
refresh: input.refresh,
})),
).toEqual([{ repository: "owner/repo", branch: "feature/docs", refresh: true }])
}).pipe(Effect.scoped, Effect.provide(referenceLayer))
})
referenceIt.effect("lets update listeners replace references and refetch the latest projection", () =>
it.effect("registers normalized sources for the owning scope", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const bus = yield* Bus.Service
const scope = yield* Scope.Scope
const observed: string[][] = []
let reentered = false
const first = yield* bus.listen((event) =>
Effect.gen(function* () {
if (event.type !== Reference.Event.Updated.type || reentered) return
reentered = true
yield* references
.transform((draft) =>
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/new") })),
)
.pipe(Scope.provide(scope))
}),
)
const second = yield* bus.listen((event) =>
event.type === Reference.Event.Updated.type
? references.list().pipe(
Effect.map((infos) => {
observed.push(infos.map((info) => info.path))
}),
)
: Effect.void,
)
yield* Effect.addFinalizer(() => first.pipe(Effect.andThen(second)))
yield* references.transform((draft) =>
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/old") })),
)
expect((yield* references.list()).map((info) => info.path)).toEqual([AbsolutePath.make("/new")])
expect(observed).toEqual([["/new"], ["/new"]])
}),
)
referenceIt.effect("registers normalized sources for the owning scope", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const parent = yield* Effect.scope
const scope = yield* Scope.fork(parent)
const scope = yield* Scope.make()
const path = AbsolutePath.make("/docs")
const source = Reference.LocalSource.make({
type: "local",
@@ -157,10 +33,10 @@ describe("Reference", () => {
yield* Scope.close(scope, Exit.void)
expect(yield* references.list()).toEqual([])
}),
}).pipe(Effect.provide(referenceLayer)),
)
referenceIt.effect("derives Git paths without exposing cache operations", () =>
it.effect("derives Git paths without exposing cache operations", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const repository = Repository.parseRemote("owner/repo")
@@ -174,10 +50,10 @@ describe("Reference", () => {
source,
}),
])
}),
}).pipe(Effect.scoped, Effect.provide(referenceLayer)),
)
referenceIt.effect("preserves configured Git descriptions", () =>
it.effect("preserves configured Git descriptions", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const repository = Repository.parseRemote("owner/repo")
@@ -196,6 +72,6 @@ describe("Reference", () => {
source,
}),
])
}),
}).pipe(Effect.scoped, Effect.provide(referenceLayer)),
)
})
+2 -4
View File
@@ -25,8 +25,7 @@ describe("RepositoryCache", () => {
await fs.writeFile(path.join(localPath, "stale.txt"), "stale")
})
const cache = yield* RepositoryCache.Service
const result = yield* cache.ensure({ reference: fixture.reference })
const result = yield* (yield* RepositoryCache.Service).ensure({ reference: fixture.reference })
expect(result.status).toBe("cloned")
expect(yield* exists(path.join(localPath, "stale.txt"))).toBe(false)
@@ -95,8 +94,7 @@ describe("RepositoryCache", () => {
Effect.gen(function* () {
yield* Effect.promise(() => git(fixture.root, "clone", fixture.remote, path.join(fixture.root, "repos")))
const cache = yield* RepositoryCache.Service
const result = yield* cache.ensure({ reference: fixture.reference })
const result = yield* (yield* RepositoryCache.Service).ensure({ reference: fixture.reference })
expect(result.status).toBe("cloned")
expect(yield* read(path.join(result.localPath, "README.md"))).toBe("one\n")
+47 -103
View File
@@ -552,7 +552,6 @@ const setup = Effect.gen(function* () {
admit,
resume,
context: session.context(sessionID),
hooks,
messages: session.messages({ sessionID }),
inbox: session.inbox(sessionID),
runPrompt: Effect.fnUntraced(function* (text: string) {
@@ -583,6 +582,13 @@ const scenario = (
}),
)
const nextRetryScheduled = (s: Scenario) =>
s.bus.subscribe(SessionEvent.RetryScheduled).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const providerUnavailable = () =>
new AIError({
reason: new TransportError({
@@ -4357,12 +4363,8 @@ describe("SessionRunnerLLM", () => {
yield* s.admit("Retry transport")
yield* s.llm.push(Stream.fail(providerUnavailable()))
yield* s.llm.push(TestLLM.text("Recovered", "retry-success"))
const scheduled = yield* s.bus.subscribe(SessionEvent.RetryScheduled).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("1599 millis")
@@ -4382,89 +4384,10 @@ describe("SessionRunnerLLM", () => {
expect((yield* s.context).filter((message) => message.type === "assistant")).toHaveLength(1)
})
scenario("allows session retry hooks to veto a proposed retry", function* (s) {
const failure = providerUnavailable()
let observed: PluginHooks.Domains["session"]["retry"] | undefined
yield* s.hooks.register("session", "retry", (event) =>
Effect.sync(() => {
observed = event
event.decision = { retry: false }
}),
)
yield* s.llm.push(Stream.fail(failure))
expect(yield* s.runPrompt("Do not retry transport").pipe(Effect.flip)).toBe(failure)
expect(s.requests).toHaveLength(1)
expect(observed).toMatchObject({
sessionID,
agent: "build",
model: { providerID: "fake", id: "fake-model" },
error: { type: "provider.transport", message: "Provider unavailable" },
attempt: 2,
decision: { retry: false },
})
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
})
scenario("allows session retry hooks to retry a terminal provider failure", function* (s) {
yield* s.hooks.register("session", "retry", (event) =>
Effect.sync(() => {
expect(event.decision).toEqual({ retry: false })
event.decision = { retry: true, delay: 0 }
}),
)
yield* s.admit("Retry invalid request")
yield* s.llm.push(Stream.fail(invalidRequest()), TestLLM.text("Recovered", "forced-retry-success"))
yield* s.resume
expect(s.requests).toHaveLength(2)
expect(yield* s.context).toMatchObject([
Expected.user("Retry invalid request"),
Expected.assistant({ finish: "stop" }, [Expected.text("Recovered")]),
])
})
scenario("uses the final session retry hook delay", function* (s) {
yield* s.hooks.register("session", "retry", (event) =>
Effect.sync(() => {
event.decision = { retry: true, delay: 10_000 }
}),
)
yield* s.hooks.register("session", "retry", (event) =>
Effect.sync(() => {
expect(event.decision).toEqual({ retry: true, delay: 10_000 })
event.decision = { retry: true, delay: 5_000 }
}),
)
yield* s.admit("Use custom retry delay")
yield* s.llm.push(Stream.fail(providerUnavailable()), TestLLM.text("Recovered", "hook-delay-success"))
const scheduled = yield* s.bus.subscribe(SessionEvent.RetryScheduled).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("4999 millis")
expect(s.requests).toHaveLength(1)
yield* TestClock.adjust("1 millis")
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
const assistant = requireAssistant(yield* s.context)
expect(assistant.retry).toBeUndefined()
})
scenario("does not start another physical attempt after interruption during retry backoff", function* (s) {
yield* s.admit("Interrupt retry backoff")
yield* s.llm.push(Stream.fail(providerUnavailable()), TestLLM.text("Must not run", "unused-retry"))
const scheduled = yield* s.bus.subscribe(SessionEvent.RetryScheduled).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* Fiber.join(scheduled)
yield* s.session.interrupt(sessionID)
@@ -4506,8 +4429,9 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(Stream.fail(incompleteStream()))
yield* s.llm.push(TestLLM.text("Recovered", "incomplete-stream-success"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
@@ -4527,8 +4451,9 @@ describe("SessionRunnerLLM", () => {
])
yield* s.llm.push(TestLLM.text("Recovered", "unknown-finish-success"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
@@ -4545,8 +4470,9 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(Stream.fail(rateLimited(5_000)))
yield* s.llm.push(TestLLM.text("Recovered", "retry-after-success"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("4999 millis")
expect(s.requests).toHaveLength(1)
yield* TestClock.adjust("1 millis")
@@ -4559,8 +4485,9 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(Stream.fail(rateLimited(3_600_000)))
yield* s.llm.push(TestLLM.text("Recovered", "retry-cap-success"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("899999 millis")
expect(s.requests).toHaveLength(1)
yield* TestClock.adjust("1 millis")
@@ -4581,8 +4508,9 @@ describe("SessionRunnerLLM", () => {
)
yield* s.llm.push(TestLLM.text(" continuation", "continued-text"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
@@ -4630,8 +4558,9 @@ describe("SessionRunnerLLM", () => {
])
yield* s.llm.push(TestLLM.text(" continuation", "unknown-continuation"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
@@ -4660,8 +4589,9 @@ describe("SessionRunnerLLM", () => {
)
yield* s.llm.push(TestLLM.text(" continuation", "rate-limit-continuation"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("4999 millis")
expect(s.requests).toHaveLength(1)
yield* TestClock.adjust("1 millis")
@@ -4698,8 +4628,9 @@ describe("SessionRunnerLLM", () => {
)
yield* s.llm.push(TestLLM.text(" continuation", "unknown-failure-continuation"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
@@ -4728,8 +4659,9 @@ describe("SessionRunnerLLM", () => {
)
yield* s.llm.push(TestLLM.text("Recovered", "reasoning-recovery"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
@@ -4770,8 +4702,9 @@ describe("SessionRunnerLLM", () => {
)
yield* s.llm.push(TestLLM.text("Recovered", "reasoning-transport-recovery"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
@@ -4912,11 +4845,16 @@ describe("SessionRunnerLLM", () => {
),
)
const scheduled = yield* Queue.unbounded<void>()
yield* s.bus.subscribe(SessionEvent.RetryScheduled).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.runForEach(() => Queue.offer(scheduled, undefined)),
Effect.forkScoped({ startImmediately: true }),
)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
for (const [index, delay] of [2_400, 4_800, 9_600, 19_200].entries()) {
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
yield* Queue.take(scheduled)
yield* TestClock.adjust(delay)
yield* s.llm.wait(index + 2)
}
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
expect(s.requests).toHaveLength(5)
@@ -4930,11 +4868,16 @@ describe("SessionRunnerLLM", () => {
const failure = providerUnavailable()
yield* s.llm.always(Stream.fail(failure))
const scheduled = yield* Queue.unbounded<void>()
yield* s.bus.subscribe(SessionEvent.RetryScheduled).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.runForEach(() => Queue.offer(scheduled, undefined)),
Effect.forkScoped({ startImmediately: true }),
)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
for (const [index, delay] of [2_400, 4_800, 9_600, 19_200].entries()) {
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
yield* Queue.take(scheduled)
yield* TestClock.adjust(delay)
yield* s.llm.wait(index + 2)
}
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
expect(s.requests).toHaveLength(5)
@@ -4979,8 +4922,9 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(Stream.fail(failure))
yield* s.llm.push(TestLLM.tool("call-after-retry", "echo", { text: "recovered" }), TestLLM.stop())
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
@@ -0,0 +1,527 @@
import { describe, expect, test } from "bun:test"
import { AIError, TransportError, type LLMEvent } from "@opencode-ai/ai"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionStep } from "@opencode-ai/core/session/runner/step"
import { SessionStepMachine } from "@opencode-ai/core/session/runner/step-machine"
import { Cause, Deferred, Effect, Exit, Fiber, Ref, Scheduler } from "effect"
import { it } from "./lib/effect"
const firstID = SessionMessage.ID.make("msg_first")
const failure = new AIError({
reason: new TransportError({ message: "Provider unavailable", transport: "http", operation: "request" }),
})
const error = { type: "provider.transport", message: "Provider unavailable" } as const
describe("SessionStepMachine", () => {
it.effect("completes a logical Step", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make<ReadonlyArray<SessionStepMachine.Context>>([])
const result = yield* SessionStepMachine.run(firstID, {
prepare: (context) =>
Ref.update(attempts, (values) => [...values, context]).pipe(
Effect.as(
SessionStepMachine.Preparation.Ready({
attempt: makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: true })),
}),
),
),
retry: () => Effect.void,
publishSynthetic: Effect.void,
})
expect(result).toBe(true)
expect(yield* Ref.get(attempts)).toEqual([
{ assistantMessageID: firstID, recoverOverflow: true, recoverContinuation: true },
])
}),
)
it.effect("pulls, publishes, and runs a local tool before settlement", () =>
Effect.gen(function* () {
const operations = yield* Ref.make<ReadonlyArray<string>>([])
const call = { type: "tool-call", id: "call_1", name: "lookup", input: {} } satisfies Extract<
LLMEvent,
{ type: "tool-call" }
>
const attempt = makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: false }), {
events: [call],
operations,
})
yield* SessionStepMachine.run(firstID, {
prepare: () => Effect.succeed(SessionStepMachine.Preparation.Ready({ attempt })),
retry: () => Effect.void,
publishSynthetic: Effect.void,
})
const observed = yield* Ref.get(operations)
expect(observed.indexOf("publish:tool-call")).toBeLessThan(observed.indexOf("tool:call_1"))
expect(observed.at(-1)).toBe("settle")
}),
)
it.effect("retries transparently with the same assistant", () =>
Effect.gen(function* () {
const outcomes: Array<SessionStep.Outcome> = [
SessionStep.Outcome.Retry({ cause: failure, error }),
SessionStep.Outcome.Completed({ needsContinuation: false }),
]
const operations = yield* Ref.make<ReadonlyArray<string>>([])
const result = yield* SessionStepMachine.run(firstID, {
prepare: (context) =>
Ref.update(operations, (values) => [...values, `attempt:${context.assistantMessageID}`]).pipe(
Effect.map(() =>
SessionStepMachine.Preparation.Ready({
attempt: makeAttempt(outcomes.shift() ?? SessionStep.Outcome.Completed({ needsContinuation: false })),
}),
),
),
retry: (context) => Ref.update(operations, (values) => [...values, `retry:${context.assistantMessageID}`]),
publishSynthetic: Effect.void,
})
expect(result).toBe(false)
expect(yield* Ref.get(operations)).toEqual([`attempt:${firstID}`, `retry:${firstID}`, `attempt:${firstID}`])
}),
)
it.effect("continues partial output only after retry and synthetic publication", () =>
Effect.gen(function* () {
const outcomes: Array<SessionStep.Outcome> = [
SessionStep.Outcome.Continue({ cause: failure, error }),
SessionStep.Outcome.Completed({ needsContinuation: false }),
]
const operations = yield* Ref.make<ReadonlyArray<string>>([])
yield* SessionStepMachine.run(firstID, {
prepare: (context) =>
Ref.update(operations, (values) => [...values, `attempt:${context.assistantMessageID}`]).pipe(
Effect.map(() =>
SessionStepMachine.Preparation.Ready({
attempt: makeAttempt(outcomes.shift() ?? SessionStep.Outcome.Completed({ needsContinuation: false })),
}),
),
),
retry: () => Ref.update(operations, (values) => [...values, "retry"]),
publishSynthetic: Ref.update(operations, (values) => [...values, "synthetic"]),
})
const observed = yield* Ref.get(operations)
expect(observed.slice(0, 3)).toEqual([`attempt:${firstID}`, "retry", "synthetic"])
expect(observed.at(3)).toStartWith("attempt:msg_")
expect(observed.at(3)).not.toBe(`attempt:${firstID}`)
}),
)
it.effect("tracks independent recovery allowances", () =>
Effect.gen(function* () {
const outcomes: Array<SessionStep.Outcome> = [
SessionStep.Outcome.RecoverFull(),
SessionStep.Outcome.Completed({ needsContinuation: false }),
SessionStep.Outcome.Completed({ needsContinuation: false }),
]
const recoveries = [false, true, false]
const attempts = yield* Ref.make<ReadonlyArray<SessionStepMachine.Context>>([])
yield* SessionStepMachine.run(firstID, {
prepare: (context) =>
Ref.update(attempts, (values) => [...values, context]).pipe(
Effect.map(() =>
SessionStepMachine.Preparation.Ready({
attempt: makeAttempt(outcomes.shift() ?? SessionStep.Outcome.Completed({ needsContinuation: false }), {
recoverOverflow: recoveries.shift(),
}),
}),
),
),
retry: () => Effect.void,
publishSynthetic: Effect.void,
})
const observed = yield* Ref.get(attempts)
expect(observed.slice(0, 2)).toEqual([
{ assistantMessageID: firstID, recoverOverflow: true, recoverContinuation: true },
{ assistantMessageID: firstID, recoverOverflow: true, recoverContinuation: false },
])
expect(observed.at(2)).toMatchObject({ recoverOverflow: false, recoverContinuation: false })
expect(observed.at(2)?.assistantMessageID).not.toBe(firstID)
}),
)
it.effect("does not begin another attempt when retry is interrupted", () =>
Effect.gen(function* () {
const retryStarted = yield* Deferred.make<void>()
const retryFinalized = yield* Deferred.make<void>()
const attempts = yield* Ref.make(0)
const machine = yield* SessionStepMachine.run(firstID, {
prepare: () =>
Ref.update(attempts, (value) => value + 1).pipe(
Effect.as(
SessionStepMachine.Preparation.Ready({
attempt: makeAttempt(SessionStep.Outcome.Retry({ cause: failure, error })),
}),
),
),
retry: () =>
Deferred.succeed(retryStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(Deferred.succeed(retryFinalized, undefined)),
),
publishSynthetic: Effect.void,
}).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(retryStarted)
yield* Fiber.interrupt(machine)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Deferred.isDone(retryFinalized)).toBe(true)
expect(yield* Ref.get(attempts)).toBe(1)
}),
)
for (const outcome of [
SessionStep.Outcome.Completed({ needsContinuation: true }),
SessionStep.Outcome.Retry({ cause: failure, error }),
SessionStep.Outcome.Continue({ cause: failure, error }),
SessionStep.Outcome.RecoverFull(),
]) {
it.effect(`cancellation during settlement prevents ${outcome._tag} from starting more work`, () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const operations = yield* Ref.make<ReadonlyArray<string>>([])
const attempt = {
...makeAttempt(outcome),
settle: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.andThen(Ref.update(operations, (values) => [...values, "settled"])),
Effect.as(outcome),
Effect.uninterruptible,
),
}
const machine = yield* SessionStepMachine.run(firstID, {
prepare: () =>
Ref.update(operations, (values) => [...values, "prepare"]).pipe(
Effect.as(SessionStepMachine.Preparation.Ready({ attempt })),
),
retry: () => Ref.update(operations, (values) => [...values, "retry"]),
publishSynthetic: Ref.update(operations, (values) => [...values, "synthetic"]),
}).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(started)
const interrupted = yield* Fiber.interrupt(machine).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(interrupted)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Ref.get(operations)).toEqual(["prepare", "settled"])
}),
)
}
it.effect("cancels provider and tools together, then closes and settles once", () =>
Effect.gen(function* () {
const providerStarted = yield* Deferred.make<void>()
const providerStopped = yield* Deferred.make<void>()
const toolStarted = yield* Deferred.make<void>()
const toolStopped = yield* Deferred.make<void>()
const operations = yield* Ref.make<ReadonlyArray<string>>([])
const calls = [{ type: "tool-call", id: "call_parallel", name: "lookup", input: {} }] as const
const pending = [...calls]
const attempt: SessionStep.Attempt = {
...makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: false }), { operations }),
observeUntilBoundary: () =>
Effect.suspend(() => {
const call = pending.shift()
if (call) return Effect.succeed(SessionStep.ProviderObservation.ToolCall({ call }))
return Deferred.succeed(providerStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(
Deferred.succeed(providerStopped, undefined).pipe(Effect.andThen(Deferred.await(toolStopped))),
),
)
}),
runTool: () =>
Deferred.succeed(toolStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(
Deferred.succeed(toolStopped, undefined).pipe(Effect.andThen(Deferred.await(providerStopped))),
),
),
settle: (settlement) =>
Effect.sync(() => {
expect(Exit.hasInterrupts(settlement.stream)).toBe(true)
expect(settlement.tools).toHaveLength(1)
expect(settlement.tools[0]?.call).toEqual(calls[0])
expect(settlement.tools.every((tool) => Exit.hasInterrupts(tool.exit))).toBe(true)
}).pipe(
Effect.andThen(Ref.update(operations, (values) => [...values, "settle"])),
Effect.as(SessionStep.Outcome.Completed({ needsContinuation: false })),
),
}
const machine = yield* SessionStepMachine.run(firstID, {
prepare: () => Effect.succeed(SessionStepMachine.Preparation.Ready({ attempt })),
retry: () => Effect.die("Unexpected retry"),
publishSynthetic: Effect.die("Unexpected continuation"),
}).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(providerStarted)
yield* Deferred.await(toolStarted)
yield* Fiber.interrupt(machine)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Ref.get(operations)).toEqual(["finish-provider", "settle"])
}),
)
it.effect("does not finalize the provider twice when cancellation races with finalization", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const operations = yield* Ref.make<ReadonlyArray<string>>([])
const attempt = {
...makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: false }), { operations }),
finishProvider: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.andThen(Ref.update(operations, (values) => [...values, "finish-provider"])),
Effect.uninterruptible,
),
}
const machine = yield* SessionStepMachine.run(firstID, {
prepare: () => Effect.succeed(SessionStepMachine.Preparation.Ready({ attempt })),
retry: () => Effect.die("Unexpected retry"),
publishSynthetic: Effect.die("Unexpected continuation"),
}).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(started)
const interrupted = yield* Fiber.interrupt(machine).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(interrupted)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Ref.get(operations)).toEqual(["read:end", "finish-provider", "settle"])
}),
)
test("cancellation awaits provider finalization and stops pending tools before settling", () => {
const definition = SessionStepMachine.definition<never, never>(firstID)
const cause = Cause.interrupt(123)
const call = { type: "tool-call", id: "call_pending", name: "lookup", input: {} } as const
const completed = { ...call, id: "call_completed" }
const state = SessionStepMachine.State.FinalizingProvider({
active: {
context: { assistantMessageID: firstID, recoverOverflow: true, recoverContinuation: true },
attempt: makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: false })),
tools: new Map([
[completed.id, { call: completed, exit: Exit.succeed(undefined) }],
[call.id, { call }],
]),
},
stream: Exit.succeed(undefined),
})
const stopping = definition.transition(state, {
_tag: "Input",
input: SessionStepMachine.Event.CancelRequested(),
cause,
})
if (stopping._tag !== "Continue") throw new Error("Expected cancellation to await owned invocations")
expect(stopping.state).toEqual({ _tag: "Stopping", from: state, cause })
expect(stopping.commands).toEqual([
{ _tag: "StopAndJoin", id: "step", ids: ["tool:call_pending"], waitFor: ["provider"] },
])
expect(
definition.transition(stopping.state, {
_tag: "Input",
input: SessionStepMachine.Event.CancelRequested(),
cause,
}),
).toEqual({ _tag: "Continue", state: stopping.state, commands: [] })
const settling = definition.transition(stopping.state, {
_tag: "InvocationsStopped",
id: "step",
exits: [
{
_tag: "InvocationExited",
id: "tool:call_pending",
generation: 1,
operation: SessionStepMachine.Operation.RunTool({ attempt: state.active.attempt, call }),
exit: Exit.interrupt(456),
},
{
_tag: "InvocationExited",
id: "provider",
generation: 2,
operation: SessionStepMachine.Operation.FinishProvider({
attempt: state.active.attempt,
stream: state.stream,
}),
exit: Exit.succeed(SessionStepMachine.Event.ProviderFinished({ exit: Exit.succeed(undefined) })),
},
],
})
if (settling._tag !== "Continue") throw new Error("Expected settlement after the joined batch")
expect(settling.state).toMatchObject({ _tag: "SettlingAttempt", stopping: cause })
expect(settling.commands).toEqual([
{
_tag: "Invoke",
id: "settlement",
operation: {
_tag: "SettleAttempt",
attempt: state.active.attempt,
settlement: {
stream: state.stream,
tools: [
{ call: completed, exit: Exit.succeed(undefined) },
{ call, exit: Exit.interrupt(456) },
],
},
},
},
])
})
for (const fixture of [
{ name: "never-started", exit: Exit.interrupt(456), replaced: false },
{
name: "queued false",
exit: Exit.succeed(SessionStepMachine.Event.OverflowRecovered({ exit: Exit.succeed(false) })),
replaced: false,
},
{
name: "queued failure",
exit: Exit.succeed(SessionStepMachine.Event.OverflowRecovered({ exit: Exit.die("Recovery failed") })),
replaced: false,
},
{
name: "queued true",
exit: Exit.succeed(SessionStepMachine.Event.OverflowRecovered({ exit: Exit.succeed(true) })),
replaced: true,
},
] as const) {
test(`cancellation reconciles ${fixture.name} overflow recovery before deciding settlement`, () => {
const definition = SessionStepMachine.definition<never, never>(firstID)
const cause = Cause.interrupt(123)
const state = SessionStepMachine.State.RecoveringOverflow({
active: {
context: { assistantMessageID: firstID, recoverOverflow: true, recoverContinuation: true },
attempt: makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: true })),
tools: new Map(),
},
stream: Exit.succeed(undefined),
})
const stopping = definition.transition(state, {
_tag: "Input",
input: SessionStepMachine.Event.CancelRequested(),
cause,
})
if (stopping._tag !== "Continue") throw new Error("Expected cancellation to await recovery")
expect(stopping.state).toEqual({ _tag: "Stopping", from: state, cause })
expect(stopping.commands).toEqual([{ _tag: "StopAndJoin", id: "step", ids: ["compaction"], waitFor: [] }])
const settled = definition.transition(stopping.state, {
_tag: "InvocationsStopped",
id: "step",
exits: [
{
_tag: "InvocationExited",
id: "compaction",
generation: 1,
operation: SessionStepMachine.Operation.RecoverOverflow({
attempt: state.active.attempt,
settlement: { stream: state.stream, tools: [] },
}),
exit: fixture.exit,
},
],
})
if (fixture.replaced) {
expect(settled).toEqual({ _tag: "Done", output: Exit.failCause(cause) })
return
}
if (settled._tag !== "Continue") throw new Error("Expected the unreplaced attempt to settle")
expect(settled.state).toEqual({ _tag: "SettlingAttempt", active: state.active, stopping: cause })
expect(settled.commands).toEqual([
{
_tag: "Invoke",
id: "settlement",
operation: {
_tag: "SettleAttempt",
attempt: state.active.attempt,
settlement: { stream: Exit.failCause(cause), tools: [] },
},
},
])
const command = settled.commands[0]
if (command?._tag !== "Invoke") throw new Error("Expected a settlement invocation")
expect(
definition.transition(settled.state, {
_tag: "InvocationExited",
id: command.id,
generation: 2,
operation: command.operation,
exit: Exit.succeed(
SessionStepMachine.Event.AttemptSettled({
exit: Exit.succeed(SessionStep.Outcome.Completed({ needsContinuation: true })),
}),
),
}),
).toEqual({ _tag: "Done", output: Exit.failCause(cause) })
})
}
for (const target of ["finishProvider", "recoverOverflow"] as const) {
it.effect(`settles once when cancellation precedes ${target} execution`, () =>
Effect.gen(function* () {
const operations = yield* Ref.make<ReadonlyArray<string>>([])
const attempt = makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: true }), { operations })
const machine = yield* Effect.withFiber((fiber) =>
SessionStepMachine.run(firstID, {
prepare: () =>
Effect.succeed(
SessionStepMachine.Preparation.Ready({
attempt: {
...attempt,
// Interrupt during construction, before the deferred invocation starts.
finishProvider: (stream) => {
if (target === "finishProvider") fiber.interruptUnsafe(123)
return attempt.finishProvider(stream)
},
recoverOverflow: (settlement) => {
if (target === "recoverOverflow") fiber.interruptUnsafe(123)
return attempt.recoverOverflow(settlement)
},
},
}),
),
retry: () => Effect.die("Unexpected retry"),
publishSynthetic: Effect.die("Unexpected continuation"),
}),
).pipe(
Effect.provideService(Scheduler.PreventSchedulerYield, true),
Effect.forkChild({ startImmediately: true }),
)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Ref.get(operations)).toEqual(["read:end", "finish-provider", "settle"])
}),
)
}
})
function makeAttempt(
outcome: SessionStep.Outcome,
options?: {
readonly events?: ReadonlyArray<LLMEvent>
readonly operations?: Ref.Ref<ReadonlyArray<string>>
readonly recoverOverflow?: boolean
},
): SessionStep.Attempt {
const events = [...(options?.events ?? [])]
const log = (value: string) =>
options?.operations ? Ref.update(options.operations, (values) => [...values, value]) : Effect.void
return {
observeUntilBoundary: () =>
Effect.gen(function* () {
const event = events.shift()
yield* log(event ? `read:${event.type}` : "read:end")
if (!event) return SessionStep.ProviderObservation.ProviderEnd()
yield* log(`publish:${event.type}`)
if (event.type !== "tool-call") return SessionStep.ProviderObservation.ProviderEnd()
return SessionStep.ProviderObservation.ToolCall({ call: event })
}),
runTool: (call) => log(`tool:${call.id}`),
finishProvider: () => log("finish-provider"),
recoverOverflow: () => Effect.succeed(options?.recoverOverflow ?? false),
settle: () => log("settle").pipe(Effect.as(outcome)),
}
}
+309 -75
View File
@@ -1,5 +1,5 @@
import { expect } from "bun:test"
import { LanguageModel, LLM, LLMEvent } from "@opencode-ai/ai"
import { AIError, LanguageModel, LLM, LLMEvent, TransportError } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
import { TestLLM } from "@opencode-ai/ai/testing"
import { Agent } from "@opencode-ai/core/agent"
@@ -11,17 +11,19 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStep } from "@opencode-ai/core/session/runner/step"
import { SessionStepMachine } from "@opencode-ai/core/session/runner/step-machine"
import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { asc, eq } from "drizzle-orm"
import { Effect, Exit, Layer } from "effect"
import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(
@@ -40,49 +42,21 @@ for (const fixture of [
] as const) {
it.effect(`settles ${fixture.finish} with tool choice ${fixture.toolChoice ?? "default"}`, () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const llm = yield* TestLLM.Test
const sessionID = Session.ID.create()
const assistantMessageID = SessionMessage.ID.create()
const start = Snapshot.ID.make("before")
const end = Snapshot.ID.make("after")
const files = [RelativePath.make("changed.ts")]
let captures = 0
let executions = 0
const steps = yield* SessionStep.make.pipe(
Effect.provide(
Layer.mock(Snapshot.Service)({
capture: () => Effect.sync(() => (captures++ === 0 ? start : end)),
files: (input) => {
expect(input).toEqual({ from: start, to: end })
return Effect.succeed(files)
},
}),
),
)
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({ id: sessionID, project_id: Project.ID.global, slug: "step", directory: "/project", version: "test" })
.run()
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "test-model", provider: "test", route: OpenAIChat.route }),
{
capabilities: { tools: true, input: ["text"], output: ["text"] },
limit: { context: 100_000, output: 1_000 },
cost: [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: { read: Money.USDPerMillionTokens.make(0.1), write: Money.USDPerMillionTokens.make(0.5) },
},
],
const s = yield* setup({
snapshot: {
capture: () => Effect.sync(() => (captures++ === 0 ? start : end)),
files: (input) => {
expect(input).toEqual({ from: start, to: end })
return Effect.succeed(files)
},
},
)
yield* llm.push(
})
yield* s.llm.push(
TestLLM.complete(
{
reason: { normalized: fixture.finish },
@@ -98,55 +72,33 @@ for (const fixture of [
LLMEvent.toolCall({ id: "call-test", name: "test", input: {} }),
),
)
const result = yield* steps
.attempt({
sessionID,
assistantMessageID,
agent: Agent.defaultID,
model,
prepared: {
retry: () => Effect.void,
request: LLM.request({ model: model.model, prompt: "Run one tool", toolChoice: fixture.toolChoice }),
options: {},
const result = yield* SessionStepMachine.run(s.assistantMessageID, {
prepare: (context) =>
s.prepare(context, {
toolChoice: fixture.toolChoice,
executeTool: () =>
Effect.sync(() => {
executions++
return { content: "Completed tool" }
}),
},
retry: (_cause, _error, retry) =>
Effect.succeed(retry ? { retry: true, attempt: 2, delay: 0 } : { retry: false }),
recoverContinuation: true,
recoverOverflow: Effect.succeed(false),
})
.pipe(Effect.exit)
}),
retry: () => Effect.die("Unexpected retry"),
publishSynthetic: Effect.die("Unexpected continuation"),
}).pipe(Effect.exit)
expect(Exit.isSuccess(result)).toBe(fixture.finish === "stop")
expect(executions).toBe(fixture.toolChoice === "none" ? 0 : 1)
if (Exit.isSuccess(result))
expect(result.value).toEqual(
SessionStep.Outcome.Completed({ needsContinuation: fixture.toolChoice !== "none" }),
)
expect(yield* llm.requests()).toHaveLength(1)
if (Exit.isSuccess(result)) expect(result.value).toBe(fixture.toolChoice !== "none")
expect(yield* s.llm.requests()).toHaveLength(1)
expect(captures).toBe(2)
const message = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, assistantMessageID))
.get()
expect(message?.data).toMatchObject({
const message = yield* s.message
expect(message).toMatchObject({
finish: fixture.finish,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 2 } },
snapshot: { start, end, files },
content: [{ type: "tool", state: { status: fixture.toolChoice === "none" ? "error" : "completed" } }],
})
expect(message?.data).toHaveProperty("cost", expect.closeTo(0.0000233, 10))
const events = yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
const types = events.map((event) => event.type)
expect(message).toHaveProperty("cost", expect.closeTo(0.0000233, 10))
const types = yield* s.events
const terminal = fixture.finish === "stop" ? "session.step.ended.1" : "session.step.failed.1"
expect(types.filter((type) => type === terminal)).toHaveLength(1)
expect(
@@ -155,3 +107,285 @@ for (const fixture of [
}),
)
}
it.effect("closes provider stream resources before the next physical retry", () =>
Effect.gen(function* () {
const s = yield* setup()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupRelease = yield* Deferred.make<void>()
const operations: string[] = []
yield* s.llm.push(
Stream.unwrap(
Effect.acquireRelease(
Effect.sync(() => operations.push("acquire")),
() =>
Deferred.succeed(cleanupStarted, undefined).pipe(
Effect.andThen(Deferred.await(cleanupRelease)),
Effect.andThen(Effect.sync(() => operations.push("release"))),
),
).pipe(
Effect.as(
Stream.fail(
new AIError({
reason: new TransportError({ message: "Request failed", transport: "http", operation: "request" }),
}),
),
),
),
),
TestLLM.stop(),
)
const run = yield* SessionStepMachine.run(s.assistantMessageID, {
prepare: (context) => Effect.sync(() => operations.push("prepare")).pipe(Effect.andThen(s.prepare(context))),
retry: () => Effect.sync(() => operations.push("retry")).pipe(Effect.asVoid),
publishSynthetic: Effect.die("Unexpected continuation"),
}).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Effect.addFinalizer(() => Deferred.succeed(cleanupRelease, undefined))
yield* Deferred.await(cleanupStarted)
expect(operations).toEqual(["prepare", "acquire"])
expect(yield* s.llm.requests()).toHaveLength(1)
expect(run.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(cleanupRelease, undefined)
expect(yield* Fiber.join(run)).toBe(false)
expect(operations).toEqual(["prepare", "acquire", "release", "retry", "prepare"])
expect(yield* s.llm.requests()).toHaveLength(2)
expect(yield* s.message).toMatchObject({ finish: "stop" })
}),
)
for (const providerExecuted of [false, true]) {
it.effect(
`commits ${providerExecuted ? "provider-hosted" : "local"} tool success during cancellation under the bus lock`,
() =>
Effect.gen(function* () {
const ready = yield* Deferred.make<void>()
const resultRelease = yield* Deferred.make<void>()
const publishing = yield* Deferred.make<void>()
const held = yield* Deferred.make<void>()
const lockRelease = yield* Deferred.make<void>()
const s = yield* setup({
observePublish: (type) =>
type === SessionEvent.Tool.Success.type ? Deferred.succeed(publishing, undefined) : Effect.void,
})
const call = LLMEvent.toolCall({ id: "call-race", name: "lookup", input: {}, providerExecuted })
let executions = 0
yield* s.llm.push(
providerExecuted
? Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), call]).pipe(
Stream.concat(
Stream.unwrap(
Deferred.succeed(ready, undefined).pipe(
Effect.andThen(Deferred.await(resultRelease)),
Effect.as(
Stream.make(
LLMEvent.toolResult({
id: call.id,
name: call.name,
providerExecuted: true,
result: { type: "text", value: "Durable result" },
}),
),
),
),
),
),
Stream.concat(Stream.never),
)
: TestLLM.hangAfter(LLMEvent.stepStart({ index: 0 }), call),
)
const run = yield* SessionStepMachine.run(s.assistantMessageID, {
prepare: (context) =>
s.prepare(context, {
executeTool: () =>
Effect.gen(function* () {
executions++
yield* Deferred.succeed(ready, undefined)
yield* Deferred.await(resultRelease)
return { content: "Durable result" }
}),
}),
retry: () => Effect.die("Unexpected retry"),
publishSynthetic: Effect.die("Unexpected continuation"),
}).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(ready)
yield* Effect.acquireRelease(
s.bus.listen((event) =>
event.type === SessionEvent.Renamed.type
? Deferred.succeed(held, undefined).pipe(Effect.andThen(Deferred.await(lockRelease)))
: Effect.void,
),
(unsubscribe) => unsubscribe,
)
// Notifications hold the real aggregate lock after the unrelated event commits.
const holder = yield* s.bus
.publish(SessionEvent.Renamed, { sessionID: s.sessionID, title: "Hold publication" })
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Effect.addFinalizer(() => Deferred.succeed(lockRelease, undefined))
yield* Deferred.await(held)
yield* Deferred.succeed(resultRelease, undefined)
yield* Deferred.await(publishing)
const cancellation = yield* Fiber.interrupt(run).pipe(Effect.forkChild({ startImmediately: true }))
yield* Effect.yieldNow
expect(cancellation.pollUnsafe()).toBeUndefined()
expect(yield* s.events).not.toContain("session.tool.success.2")
yield* Deferred.succeed(lockRelease, undefined)
yield* Fiber.join(holder)
yield* Fiber.join(cancellation)
expect(Exit.hasInterrupts(yield* Fiber.await(run))).toBe(true)
expect(executions).toBe(providerExecuted ? 0 : 1)
expect(yield* s.llm.requests()).toHaveLength(1)
const events = yield* s.events
expect(events.filter((type) => type === "session.tool.success.2")).toHaveLength(1)
expect(events).not.toContain("session.tool.failed.2")
expect(events.filter((type) => type === "session.step.failed.1")).toHaveLength(1)
expect(events.indexOf("session.tool.success.2")).toBeLessThan(events.indexOf("session.step.failed.1"))
expect(yield* s.message).toMatchObject({
finish: "error",
error: { type: "aborted" },
content: [
{
type: "tool",
id: call.id,
executed: providerExecuted,
state: { status: "completed", content: [{ type: "text", text: "Durable result" }] },
},
],
})
}),
)
}
it.effect("recovers overflow instead of generically retrying a subsequent transport failure", () =>
Effect.gen(function* () {
const s = yield* setup()
const contexts: SessionStepMachine.Context[] = []
const operations: string[] = []
yield* s.llm.push(
TestLLM.failAfter(
new AIError({
reason: new TransportError({ message: "Read failed", transport: "http", operation: "read" }),
}),
LLMEvent.stepStart({ index: 0 }),
LLMEvent.providerError({ message: "Prompt too long", classification: "context-overflow" }),
),
TestLLM.stop(),
)
const result = yield* SessionStepMachine.run(s.assistantMessageID, {
prepare: (context) =>
Effect.sync(() => contexts.push(context)).pipe(
Effect.andThen(
s.prepare(context, {
recoverOverflow: Effect.sync(() => {
operations.push("compact")
return true
}),
}),
),
),
retry: () => Effect.sync(() => operations.push("retry")).pipe(Effect.asVoid),
publishSynthetic: Effect.die("Unexpected continuation"),
})
expect(result).toBe(false)
expect(operations).toEqual(["compact"])
expect(yield* s.llm.requests()).toHaveLength(2)
expect(contexts).toHaveLength(2)
expect(contexts[0]).toMatchObject({ assistantMessageID: s.assistantMessageID, recoverOverflow: true })
expect(contexts[1]).toMatchObject({ recoverOverflow: false })
expect(contexts[1]?.assistantMessageID).not.toBe(s.assistantMessageID)
expect(yield* s.events).not.toContain("session.step.failed.1")
}),
)
const setup = Effect.fnUntraced(function* (
options: {
readonly snapshot?: Pick<Snapshot.Interface, "capture" | "files">
readonly observePublish?: (type: string) => Effect.Effect<unknown>
} = {},
) {
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const llm = yield* TestLLM.Test
const sessionID = Session.ID.create()
const assistantMessageID = SessionMessage.ID.create()
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({ id: sessionID, project_id: Project.ID.global, slug: "step", directory: "/project", version: "test" })
.run()
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "test-model", provider: "test", route: OpenAIChat.route }),
{
capabilities: { tools: true, input: ["text"], output: ["text"] },
limit: { context: 100_000, output: 1_000 },
cost: [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: { read: Money.USDPerMillionTokens.make(0.1), write: Money.USDPerMillionTokens.make(0.5) },
},
],
},
)
const steps = yield* SessionStep.make.pipe(
Effect.provide(
Layer.mock(Snapshot.Service)(
options.snapshot ?? { capture: () => Effect.undefined, files: () => Effect.succeed([]) },
),
),
Effect.provideService(Bus.Service, {
...bus,
publish: (definition, data, publishOptions) =>
(options.observePublish?.(definition.type) ?? Effect.void).pipe(
Effect.andThen(bus.publish(definition, data, publishOptions)),
),
}),
)
return {
bus,
llm,
sessionID,
assistantMessageID,
prepare: (
context: SessionStepMachine.Context,
input?: {
readonly toolChoice?: "none"
readonly executeTool?: SessionStep.Input["prepared"]["executeTool"]
readonly recoverOverflow?: Effect.Effect<boolean>
},
) =>
steps
.open({
sessionID,
assistantMessageID: context.assistantMessageID,
agent: Agent.defaultID,
model,
prepared: {
request: LLM.request({ model: model.model, prompt: "Run one step", toolChoice: input?.toolChoice }),
options: {},
executeTool: input?.executeTool ?? (() => Effect.die("Unexpected tool execution")),
},
recoverContinuation: context.recoverContinuation,
recoverOverflow: input?.recoverOverflow ?? Effect.succeed(false),
})
.pipe(Effect.map((attempt) => SessionStepMachine.Preparation.Ready({ attempt }))),
message: db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, assistantMessageID))
.get()
.pipe(Effect.map((row) => row?.data)),
events: db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.map((rows) => rows.map((row) => row.type))),
}
})
+84 -476
View File
@@ -1,18 +1,10 @@
import { describe, expect } from "bun:test"
import { State } from "@opencode-ai/core/state"
import { Cause, Deferred, Effect, Exit, Fiber, Scheduler, Scope } from "effect"
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { TestClock } from "effect/testing"
import { it } from "./lib/effect"
import { testEffect } from "./lib/effect"
function valuesState(
hooks: Pick<State.Options<{ values: string[] }, { add: (item: string) => void }>, "prepare" | "notify"> = {},
) {
return State.create({
initial: () => ({ values: new Array<string>() }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
...hooks,
})
}
const it = testEffect(Layer.empty)
describe("State", () => {
it.effect("commits a transform atomically when its updater is interrupted", () =>
@@ -20,13 +12,17 @@ describe("State", () => {
const rebuilding = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let block = true
const state = valuesState({
notify: () =>
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (value: string) => draft.values.push(value) }),
finalize: () =>
block ? Deferred.succeed(rebuilding, undefined).pipe(Effect.andThen(Deferred.await(release))) : Effect.void,
})
const scope = yield* Scope.make()
const fiber = yield* state
.transform((editor) => editor.add("registered"))
.transform((editor) => {
editor.add("registered")
})
.pipe(Scope.provide(scope), Effect.forkChild)
yield* Deferred.await(rebuilding)
const interruption = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild)
@@ -40,16 +36,20 @@ describe("State", () => {
}),
)
it.effect("makes rebuilt state visible before notifying", () =>
it.effect("commits rebuilt state before finalize runs", () =>
Effect.gen(function* () {
const observed: string[][] = []
const state: ReturnType<typeof valuesState> = valuesState({
notify: () => Effect.sync(() => observed.push([...state.get().values])),
const state: State.Interface<{ values: string[] }, { add: (item: string) => void }> = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => observed.push([...state.get().values])),
})
yield* state.transform((draft) => draft.add("value"))
yield* state.transform((draft) => {
draft.add("value")
})
// Update events publish from notify, so consumers reading on the event
// Update events publish from finalize, so consumers reading on the event
// must observe the rebuilt state, not the previous one.
expect(observed).toEqual([["value"]])
}),
@@ -58,9 +58,14 @@ describe("State", () => {
it.effect("runs transforms during every reload", () =>
Effect.gen(function* () {
let value = "first"
const state = valuesState()
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
})
yield* state.transform((editor) => editor.add(value))
yield* state.transform((editor) => {
editor.add(value)
})
expect(state.get().values).toEqual(["first"])
value = "second"
@@ -71,327 +76,18 @@ describe("State", () => {
}),
)
it.effect("reads registrations and disposals inside a batch without publishing", () =>
Effect.gen(function* () {
const observed: string[][] = []
let replays = 0
const state: ReturnType<typeof valuesState> = valuesState({
notify: () => Effect.sync(() => observed.push([...state.get().values])),
})
const scope = yield* Scope.make()
yield* State.batch(
Effect.gen(function* () {
yield* state
.transform((draft) => {
replays++
draft.add("value")
})
.pipe(Scope.provide(scope))
const snapshot = state.get()
expect(snapshot.values).toEqual(["value"])
expect(state.get()).toBe(snapshot)
expect(replays).toBe(1)
expect(observed).toEqual([])
yield* Scope.close(scope, Exit.void)
expect(state.get().values).toEqual([])
expect(snapshot.values).toEqual(["value"])
expect(observed).toEqual([])
}),
)
expect(observed).toEqual([[]])
}),
)
it.effect("reads a requested reload without waiting for its notification debounce", () =>
Effect.gen(function* () {
let value = "first"
let replays = 0
const observed: string[][] = []
const state: ReturnType<typeof valuesState> = valuesState({
notify: () => Effect.sync(() => observed.push([...state.get().values])),
})
yield* state.transform((draft) => {
replays++
draft.add(value)
})
const snapshot = state.get()
observed.length = 0
value = "second"
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("50 millis")
expect(state.get().values).toEqual(["second"])
expect(snapshot.values).toEqual(["first"])
expect(replays).toBe(2)
expect(observed).toEqual([])
yield* TestClock.adjust("450 millis")
yield* Fiber.join(reload)
expect(observed).toEqual([["second"]])
expect(replays).toBe(2)
}),
)
it.effect("can await reload inside a batch while deferring its notification", () =>
Effect.gen(function* () {
let value = "first"
let notifications = 0
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => draft.add(value))
expect(state.get().values).toEqual(["first"])
value = "second"
yield* state.reload()
expect(state.get().values).toEqual(["second"])
expect(notifications).toBe(0)
}),
)
expect(notifications).toBe(1)
}),
)
it.effect("prepares derived data during reads without running observers", () =>
Effect.gen(function* () {
let notifications = 0
const state = State.create({
initial: () => ({ values: [] as string[], joined: "" }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
prepare: (data) => {
data.joined = data.values.join(",")
},
notify: () => Effect.sync(() => notifications++),
})
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => draft.add("first"))
yield* state.transform((draft) => draft.add("second"))
expect(state.get().joined).toBe("first,second")
expect(notifications).toBe(0)
}),
)
expect(notifications).toBe(1)
}),
)
it.effect("keeps replay failures observable without replacing the previous snapshot", () =>
Effect.gen(function* () {
let fail = false
const state = valuesState({
prepare: () => {
if (fail) throw new Error("preparation failed")
},
})
yield* state.transform((draft) => draft.add("first"))
const snapshot = state.get()
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => draft.add("second"))
fail = true
expect(() => state.get()).toThrow("preparation failed")
expect(() => state.get()).toThrow("preparation failed")
expect(snapshot.values).toEqual(["first"])
fail = false
expect(state.get().values).toEqual(["first", "second"])
}),
)
}),
)
it.effect("allows an observer to await a registration on the same state", () =>
Effect.gen(function* () {
const scope = yield* Scope.Scope
let added = false
const observed: string[][] = []
const state: ReturnType<typeof valuesState> = valuesState({
notify: () =>
Effect.gen(function* () {
observed.push([...state.get().values])
if (added) return
added = true
yield* state.transform((draft) => draft.add("second")).pipe(Scope.provide(scope))
}),
})
yield* state.transform((draft) => draft.add("first"))
expect(observed).toEqual([["first"], ["first", "second"]])
expect(state.get().values).toEqual(["first", "second"])
}),
)
it.effect("allows a debounced observer to await another reload", () =>
Effect.gen(function* () {
let value = "first"
let reloadAgain = false
const observed: string[][] = []
const state: ReturnType<typeof valuesState> = valuesState({
notify: () =>
Effect.gen(function* () {
observed.push([...state.get().values])
if (!reloadAgain) return
reloadAgain = false
value = "third"
yield* state.reload()
}),
})
yield* state.transform((draft) => draft.add(value))
observed.length = 0
value = "second"
reloadAgain = true
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("1 second")
yield* Fiber.join(reload)
expect(observed).toEqual([["second"], ["third"]])
expect(state.get().values).toEqual(["third"])
}),
)
it.effect("keeps reload waiters associated with their own notification results", () =>
Effect.gen(function* () {
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let value = "first"
let block = false
const observed: string[][] = []
const state: ReturnType<typeof valuesState> = valuesState({
notify: () =>
Effect.gen(function* () {
observed.push([...state.get().values])
if (!block) return
block = false
yield* Deferred.succeed(entered, undefined)
yield* Deferred.await(release)
return yield* Effect.die(new Error("first notification failed"))
}),
})
yield* state.transform((draft) => draft.add(value))
// Release the detached worker before the earlier registration finalizer runs.
yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined))
observed.length = 0
value = "second"
block = true
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Deferred.await(entered)
value = "third"
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect(state.get().values).toEqual(["third"])
yield* TestClock.adjust("500 millis")
yield* Fiber.join(second)
expect(first.pollUnsafe()).toBeUndefined()
expect(observed).toEqual([["second"], ["third"]])
yield* Deferred.succeed(release, undefined)
const exit = yield* Fiber.await(first)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("first notification failed")
}),
)
it.effect("keeps coalesced reload callers independent of cancellation and shares their notification failure", () =>
Effect.gen(function* () {
let fail = false
let notifications = 0
const failure = new Error("notification failed")
const state = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () =>
Effect.sync(() => {
notifications++
if (fail) throw failure
}),
})
yield* state.transform(() => {})
notifications = 0
fail = true
const cancelled = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* Fiber.interrupt(cancelled)
yield* TestClock.adjust("500 millis")
const exits = yield* Fiber.awaitAll([first, second])
fail = false
expect(Exit.hasInterrupts(yield* Fiber.await(cancelled))).toBe(true)
expect(exits.map((exit) => Exit.isFailure(exit) && Cause.squash(exit.cause))).toEqual([failure, failure])
expect(notifications).toBe(1)
const recovered = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(recovered)
expect(notifications).toBe(2)
}),
)
it.effect("continues publishing when a reload caller is cancelled while scheduling its worker", () =>
Effect.gen(function* () {
let value = "first"
let notifications = 0
let interrupted = false
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
yield* state.transform((draft) => draft.add(value))
notifications = 0
value = "second"
const cancelled = yield* Effect.withFiber((fiber) => {
const base = new Scheduler.MixedScheduler("sync")
const scheduler: Scheduler.Scheduler = {
executionMode: base.executionMode,
// Keep the first scheduled task at the detached worker handoff.
shouldYield: () => false,
makeDispatcher: () => {
const dispatcher = base.makeDispatcher()
return {
scheduleTask: (task, priority) => {
if (!interrupted) {
interrupted = true
fiber.interruptUnsafe()
}
dispatcher.scheduleTask(task, priority)
},
flush: () => dispatcher.flush(),
}
},
}
return state.reload().pipe(Effect.provideService(Scheduler.Scheduler, scheduler))
}).pipe(Effect.forkChild({ startImmediately: true }))
const exit = yield* Fiber.await(cancelled)
expect(interrupted).toBe(true)
expect(Exit.hasInterrupts(exit)).toBe(true)
expect(state.get().values).toEqual(["second"])
yield* TestClock.adjust("500 millis")
expect(notifications).toBe(1)
value = "third"
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(state.get().values).toEqual(["third"])
expect(notifications).toBe(2)
}),
)
it.effect("disposes a transform once and rebuilds remaining state", () =>
Effect.gen(function* () {
const state = valuesState()
yield* state.transform((editor) => editor.add("first"))
const registration = yield* state.transform((editor) => editor.add("second"))
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
})
yield* state.transform((editor) => {
editor.add("first")
})
const registration = yield* state.transform((editor) => {
editor.add("second")
})
expect(state.get().values).toEqual(["first", "second"])
yield* registration.dispose
@@ -402,137 +98,49 @@ describe("State", () => {
}),
)
it.effect("batches notifications", () =>
it.effect("batches automatic rebuilds", () =>
Effect.gen(function* () {
let notifications = 0
const first = valuesState({ notify: () => Effect.sync(() => notifications++) })
const second = valuesState({ notify: () => Effect.sync(() => notifications++) })
let finalized = 0
const first = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
const second = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
yield* State.batch(
Effect.gen(function* () {
yield* first.transform((draft) => draft.add("first"))
yield* first.transform((draft) => draft.add("second"))
yield* second.transform((draft) => draft.add("third"))
expect(notifications).toBe(0)
yield* first.transform((draft) => {
draft.add("first")
})
yield* first.transform((draft) => {
draft.add("second")
})
yield* second.transform((draft) => {
draft.add("third")
})
expect(finalized).toBe(0)
}),
)
expect(first.get().values).toEqual(["first", "second"])
expect(second.get().values).toEqual(["third"])
expect(notifications).toBe(2)
expect(finalized).toBe(2)
}),
)
it.effect("closes a batched observer's owning scope without losing the body's failure", () =>
Effect.gen(function* () {
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const registrations = yield* Scope.make()
const owner = yield* Scope.make()
yield* Effect.addFinalizer(() =>
Deferred.succeed(release, undefined).pipe(
Effect.andThen(Scope.close(owner, Exit.void)),
Effect.andThen(State.batch(Scope.close(registrations, Exit.void), { flush: false })),
),
)
const state = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))),
})
const writer = yield* State.batch(
state.transform(() => {}).pipe(Scope.provide(registrations), Effect.andThen(Effect.fail("batch body failed"))),
).pipe(Effect.forkIn(owner, { startImmediately: true }))
yield* Deferred.await(entered)
const shutdown = yield* Scope.close(owner, Exit.void).pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("1 millis")
expect(shutdown.pollUnsafe()).toBeDefined()
expect(yield* Deferred.isDone(release)).toBe(false)
const exit = yield* Fiber.await(writer)
expect(Exit.hasInterrupts(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("batch body failed")
}),
)
it.effect("lets batch observers read the other states' accepted changes", () =>
Effect.gen(function* () {
const observed: string[][] = []
const first = valuesState({
notify: () => Effect.sync(() => observed.push([...second.get().values])),
})
const second = valuesState()
yield* State.batch(
Effect.gen(function* () {
yield* first.transform((draft) => draft.add("first"))
yield* second.transform((draft) => draft.add("second"))
}),
)
expect(observed).toEqual([["second"]])
}),
)
;["replay", "notification"].forEach((failure) =>
it.effect(`notifies the other states when a batch ${failure} fails`, () =>
Effect.gen(function* () {
let fail = true
const observed: string[] = []
const first = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () => Effect.sync(() => observed.push("first")),
})
const failing = State.create({
initial: () => ({}),
draft: (draft) => draft,
prepare: () => {
if (fail && failure === "replay") throw new Error("replay failed")
},
notify: () =>
fail ? Effect.die(new Error("notification failed")) : Effect.sync(() => observed.push("failing")),
})
const last = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () => Effect.sync(() => observed.push("last")),
})
const exit = yield* State.batch(
Effect.gen(function* () {
yield* first.transform(() => {})
yield* failing.transform(() => {})
yield* last.transform(() => {})
return yield* Effect.die(new Error("batch failed"))
}),
).pipe(Effect.exit)
fail = false
expect(observed).toEqual(["first", "last"])
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.pretty(exit.cause)).toContain("batch failed")
expect(Cause.pretty(exit.cause)).toContain(`${failure} failed`)
}
const reload = yield* failing.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(observed).toEqual(["first", "last", "failing"])
}),
),
)
it.effect("discards teardown rebuilds and pending reloads while still running cleanup", () =>
Effect.gen(function* () {
let notifications = 0
let prepared = 0
let finalized = 0
let disposed = 0
const state = valuesState({
prepare: () => {
prepared++
},
notify: () => Effect.sync(() => notifications++),
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
const scope = yield* Scope.make()
yield* Scope.addFinalizer(
@@ -540,44 +148,38 @@ describe("State", () => {
Effect.sync(() => disposed++),
)
const registration = yield* state.transform((draft) => draft.add("value")).pipe(Scope.provide(scope))
const snapshot = state.get()
expect(notifications).toBe(1)
expect(prepared).toBe(1)
expect(finalized).toBe(1)
const pending = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("250 millis")
yield* State.batch(Scope.close(scope, Exit.void), { flush: false })
expect(disposed).toBe(1)
expect(notifications).toBe(1)
expect(state.get()).toBe(snapshot)
expect(prepared).toBe(1)
expect(finalized).toBe(1)
yield* TestClock.adjust("500 millis")
yield* Fiber.join(pending)
yield* registration.dispose
yield* state.reload()
expect(notifications).toBe(1)
expect(state.get()).toBe(snapshot)
expect(prepared).toBe(1)
expect(finalized).toBe(1)
}),
)
it.effect("keeps teardown suppression separate from an enclosing live batch", () =>
Effect.gen(function* () {
const notifications: string[] = []
const finalized: string[] = []
const closing = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () => Effect.sync(() => notifications.push("closing")),
finalize: () => Effect.sync(() => finalized.push("closing")),
})
const live = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () => Effect.sync(() => notifications.push("live")),
finalize: () => Effect.sync(() => finalized.push("live")),
})
const scope = yield* Scope.make()
yield* closing.transform(() => {}).pipe(Scope.provide(scope))
notifications.length = 0
finalized.length = 0
yield* State.batch(
Effect.gen(function* () {
@@ -585,27 +187,33 @@ describe("State", () => {
yield* State.batch(Scope.close(scope, Exit.void), { flush: false })
}),
)
expect(notifications).toEqual(["live"])
expect(finalized).toEqual(["live"])
}),
)
it.effect("debounces reload bursts", () =>
Effect.gen(function* () {
let notifications = 0
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
yield* state.transform((draft) => draft.add("value"))
notifications = 0
let finalized = 0
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
yield* state.transform((draft) => {
draft.add("value")
})
finalized = 0
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("250 millis")
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("499 millis")
expect(notifications).toBe(0)
expect(finalized).toBe(0)
yield* TestClock.adjust("1 millis")
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(notifications).toBe(1)
expect(finalized).toBe(1)
}),
)
})
+21 -28
View File
@@ -311,7 +311,7 @@ describe("Tool", () => {
}),
)
it.effect("reads refreshed sources before notifications and keeps advertised snapshots", () =>
it.effect("replays empty sources on reload and keeps advertised snapshots", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
let source: Info[] = []
@@ -321,24 +321,24 @@ describe("Tool", () => {
const tool = { ...constant("first"), name: "echo", options: { codemode: false } }
source = [tool]
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
const advertised = yield* service.snapshot()
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
yield* TestClock.adjust("500 millis")
yield* Fiber.join(first)
const advertised = yield* service.snapshot()
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
tool.execute = constant("second").execute
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "second" })
yield* TestClock.adjust("500 millis")
yield* Fiber.join(second)
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "second" })
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
source = []
const removed = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
yield* TestClock.adjust("500 millis")
yield* Fiber.join(removed)
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
}),
)
@@ -370,7 +370,7 @@ describe("Tool", () => {
}),
)
it.effect("batches tool notifications with fresh snapshots and suppresses terminal teardown replay", () =>
it.effect("batches tool publication and suppresses terminal teardown replay", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const runs: string[] = []
@@ -386,8 +386,7 @@ describe("Tool", () => {
draft.add({ ...constant("overlay"), name: "echo", options: { codemode: false } })
})
expect(runs).toEqual([])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["echo", "execute"])
expect(runs).toEqual(["base", "overlay"])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}).pipe(Scope.provide(scope)),
)
@@ -546,32 +545,23 @@ describe("Tool", () => {
}),
)
it.effect("compiles healthy tools before notifying invalid definition diagnostics", () => {
it.effect("logs invalid tool definitions without dropping healthy tools", () => {
const output: unknown[] = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
output.push(entry.message)
})
return Effect.gen(function* () {
const service = yield* Tool.Service
const snapshot = yield* State.batch(
Effect.gen(function* () {
yield* service.transform((draft) => {
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
draft.add({
name: "phone_type",
input: Schema.Struct({}),
execute: () => Effect.succeed({ content: "ok" }),
options: { codemode: false },
} as unknown as Info)
draft.add({ ...make(), name: "codemode" })
})
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
expect(output).toEqual([])
return snapshot
}),
)
yield* service.transform((draft) => {
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
draft.add({
name: "phone_type",
input: Schema.Struct({}),
execute: () => Effect.succeed({ content: "ok" }),
options: { codemode: false },
} as unknown as Info)
draft.add({ ...make(), name: "codemode" })
})
expect(output).toEqual([
[
@@ -583,6 +573,9 @@ describe("Tool", () => {
},
],
])
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
expect((yield* snapshot.execute(call("phone_type")).pipe(Effect.flip)).message).toBe("Unknown tool: phone_type")
}).pipe(Effect.provide(Logger.layer([logger])))
})
+106 -387
View File
@@ -2,40 +2,35 @@ import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppProcess } from "@opencode-ai/util/process"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { State } from "@opencode-ai/core/state"
import { Vcs } from "@opencode-ai/core/vcs"
import { VcsGitPlugin } from "@opencode-ai/core/plugin/vcs/git"
import type { VcsDefinition, VcsDiffInput } from "@opencode-ai/plugin/effect/vcs"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { locationLayer } from "./fixture/location"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it, testEffect } from "./lib/effect"
import { it } from "./lib/effect"
import { host } from "./plugin/host"
const Done = Bus.ephemeral({ type: "test.vcs.done", schema: {} })
const synthetic = testEffect(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node]), [
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
]),
)
const provide = (directory: string, input: { git?: boolean } = {}) =>
Effect.provide(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node]), [
[
Location.node,
locationLayer(
{ directory: AbsolutePath.make(directory) },
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
),
),
),
],
]),
@@ -89,36 +84,40 @@ const provider = (input: Partial<VcsDefinition> = {}) =>
}) satisfies VcsDefinition
describe("Vcs", () => {
synthetic.effect("returns empty results outside version control", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
expect(yield* vcs.info()).toEqual({ branch: {} })
expect(yield* vcs.branches()).toEqual([])
expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([])
expect(yield* vcs.diff("branch")).toEqual([])
}),
it.live("returns empty results outside version control", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
expect(yield* vcs.info()).toEqual({ branch: {} })
expect(yield* vcs.branches()).toEqual([])
expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([])
expect(yield* vcs.diff("branch")).toEqual([])
}).pipe(provide(directory)),
),
)
synthetic.effect("serves scoped providers and restores the fallback after disposal", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const registration = yield* vcs.transform((draft) => {
draft.add(provider())
draft.default.set("custom")
})
it.live("serves scoped providers and restores the fallback after disposal", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const registration = yield* vcs.transform((draft) => {
draft.add(provider())
draft.default.set("custom")
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
expect(yield* vcs.branches()).toEqual(["feature", "main"])
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
expect(yield* vcs.diff("working")).toEqual([
{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" },
])
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
expect(yield* vcs.branches()).toEqual(["feature", "main"])
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
expect(yield* vcs.diff("working")).toEqual([
{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" },
])
yield* registration.dispose
expect(yield* vcs.info()).toEqual({ branch: {} })
expect(yield* vcs.status()).toEqual([])
}),
yield* registration.dispose
expect(yield* vcs.info()).toEqual({ branch: {} })
expect(yield* vcs.status()).toEqual([])
}).pipe(provide(directory)),
),
)
it.live("automatically selects a provider matching the resolved repository", () =>
@@ -134,360 +133,80 @@ describe("Vcs", () => {
),
)
synthetic.effect("passes location scope and bounded diff options to providers", () =>
Effect.gen(function* () {
const observed: VcsDiffInput[] = []
const vcs = yield* Vcs.Service
const location = yield* Location.Service
yield* vcs.transform((draft) => {
draft.add(
provider({
diff: (input) =>
Effect.sync(() => {
observed.push(input)
return [{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }]
}),
}),
)
draft.default.set("custom")
})
yield* vcs.diff("branch", { context: 3 })
expect(observed).toEqual([
{
directory: location.directory,
worktree: location.directory,
canonical: location.directory,
mode: "branch",
context: 3,
maxOutputBytes: 10_000_000,
},
])
}),
)
synthetic.effect("validates provider results and bounds oversized patches", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
yield* vcs.transform((draft) => {
draft.add(
provider({
status: () => Effect.succeed([{ file: "file.txt", additions: -1, deletions: 0, status: "added" }]),
diff: () =>
Effect.succeed([
{ file: "file.txt", patch: "x".repeat(10_000_001), additions: 1, deletions: 0, status: "added" },
]),
}),
)
draft.default.set("custom")
})
expect(yield* vcs.status()).toEqual([])
const rows = yield* vcs.diff("working")
expect(rows).toHaveLength(1)
expect(Buffer.byteLength(rows[0].patch)).toBeLessThan(1000)
expect(rows[0].additions).toBe(1)
}),
)
synthetic.effect("preserves provider interruption", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
let interrupt = false
yield* vcs.transform((draft) => {
draft.add(
provider({
info: () =>
interrupt ? Effect.interrupt : Effect.succeed({ branch: { current: "feature", default: "main" } }),
status: () => Effect.never,
}),
)
draft.default.set("custom")
})
const fiber = yield* Effect.forkChild(vcs.status())
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
interrupt = true
const reload = yield* vcs.reload().pipe(Effect.timeout("1 second"), Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("1 second")
const reloaded = yield* Fiber.await(reload)
expect(Exit.isFailure(reloaded) && Cause.hasInterruptsOnly(reloaded.cause)).toBeTrue()
}),
)
it.live("keeps watching HEAD changes after a transform replay failure", () =>
withGit((directory) =>
it.live("passes location scope and bounded diff options to providers", () =>
withTmp((directory) =>
Effect.gen(function* () {
const observed: VcsDiffInput[] = []
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
const replayed = yield* Deferred.make<void>()
const faulty = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(faulty, Exit.void))
let branch = "initial"
yield* vcs.transform((draft) =>
draft.add(provider({ id: "git", info: () => Effect.sync(() => ({ branch: { current: branch } })) })),
)
const failure = new Error("fixture replay failed")
let replays = 0
const failed = yield* vcs
.transform(() => {
if (++replays === 2) Deferred.doneUnsafe(replayed, Exit.void)
throw failure
})
.pipe(Scope.provide(faulty), Effect.exit)
expect(Exit.isFailure(failed) && Cause.squash(failed.cause)).toBe(failure)
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
yield* Deferred.await(replayed).pipe(Effect.timeout("1 second"))
yield* Effect.yieldNow
const status = yield* vcs.status().pipe(Effect.exit)
expect(Exit.isFailure(status) && Cause.squash(status.cause)).toBe(failure)
expect((yield* vcs.info()).branch.current).toBe("initial")
branch = "recovered"
yield* Scope.close(faulty, Exit.void)
expect((yield* vcs.info()).branch.current).toBe("recovered")
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.runHead, Effect.timeout("1 second"), Effect.forkScoped({ startImmediately: true }))
branch = "after-recovery"
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
const event = yield* Fiber.join(updated)
expect((yield* vcs.info()).branch.current).toBe("after-recovery")
expect(Option.getOrUndefined(event)).toMatchObject({ data: { branch: "after-recovery" } })
}),
),
)
it.effect("stops in-flight and queued reloads when its layer closes", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const root = yield* Scope.make()
yield* Effect.addFinalizer(() =>
Deferred.succeed(release, undefined).pipe(
Effect.andThen(State.batch(Scope.close(root, Exit.void), { flush: false })),
Effect.andThen(TestClock.adjust("500 millis")),
),
)
const context = yield* Layer.buildWithScope(
LayerNode.compile(Vcs.node, [
[Bus.node, Layer.succeed(Bus.Service, bus)],
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
]),
root,
)
const vcs = Context.get(context, Vcs.Service)
const reads: string[] = []
const observed: (string | undefined)[] = []
yield* Effect.acquireRelease(
bus.listen((event) =>
Effect.sync(() => {
if (event.type !== VcsEvent.BranchUpdated.type) return
observed.push(Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch)
}),
),
(unsubscribe) => unsubscribe,
)
let branch = "initial"
let block = false
yield* vcs
.transform((draft) => {
draft.add(
provider({
info: () =>
Effect.gen(function* () {
const value = branch
reads.push(value)
if (block) {
block = false
yield* Deferred.succeed(entered, undefined)
yield* Deferred.await(release)
}
return { branch: { current: value } }
}),
}),
)
draft.default.set("custom")
})
.pipe(Scope.provide(root))
observed.length = 0
block = true
const first = yield* vcs.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Deferred.await(entered).pipe(Effect.timeout("1 second"), TestClock.withLive)
branch = "late"
const second = yield* vcs.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Effect.yieldNow
expect(reads).toEqual(["initial", "initial"])
expect(first.pollUnsafe()).toBeUndefined()
expect(second.pollUnsafe()).toBeUndefined()
const snapshot = yield* vcs.info()
const shutdown = yield* State.batch(Scope.close(root, Exit.void), { flush: false }).pipe(
Effect.forkChild({ startImmediately: true }),
)
yield* TestClock.adjust("1 millis")
expect(shutdown.pollUnsafe()).toBeDefined()
expect(first.pollUnsafe()).toBeDefined()
expect(second.pollUnsafe()).toBeDefined()
expect(yield* Deferred.isDone(release)).toBe(false)
yield* Fiber.join(shutdown)
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(reads).toEqual(["initial", "initial"])
expect(observed).toEqual([])
expect(yield* vcs.info()).toBe(snapshot)
}).pipe(Effect.provide(LayerNode.compile(Bus.node))),
)
it.live("serializes filesystem and config refreshes while reading the latest desired provider", () =>
withGit((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const accepted = yield* Deferred.make<void>()
const reads: string[] = []
yield* vcs.transform((draft) =>
draft.add(
provider({
id: "git",
info: () =>
Effect.gen(function* () {
reads.push(reads.length === 0 ? "initial" : "filesystem")
if (reads.length === 1) return { branch: { current: "initial" } }
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
return { branch: { current: "filesystem" } }
}),
}),
),
)
const updates = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(2), Stream.runLast, Effect.forkScoped({ startImmediately: true }))
yield* Effect.gen(function* () {
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
yield* Deferred.await(started)
const configured = yield* State.batch(
Effect.gen(function* () {
yield* vcs.transform((draft) =>
draft.add(
provider({
id: "git",
info: () =>
Effect.sync(() => {
reads.push("config")
return { branch: { current: "config" } }
}),
status: () => Effect.succeed([{ file: "config.txt", additions: 1, deletions: 0, status: "added" }]),
}),
),
)
expect((yield* vcs.status())[0]?.file).toBe("config.txt")
expect(yield* vcs.info()).toEqual({ branch: { current: "initial" } })
yield* Deferred.succeed(accepted, undefined)
}),
).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(accepted)
expect(reads).toEqual(["initial", "filesystem"])
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(configured)
expect(Option.getOrUndefined(yield* Fiber.join(updates))?.data.branch).toBe("config")
expect(yield* vcs.info()).toEqual({ branch: { current: "config" } })
expect(reads).toEqual(["initial", "filesystem", "config"])
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined)))
}),
),
)
synthetic.effect("keeps branch streams current when listeners change the selected provider", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
const scope = yield* Effect.scope
const updates = yield* bus.subscribe([VcsEvent.BranchUpdated, Done]).pipe(
Stream.takeUntil((event) => event.type === Done.type),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
)
const unsubscribe = yield* bus.listen((event) => {
if (
event.type !== VcsEvent.BranchUpdated.type ||
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch !== "feature"
)
return Effect.void
return vcs
.transform((draft) =>
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "listener" } }) })),
)
.pipe(Scope.provide(scope), Effect.asVoid)
})
yield* Effect.gen(function* () {
yield* vcs.transform((draft) => {
draft.add(provider())
draft.add(
provider({
diff: (input) =>
Effect.sync(() => {
observed.push(input)
return [{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }]
}),
}),
)
draft.default.set("custom")
})
yield* bus.publish(Done, {})
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
expect(yield* vcs.info()).toEqual({ branch: { current: "listener" } })
expect(events.length).toBeGreaterThanOrEqual(2)
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
}).pipe(Effect.ensuring(unsubscribe))
}),
yield* vcs.diff("branch", { context: 3 })
expect(observed).toEqual([
{
directory,
worktree: directory,
canonical: directory,
mode: "branch",
context: 3,
maxOutputBytes: 10_000_000,
},
])
}).pipe(provide(directory)),
),
)
synthetic.effect("does not roll back branch streams when an older listener finishes late", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const updates = yield* bus.subscribe([VcsEvent.BranchUpdated, Done]).pipe(
Stream.takeUntil((event) => event.type === Done.type),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
)
const unsubscribe = yield* bus.listen((event) =>
event.type === VcsEvent.BranchUpdated.type &&
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch === "older"
? Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release)))
: Effect.void,
)
it.live("validates provider results and bounds oversized patches", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
yield* vcs.transform((draft) => {
draft.add(
provider({
status: () => Effect.succeed([{ file: "file.txt", additions: -1, deletions: 0, status: "added" }]),
diff: () =>
Effect.succeed([
{ file: "file.txt", patch: "x".repeat(10_000_001), additions: 1, deletions: 0, status: "added" },
]),
}),
)
draft.default.set("custom")
})
yield* Effect.gen(function* () {
const older = yield* vcs
.transform((draft) => {
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "older" } }) }))
draft.default.set("custom")
})
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(entered)
yield* vcs.transform((draft) =>
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "newer" } }) })),
)
expect(older.pollUnsafe()).toBeUndefined()
expect((yield* vcs.info()).branch.current).toBe("newer")
expect(yield* vcs.status()).toEqual([])
const rows = yield* vcs.diff("working")
expect(rows).toHaveLength(1)
expect(Buffer.byteLength(rows[0].patch)).toBeLessThan(1000)
expect(rows[0].additions).toBe(1)
}).pipe(provide(directory)),
),
)
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(older)
yield* bus.publish(Done, {})
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
expect(events.length).toBeGreaterThanOrEqual(2)
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined).pipe(Effect.andThen(unsubscribe))))
}),
it.live("preserves provider interruption", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
yield* vcs.transform((draft) => {
draft.add(provider({ status: () => Effect.never }))
draft.default.set("custom")
})
const fiber = yield* Effect.forkChild(vcs.status())
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
}).pipe(provide(directory)),
),
)
it.live("lists local branches by recent activity", () =>
-4
View File
@@ -101,10 +101,6 @@ await ctx.session.hook("context", (event) => {
event.tools.read.description = "Read a file using narrow line ranges."
delete event.tools.write
})
await ctx.session.hook("retry", (event) => {
if (event.attempt >= 3) event.decision = { retry: false }
})
```
Promise tools use complete executable tool values with async executors:
-7
View File
@@ -98,13 +98,6 @@ yield *
delete event.tools.write
}),
)
yield *
ctx.session.hook("retry", (event) =>
Effect.sync(() => {
if (event.attempt >= 3) event.decision = { retry: false }
}),
)
```
## Reloading A Domain
-13
View File
@@ -5,7 +5,6 @@ import type { Model } from "@opencode-ai/schema/model"
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import type { SessionError } from "@opencode-ai/schema/session-error"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { JsonSchema, Types } from "effect"
import type { ModelHooks } from "./registration.js"
@@ -53,24 +52,12 @@ export interface SessionHttpResponse {
response: Response
}
export type SessionRetryDecision = { retry: false } | { retry: true; delay: number }
export interface SessionRetry {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly error: SessionError.Error
readonly attempt: number
decision: SessionRetryDecision
}
export interface SessionHooks {
readonly prompt: SessionPrompt
readonly context: SessionContext
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
readonly retry: SessionRetry
}
export type SessionDomain = Pick<
+2 -2
View File
@@ -1,5 +1,5 @@
import type { WebSearch } from "@opencode-ai/schema/websearch"
import type { WebSearchApi } from "@opencode-ai/client/effect/api"
import type { WebsearchApi } from "@opencode-ai/client/effect/api"
import type { Effect } from "effect"
import type { Transform } from "./registration.js"
@@ -9,7 +9,7 @@ export interface WebSearchDefinition {
readonly execute: (input: WebSearch.ProviderInput) => Effect.Effect<readonly WebSearch.Result[], unknown>
}
export interface WebSearchDomain extends WebSearchApi<unknown> {
export interface WebSearchDomain extends WebsearchApi<unknown> {
readonly transform: Transform<WebSearchDraft>
readonly reload: () => Effect.Effect<void>
}
-13
View File
@@ -5,7 +5,6 @@ import type { Model } from "@opencode-ai/schema/model"
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import type { SessionError } from "@opencode-ai/schema/session-error"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { JsonSchema, Types } from "effect"
import type { ModelHooks } from "./registration.js"
@@ -53,24 +52,12 @@ export interface SessionHttpResponse {
response: Response
}
export type SessionRetryDecision = { retry: false } | { retry: true; delay: number }
export interface SessionRetry {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly error: SessionError.Error
readonly attempt: number
decision: SessionRetryDecision
}
export interface SessionHooks {
readonly prompt: SessionPrompt
readonly context: SessionContext
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
readonly retry: SessionRetry
}
export type SessionDomain = Pick<
+9 -7
View File
@@ -55,13 +55,15 @@ export const makeFormGroup = <
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.Array(Form.Info) }),
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.form.list",
summary: "List session forms",
description: "Retrieve pending forms for a session.",
}),
),
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.form.list",
summary: "List session forms",
description: "Retrieve pending forms for a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.form.create", "/api/session/:sessionID/form", {
+9 -7
View File
@@ -90,13 +90,15 @@ export const makePermissionGroup = <
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(Permission.Request) }),
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.list",
summary: "List session permission requests",
description: "Retrieve pending permission requests owned by a session.",
}),
),
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.list",
summary: "List session permission requests",
description: "Retrieve pending permission requests owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.get("session.permission.get", "/api/session/:sessionID/permission/:requestID", {
+5 -16
View File
@@ -1,6 +1,4 @@
import { Database } from "@opencode-ai/core/database/database"
import { Form } from "@opencode-ai/core/form"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import {
ConflictError,
FormAlreadySettledError,
@@ -8,10 +6,10 @@ import {
FormNotFoundError,
InvalidRequestError,
} from "@opencode-ai/protocol/errors"
import { Effect, Option } from "effect"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { requestRef, response, sessionRef, withLoadedLocationServices } from "../location"
import { response } from "../location"
function missingForm(id: Form.ID) {
return new FormNotFoundError({ id, message: `Form not found: ${id}` })
@@ -19,8 +17,6 @@ function missingForm(id: Form.ID) {
export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const database = yield* Database.Service
const requireOwnedForm = Effect.fnUntraced(function* (sessionID: Form.Info["sessionID"], formID: Form.ID) {
const form = yield* Form.Service
const info = yield* form.get(formID).pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(formID)))
@@ -39,16 +35,9 @@ export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =
.handle(
"session.form.list",
Effect.fn(function* (ctx) {
const ref =
ctx.params.sessionID === "global"
? requestRef(ctx.request)
: yield* sessionRef(database, ctx.params.sessionID)
const forms = yield* withLoadedLocationServices(
locations,
ref,
Form.Service.use((form) => form.list({ sessionID: ctx.params.sessionID })),
)
return { data: Option.getOrElse(forms, () => []) }
const form = yield* Form.Service
const forms = yield* form.list({ sessionID: ctx.params.sessionID })
return { data: forms }
}),
)
.handle(
+4 -13
View File
@@ -1,13 +1,11 @@
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Permission } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Effect, Option } from "effect"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { PermissionNotFoundError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { response, sessionRef, withLoadedLocationServices } from "../location"
import { response } from "../location"
function missingRequest(id: Permission.ID) {
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
@@ -15,8 +13,6 @@ function missingRequest(id: Permission.ID) {
export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", (handlers) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const database = yield* Database.Service
const requireOwnedRequest = Effect.fnUntraced(function* (
sessionID: Permission.Request["sessionID"],
requestID: Permission.ID,
@@ -67,13 +63,8 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
.handle(
"session.permission.list",
Effect.fn(function* (ctx) {
const ref = yield* sessionRef(database, ctx.params.sessionID)
const requests = yield* withLoadedLocationServices(
locations,
ref,
Permission.Service.use((permission) => permission.forSession(ctx.params.sessionID)),
)
return { data: Option.getOrElse(requests, () => []) }
const permission = yield* Permission.Service
return { data: yield* permission.forSession(ctx.params.sessionID) }
}),
)
.handle(
+1 -41
View File
@@ -1,13 +1,8 @@
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Workspace } from "@opencode-ai/core/workspace"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { Effect, Layer } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
@@ -31,41 +26,6 @@ export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
})
}
const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
export function sessionRef(database: Context.Service.Shape<typeof Database.Service>, sessionID: unknown) {
return Effect.gen(function* () {
const id = yield* decodeSessionID(sessionID).pipe(
Effect.mapError(() => new InvalidRequestError({ message: "Invalid session ID", field: "sessionID" })),
)
const row = yield* database.db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, id))
.get()
.pipe(Effect.orDie)
if (!row) return yield* new SessionNotFoundError({ sessionID: id, message: `Session not found: ${id}` })
return Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
})
})
}
export function withLoadedLocationServices<A, E>(
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
ref: Location.Ref,
effect: Effect.Effect<A, E, LocationServices>,
) {
return Effect.scoped(
Effect.gen(function* () {
const context = yield* locations.contextEffectOption(ref)
if (Option.isNone(context)) return Option.none<A>()
return Option.some(yield* effect.pipe(Effect.provide(context.value)))
}),
)
}
export function requestRef(request: HttpServerRequest.HttpServerRequest): Location.Ref {
const query = new URL(request.url, "http://localhost").searchParams
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
@@ -1,10 +1,16 @@
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Workspace } from "@opencode-ai/core/workspace"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { Effect, Layer } from "effect"
import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter, HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { requestRef, sessionRef, type LocationServices } from "../location"
import { requestRef, type LocationServices } from "../location"
export class FormLocationMiddleware extends HttpApiMiddleware.Service<
FormLocationMiddleware,
@@ -13,10 +19,12 @@ export class FormLocationMiddleware extends HttpApiMiddleware.Service<
error: [InvalidRequestError, SessionNotFoundError],
}) {}
const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
export const formLocationLayer = Layer.effect(
FormLocationMiddleware,
Effect.gen(function* () {
const database = yield* Database.Service
const { db } = yield* Database.Service
const locations = yield* LocationServiceMap.Service
return FormLocationMiddleware.of((effect) =>
@@ -30,8 +38,38 @@ export const formLocationLayer = Layer.effect(
return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))))
}
const ref = yield* sessionRef(database, route.params.sessionID)
return yield* effect.pipe(Effect.provide(locations.get(ref)))
const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
Effect.mapError(
() =>
new InvalidRequestError({
message: "Invalid session ID",
field: "sessionID",
}),
),
)
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row) {
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
})
}
return yield* effect.pipe(
Effect.provide(
locations.get(
Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
}),
),
),
)
}),
)
}),
@@ -1,10 +1,16 @@
import { Database } from "@opencode-ai/core/database/database"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Effect, Layer } from "effect"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Workspace } from "@opencode-ai/core/workspace"
import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { sessionRef, type LocationServices } from "../location"
import type { LocationServices } from "../location"
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
SessionLocationMiddleware,
@@ -13,17 +19,48 @@ export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
error: [InvalidRequestError, SessionNotFoundError],
}) {}
const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
export const sessionLocationLayer = Layer.effect(
SessionLocationMiddleware,
Effect.gen(function* () {
const database = yield* Database.Service
const { db } = yield* Database.Service
const locations = yield* LocationServiceMap.Service
return SessionLocationMiddleware.of((effect) =>
Effect.gen(function* () {
const route = yield* HttpRouter.RouteContext
const ref = yield* sessionRef(database, route.params.sessionID)
return yield* effect.pipe(Effect.provide(locations.get(ref)))
const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
Effect.mapError(
() =>
new InvalidRequestError({
message: "Invalid session ID",
field: "sessionID",
}),
),
)
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row)
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
})
return yield* effect.pipe(
Effect.provide(
locations.get(
Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
}),
),
),
)
}),
)
}),
+2 -64
View File
@@ -1,27 +1,14 @@
import { describe, expect, test } from "bun:test"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Vcs } from "@opencode-ai/core/vcs"
import { Credential } from "@opencode-ai/schema/credential"
import { Event } from "@opencode-ai/schema/event"
import { IntegrationID } from "@opencode-ai/schema/integration-id"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Deferred, Effect, Exit, Fiber, Option, Schema, Scope, Stream } from "effect"
import { locationLayer } from "../../core/test/fixture/location"
import { it, testEffect } from "../../core/test/lib/effect"
import { Deferred, Effect, Exit, Fiber, Option, Schema, Stream } from "effect"
import { it } from "../../core/test/lib/effect"
import { EventFeed } from "../src/event-feed"
const Internal = Bus.ephemeral({ type: "test.internal", schema: { value: Schema.String } })
const vcsIt = testEffect(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
[Database.node, Database.configured({ path: ":memory:" })],
]),
)
const event = (id: string): Event.Payload<typeof Agent.Event.Updated> => ({
id: Event.ID.make(`evt_${id}`),
@@ -57,55 +44,6 @@ describe("EventFeed", () => {
expect(EventFeed.frame(payload)).toBe(`data: ${JSON.stringify(payload)}\n\n`)
})
vcsIt.effect("delivers the latest VCS branch after an earlier legacy listener reenters", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const vcs = yield* Vcs.Service
const scope = yield* Scope.Scope
const provider = {
id: "fixture",
name: "Fixture",
info: () => Effect.succeed({ branch: { current: "outer" } }),
branches: () => Effect.succeed([]),
status: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
}
const unsubscribe = yield* bus.listen((event) =>
event.type === VcsEvent.BranchUpdated.type &&
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch === "outer"
? vcs
.transform((draft) =>
draft.add({ ...provider, info: () => Effect.succeed({ branch: { current: "inner" } }) }),
)
.pipe(Scope.provide(scope), Effect.asVoid)
: Effect.void,
)
// Unsubscribe before registration teardown can restore "outer" and reenter the listener.
yield* Effect.gen(function* () {
const feed = yield* EventFeed.make(bus.listen, {
encode: (event) => (event.type === VcsEvent.BranchUpdated.type ? (event.data.branch ?? "none") : event.type),
})
const stream = yield* feed.subscribe
const received = yield* stream.pipe(
Stream.takeUntil((frame) => frame === Agent.Event.Updated.type, { excludeLast: true }),
Stream.runLast,
Effect.forkScoped({ startImmediately: true }),
)
yield* vcs.transform((draft) => {
draft.add(provider)
draft.default.set(provider.id)
})
yield* unsubscribe
yield* bus.publish(Agent.Event.Updated, {})
const info = yield* vcs.info()
expect(info.branch.current).toBe("inner")
expect(Option.getOrUndefined(yield* Fiber.join(received))).toBe(info.branch.current)
}).pipe(Effect.ensuring(unsubscribe))
}),
)
it.effect("encodes once and delivers the same frame to every subscriber", () =>
Effect.gen(function* () {
let encodes = 0
-124
View File
@@ -4,7 +4,6 @@ import { makeMemoryDriver } from "@opencode-ai/core/environment/index"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { Effect } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
@@ -277,129 +276,6 @@ it.live("serves the session view operation and missing-session error", () =>
}),
)
it.live("does not load a location when reading pending session requests", () =>
Effect.gen(function* () {
const config = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-pending-read-")))
const handler = yield* ServerFetch.make({
...options,
config: {
directory: config.path,
project: false,
content: JSON.stringify({ permissions: [{ action: "shell", resource: "*", effect: "ask" }] }),
},
})
const created = (yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}),
).then((response) => response.json()),
)) as { data: { id: string } }
const loaded = () =>
Effect.promise(() =>
handler(new Request("http://opencode.local/api/debug/location")).then(
(response) => response.json() as Promise<unknown[]>,
),
)
expect(yield* loaded()).toEqual([])
for (const resource of ["permission", "form"]) {
const response = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/${created.data.id}/${resource}`)),
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toEqual({ data: [] })
const missing = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/ses_missing_pending/${resource}`)),
)
expect(missing.status).toBe(404)
}
const global = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session/global/form", {
headers: { "x-opencode-directory": encodeURIComponent(process.cwd()) },
}),
),
)
expect(global.status).toBe(200)
expect(yield* Effect.promise(() => global.json())).toEqual({ data: [] })
expect(yield* loaded()).toEqual([])
const createdForm = yield* Effect.promise(() =>
handler(
new Request(`http://opencode.local/api/session/${created.data.id}/form`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "Test form", fields: [{ key: "answer", type: "string" }] }),
}),
),
)
expect(createdForm.status).toBe(200)
const forms = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/${created.data.id}/form`)),
)
expect(forms.status).toBe(200)
expect(yield* Effect.promise(() => forms.json())).toMatchObject({
data: [{ title: "Test form" }],
})
expect(yield* loaded()).toHaveLength(1)
const globalForm = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session/global/form", {
method: "POST",
headers: {
"content-type": "application/json",
"x-opencode-directory": encodeURIComponent(process.cwd()),
},
body: JSON.stringify({ title: "Global form", fields: [{ key: "answer", type: "string" }] }),
}),
),
)
expect(globalForm.status).toBe(200)
const globalForms = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session/global/form", {
headers: { "x-opencode-directory": encodeURIComponent(process.cwd()) },
}),
),
)
expect(globalForms.status).toBe(200)
expect(yield* Effect.promise(() => globalForms.json())).toMatchObject({ data: [{ title: "Global form" }] })
// Agent permission policy is installed by plugin activation.
expect((yield* ready(handler)).status).toBe(200)
const createdPermission = yield* Effect.promise(() =>
handler(
new Request(`http://opencode.local/api/session/${created.data.id}/permission`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id: "per_pending_read", action: "shell", resources: ["pwd"] }),
}),
),
)
expect(createdPermission.status).toBe(200)
expect(yield* Effect.promise(() => createdPermission.json())).toEqual({
data: { id: "per_pending_read", effect: "ask" },
})
const permissions = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/${created.data.id}/permission`)),
)
expect(permissions.status).toBe(200)
expect(yield* Effect.promise(() => permissions.json())).toMatchObject({
data: [{ id: "per_pending_read", sessionID: created.data.id, action: "shell", resources: ["pwd"] }],
})
expect(yield* loaded()).toHaveLength(1)
}),
)
// Pins the eager-boot guarantee: the application layer is built before the handler returns, so
// an aborted first request cannot interrupt layer construction and wedge every later request
// (the Effect-TS/effect#6319 failure class that lazy first-request builds are prone to).
+4 -3
View File
@@ -1,4 +1,4 @@
import { isArrayNonEmpty } from "effect/Array"
import type { NonEmptyReadonlyArray } from "effect/Array"
import * as NodeSink from "@effect/platform-node/NodeSink"
import * as NodeStream from "@effect/platform-node/NodeStream"
import { Deferred, Effect, Exit, FileSystem, Layer, Path, PlatformError, Predicate, Sink, Stream } from "effect"
@@ -60,9 +60,10 @@ const flatten = (command: ChildProcess.Command) => {
}
walk(command)
if (!isArrayNonEmpty(commands)) throw new Error("flatten produced empty commands array")
if (commands.length === 0) throw new Error("flatten produced empty commands array")
const [head, ...tail] = commands
return {
commands,
commands: [head, ...tail] as NonEmptyReadonlyArray<ChildProcess.StandardCommand>,
opts,
}
}
+3 -14
View File
@@ -220,22 +220,11 @@ const layer = Layer.effect(
const built: Stream.Stream<string, AppProcessError | PlatformError> = Stream.unwrap(
Effect.gen(function* () {
const handle = yield* spawner.spawn(command)
const streams =
options?.includeStderr === true
? yield* handle.stderr.pipe(
Stream.broadcastN({ n: 2, capacity: 16 }),
Effect.map((copies) => ({
source: Stream.merge(handle.stdout, copies[0]),
diagnostics: copies[1],
})),
)
: { source: handle.stdout, diagnostics: handle.stderr }
const stderrFiber = yield* Effect.forkScoped(
collectStream(streams.diagnostics, options?.maxErrorBytes).pipe(
Effect.map((x) => x.buffer.toString("utf8")),
),
collectStream(handle.stderr, options?.maxErrorBytes).pipe(Effect.map((x) => x.buffer.toString("utf8"))),
)
const lines = streams.source.pipe(
const source = options?.includeStderr === true ? handle.all : handle.stdout
const lines = source.pipe(
Stream.decodeText,
Stream.splitLines,
Stream.filter((line) => line.length > 0),
@@ -1128,35 +1128,6 @@ effect: (ctx) =>
}),
```
Override the retry decision for a provider failure or replace its delay in milliseconds. The hook runs after OpenCode
classifies the failure and proposes its policy, but before any retry is scheduled. It does not expose how OpenCode
internally performs the next attempt.
```ts
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.session.hook("retry", (event) =>
Effect.sync(() => {
if (event.error.status === 429) {
event.decision = { retry: true, delay: 10_000 }
return
}
if (event.error.type === "provider.invalid-request" && event.attempt === 2) {
event.decision = { retry: true, delay: 0 }
return
}
if (event.attempt >= 3) event.decision = { retry: false }
}),
)
}),
```
The initial `decision` is OpenCode's policy, so hooks may make a normally terminal provider failure retryable or veto a
proposed retry. Multiple hooks run in registration order and later hooks see the current decision. The built-in maximum
attempt count remains a hard limit. `attempt` is the physical attempt being proposed; the initial request is attempt `1`,
so the first retry is attempt `2`. Invalid delays (`NaN`, infinity, or negative values) fall back to the computed delay.
Context-overflow recovery remains separate because it compacts the conversation instead of retrying the same request.
#### Reference
```ts
@@ -1165,18 +1136,6 @@ interface SessionHooks {
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
readonly retry: SessionRetry
}
type RetryDecision = { retry: false } | { retry: true; delay: number }
interface SessionRetry {
readonly sessionID: string
readonly agent: string
readonly model: { providerID: string; id: string; variant?: string }
readonly error: { type: string; message: string; status?: number }
readonly attempt: number
decision: RetryDecision
}
interface SessionHookDomain {
@@ -95,10 +95,10 @@ Pass plugin options with the object form in `opencode.json(c)`.
{
"package": "./plugins/company.ts",
"options": {
"strict": true,
},
},
],
"strict": true
}
}
]
}
```
@@ -120,10 +120,6 @@ export default Plugin.define({
Transforms are a central pattern in the plugin API and modify how OpenCode works. Plugins register
transforms and each builds on the changes made before it.
Transform callbacks are synchronous and should only edit their draft. Load external data before registering or
reloading a transform. Registry reads replay pending changes when needed, so definitions registered earlier in
setup are readable without waiting for all plugins to finish setup. Update notifications are batched separately.
Say we have a plugin that adds one model to the catalog.
```ts title="plugins/models.ts"
@@ -163,7 +159,8 @@ export default Plugin.define({
})
```
Now say the first plugin fetches its model list from a dynamic source. It can call `reload` when that list changes.
Now say the first plugin dynamically fetches can fetch its model list from a
dynamic source. It can call `reload` when that list changes.
```ts title="plugins/models.ts"
import { Plugin } from "@opencode-ai/plugin"
@@ -193,12 +190,7 @@ export default Plugin.define({
})
```
`reload` invalidates the catalog without changing transform order, so the output-price policy still filters the
refreshed models. Reads replay those changes immediately rather than waiting for the update notification's
500 ms debounce. If nothing reads the catalog, it is rebuilt before the notification instead.
Reading definitions does not start or await background resource work. MCP connections, Git reference checkouts,
and cached VCS information retain their own lifecycle and readiness behavior.
`reload` replays every catalog transform in order, so the output-price policy still filters the refreshed models.
## API
@@ -476,23 +468,14 @@ interface IntegrationContext {
key(input: IntegrationConnectKeyInput, requestOptions?: RequestOptions): Promise<void>
}
oauth: {
connect(
input: IntegrationOauthConnectInput,
requestOptions?: RequestOptions,
): Promise<IntegrationOauthConnectOutput>
connect(input: IntegrationOauthConnectInput, requestOptions?: RequestOptions): Promise<IntegrationOauthConnectOutput>
status(input: IntegrationOauthStatusInput, requestOptions?: RequestOptions): Promise<IntegrationOauthStatusOutput>
complete(input: IntegrationOauthCompleteInput, requestOptions?: RequestOptions): Promise<void>
cancel(input: IntegrationOauthCancelInput, requestOptions?: RequestOptions): Promise<void>
}
command: {
connect(
input: IntegrationCommandConnectInput,
requestOptions?: RequestOptions,
): Promise<IntegrationCommandConnectOutput>
status(
input: IntegrationCommandStatusInput,
requestOptions?: RequestOptions,
): Promise<IntegrationCommandStatusOutput>
connect(input: IntegrationCommandConnectInput, requestOptions?: RequestOptions): Promise<IntegrationCommandConnectOutput>
status(input: IntegrationCommandStatusInput, requestOptions?: RequestOptions): Promise<IntegrationCommandStatusOutput>
cancel(input: IntegrationCommandCancelInput, requestOptions?: RequestOptions): Promise<void>
}
transform(callback: (draft: IntegrationDraft) => void): Promise<Registration>
@@ -1121,30 +1104,6 @@ await ctx.session.hook("http.response", (event) => {
})
```
Override the retry decision for a provider failure or replace its delay in milliseconds. The hook runs after OpenCode
classifies the failure and proposes its policy, but before any retry is scheduled. It does not expose how OpenCode
internally performs the next attempt.
```ts
await ctx.session.hook("retry", (event) => {
if (event.error.status === 429) {
event.decision = { retry: true, delay: 10_000 }
return
}
if (event.error.type === "provider.invalid-request" && event.attempt === 2) {
event.decision = { retry: true, delay: 0 }
return
}
if (event.attempt >= 3) event.decision = { retry: false }
})
```
The initial `decision` is OpenCode's policy, so hooks may make a normally terminal provider failure retryable or veto a
proposed retry. Multiple hooks run in registration order and later hooks see the current decision. The built-in maximum
attempt count remains a hard limit. `attempt` is the physical attempt being proposed; the initial request is attempt `1`,
so the first retry is attempt `2`. Invalid delays (`NaN`, infinity, or negative values) fall back to the computed delay.
Context-overflow recovery remains separate because it compacts the conversation instead of retrying the same request.
#### Reference
```ts
@@ -1156,18 +1115,6 @@ interface SessionHooks {
"model.request": SessionModelRequestHook
"http.request": SessionHttpRequestHook
"http.response": SessionHttpResponseHook
retry: SessionRetryHook
}
type RetryDecision = { retry: false } | { retry: true; delay: number }
interface SessionRetryHook {
readonly sessionID: string
readonly agent: string
readonly model: { providerID: string; id: string; variant?: string }
readonly error: { type: string; message: string; status?: number }
readonly attempt: number
decision: RetryDecision
}
interface SessionContextHook {
@@ -1254,7 +1201,10 @@ await ctx.shell.hook("create.before", (event) => {
```ts
interface ShellHookContext {
hook(name: "create.before", callback: (event: ShellCreateBefore) => Promise<void> | void): Promise<Registration>
hook(
name: "create.before",
callback: (event: ShellCreateBefore) => Promise<void> | void,
): Promise<Registration>
}
interface ShellCreateBefore {