mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 04:56:20 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e9189d330 | ||
|
|
7a0b4299e5 |
@@ -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,
|
||||
|
||||
Vendored
-2
@@ -1,7 +1,5 @@
|
||||
/// <reference types="@solidjs/start/env" />
|
||||
|
||||
import "@solidjs/start"
|
||||
|
||||
export declare module "@solidjs/start/server" {
|
||||
export type APIEvent = { request: Request }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
@@ -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, {})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()))),
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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 []
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ const layer = Layer.effect(
|
||||
draft.tools.delete(id)
|
||||
},
|
||||
}),
|
||||
notify: () =>
|
||||
finalize: () =>
|
||||
Effect.forEach(
|
||||
state.get().errors,
|
||||
({ tool, error }) =>
|
||||
|
||||
@@ -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
@@ -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 }),
|
||||
)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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)),
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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", () =>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user