Compare commits

...
Author SHA1 Message Date
Kit Langton 7e9189d330 refactor(core): use tagged constructors and exhaustive matching 2026-08-28 16:40:03 -04:00
Kit Langton 7a0b4299e5 refactor(core): unify logical step orchestration in a state machine 2026-08-28 16:25:10 -04:00
Kit Langton 964245bc2a test(core): isolate project adoption fixture (#46009) 2026-08-28 16:18:36 -04:00
Kit Langton aea3e7c1d2 fix(core): refresh plugins in background (#45993) 2026-08-28 19:43:12 +00:00
Kit Langton 3625942952 feat(core): pass session context to MCP tools (#46008) 2026-08-28 15:43:06 -04:00
Kit Langton 0593a6b8eb fix(core): isolate models seed replay (#45686) 2026-08-28 15:26:22 -04:00
Kit Langton 426e5c6389 fix(tui): open recent picker before server reads (#45977)
Open the recent-session and project picker synchronously with selectable cached rows and independent refreshes. Reconcile committed moves and deletions without restoring stale rows, preserve dismissal and selection through delayed reads, and keep filtered selections visible after asynchronous results arrive.
2026-08-28 15:18:24 -04:00
Kit Langton ebdfcf4866 test(tui): remove brittle animation sampling (#46003) 2026-08-28 19:16:47 +00:00
Kit Langton 000d0882c3 docs(core): record adapter fork boundaries (#45691) 2026-08-28 15:14:46 -04:00
Kit Langton 6062e30cb9 test(core): assert typed failures directly (#45690) 2026-08-28 15:14:42 -04:00
Kit Langton 3badee1a3c test(core): simplify config test setup (#45689) 2026-08-28 15:14:39 -04:00
Kit Langton 4a0256d374 test(core): use effect search harness (#45682) 2026-08-28 15:14:36 -04:00
Kit Langton 52ec62bef0 docs: correct config discovery boundary (#45681) 2026-08-28 15:14:32 -04:00
Kit Langton 31af9858fd test(core): assert pending prompt identity (#45680) 2026-08-28 15:14:28 -04:00
Kit Langton 3151660fbb refactor(core): name shell records as commands (#45693) 2026-08-28 15:00:37 -04:00
Kit Langton 0362ef48ff test(core): isolate transport metrics (#45688) 2026-08-28 15:00:33 -04:00
Kit Langton facd7ff452 refactor(core): simplify V1 migration effects (#45685) 2026-08-28 15:00:29 -04:00
Kit Langton 134cdda333 fix(core): normalize SDK file data (#45679) 2026-08-28 14:56:46 -04:00
Kit Langton 5634ef1bb6 refactor(core): simplify manual compaction (#45678) 2026-08-28 14:56:40 -04:00
Kit Langton 2379ab3d51 fix(core): defer memory filesystem observation (#45675) 2026-08-28 14:56:36 -04:00
Kit Langton 5c908ebba5 refactor(core): simplify reference config precedence (#45673) 2026-08-28 14:56:31 -04:00
Kit Langton ba0755d933 refactor(core): reuse platform contract types (#45666) 2026-08-28 14:56:27 -04:00
Kit Langton f7d6b00c1e test(core): use collected arrays directly (#45665) 2026-08-28 14:56:21 -04:00
61 changed files with 4227 additions and 1426 deletions
+12 -4
View File
@@ -482,8 +482,7 @@ function toolMessage(input: LLMRequest["messages"][number]) {
const value = part.result.value.filter((item) => {
if (item.type !== "file") return true
if (!item.mime.startsWith("image/") && item.mime !== "application/pdf") return true
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1] ?? item.uri
media.push({ type: "file", mediaType: item.mime, data, filename: item.name })
media.push({ type: "file", mediaType: item.mime, data: fileData(item.uri), filename: item.name })
return false
})
return toolResultPart({
@@ -507,7 +506,7 @@ function text(part: ContentPart) {
function userPart(part: ContentPart): UserContent {
if (part.type === "text") return [{ type: "text", text: part.text }]
if (part.type === "media")
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
return []
}
@@ -516,7 +515,7 @@ function assistantPart(part: ContentPart): AssistantContent {
case "text":
return [{ type: "text", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
case "media":
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
case "reasoning":
return [{ type: "reasoning", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
case "tool-call":
@@ -535,6 +534,15 @@ function assistantPart(part: ContentPart): AssistantContent {
}
}
function fileData(data: Extract<ContentPart, { type: "media" }>["data"]) {
if (typeof data !== "string") return data
const base64 = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(data)?.[1]
if (base64 !== undefined) return base64
if (!URL.canParse(data)) return data
const url = new URL(data)
return url.protocol === "http:" || url.protocol === "https:" ? url : data
}
function toolResultPart(part: ContentPart): ToolResultContent[] {
if (part.type !== "tool-result") return []
return [
+1 -3
View File
@@ -20,14 +20,13 @@ export const Plugin = define({
const global = yield* Global.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.reference.reload())
yield* ctx.reference.transform((draft) => {
const entries = new Map<string, Reference.Source>()
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
const description = typeof entry === "string" ? undefined : entry.description
const hidden = typeof entry === "string" ? undefined : entry.hidden
entries.set(
draft.add(
name,
local(entry)
? Reference.LocalSource.make({
@@ -48,7 +47,6 @@ export const Plugin = define({
)
}
}
for (const [name, source] of entries) draft.add(name, source)
})
}),
})
@@ -0,0 +1,35 @@
# Effect Drizzle SQLite Adapter
This subtree is an upstream-derived Drizzle ORM fork adapted to run SQLite query
builders over Effect's generic `SqlClient`. It is maintained source, not
generated output.
## Provenance
The implementation is derived from Drizzle ORM's Effect SQLite driver/session,
SQLite Effect query builders, and shared query-builder utilities. The
corresponding upstream source families are `drizzle-orm/src/effect-sqlite`,
`drizzle-orm/src/sqlite-core`, and `drizzle-orm/src/utils.ts`.
The exact upstream revision originally copied into this repository is unknown.
The currently pinned `drizzle-orm` version is a compatibility dependency, not
copy provenance.
## Local Boundary
The supported local entrypoint is `@opencode-ai/core/database/drizzle`, exposed
as the `EffectDrizzleSqlite` namespace. OpenCode's database service consumes that
facade from `database/database.ts`.
Material local adaptations include:
- a runtime-independent driver over Effect's generic `SqlClient`
- local cache, mapping, and runtime-inspection helpers
- suppressed statement tracing beneath the database operation boundary
- explicit SQLite transactions and savepoints
- native transaction delegation for Durable Object SQLite
- deliberate query-builder variance annotations
Preserve these adaptations when comparing or synchronizing upstream code.
Focused regression coverage is in `test/database-drizzle.test.ts` and
`test/sqlite-workerd.test.ts`.
@@ -36,14 +36,14 @@ export const DefaultServices = Layer.merge(EffectCache.Default, EffectLogger.Def
*
* @example
* ```ts
* import { SqliteClient } from '@effect/sql-sqlite-node';
* import * as SQLiteDrizzle from 'drizzle-orm/effect-sqlite';
* import * as Effect from 'effect/Effect';
* import { SqliteClient } from "@effect/sql-sqlite-node"
* import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
* import { Effect } from "effect"
*
* const db = yield* SQLiteDrizzle.make({ relations }).pipe(
* Effect.provide(SQLiteDrizzle.DefaultServices),
* Effect.provide(SqliteClient.layer({ filename: 'sqlite.db' })),
* );
* const db = yield* EffectDrizzleSqlite.make({ relations }).pipe(
* Effect.provide(EffectDrizzleSqlite.DefaultServices),
* Effect.provide(SqliteClient.layer({ filename: "sqlite.db" })),
* )
* ```
*/
export const make = Effect.fn("SQLiteDrizzle.make")(function* <TRelations extends AnyRelations = EmptyRelations>(
+129 -132
View File
@@ -481,7 +481,7 @@ export function status(): Effect.Effect<Status, never, Database.Service> {
if (runtimeState.status === "error") return runtimeState
if (state?.phase === "completed") return { status: "completed" as const }
return { status: "required" as const }
}).pipe(Effect.orDie)
})
}
export const layer = Layer.effectDiscard(
@@ -521,76 +521,75 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const migrate = Effect.gen(function* () {
const now = Date.now()
yield* db.run(sql`
const now = Date.now()
yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
INSERT OR IGNORE INTO session_v2 (
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
@@ -605,81 +604,79 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
FROM session
WHERE id = ${nextID.id}
`)
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run(),
)
yield* tx
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run()
}),
)
.pipe(Effect.orDie)
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
.run(),
)
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
})
return yield* migrate
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
}).pipe(Effect.orDie),
)
}
@@ -708,7 +705,7 @@ function countNextSessions(sourcePath: string | undefined) {
if (!isNextDatabase(source)) return 0
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
}),
).pipe(Effect.orElseSucceed(() => 0))
)
}
function importNextDatabase(
+236
View File
@@ -0,0 +1,236 @@
export * as StateMachine from "./state-machine.js"
import { Cause, Effect, Exit, Fiber, Queue, type Scope } from "effect"
export type Command<Operation> =
| {
readonly _tag: "Invoke"
readonly id: string
readonly operation: Operation
}
| {
readonly _tag: "Stop"
readonly id: string
}
| {
readonly _tag: "StopAndJoin"
readonly id: string
readonly ids: ReadonlyArray<string>
readonly waitFor: ReadonlyArray<string>
}
export type InvocationExited<Event, Operation, Error> = {
readonly _tag: "InvocationExited"
readonly id: string
readonly generation: number
readonly operation: Operation
readonly exit: Exit.Exit<Event, Error>
}
export type RuntimeEvent<Event, Operation, Error> =
| {
readonly _tag: "Input"
readonly input: Event
readonly cause?: Cause.Cause<never>
}
| InvocationExited<Event, Operation, Error>
| {
readonly _tag: "InvocationsStopped"
readonly id: string
readonly exits: ReadonlyArray<InvocationExited<Event, Operation, Error>>
}
export type Continue<State, Operation> = {
readonly _tag: "Continue"
readonly state: State
readonly commands: ReadonlyArray<Command<Operation>>
}
export type Decision<State, Operation, Output> =
| Continue<State, Operation>
| {
readonly _tag: "Done"
readonly output: Output
}
export type Definition<State, Event, Operation, Error, Output> = {
readonly initial: Continue<State, Operation>
readonly transition: (
state: State,
event: RuntimeEvent<Event, Operation, Error>,
) => Decision<State, Operation, Output>
readonly interruption?: Event
}
export type Executor<Event, Operation, Error, Requirements> = (
operation: Operation,
) => Effect.Effect<Event, Error, Requirements>
export function define<State, Event, Operation, Error, Output>(
definition: Definition<State, Event, Operation, Error, Output>,
) {
return definition
}
export function next<State, Operation = never>(state: State, ...commands: ReadonlyArray<Command<Operation>>) {
return { _tag: "Continue", state, commands } as const
}
export function done<Output>(output: Output) {
return { _tag: "Done", output } as const
}
export function invoke<Operation>(id: string, operation: Operation): Command<Operation> {
return { _tag: "Invoke", id, operation }
}
export function stop(id: string): Command<never> {
return { _tag: "Stop", id }
}
/** Stops `ids`, awaits `waitFor` without interruption, and delivers their exits as one batch. */
export function stopAndJoin(
id: string,
ids: ReadonlyArray<string>,
waitFor: ReadonlyArray<string> = [],
): Command<never> {
return { _tag: "StopAndJoin", id, ids, waitFor }
}
export const run = Effect.fn("StateMachine.run")(function* <State, Event, Operation, Error, Output, Requirements>(
definition: Definition<State, Event, Operation, Error, Output>,
execute: Executor<Event, Operation, Error, Requirements>,
) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.scoped(
Effect.gen(function* () {
const queue = yield* Queue.unbounded<RuntimeEvent<Event, Operation, Error>>()
const invocations = new Map<
string,
{
readonly generation: number
readonly operation: Operation
readonly fiber: Fiber.Fiber<Event, Error>
}
>()
let generation = 0
const executeCommands = Effect.fnUntraced(function* (
commands: ReadonlyArray<Command<Operation>>,
interruptibleExecution: boolean,
) {
yield* Effect.forEach(
commands,
(command) =>
Effect.gen(function* () {
if (command._tag === "Stop") {
const invocation = invocations.get(command.id)
yield* invocation
? Fiber.interrupt(invocation.fiber)
: Effect.die(new Error(`Unknown state machine invocation: ${command.id}`))
return
}
if (command._tag === "StopAndJoin") {
const captured = [...command.ids, ...command.waitFor].flatMap((id) => {
const invocation = invocations.get(id)
return invocation ? [{ id, ...invocation }] : []
})
if (captured.length !== command.ids.length + command.waitFor.length)
yield* Effect.die(new Error("Unknown state machine invocation in StopAndJoin"))
// Invalidate individual exits, including ones already queued, before interrupting.
captured.forEach((invocation) => invocations.delete(invocation.id))
yield* Fiber.interruptAll(captured.slice(0, command.ids.length).map((invocation) => invocation.fiber))
const exits = yield* Effect.forEach(captured, (invocation) =>
Fiber.await(invocation.fiber).pipe(
Effect.map((exit) => ({
_tag: "InvocationExited" as const,
id: invocation.id,
generation: invocation.generation,
operation: invocation.operation,
exit,
})),
),
)
yield* Queue.offer(queue, { _tag: "InvocationsStopped", id: command.id, exits })
return
}
const previous = invocations.get(command.id)
if (previous) yield* Fiber.interrupt(previous.fiber)
generation += 1
const current = generation
const execution = interruptibleExecution
? restore(execute(command.operation))
: execute(command.operation)
const fiber = yield* execution.pipe(Effect.forkScoped({ startImmediately: false }))
invocations.set(command.id, { generation: current, operation: command.operation, fiber })
// A deferred child may be interrupted before an Effect.onExit observer starts.
fiber.addObserver((exit) => {
Queue.offerUnsafe(queue, {
_tag: "InvocationExited",
id: command.id,
generation: current,
operation: command.operation,
exit,
})
})
}),
{ discard: true },
)
})
const handleInterruption = (
state: State,
cause: Cause.Cause<never>,
): Effect.Effect<Output, never, Requirements | Scope.Scope> =>
Effect.gen(function* () {
if (!Cause.hasInterruptsOnly(cause) || definition.interruption === undefined)
return yield* Effect.failCause(cause)
return yield* dispatch(
definition.transition(state, {
_tag: "Input",
input: definition.interruption,
cause,
}),
true,
)
})
const dispatch = (
decision: Decision<State, Operation, Output>,
interrupted: boolean,
): Effect.Effect<Output, never, Requirements | Scope.Scope> =>
Effect.gen(function* () {
if (decision._tag === "Done") return decision.output
yield* executeCommands(decision.commands, !interrupted)
if (interrupted) return yield* Effect.suspend(() => loop(decision.state, true))
const boundary = yield* restore(Effect.void).pipe(Effect.exit)
if (Exit.isFailure(boundary)) return yield* handleInterruption(decision.state, boundary.cause)
return yield* Effect.suspend(() => loop(decision.state, false))
})
const loop = (state: State, interrupted: boolean): Effect.Effect<Output, never, Requirements | Scope.Scope> =>
Effect.gen(function* () {
const received = yield* (interrupted ? Queue.take(queue) : restore(Queue.take(queue))).pipe(Effect.exit)
if (Exit.isFailure(received)) return yield* handleInterruption(state, received.cause)
if (received.value._tag === "InvocationExited") {
const invocation = invocations.get(received.value.id)
if (!invocation || invocation.generation !== received.value.generation) {
return yield* Effect.suspend(() => loop(state, interrupted))
}
invocations.delete(received.value.id)
}
return yield* dispatch(definition.transition(state, received.value), interrupted)
})
return yield* dispatch(definition.initial, false)
}),
),
)
})
+55 -52
View File
@@ -61,21 +61,23 @@ export const makeMemoryDriver = (): MemoryDriver => {
}
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
const overrides: FilesImpl = {
stat: (value) => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
},
read: (value, range) => {
const original = lookup(value)
if (!original) return Effect.fail(new NotFound({ path: value }))
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
},
stat: (value) =>
Effect.suspend(() => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
}),
read: (value, range) =>
Effect.gen(function* () {
const original = lookup(value)
if (!original) return yield* new NotFound({ path: value })
if (original.type === "directory") return yield* new WrongKind({ path: value, actual: "directory" })
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return yield* new NotFound({ path: value })
if (node.type !== "file") return yield* new WrongKind({ path: value, actual: node.type })
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return { info: info(node), bytes: bytes.slice() }
}),
write: (value, bytes) =>
Effect.try({
try: () => {
@@ -89,17 +91,17 @@ export const makeMemoryDriver = (): MemoryDriver => {
},
catch: (cause) => failed(value, cause),
}),
list: (value) => {
const target = resolveKey(value, true) ?? key(value)
const node = nodes.get(target)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const entries = [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
return Effect.succeed(entries)
},
list: (value) =>
Effect.gen(function* () {
const target = resolveKey(value, true) ?? key(value)
const node = nodes.get(target)
if (!node) return yield* new NotFound({ path: value })
if (node.type !== "directory") return yield* new WrongKind({ path: value, actual: node.type })
return [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
}),
remove: (value) =>
Effect.sync(() => {
const target = resolveKey(value, false) ?? key(value)
@@ -107,32 +109,33 @@ export const makeMemoryDriver = (): MemoryDriver => {
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
}
}),
move: (from, to) => {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return Effect.fail(new NotFound({ path: from }))
return Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
},
move: (from, to) =>
Effect.gen(function* () {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return yield* new NotFound({ path: from })
yield* Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
}),
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
}
+36 -3
View File
@@ -1,5 +1,38 @@
This is a temporary package used primarily for GitHub Copilot compatibility.
# GitHub Copilot AI SDK Adapters
These DO NOT apply for openai-compatible providers or majority of providers supporting completions/responses apis. THIS IS ONLY FOR GITHUB COPILOT!!!
This directory contains upstream-derived AI SDK implementations adapted for
GitHub Copilot. It is not a generic OpenAI-compatible provider.
Avoid making edits to these files
## Provenance
- `chat/` is derived from the Vercel AI SDK
`@ai-sdk/openai-compatible` chat implementation.
- `responses/` is derived from the Vercel AI SDK `@ai-sdk/openai` Responses
implementation.
- The exact upstream revisions originally copied into this repository are
unknown. Current dependency versions and the `VERSION` constant in
`copilot-provider.ts` are not copy provenance.
## Ownership
Keep `chat/` and `responses/` structurally close to their upstream modules, but
preserve the intentional Copilot adaptations: the `copilot` options and metadata
namespace, `thinking_budget`, reasoning text and opaque reasoning, stateless
Responses requests with encrypted reasoning, rotating response item IDs, and
explicit function-tool strictness taking precedence over the global fallback.
`copilot-provider.ts` is the local adapter assembly entrypoint used by
`plugin/provider/github-copilot.ts`. `models.ts` is OpenCode-owned catalog
reconciliation, not vendored SDK code. Authentication, request headers, model
routing, and integration lifecycle are also owned by the provider plugin.
When updating the upstream-shaped modules, compare against both source packages
and reapply the documented Copilot adaptations. Focused regression coverage is
in:
- `test/github-copilot/copilot-chat-model.test.ts`
- `test/github-copilot/convert-to-copilot-messages.test.ts`
- `test/github-copilot/openai-responses-language-model.test.ts`
- `test/github-copilot/openai-responses-prepare-tools.test.ts`
- `test/github-copilot/models.test.ts`
- `test/plugin/provider-github-copilot.test.ts`
+2 -7
View File
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { FileSystem } from "../filesystem.js"
import { DecodeError, ResizerUnavailableError, SizeError } from "../image.js"
import { DecodeError, ResizerUnavailableError, SizeError, type Limits } from "../image.js"
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
@@ -33,12 +33,7 @@ export const make = Effect.gen(function* () {
return Effect.fn("Image.Photon.normalize")(function* (
resource: string,
content: FileSystem.Content & { readonly encoding: "base64" },
limits: {
readonly autoResize: boolean
readonly maxWidth: number
readonly maxHeight: number
readonly maxBase64Bytes: number
},
limits: Readonly<Limits>,
) {
const photon = yield* loadPhoton
const decoded = yield* Effect.try({
+7 -1
View File
@@ -26,6 +26,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Schema } from "effect"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import type { Session } from "@opencode-ai/schema/session"
import { McpStdio } from "./stdio.js"
const DEFAULT_STARTUP_TIMEOUT = 30_000
@@ -156,6 +157,7 @@ export interface Connection {
readonly callTool: (input: {
readonly name: string
readonly args?: Record<string, unknown>
readonly sessionID?: Session.ID
}) => Effect.Effect<CallToolResult, Error>
readonly onClose: (callback: () => void) => void
/** Registers a callback fired when the server emits an MCP logging notification. */
@@ -396,7 +398,11 @@ export const connect = Effect.fnUntraced(function* (
Effect.tryPromise({
try: (signal) =>
client.callTool(
{ name: input.name, arguments: input.args ?? {} },
{
name: input.name,
arguments: input.args ?? {},
...(input.sessionID === undefined ? {} : { _meta: { sessionID: input.sessionID } }),
},
CallToolResultSchema,
// Keep progress tokens available while enforcing a hard wall-clock execution timeout.
{ signal, timeout: executionTimeout, onprogress: () => {} },
+3 -1
View File
@@ -3,6 +3,7 @@ export * as Mcp from "./index.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
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, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
@@ -153,6 +154,7 @@ export interface Interface extends State.Transformable<Draft> {
readonly server: ServerName | string
readonly name: string
readonly args?: Record<string, unknown>
readonly sessionID?: Session.ID
}) => Effect.Effect<ToolResult, NotFoundError | ToolCallError>
readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly prompts: () => Effect.Effect<Prompt[]>
@@ -762,7 +764,7 @@ export const layer = (options?: Options) =>
message: "MCP server is not connected",
})
const result = yield* target.entry.client
.callTool({ name: input.name, args: input.args })
.callTool({ name: input.name, args: input.args, sessionID: input.sessionID })
.pipe(
Effect.mapError(
(error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
+5 -30
View File
@@ -4,7 +4,7 @@ import os from "node:os"
import path from "node:path"
import { Context, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Added, Handoff, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
import { Added, Handoff, PersistentPty, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
import { Session } from "@opencode-ai/schema/session"
import { Bus } from "../bus.js"
import { Pty } from "@opencode-ai/schema/pty"
@@ -26,19 +26,9 @@ export { Handoff } from "@opencode-ai/schema/persistent-pty"
export const Options = Schema.Struct({ handoff: Schema.optional(Handoff) })
export type Options = typeof Options.Type
export type Info = Pty.Info & {
readonly sessionID: Session.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type Info = PersistentPty.Info
export type Snapshot = {
readonly info: Info
readonly text: string
readonly checkpoint: Uint8Array
readonly cursor: { readonly x: number; readonly y: number }
}
export type Snapshot = PersistentPty.Snapshot
export type Attachment = {
readonly info: Info
@@ -161,15 +151,7 @@ export const configured = (options: Options = {}) =>
const create = Effect.fn("PersistentPty.create")(function* (
sessionID: Session.ID,
input: {
readonly command?: string
readonly args: readonly string[]
readonly cwd?: string
readonly title: string
readonly env: Readonly<Record<string, string>>
readonly cols?: number
readonly rows?: number
},
input: Parameters<Interface["create"]>[1],
) {
const response = yield* request(
daemon,
@@ -338,14 +320,7 @@ export const configured = (options: Options = {}) =>
const attach = Effect.fn("PersistentPty.attach")(function* (
id: Pty.ID,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
input: Parameters<Interface["attach"]>[1],
) {
yield* get(id)
const attachment = yield* daemon
+1 -1
View File
@@ -37,7 +37,7 @@ export const ModelsDevPlugin = define({
})
for (const model of provider.models) {
if (model.status === "deprecated") continue
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, structuredClone(model)))
}
}
})
+1 -1
View File
@@ -40,7 +40,7 @@ export const load = Effect.fn("PluginModule.load")(function* (
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
+6 -4
View File
@@ -76,11 +76,13 @@ to every project for that user. Project configuration can live in any directory
as `opencode.json(c)` or `.opencode/opencode.json(c)`, including nested packages
in a monorepo.
When OpenCode starts, it searches from the current directory up to the project
root. It merges direct `opencode.json(c)` files from root to current directory,
During ordinary project discovery, OpenCode searches the current Location
directory and every ancestor through the filesystem root, including directories
above the detected project or repository root. It merges direct
`opencode.json(c)` files from the farthest ancestor to the current directory,
then does the same for `.opencode/opencode.json(c)` files. This means every
`.opencode` config overrides every direct config. Global configuration has the
lowest precedence.
discovered `.opencode` config overrides every discovered direct config. Global
filesystem configuration has lower precedence than these discovered documents.
Common configuration fields include `model`, `default_agent`, `permissions`,
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
+16
View File
@@ -78,6 +78,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
refreshes: [...packages.entries()].flatMap(([target, plugin]) =>
!path.isAbsolute(target) && enabled.has(plugin.id) ? [target] : [],
),
}
})
@@ -89,6 +92,7 @@ export const layer = Layer.effect(
const instance = yield* InstancePlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const npm = yield* Npm.Service
const ready = yield* Latch.make()
let observed = 0
@@ -113,6 +117,18 @@ export const layer = Layer.effect(
const resolved = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(resolved.plugins, resolved.failures)
if (resolved.refreshes.length) {
yield* Effect.forEach(
resolved.refreshes,
(target) =>
npm
.add(target, { subpaths: ["server", ""], refresh: true })
.pipe(
Effect.catchCause((cause) => Effect.logWarning("failed to refresh package plugin", { target, cause })),
),
{ concurrency: "unbounded", discard: true },
).pipe(Effect.forkDetach)
}
})
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
+20 -19
View File
@@ -393,26 +393,27 @@ export const layer = Layer.effect(
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
const resolved = yield* input.resolveModel(input.session).pipe(
Effect.catch((cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
),
return yield* input.resolveModel(input.session).pipe(
Effect.matchEffect({
onFailure: (cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
onSuccess: (resolved) =>
execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
}),
}),
)
if ("status" in resolved) return resolved
return yield* execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
})
})
return Service.of({
transform: state.transform,
@@ -131,11 +131,7 @@ const layer = Layer.effect(
return (yield* rows(sessionID, false)).map((row) => ({ key: row.key, value: row.value }))
})
const put = Effect.fn("InstructionEntry.put")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
readonly value: Schema.Json
}) {
const put = Effect.fn("InstructionEntry.put")(function* (input: Parameters<Interface["put"]>[0]) {
const actualBytes = Buffer.byteLength(JSON.stringify(input.value), "utf8")
if (actualBytes > MaxValueBytes)
yield* new ValueTooLargeError({
@@ -159,10 +155,7 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
})
const remove = Effect.fn("InstructionEntry.remove")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
}) {
const remove = Effect.fn("InstructionEntry.remove")(function* (input: Parameters<Interface["remove"]>[0]) {
yield* db
.update(InstructionEntryTable)
.set({ value: null, removed: true, time_updated: Date.now() })
+1 -4
View File
@@ -43,10 +43,7 @@ const layer = Layer.effect(
// are re-discovered and re-injected instead of staying silently lost.
const inFlight = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
const load = Effect.fn("SessionInstructions.load")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly paths: ReadonlyArray<string>
}) {
const load = Effect.fn("SessionInstructions.load")(function* (input: Parameters<Interface["load"]>[0]) {
const claimed = yield* Ref.modify(inFlight, (map) => {
const existing = map.get(input.sessionID) ?? new Set<string>()
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
+74 -86
View File
@@ -15,13 +15,14 @@ import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { SessionTitle } from "../title.js"
import { DrainResult, Service, type Continuation } from "./index.js"
import { DrainResult, Service, type Interface } from "./index.js"
import { Snapshot } from "../../snapshot.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
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"
@@ -44,12 +45,7 @@ const layer = Layer.effect(
// Title generation starts once input is visible and must not delay model execution.
const titles = yield* FiberMap.make<SessionSchema.ID, void, never>()
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
readonly continuation?: Continuation
readonly promotable?: SessionInbox.Promotable
}) {
const drain = Effect.fn("SessionRunner.drain")(function* (input: Parameters<Interface["drain"]>[0]) {
const sessionID = input.sessionID
let force = input.force
let continuing = input.continuation !== undefined
@@ -172,91 +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* 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,
recoverContinuation,
recoverOverflow: Effect.suspend(() =>
recoverOverflow && compaction.enabled()
? compaction.compact(compactionInput).pipe(Effect.map((result) => result.status === "completed"))
: Effect.succeed(false),
),
})
const completed = yield* SessionStep.Outcome.$match(outcome, {
Completed: (outcome) => Effect.succeed(outcome.needsContinuation),
Retry: (outcome) =>
retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
Pull.catchDone(() =>
bus
.publish(SessionEvent.Step.Failed, { sessionID, assistantMessageID, error: outcome.error })
.pipe(Effect.andThen(outcome.cause)),
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,
prepared,
recoverContinuation: state.recoverContinuation,
recoverOverflow: Effect.suspend(() =>
compaction.enabled()
? compaction.compact(compactionInput).pipe(Effect.map((result) => result.status === "completed"))
: Effect.succeed(false),
),
Effect.asVoid,
}),
})
}),
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,
),
Continue: Effect.fnUntraced(function* (outcome) {
yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
Pull.catchDone(() => outcome.cause),
)
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
}
Effect.asVoid,
),
publishSynthetic: bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_INCOMPLETE_STREAM,
}),
})
})
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
@@ -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}`)
}
+208 -178
View File
@@ -9,13 +9,12 @@ import {
type ProviderErrorEvent,
type ToolCall,
} from "@opencode-ai/ai"
import { Cause, Data, Effect, Exit, Fiber, Option, Stream } from "effect"
import { Cause, Data, Effect, Exit, Option, Pull, Scope, Stream } from "effect"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Agent } from "../../agent.js"
import { Bus } from "../../bus.js"
import { Permission } from "../../permission.js"
import { Snapshot } from "../../snapshot.js"
import { Tool } from "../../tool.js"
import { ToolOutput } from "../../tool-output.js"
import { QuestionTool } from "../../tool/plugin/question.js"
import { StepFailedError } from "../error.js"
@@ -34,11 +33,10 @@ export type Outcome = Data.TaggedEnum<{
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
@@ -49,6 +47,27 @@ interface Input {
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
@@ -60,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,
@@ -70,185 +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()
if (llmFailure && llmError && SessionRunnerRetry.isRetryable(llmFailure) && !recorded.outputStarted) {
// Retry state projects onto the existing assistant, even before it has produced output.
yield* publisher.startAssistant()
return Outcome.Retry({ cause: llmFailure, error: llmError })
// 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)
// 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(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) => {
@@ -259,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 []
+49 -49
View File
@@ -122,8 +122,8 @@ const layer = () =>
const environments = yield* SessionEnvironment.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
const exitOrder: string[] = []
const commands = new Map<Shell.ID, Active>()
const exitOrder: Shell.ID[] = []
const outputDir = path.join(global.data, DIRECTORY, location.project.id)
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
@@ -132,44 +132,44 @@ const layer = () =>
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
for (const command of commands.values()) {
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
// Teardown interrupts pending commands; it is not a terminal command failure.
yield* Deferred.interrupt(session.done)
yield* Deferred.interrupt(command.done)
}
sessions.clear()
commands.clear()
exitOrder.length = 0
}),
)
const require = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
const command = commands.get(id)
if (!command) return yield* new NotFoundError({ id })
return command
})
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
const removeCommand = Effect.fnUntraced(function* (id: Shell.ID) {
const command = commands.get(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (!session) return
sessions.delete(id)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
if (!command) return
commands.delete(id)
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* Deferred.fail(command.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(command.file).catch(() => {}))
yield* bus.publish(Shell.Event.Deleted, { id })
})
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeSession(id)
yield* removeCommand(id)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
return Array.from(commands.values())
.filter((command) => command.info.status === "running")
.map((command) => command.info)
})
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
@@ -181,24 +181,24 @@ const layer = () =>
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
const command = yield* require(id)
if (command.info.status !== "running" || !command.timeout) return command.info
yield* command.timeout(duration)
return command.info
})
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const command = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
if (cursor >= command.size) return { output: "", cursor: command.size, size: command.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
const length = Math.min(limit, command.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(session.file, { start, end: start + length - 1 })
const stream = createReadStream(command.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
@@ -212,7 +212,7 @@ const layer = () =>
return {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: session.size,
size: command.size,
truncated: false,
}
})
@@ -257,7 +257,7 @@ const layer = () =>
// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
// end). `create` returns once `ready` resolves with the registered command.
const ready = Deferred.makeUnsafe<Active, AppProcess.AppProcessError>()
runFork(
Effect.scoped(
@@ -275,7 +275,7 @@ const layer = () =>
.pipe(
Effect.mapError((cause) => new AppProcess.AppProcessError({ command: invocation.command, cause })),
)
const session: Active = {
const command: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
@@ -283,7 +283,7 @@ const layer = () =>
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
commands.set(id, command)
const stream = createWriteStream(file)
const outputDone = Latch.makeUnsafe()
@@ -291,7 +291,7 @@ const layer = () =>
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
session.size += chunk.length
command.size += chunk.length
}),
),
)
@@ -317,8 +317,8 @@ const layer = () =>
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
if (command.info.status !== "running") return
command.info = produce(command.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
@@ -326,10 +326,10 @@ const layer = () =>
yield* beforeWait
yield* outputDone.await
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// command still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(session.done, session.info)
yield* Deferred.succeed(command.done, command.info)
yield* bus.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
@@ -339,19 +339,19 @@ const layer = () =>
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(Shell.ID.make(oldest))
yield* removeCommand(oldest)
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
})
session.timeout = (duration) =>
command.timeout = (duration) =>
Effect.gen(function* () {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
command.timeoutFiber = undefined
if (duration === 0 || command.info.status !== "running") return
command.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
@@ -360,7 +360,7 @@ const layer = () =>
)
})
yield* session.timeout(invocation.timeout)
yield* command.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
@@ -370,16 +370,16 @@ const layer = () =>
)
yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session)
yield* Deferred.succeed(ready, command)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
yield* Deferred.await(command.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catchTag("AppProcessError", (error) => Deferred.fail(ready, error))),
)
const session = yield* Deferred.await(ready)
return session.info
const command = yield* Deferred.await(ready)
return command.info
})
return Service.of({ create, list, get, wait, timeout, output, remove })
+1
View File
@@ -72,6 +72,7 @@ export const layer = Layer.effect(
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
sessionID: context.sessionID,
})
.pipe(
Effect.catchTags({
+120 -3
View File
@@ -375,7 +375,100 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
}),
)
it.effect("moves a tool image through the real Mistral provider as a user message", () =>
it.effect("normalizes file data across AI SDK prompt parts", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model(model("opaque-provider"))
const bytes = new Uint8Array([0, 1, 2, 3])
const prepared = yield* compileRequest(
LLM.request({
model: resolved,
messages: [
Message.user([
{ type: "media", mediaType: "image/png", data: bytes, filename: "bytes.png" },
{ type: "media", mediaType: "image/png", data: "AAAA", filename: "base64.png" },
{
type: "media",
mediaType: "image/png",
data: "data:image/png;charset=utf-8;base64,AQID",
filename: "inline.png",
},
{ type: "media", mediaType: "image/png", data: "https://example.com/image.png" },
{ type: "media", mediaType: "image/png", data: "s3://bucket/image.png" },
]),
Message.assistant({
type: "media",
mediaType: "application/pdf",
data: "http://example.com/document.pdf",
filename: "document.pdf",
}),
Message.tool({
id: "call_1",
name: "screenshot",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,BAUG", mime: "image/png", name: "tool.png" }],
},
}),
],
}),
)
expect(prepared.body.prompt).toEqual([
{
role: "user",
content: [
{ type: "file", mediaType: "image/png", data: bytes, filename: "bytes.png" },
{ type: "file", mediaType: "image/png", data: "AAAA", filename: "base64.png" },
{ type: "file", mediaType: "image/png", data: "AQID", filename: "inline.png" },
{
type: "file",
mediaType: "image/png",
data: new URL("https://example.com/image.png"),
filename: undefined,
},
{ type: "file", mediaType: "image/png", data: "s3://bucket/image.png", filename: undefined },
],
},
{
role: "assistant",
content: [
{
type: "file",
mediaType: "application/pdf",
data: new URL("http://example.com/document.pdf"),
filename: "document.pdf",
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_1",
toolName: "screenshot",
output: { type: "text", value: "Media attached in the following user message." },
providerOptions: undefined,
},
],
},
{
role: "user",
content: [
{ type: "text", text: "Attached media from tool result:" },
{ type: "file", mediaType: "image/png", data: "BAUG", filename: "tool.png" },
],
},
])
}),
)
it.effect("normalizes user and tool media through the real Mistral provider", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let body: { messages?: unknown[] } | undefined
@@ -415,7 +508,14 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
LLM.request({
model: resolved,
messages: [
Message.user("Inspect the screenshot."),
Message.user([
{ type: "text", text: "Inspect the attachments." },
{ type: "media", mediaType: "image/png", data: new Uint8Array([0, 1, 2, 3]) },
{ type: "media", mediaType: "image/png", data: "AQID" },
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,BAUG" },
{ type: "media", mediaType: "image/png", data: "http://example.com/image.png" },
{ type: "media", mediaType: "application/pdf", data: "https://example.com/document.pdf" },
]),
Message.assistant({ type: "tool-call", id: "call_1", name: "screenshot", input: {} }),
Message.tool({
type: "tool-result",
@@ -426,6 +526,12 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
value: [
{ type: "text", text: "Screenshot captured" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "screen.png" },
{
type: "file",
uri: "https://example.com/tool-document.pdf",
mime: "application/pdf",
name: "tool-document.pdf",
},
],
},
}),
@@ -434,7 +540,17 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
).pipe(Effect.provide(client))
expect(body?.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Inspect the screenshot." }] },
{
role: "user",
content: [
{ type: "text", text: "Inspect the attachments." },
{ type: "image_url", image_url: "data:image/png;base64,AAECAw==" },
{ type: "image_url", image_url: "data:image/png;base64,AQID" },
{ type: "image_url", image_url: "data:image/png;base64,BAUG" },
{ type: "image_url", image_url: "http://example.com/image.png" },
{ type: "document_url", document_url: "https://example.com/document.pdf" },
],
},
{
role: "assistant",
content: "",
@@ -457,6 +573,7 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
content: [
{ type: "text", text: "Attached media from tool result:" },
{ type: "image_url", image_url: "data:image/png;base64,AAAA" },
{ type: "document_url", document_url: "https://example.com/tool-document.pdf" },
],
},
])
+22 -24
View File
@@ -85,8 +85,8 @@ describe("Bus Session routing", () => {
projectID: Project.ID.global,
})
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first))).toEqual([moved, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, same, done])
expect(yield* Fiber.join(first)).toEqual([moved, done])
expect(yield* Fiber.join(second)).toEqual([moved, after, same, done])
expect(moved.location).toEqual(a)
}),
)
@@ -130,8 +130,8 @@ describe("Bus Session routing", () => {
)
const after = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: child })
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first)).map((event) => event.id)).toEqual([eventID, after.id, done.id])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
expect((yield* Fiber.join(first)).map((event) => event.id)).toEqual([eventID, after.id, done.id])
expect(yield* Fiber.join(second)).toEqual([done])
}),
)
}),
@@ -158,19 +158,17 @@ describe("Bus Session routing", () => {
const explicit = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: id }, { location: b })
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first))).toEqual([renamed, text, broadcast, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([broadcast, explicit, done])
expect(Array.from(yield* Fiber.join(workspace))).toEqual([broadcast, done])
expect(Array.from(yield* Fiber.join(global))).toEqual([renamed, text, broadcast, explicit, done])
expect(yield* Fiber.join(first)).toEqual([renamed, text, broadcast, done])
expect(yield* Fiber.join(second)).toEqual([broadcast, explicit, done])
expect(yield* Fiber.join(workspace)).toEqual([broadcast, done])
expect(yield* Fiber.join(global)).toEqual([renamed, text, broadcast, explicit, done])
expect(listened).toEqual([renamed, text, broadcast, explicit, done])
expect(renamed).not.toHaveProperty("location")
expect(text).not.toHaveProperty("location")
expect(JSON.parse(JSON.stringify(renamed))).not.toHaveProperty("location")
const history = yield* bus.log({ aggregateID: id }).pipe(Stream.runCollect)
expect(
Array.from(history)
.filter((event): event is Event.Payload => !Bus.isSynced(event))
.every((event) => !event.location),
history.filter((event): event is Event.Payload => !Bus.isSynced(event)).every((event) => !event.location),
).toBe(true)
}),
)
@@ -197,8 +195,8 @@ describe("Bus Session routing", () => {
yield* bus.publish(SessionEvent.Moved, { sessionID: id, location: b, projectID: Project.ID.global })
const expected = yield* bus.publish(SessionEvent.Renamed, { sessionID: id, title: "destination" })
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(typed))).toEqual([expected])
expect(Array.from(yield* Fiber.join(multiple))).toEqual([expected, done])
expect(yield* Fiber.join(typed)).toEqual([expected])
expect(yield* Fiber.join(multiple)).toEqual([expected, done])
}),
)
@@ -226,8 +224,8 @@ describe("Bus Session routing", () => {
const done = yield* bus.publish(Done, {})
yield* Deferred.succeed(gate, undefined)
expect(Array.from(yield* Fiber.join(first))).toEqual([created, before, moved, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, done])
expect(yield* Fiber.join(first)).toEqual([created, before, moved, done])
expect(yield* Fiber.join(second)).toEqual([moved, after, done])
expect(moved).not.toHaveProperty("location")
}),
)
@@ -245,9 +243,9 @@ describe("Bus Session routing", () => {
const database = yield* Database.Service
expect(yield* database.db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()).toBeUndefined()
expect(Array.from(yield* Fiber.join(first))).toEqual([deleted, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
expect(Array.from(yield* Fiber.join(global))).toEqual([deleted, missing, done])
expect(yield* Fiber.join(first)).toEqual([deleted, done])
expect(yield* Fiber.join(second)).toEqual([done])
expect(yield* Fiber.join(global)).toEqual([deleted, missing, done])
}),
)
@@ -266,8 +264,8 @@ describe("Bus Session routing", () => {
])
const done = yield* bus.publish(Done, {})
yield* Deferred.succeed(gate, undefined)
expect(Array.from(yield* Fiber.join(first))).toEqual([events[0], events[1], done])
expect(Array.from(yield* Fiber.join(second))).toEqual([events[1], events[2], events[3], done])
expect(yield* Fiber.join(first)).toEqual([events[0], events[1], done])
expect(yield* Fiber.join(second)).toEqual([events[1], events[2], events[3], done])
}),
)
@@ -295,8 +293,8 @@ describe("Bus Session routing", () => {
const done = yield* bus.publish(Done, {})
expect(Exit.isFailure(single)).toBe(true)
expect(Exit.isFailure(batch)).toBe(true)
expect(Array.from(yield* Fiber.join(first))).toEqual([before, after, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
expect(yield* Fiber.join(first)).toEqual([before, after, done])
expect(yield* Fiber.join(second)).toEqual([done])
}),
)
@@ -327,8 +325,8 @@ describe("Bus Session routing", () => {
{ publish: true },
)
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first))).toEqual([done])
const received = Array.from(yield* Fiber.join(second))
expect(yield* Fiber.join(first)).toEqual([done])
const received = yield* Fiber.join(second)
expect(received.map((event) => event.id)).toEqual([after.id, replayID, done.id])
expect(received[1]).not.toHaveProperty("location")
}),
+15 -15
View File
@@ -123,7 +123,7 @@ describe("Bus", () => {
yield* bus.publish(Message, { text: "hello" })
yield* bus.publish(CountMessage, { count: 2 })
const received = Array.from(yield* Fiber.join(fiber)).map((event) =>
const received = (yield* Fiber.join(fiber)).map((event) =>
event.type === "test.message" ? event.data.text : event.data.count,
)
expect(received).toEqual(["hello", 2])
@@ -136,7 +136,7 @@ describe("Bus", () => {
const fiber = yield* bus.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const event = yield* bus.publish(Message, { text: "hello" })
const received = Array.from(yield* Fiber.join(fiber))
const received = yield* Fiber.join(fiber)
expect(received).toEqual([event])
expect(event.type).toBe("test.message")
@@ -212,8 +212,8 @@ describe("Bus", () => {
yield* Effect.yieldNow
const event = yield* bus.publish(Message, { text: "hello" })
expect(Array.from(yield* Fiber.join(typed))).toEqual([event])
expect(Array.from(yield* Fiber.join(wildcard))).toEqual([event])
expect(yield* Fiber.join(typed)).toEqual([event])
expect(yield* Fiber.join(wildcard)).toEqual([event])
}),
)
@@ -602,7 +602,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "two"))
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[1, durableData(aggregateID, "one")],
[2, durableData(aggregateID, "two")],
])
@@ -618,7 +618,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, durableData(aggregateID, "zero")],
[1, durableData(aggregateID, "one")],
])
@@ -653,7 +653,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "during handoff"))
yield* Deferred.succeed(continueRead, undefined)
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, durableData(aggregateID, "during handoff")],
])
}).pipe(Effect.provide(eventLayer))
@@ -672,7 +672,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, String(index)))
}
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
Array.from({ length: count }, (_, index) => [index, durableData(aggregateID, String(index))]),
)
}),
@@ -688,7 +688,7 @@ describe("Bus", () => {
yield* bus.publish(Message, { text: "live only" })
yield* bus.publish(DurableMessage, durableData(aggregateID, "durable"))
expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
expect((yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
}),
)
@@ -1268,7 +1268,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
const items = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
const items = yield* Stream.runCollect(bus.log({ aggregateID }))
expect(items.map((item) => (Bus.isSynced(item) ? item.type : item.durable?.seq))).toEqual([
Event.Seq.make(0),
@@ -1284,9 +1284,9 @@ describe("Bus", () => {
const bus = yield* Bus.Service
const aggregateID = Session.ID.create()
const empty = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
const empty = yield* Stream.runCollect(bus.log({ aggregateID }))
yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
const drained = Array.from(yield* Stream.runCollect(bus.log({ aggregateID, after: 0 })))
const drained = yield* Stream.runCollect(bus.log({ aggregateID, after: 0 }))
expect(empty).toEqual([{ type: "log.synced", aggregateID }])
expect(empty[0]).not.toHaveProperty("seq")
@@ -1306,7 +1306,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
const items = Array.from(yield* Fiber.join(fiber))
const items = yield* Fiber.join(fiber)
expect(items.map((item) => (Bus.isSynced(item) ? item : item.durable?.seq))).toEqual([
Event.Seq.make(0),
{ type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
@@ -1330,7 +1330,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "three"))
yield* bus.publish(DurableMessage, durableData(aggregateID, "four"))
const items = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
const items = yield* Stream.runCollect(bus.log({ aggregateID }))
expect(items.map((item) => (Bus.isSynced(item) ? item.type : item.durable?.seq))).toEqual([
Event.Seq.make(0),
@@ -1378,7 +1378,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
yield* Deferred.succeed(releaseRead, undefined)
const items = Array.from(yield* Fiber.join(fiber))
const items = yield* Fiber.join(fiber)
expect(items.map((item) => (Bus.isSynced(item) ? item : item.durable?.seq))).toEqual([
Event.Seq.make(0),
{ type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
+4 -16
View File
@@ -330,10 +330,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
)
it.live("loads legacy file-based agents from config directories", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
@@ -407,10 +404,7 @@ Use native v2 fields.`,
for (const testCase of sourceCases()) {
it.effect(`rebuilds agents when a source file is ${testCase.name}`, () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, testCase.source)
@@ -445,10 +439,7 @@ Use native v2 fields.`,
}
it.effect("coalesces updates inside the debounce window into one rebuild", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "agents")
@@ -485,10 +476,7 @@ Use native v2 fields.`,
)
it.effect("ignores updates outside agent source directories", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "agents")
+4 -16
View File
@@ -54,10 +54,7 @@ const decode = Schema.decodeUnknownSync(Info)
describe("ConfigCommandPlugin.Plugin", () => {
it.live("loads inline and file-based commands in config order", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
@@ -166,10 +163,7 @@ Review files`,
for (const testCase of sourceCases()) {
it.effect(`rebuilds commands when a source file is ${testCase.name}`, () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "commands")
@@ -212,10 +206,7 @@ Review files`,
}
it.effect("coalesces updates inside the debounce window into one rebuild", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "commands")
@@ -254,10 +245,7 @@ Review files`,
)
it.effect("ignores updates outside command source directories", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "commands")
+255 -335
View File
@@ -1,7 +1,7 @@
import path from "path"
import fs from "fs/promises"
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
import { describe, expect, test } from "bun:test"
import { Effect, Fiber, Layer, Logger, Schema, Stream } from "effect"
import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config"
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
@@ -77,10 +77,7 @@ const provider = {
describe("Config", () => {
it.live("excludes home-level claude and agents directories when global is disabled", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const home = path.join(global, "home")
@@ -120,10 +117,7 @@ describe("Config", () => {
)
it.live("excludes global config reached through the project walk when global is disabled", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
// The location sits BENEATH the global config dir, so the upward walk
// reaches the global opencode.json as a direct file.
@@ -156,10 +150,7 @@ describe("Config", () => {
)
it.live("loads explicit file and content overrides in priority order", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
@@ -194,10 +185,7 @@ describe("Config", () => {
)
it.live("skips project configuration when project discovery is disabled", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
@@ -225,10 +213,7 @@ describe("Config", () => {
)
it.live("reloads external config and publishes directory updates", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
@@ -261,10 +246,7 @@ describe("Config", () => {
)
it.live("exposes filesystem updates under config roots through changes", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
@@ -296,10 +278,7 @@ describe("Config", () => {
// watch being torn down, making recreation invisible) only reproduces with
// path-faithful event delivery.
it.live("keeps watching a deleted config file so recreating it reloads", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
@@ -369,26 +348,24 @@ describe("Config", () => {
}).pipe(Effect.provide(Config.testLayer())),
)
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
Effect.sync(() => {
const entries = [
new Document({
type: "document",
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
}),
new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
new Document({ type: "document", info: new Info({}) }),
new Document({
type: "document",
info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
}),
]
test("returns the latest defined scalar from priority-ordered documents", () => {
const entries = [
new Document({
type: "document",
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
}),
new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
new Document({ type: "document", info: new Info({}) }),
new Document({
type: "document",
info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
}),
]
expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
expect(Config.latest(entries, "default_agent")).toBeUndefined()
}),
)
expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
expect(Config.latest(entries, "default_agent")).toBeUndefined()
})
it.live("tolerates unavailable authenticated wellknown config and reloads it later", () =>
Effect.acquireUseRelease(
@@ -580,268 +557,241 @@ describe("Config", () => {
).pipe(Effect.provide(Logger.layer([logger])))
})
it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
Effect.sync(() => {
FastCheck.assert(
FastCheck.property(Schema.toArbitrary(ConfigV1.Info)(FastCheck), (info) => {
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
),
)
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
}),
{ numRuns: 100 },
)
}),
)
test("migrates arbitrary v1 configuration into valid v2 configuration", () => {
FastCheck.assert(
FastCheck.property(Schema.toArbitrary(ConfigV1.Info)(FastCheck), (info) => {
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
),
)
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
}),
{ numRuns: 100 },
)
}, 30_000)
it.effect("migrates the v1 experimental subagent depth", () =>
Effect.sync(() => {
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
}),
)
test("migrates the v1 experimental subagent depth", () => {
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
})
it.effect("migrates the v1 small model to the title agent", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
small_model: "anthropic/claude-haiku-4-5",
agent: { title: { prompt: "Custom title prompt" } },
}).agents?.title,
).toEqual({
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
system: "Custom title prompt",
})
}),
)
test("migrates the v1 small model to the title agent", () => {
expect(
ConfigMigrateV1.migrate({
small_model: "anthropic/claude-haiku-4-5",
agent: { title: { prompt: "Custom title prompt" } },
}).agents?.title,
).toEqual({
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
system: "Custom title prompt",
})
})
it.effect("migrates v1 provider lists to policies", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
enabled_providers: ["anthropic", "openai"],
disabled_providers: ["openai"],
}).experimental?.policies,
).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "deny" },
])
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
])
}),
)
test("migrates v1 provider lists to policies", () => {
expect(
ConfigMigrateV1.migrate({
enabled_providers: ["anthropic", "openai"],
disabled_providers: ["openai"],
}).experimental?.policies,
).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "deny" },
])
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
])
})
it.effect("migrates v1 provider setup options into AISDK settings", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
bedrock: {
npm: "@ai-sdk/amazon-bedrock",
models: { claude: { provider: { npm: "@ai-sdk/anthropic" } } },
options: {
headers: { "x-test": "1" },
body: { trace: true },
region: "us-east-1",
profile: "dev",
},
test("migrates v1 provider setup options into AISDK settings", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
bedrock: {
npm: "@ai-sdk/amazon-bedrock",
models: { claude: { provider: { npm: "@ai-sdk/anthropic" } } },
options: {
headers: { "x-test": "1" },
body: { trace: true },
region: "us-east-1",
profile: "dev",
},
},
})
},
})
expect(migrated.providers?.bedrock).toMatchObject({
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
models: { claude: { package: Provider.aisdk("@ai-sdk/anthropic") } },
settings: { region: "us-east-1", profile: "dev" },
headers: { "x-test": "1" },
body: { trace: true },
})
}),
)
expect(migrated.providers?.bedrock).toMatchObject({
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
models: { claude: { package: Provider.aisdk("@ai-sdk/anthropic") } },
settings: { region: "us-east-1", profile: "dev" },
headers: { "x-test": "1" },
body: { trace: true },
})
})
it.effect("renames old provider IDs while migrating v1 configuration", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
model: "azure-cognitive-services/deployment",
enabled_providers: ["google-vertex-anthropic"],
disabled_providers: ["azure-cognitive-services"],
agent: {
reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
test("renames old provider IDs while migrating v1 configuration", () => {
const migrated = ConfigMigrateV1.migrate({
model: "azure-cognitive-services/deployment",
enabled_providers: ["google-vertex-anthropic"],
disabled_providers: ["azure-cognitive-services"],
agent: {
reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
},
command: {
review: { template: "Review", model: "azure-cognitive-services/deployment" },
},
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/azure",
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
models: { deployment: {} },
},
"google-vertex-anthropic": {
npm: "@ai-sdk/google-vertex/anthropic",
options: { project: "test-project", location: "us-central1" },
models: { "claude-sonnet": {} },
},
},
})
expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
expect(migrated.experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "google-vertex", effect: "allow" },
{ action: "provider.use", resource: "azure", effect: "deny" },
])
expect(migrated.providers?.azure).toMatchObject({
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
package: Provider.aisdk("@ai-sdk/azure"),
models: { deployment: {} },
})
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
expect(migrated.providers?.["google-vertex"]).toMatchObject({
settings: { project: "test-project", location: "us-central1" },
models: {
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
},
})
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
})
test("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/openai-compatible",
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
},
},
})
expect(migrated.providers?.azure).toMatchObject({
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: {
baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
},
})
})
test("ignores old provider IDs when the current provider ID is configured", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
azure: { models: { current: {} } },
"azure-cognitive-services": { models: { legacy: {} } },
"google-vertex": { models: { gemini: {} } },
"google-vertex-anthropic": { models: { claude: {} } },
},
})
expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
})
test("preserves the built-in package for v1 Vertex Anthropic custom models", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
"google-vertex-anthropic": {
models: { claude: {} },
},
},
})
expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
)
})
test("migrates v1 interleaved fields to compatibility", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
custom: {
models: {
object: { interleaved: { field: "vendor_reasoning" } },
string: { interleaved: "reasoning_text" },
boolean: { interleaved: true },
},
},
},
})
expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
reasoningField: "vendor_reasoning",
})
expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
})
test("migrates v1 command configuration", () => {
expect(
ConfigMigrateV1.migrate({
command: {
review: { template: "Review", model: "azure-cognitive-services/deployment" },
},
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/azure",
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
models: { deployment: {} },
},
"google-vertex-anthropic": {
npm: "@ai-sdk/google-vertex/anthropic",
options: { project: "test-project", location: "us-central1" },
models: { "claude-sonnet": {} },
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: "anthropic/claude",
variant: "high",
subtask: true,
},
},
})
}).commands,
).toEqual({
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: { providerID: "anthropic", model: "claude", variant: "high" },
subtask: true,
},
})
})
expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
expect(migrated.experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "google-vertex", effect: "allow" },
{ action: "provider.use", resource: "azure", effect: "deny" },
])
expect(migrated.providers?.azure).toMatchObject({
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
package: Provider.aisdk("@ai-sdk/azure"),
models: { deployment: {} },
})
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
expect(migrated.providers?.["google-vertex"]).toMatchObject({
settings: { project: "test-project", location: "us-central1" },
models: {
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
test("normalizes renamed permission actions when migrating v1 permissions", () => {
expect(
ConfigMigrateV1.migrate({
permission: {
task: "ask",
bash: { "git status": "allow", "*": "deny" },
write: "deny",
read: "allow",
},
})
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
}),
)
it.effect("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/openai-compatible",
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
},
},
})
expect(migrated.providers?.azure).toMatchObject({
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: {
baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
},
})
}),
)
it.effect("ignores old provider IDs when the current provider ID is configured", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
azure: { models: { current: {} } },
"azure-cognitive-services": { models: { legacy: {} } },
"google-vertex": { models: { gemini: {} } },
"google-vertex-anthropic": { models: { claude: {} } },
},
})
expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
}),
)
it.effect("preserves the built-in package for v1 Vertex Anthropic custom models", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
"google-vertex-anthropic": {
models: { claude: {} },
},
},
})
expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
)
}),
)
it.effect("migrates v1 interleaved fields to compatibility", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
custom: {
models: {
object: { interleaved: { field: "vendor_reasoning" } },
string: { interleaved: "reasoning_text" },
boolean: { interleaved: true },
},
},
},
})
expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
reasoningField: "vendor_reasoning",
})
expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
}),
)
it.effect("migrates v1 command configuration", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
command: {
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: "anthropic/claude",
variant: "high",
subtask: true,
},
},
}).commands,
).toEqual({
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: { providerID: "anthropic", model: "claude", variant: "high" },
subtask: true,
},
})
}),
)
it.effect("normalizes renamed permission actions when migrating v1 permissions", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
permission: {
task: "ask",
bash: { "git status": "allow", "*": "deny" },
write: "deny",
read: "allow",
},
}).permissions,
).toEqual([
{ action: "subagent", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
{ action: "shell", resource: "*", effect: "deny" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
])
}),
)
}).permissions,
).toEqual([
{ action: "subagent", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
{ action: "shell", resource: "*", effect: "deny" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
])
})
it.live("returns an empty configuration when directory files do not exist", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const config = yield* Config.Service
@@ -856,10 +806,7 @@ describe("Config", () => {
)
it.live("deduplicates global ecosystem directories found during upward discovery", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
@@ -888,10 +835,7 @@ describe("Config", () => {
)
it.live("does not watch ecosystem config roots", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -919,10 +863,7 @@ describe("Config", () => {
)
it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1033,10 +974,7 @@ describe("Config", () => {
)
it.live("does not load legacy config.json files", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1055,10 +993,7 @@ describe("Config", () => {
)
it.live("accepts $schema metadata without writing it into config files", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const file = path.join(tmp.path, "opencode.json")
@@ -1082,10 +1017,7 @@ describe("Config", () => {
)
it.live("loads supported scalar and resource configuration", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1271,10 +1203,7 @@ describe("Config", () => {
)
it.live("migrates the deprecated reference key into references", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1307,10 +1236,7 @@ describe("Config", () => {
)
it.live("migrates v1 configuration when a v1-only key is present", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1482,10 +1408,7 @@ describe("Config", () => {
)
it.live("ignores an invalid file while loading valid config values", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1511,10 +1434,7 @@ describe("Config", () => {
)
it.live("loads global and ancestor configuration across the project boundary", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const root = path.join(tmp.path, "repo")
+91 -11
View File
@@ -2,13 +2,15 @@ import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect } from "bun:test"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
@@ -18,7 +20,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Fiber, Logger, Stream } from "effect"
import { Effect, Fiber, Layer, Logger, Schedule, Stream } from "effect"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { tempGlobalLayer } from "../fixture/global"
@@ -35,6 +37,40 @@ const staticIt = testEffect(
[Global.node, tempGlobalLayer],
]),
)
const refreshNpm = makeGlobalNode({
service: Npm.Service,
layer: Layer.effect(
Npm.Service,
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "background-refresh-plugin")
const installed = { directory, entrypoint: pathToFileURL(path.join(directory, "index.js")).href }
return Npm.Service.of({
add: (_pkg, options) =>
options?.refresh
? Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-requested"), ""))
yield* waitForFile(path.join(directory, "refresh-release")).pipe(Effect.orDie)
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-finished"), ""))
return installed
})
: Effect.succeed(installed),
resolve: () => Effect.succeed(installed),
which: () => Effect.succeed(undefined),
})
}),
),
deps: [Global.node],
})
const refreshIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
[
[Global.node, tempGlobalLayer],
[Npm.node, refreshNpm],
],
),
)
describe("PluginSupervisor config", () => {
it.live("applies selectors in order", () =>
@@ -51,7 +87,6 @@ describe("PluginSupervisor config", () => {
}),
),
)
it.live("allows the built-in Plan agent to be disabled", () =>
withLocation(
{ agents: { plan: { disabled: true } } },
@@ -270,7 +305,7 @@ describe("PluginSupervisor config", () => {
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(EffectPlugin.define({ id: "static-sdk", effect: () => Effect.void }))
yield* sdk.register(define({ id: "static-sdk", effect: () => Effect.void }))
yield* withLocation(
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
Effect.gen(function* () {
@@ -380,7 +415,7 @@ describe("PluginSupervisor config", () => {
it.live("loads user plugins before internal post plugins", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(EffectPlugin.define({ id: "sdk-order", effect: () => Effect.void }))
yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void }))
yield* withLocation(
{
plugins: [
@@ -431,8 +466,8 @@ describe("PluginSupervisor config", () => {
it.live("unblocks flush when plugin activation fails", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(EffectPlugin.define({ id: "duplicate-id", effect: () => Effect.void }))
yield* sdk.register(EffectPlugin.define({ id: "duplicate-id", effect: () => Effect.void }))
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
yield* withLocation(
undefined,
Effect.gen(function* () {
@@ -441,6 +476,47 @@ describe("PluginSupervisor config", () => {
)
}),
)
refreshIt.live("refreshes active package plugins after setup without blocking flush", () =>
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "background-refresh-plugin")
const activated = path.join(directory, "activated")
const release = path.join(directory, "release")
const refreshed = path.join(directory, "refresh-requested")
const refreshRelease = path.join(directory, "refresh-release")
const refreshFinished = path.join(directory, "refresh-finished")
yield* Effect.promise(async () => {
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(
path.join(directory, "index.js"),
`export default {
id: "background-refresh-plugin",
async setup() {
await Bun.write(${JSON.stringify(activated)}, "")
while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(10)
},
}`,
)
})
yield* withLocation(
{ plugins: ["background-refresh-plugin"] },
Effect.gen(function* () {
yield* waitForFile(activated)
yield* Effect.sleep("100 millis")
expect(yield* Effect.promise(() => Bun.file(refreshed).exists())).toBeFalse()
yield* Effect.promise(() => Bun.write(release, ""))
yield* waitForFile(refreshed)
yield* ready().pipe(Effect.timeout("2 seconds"))
yield* Effect.promise(() => Bun.write(refreshRelease, ""))
yield* waitForFile(refreshFinished)
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("background-refresh-plugin")
}),
)
}),
)
})
const ready = Effect.fnUntraced(function* () {
@@ -448,16 +524,20 @@ const ready = Effect.fnUntraced(function* () {
yield* supervisor.flush
})
const waitForFile = (file: string) =>
Effect.promise(() => Bun.file(file).exists()).pipe(
Effect.filterOrFail((exists) => exists),
Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }),
Effect.timeout("2 seconds"),
)
function withLocation<A, E, R>(
config: unknown,
effect: Effect.Effect<A, E, R>,
fixtures = false,
prepare?: (directory: string) => Promise<void>,
) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
return Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.tap((tmp) =>
Effect.promise(async () => {
await prepare?.(tmp.path)
+43
View File
@@ -34,6 +34,41 @@ const decode = Schema.decodeUnknownSync(Info)
const document = path.join(import.meta.dir, "opencode.json")
describe("config plugin reloads", () => {
it.effect("preserves reference precedence and insertion order across documents", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const references = yield* Reference.Service
const host = yield* PluginHost.make(plugins)
yield* references.transform((draft) =>
draft.add(
"external",
Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/references/external") }),
),
)
yield* ConfigReferencePlugin.Plugin.effect(host)
const result = yield* references.list()
expect(result.map((reference) => reference.name)).toEqual(["external", "shared", "first", "second"])
expect(result.find((reference) => reference.name === "shared")?.path).toBe(
AbsolutePath.make(path.resolve("/config/second/shared")),
)
}).pipe(
Effect.provide(
Config.testLayer([
referenceConfig("/config/first/opencode.json", {
shared: "./shared",
first: "./first",
}),
referenceConfig("/config/second/opencode.json", {
shared: "./shared",
second: "./second",
}),
]),
),
Effect.provideService(Global.Service, Global.Service.of(Global.make())),
),
)
it.live("reloads config-backed domains without reloading external plugins", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
@@ -102,6 +137,14 @@ function config(name: string) {
})
}
function referenceConfig(file: string, references: Record<string, string>) {
return new Document({
type: "document",
path: AbsolutePath.make(file),
info: decode({ references }),
})
}
function title(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
@@ -16,18 +16,7 @@ function js(code: string, opts?: ChildProcess.CommandOptions) {
}
function decodeByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) {
return Stream.runCollect(stream).pipe(
Effect.map((chunks) => {
const total = chunks.reduce((acc, x) => acc + x.length, 0)
const out = new Uint8Array(total)
let off = 0
for (const chunk of chunks) {
out.set(chunk, off)
off += chunk.length
}
return new TextDecoder("utf-8").decode(out).trim()
}),
)
return Stream.mkUint8Array(stream).pipe(Effect.map((bytes) => new TextDecoder("utf-8").decode(bytes).trim()))
}
function alive(pid: number) {
@@ -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)
}),
)
})
+140 -141
View File
@@ -1,6 +1,6 @@
import { describe, expect, spyOn, test } from "bun:test"
import { describe, expect, spyOn } from "bun:test"
import fuzzysort from "fuzzysort"
import { mkdir, mkdtemp, rm } from "node:fs/promises"
import { mkdir } from "node:fs/promises"
import os from "os"
import path from "path"
import { Deferred, Effect, Layer } from "effect"
@@ -14,6 +14,8 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { it } from "../lib/effect"
const ripgrepStub = (entry: string, onFind: (input: Ripgrep.FindInput) => void) =>
Layer.succeed(
@@ -32,14 +34,13 @@ const ripgrepStub = (entry: string, onFind: (input: Ripgrep.FindInput) => void)
)
describe("FileSystemSearch", () => {
test("honors wildcard directory rules from .gitignore", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-fff-ignore-"))
try {
await mkdir(path.join(directory, "rust/target/debug/deps"), { recursive: true })
await Bun.write(path.join(directory, ".gitignore"), "**/target/\n")
await Bun.write(path.join(directory, "rust/target/debug/deps/ignored.rs"), "ignored")
const git = Bun.spawnSync(["git", "init", "-q"], { cwd: directory })
expect(git.exitCode).toBe(0)
it.live("honors wildcard directory rules from .gitignore", () =>
Effect.gen(function* () {
const directory = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-fff-ignore-")))).path
yield* Effect.promise(() => mkdir(path.join(directory, "rust/target/debug/deps"), { recursive: true }))
yield* Effect.promise(() => Bun.write(path.join(directory, ".gitignore"), "**/target/\n"))
yield* Effect.promise(() => Bun.write(path.join(directory, "rust/target/debug/deps/ignored.rs"), "ignored"))
expect(Bun.spawnSync(["git", "init", "-q"], { cwd: directory }).exitCode).toBe(0)
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
const layer = FileSystemSearch.fffLayer.pipe(
@@ -54,26 +55,22 @@ describe("FileSystemSearch", () => {
),
),
)
const entries = await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
return yield* search.find({ query: "target" })
}).pipe(Effect.provide(layer), Effect.scoped),
)
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
const entries = yield* search.find({ query: "target" })
expect(entries.every((entry) => !entry.path.startsWith("rust/target/"))).toBe(true)
}).pipe(Effect.provide(layer))
}),
)
expect(entries.every((entry) => !entry.path.startsWith("rust/target/"))).toBe(true)
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("selects the ripgrep layer for workspace-backed locations even when vcs would pick fff", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-search-workspace-"))
try {
it.live("selects the ripgrep layer for workspace-backed locations even when vcs would pick fff", () =>
Effect.gen(function* () {
const directory = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-search-workspace-"))))
.path
// A local file that only an fff index of the server directory could surface.
// The fff-vs-ripgrep discrimination only bites where Fff.available() is
// true; elsewhere the layer choice already falls back to ripgrep.
await Bun.write(path.join(directory, "server-local.ts"), "server local")
yield* Effect.promise(() => Bun.write(path.join(directory, "server-local.ts"), "server local"))
let observed: Ripgrep.FindInput | undefined
const ref = Location.Ref.make({
directory: AbsolutePath.make(directory),
@@ -92,37 +89,35 @@ describe("FileSystemSearch", () => {
[Ripgrep.node, ripgrepStub("remote.ts", (input) => (observed = input))],
])
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
const entries = yield* search.find({ query: "ts", type: "file" })
expect(observed?.cwd).toBe(directory)
expect(entries.map((entry) => entry.path)).toEqual([RelativePath.make("remote.ts")])
}).pipe(Effect.provide(layer), Effect.scoped),
)
} finally {
await rm(directory, { recursive: true, force: true })
}
})
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
const entries = yield* search.find({ query: "ts", type: "file" })
expect(observed?.cwd).toBe(directory)
expect(entries.map((entry) => entry.path)).toEqual([RelativePath.make("remote.ts")])
}).pipe(Effect.provide(layer))
}),
)
test("bounds a home scan even when home is detected as a repository", async () => {
let observed: Ripgrep.FindInput | undefined
const home = AbsolutePath.make(os.homedir())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: home }, { vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } }),
it.live("bounds a home scan even when home is detected as a repository", () =>
Effect.gen(function* () {
let observed: Ripgrep.FindInput | undefined
const home = AbsolutePath.make(os.homedir())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: home },
{ vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } },
),
),
),
),
],
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
])
await Effect.runPromise(
Effect.gen(function* () {
],
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
])
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* Effect.sleep("10 millis")
expect(observed).toBeUndefined()
@@ -132,52 +127,52 @@ describe("FileSystemSearch", () => {
expect((yield* search.find({ query: "src", type: "directory" }))[0]?.path).toBe(
RelativePath.make(`src${path.sep}`),
)
}).pipe(Effect.provide(layer), Effect.scoped),
)
})
}).pipe(Effect.provide(layer))
}),
)
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
let scans = 0
const started = Effect.runSync(Deferred.make<void>())
const release = Effect.runSync(Deferred.make<void>())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
it.effect("refreshes a stale ripgrep index atomically without blocking search", () =>
Effect.gen(function* () {
let scans = 0
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
),
),
),
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
if (scans > 1) {
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
}
const entry = FileSystem.Entry.make({
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
type: "file",
})
if (input.onEntry) yield* input.onEntry(entry)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
if (scans > 1) {
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
}
const entry = FileSystem.Entry.make({
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
type: "file",
})
if (input.onEntry) yield* input.onEntry(entry)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
await Effect.runPromise(
Effect.gen(function* () {
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* search.find({ query: "old", type: "file" })
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
@@ -196,47 +191,53 @@ describe("FileSystemSearch", () => {
}).pipe(Effect.repeat({ until: (entries) => entries.length > 0 }))
expect(refreshed[0]?.path).toBe(RelativePath.make("src/new.ts"))
expect(scans).toBe(2)
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
)
})
}).pipe(Effect.provide(layer))
}),
)
test("reuses location-owned fuzzy targets across index refreshes", async () => {
let scans = 0
const second = Effect.runSync(Deferred.make<void>())
const prepare = spyOn(fuzzysort, "prepare")
const cleanup = spyOn(fuzzysort, "cleanup")
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
it.effect("reuses location-owned fuzzy targets across index refreshes", () =>
Effect.gen(function* () {
let scans = 0
const second = yield* Deferred.make<void>()
const prepare = yield* Effect.acquireRelease(
Effect.sync(() => spyOn(fuzzysort, "prepare")),
(value) => Effect.sync(() => value.mockRestore()),
)
const cleanup = yield* Effect.acquireRelease(
Effect.sync(() => spyOn(fuzzysort, "cleanup")),
(value) => Effect.sync(() => value.mockRestore()),
)
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
),
),
),
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
if (input.onEntry) yield* input.onEntry(entry)
if (scans > 1) yield* Deferred.succeed(second, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
if (input.onEntry) yield* input.onEntry(entry)
if (scans > 1) yield* Deferred.succeed(second, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
await Effect.runPromise(
Effect.gen(function* () {
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* search.find({ query: "index", type: "file" })
yield* TestClock.adjust("10 seconds")
@@ -246,9 +247,7 @@ describe("FileSystemSearch", () => {
expect(prepare).toHaveBeenCalledTimes(2)
expect(cleanup).toHaveBeenCalledTimes(3)
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
)
prepare.mockRestore()
cleanup.mockRestore()
})
}).pipe(Effect.provide(layer))
}),
)
})
+1 -1
View File
@@ -186,7 +186,7 @@ describe("Integration", () => {
value: Credential.Key.make({ type: "key", key: "secret", configuration: { accountId: "account" } }),
}),
])
expect(Array.from(yield* Fiber.join(created), (event) => ({ type: event.type, data: event.data }))).toEqual([
expect((yield* Fiber.join(created)).map((event) => ({ type: event.type, data: event.data }))).toEqual([
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Switched.type, data: { credentialID: stored[0]?.id, integrationID } },
])
@@ -49,6 +49,29 @@ export const environmentConformance = <E>(
}),
)
check("observes filesystem state when an operation executes", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/deferred.txt`
const source = `${harness.root}/source.txt`
const destination = `${harness.root}/destination.txt`
const read = harness.files.read(target)
const stat = harness.files.stat(target)
const list = harness.files.list(harness.root)
const move = harness.files.move(source, destination)
yield* harness.files.write(target, bytes("first"))
yield* harness.files.write(source, bytes("moved"))
expect(text((yield* read).bytes)).toBe("first")
expect((yield* stat).size).toBe(5)
expect(yield* list).toContainEqual({ name: "deferred.txt", type: "file" })
yield* move
expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
yield* harness.files.write(target, bytes("second"))
expect(text((yield* read).bytes)).toBe("second")
}),
)
check("reports missing paths", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/missing`
+104
View File
@@ -49,6 +49,7 @@ import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/t
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
let decision: Effect.Effect<void, Permission.Error> = Effect.void
let calls = 0
let invocations: Array<Parameters<Mcp.Interface["callTool"]>[0]> = []
type ResourcePage = {
items: Array<{ name: string; uri: string; description?: string; mimeType?: string }>
@@ -83,6 +84,12 @@ function resourceServer(
resourceLists: 0,
templateLists: 0,
toolLists: 0,
toolCalls: [] as Array<{
name: string
arguments: Record<string, unknown> | undefined
sessionID: unknown
progressToken: unknown
}>,
initializations: 0,
urls: [] as string[],
}
@@ -132,6 +139,17 @@ function resourceServer(
}
})
}
if (!input.emptyElicitation && !input.urlElicitation) {
protocol.setRequestHandler(CallToolRequestSchema, (request) => {
state.toolCalls.push({
name: request.params.name,
arguments: request.params.arguments,
sessionID: request.params._meta?.sessionID,
progressToken: request.params._meta?.progressToken,
})
return Promise.resolve({ content: [] })
})
}
if (input.resources !== false) {
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
state.resourceLists += 1
@@ -313,6 +331,7 @@ const mcp = Layer.mock(Mcp.Service, {
callTool: (input) =>
Effect.sync(() => {
calls += 1
invocations.push(input)
if (input.name === "fail")
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
@@ -380,6 +399,43 @@ test("MCP tool names match V1 sanitization", () => {
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
})
test("passes session IDs as MCP request metadata", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer()
const connection = yield* connect(
"session-metadata",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
)
yield* connection.callTool({
name: "echo",
args: { text: "hello" },
sessionID: Session.ID.make("ses_mcp_metadata"),
})
yield* connection.callTool({ name: "echo" })
expect(server.state.toolCalls).toEqual([
{
name: "echo",
arguments: { text: "hello" },
sessionID: "ses_mcp_metadata",
progressToken: expect.any(Number),
},
{
name: "echo",
arguments: {},
sessionID: undefined,
progressToken: expect.any(Number),
},
])
expect(server.state.toolCalls[0]?.progressToken).not.toBe(server.state.toolCalls[1]?.progressToken)
}),
),
)
})
test("preserves output schema validation across paginated tool discovery", async () => {
const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
@@ -1610,6 +1666,54 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
}),
)
it.effect("forwards the invoking session through direct and Code Mode MCP tools", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
invocations = []
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.definitions.find((tool) => tool.name === "direct_lookup")?.inputSchema).not.toHaveProperty(
"properties.sessionID",
)
expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).not.toContain("sessionID")
const directSessionID = Session.ID.make("ses_mcp_direct")
yield* toolSet.execute({
sessionID: directSessionID,
...toolIdentity,
call: { type: "tool-call", id: "call_mcp_direct", name: "direct_lookup", input: {} },
})
expect(invocations[0]).toEqual({
server: "direct",
name: "lookup",
args: {},
sessionID: directSessionID,
})
const codeModeSessionID = Session.ID.make("ses_mcp_codemode")
yield* toolSet.execute({
sessionID: codeModeSessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call_mcp_codemode",
name: "execute",
input: { code: "return await tools.demo.search({})" },
},
})
expect(invocations[1]).toEqual({
server: "demo",
name: "search",
args: {},
sessionID: codeModeSessionID,
})
}),
)
it.effect("returns content-only MCP results through Code Mode", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
+1 -1
View File
@@ -234,7 +234,7 @@ describe("Npm.add", () => {
const first = await Effect.gen(function* () {
const npm = yield* Npm.Service
const mutableEntry = yield* npm.add(mutable, { refresh: true })
const mutableEntry = yield* npm.add(mutable)
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
yield* Effect.promise(async () => {
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
+62 -1
View File
@@ -1,7 +1,7 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer } from "effect"
import { Effect, Exit, Layer, Scope } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -11,6 +11,8 @@ import { Location } from "@opencode-ai/core/location"
import { Model } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -18,6 +20,7 @@ import { withEnv } from "../fixture/env"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { catalogHost, host, integrationHost } from "./host"
import { PluginTestLayer } from "./fixture"
const locationLayer = Layer.succeed(
Location.Service,
@@ -27,10 +30,68 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no
[Location.node, locationLayer],
])
const it = testEffect(layer)
const real = testEffect(PluginTestLayer)
const models = (file: string) =>
AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.configured({ file, fetch: false })]])
describe("ModelsDevPlugin", () => {
real.effect("keeps the retained model seed unchanged across catalog replay", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugins = yield* Plugin.Service
const providerID = Provider.ID.make("acme")
const modelID = Model.ID.make("model")
const modelsDev = ModelsDev.Service.of({
get: () =>
Effect.succeed([
{
info: {
id: providerID,
name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
},
environment: [],
models: [
{
id: modelID,
modelID,
providerID,
name: "Model",
capabilities: { tools: true, input: [], output: [] },
variants: [],
time: { released: Date.parse("2026-01-01") },
cost: [],
status: "active",
enabled: true,
limit: { context: 128_000, output: 32_000 },
},
],
},
] satisfies readonly ModelsDev.Snapshot[]),
refresh: () => Effect.void,
})
const pluginHost = yield* PluginHost.make(plugins)
yield* ModelsDevPlugin.effect(pluginHost).pipe(Effect.provideService(ModelsDev.Service, modelsDev))
const scope = yield* Scope.make()
yield* catalog
.transform((draft) =>
draft.model.update(providerID, modelID, (model) => {
model.variants ??= []
model.variants.push({ id: Model.VariantID.make("configured") })
}),
)
.pipe(Scope.provide(scope))
expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([
{ id: Model.VariantID.make("configured") },
])
yield* Scope.close(scope, Exit.void)
expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([])
}),
)
it.effect("projects normalized models.dev snapshots into the catalog", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+29
View File
@@ -0,0 +1,29 @@
import path from "path"
import { pathToFileURL } from "url"
import { expect, test } from "bun:test"
import { PluginModule } from "@opencode-ai/core/plugin/module"
import { Npm } from "@opencode-ai/util/npm"
import { Effect } from "effect"
test("loads cached plugin packages without requesting a refresh", async () => {
const calls: unknown[] = []
const entrypoint = path.join(import.meta.dir, "fixtures", "config-effect-plugin.ts")
const plugin = await PluginModule.load({ type: "add", target: "fixture-plugin", options: {} }).pipe(
Effect.provideService(
Npm.Service,
Npm.Service.of({
add: (_pkg, options) =>
Effect.sync(() => {
calls.push(options)
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
}),
resolve: () => Effect.die(new Error("Unexpected resolve")),
which: () => Effect.die(new Error("Unexpected which")),
}),
),
Effect.runPromise,
)
expect(plugin.id).toBe("config-effect-plugin")
expect(calls).toEqual([{ subpaths: ["server", ""] }])
})
+13 -35
View File
@@ -70,20 +70,12 @@ describe("AppProcess", () => {
"requireSuccess fails on non-zero exit",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const exit = yield* Effect.exit(
svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess)),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1)
expect((reason.error as AppProcess.AppProcessError).message).toContain("Command failed (exit 1)")
} else {
throw new Error("expected fail reason")
}
}
const error = yield* svc
.run(cmd("-e", "process.exit(1)"))
.pipe(Effect.flatMap(AppProcess.requireSuccess), Effect.flip)
expect(error).toBeInstanceOf(AppProcess.AppProcessError)
expect(error.exitCode).toBe(1)
expect(error.message).toContain("Command failed (exit 1)")
}),
)
@@ -105,15 +97,9 @@ describe("AppProcess", () => {
expect(okZero.exitCode).toBe(0)
const okOne = yield* svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(requireZeroOrOne))
expect(okOne.exitCode).toBe(1)
const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne)))
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2)
}
}
const error = yield* svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne), Effect.flip)
expect(error).toBeInstanceOf(AppProcess.AppProcessError)
expect(error.exitCode).toBe(2)
}),
)
@@ -313,18 +299,10 @@ describe("AppProcess", () => {
.runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
.pipe(Stream.runCollect)
expect(Array.from(allowed)).toEqual(["only"])
const exit = yield* Effect.exit(
svc
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
.pipe(Stream.runCollect),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
}
}
const error = yield* svc
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
.pipe(Stream.runCollect, Effect.flip)
expect(error).toBeInstanceOf(AppProcess.AppProcessError)
}),
)
@@ -20,6 +20,8 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { App } from "@opencode-ai/core/app"
import { Agent } from "@opencode-ai/core/agent"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -301,6 +303,54 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
}),
)
it.effect("manual compaction records model resolution failures without calling the model", () =>
Effect.gen(function* () {
requests = []
const compaction = yield* SessionCompaction.Service
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_manual_resolution_failure")
const session = yield* insertSession(sessionID)
const modelRequests = yield* SessionModelRequest.Service
const inputID = SessionMessage.ID.make("msg_manual_resolution_failure")
expect(
yield* compaction.compactManual({
session,
resolveModel: () =>
Effect.fail(
new SessionRunnerModel.ModelUnavailableError({
providerID: Provider.ID.make("test"),
modelID: Model.ID.make("missing"),
}),
),
prepare: modelRequests.prepare,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize this conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
],
inputID,
}),
).toEqual({
status: "failed",
error: { type: "provider.no-route", message: "Model unavailable: test/missing" },
})
expect(requests).toHaveLength(0)
expect(yield* store.context(sessionID)).toMatchObject([
{
id: inputID,
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "provider.no-route", message: "Model unavailable: test/missing" },
},
])
}),
)
it.effect("forked session compaction reuses the fork root prompt cache key", () =>
Effect.gen(function* () {
requests = []
+12 -1
View File
@@ -67,6 +67,17 @@ const liveIt = testEffect(
],
),
)
const projectIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
// Project adoption needs plain-prompt admission, not live plugin/provider startup.
[LocationServiceMap.node, promptLocationLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const id = Session.ID.create()
@@ -119,7 +130,7 @@ describe("Session.create", () => {
),
)
liveIt.live("follows the directory's project identity established after creation", () =>
projectIt.live("follows the directory's project identity established after creation", () =>
withTmp((directory) =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -857,8 +857,7 @@ describe("SessionModelTransport", () => {
test("records metadata-only lifecycle metrics", async () => {
const fixture = automatic()
await run(
fixture.connector,
await Effect.runPromise(
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session)
@@ -874,7 +873,11 @@ describe("SessionModelTransport", () => {
)
expect(JSON.stringify(lifecycle)).not.toContain("secret-one")
expect(JSON.stringify(lifecycle)).not.toContain("secret-two")
}),
}).pipe(
Effect.provide(SessionModelTransport.makeLayer(fixture.connector)),
Effect.scoped,
Effect.provideService(Metric.MetricRegistry, new Map()),
),
)
})
})
+4 -4
View File
@@ -78,8 +78,7 @@ const locations = Layer.effect(
Layer.mock(Snapshot.Service, {
capture: () =>
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () =>
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () => (ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready"))),
}),
Layer.succeed(
PluginSupervisor.Service,
@@ -172,8 +171,9 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => {
describe("Session.prompt", () => {
it.effect("exposes the execution registry", () =>
Effect.gen(function* () {
const session = yield* Session.Service
activeSessions.add(sessionID)
expect(Array.from(yield* (yield* Session.Service).active)).toEqual([sessionID])
expect(Array.from(yield* session.active)).toEqual([sessionID])
}).pipe(Effect.ensuring(Effect.sync(() => activeSessions.clear()))),
)
@@ -557,7 +557,7 @@ describe("Session.prompt", () => {
yield* session.resume(sessionID)
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admitted(message.id)).not.toHaveProperty("promotedSeq")
expect((yield* session.inbox(sessionID)).map((item) => item.id)).toEqual([message.id])
expect(executionCalls).toEqual([sessionID])
expect(wakeCalls).toEqual([])
}),
+48 -23
View File
@@ -582,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,8 +4364,9 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(Stream.fail(providerUnavailable()))
yield* s.llm.push(TestLLM.text("Recovered", "retry-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("1599 millis")
expect(s.requests).toHaveLength(1)
yield* TestClock.adjust("801 millis")
@@ -4379,11 +4387,7 @@ describe("SessionRunnerLLM", () => {
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)
@@ -4425,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)
@@ -4446,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)
@@ -4464,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")
@@ -4478,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")
@@ -4500,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)
@@ -4549,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)
@@ -4579,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")
@@ -4617,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)
@@ -4647,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)
@@ -4689,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)
@@ -4831,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)
@@ -4849,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)
@@ -4898,8 +4922,9 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(Stream.fail(failure))
yield* s.llm.push(TestLLM.tool("call-after-retry", "echo", { text: "recovered" }), TestLLM.stop())
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
@@ -0,0 +1,527 @@
import { describe, expect, test } from "bun:test"
import { AIError, TransportError, type LLMEvent } from "@opencode-ai/ai"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionStep } from "@opencode-ai/core/session/runner/step"
import { SessionStepMachine } from "@opencode-ai/core/session/runner/step-machine"
import { Cause, Deferred, Effect, Exit, Fiber, Ref, Scheduler } from "effect"
import { it } from "./lib/effect"
const firstID = SessionMessage.ID.make("msg_first")
const failure = new AIError({
reason: new TransportError({ message: "Provider unavailable", transport: "http", operation: "request" }),
})
const error = { type: "provider.transport", message: "Provider unavailable" } as const
describe("SessionStepMachine", () => {
it.effect("completes a logical Step", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make<ReadonlyArray<SessionStepMachine.Context>>([])
const result = yield* SessionStepMachine.run(firstID, {
prepare: (context) =>
Ref.update(attempts, (values) => [...values, context]).pipe(
Effect.as(
SessionStepMachine.Preparation.Ready({
attempt: makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: true })),
}),
),
),
retry: () => Effect.void,
publishSynthetic: Effect.void,
})
expect(result).toBe(true)
expect(yield* Ref.get(attempts)).toEqual([
{ assistantMessageID: firstID, recoverOverflow: true, recoverContinuation: true },
])
}),
)
it.effect("pulls, publishes, and runs a local tool before settlement", () =>
Effect.gen(function* () {
const operations = yield* Ref.make<ReadonlyArray<string>>([])
const call = { type: "tool-call", id: "call_1", name: "lookup", input: {} } satisfies Extract<
LLMEvent,
{ type: "tool-call" }
>
const attempt = makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: false }), {
events: [call],
operations,
})
yield* SessionStepMachine.run(firstID, {
prepare: () => Effect.succeed(SessionStepMachine.Preparation.Ready({ attempt })),
retry: () => Effect.void,
publishSynthetic: Effect.void,
})
const observed = yield* Ref.get(operations)
expect(observed.indexOf("publish:tool-call")).toBeLessThan(observed.indexOf("tool:call_1"))
expect(observed.at(-1)).toBe("settle")
}),
)
it.effect("retries transparently with the same assistant", () =>
Effect.gen(function* () {
const outcomes: Array<SessionStep.Outcome> = [
SessionStep.Outcome.Retry({ cause: failure, error }),
SessionStep.Outcome.Completed({ needsContinuation: false }),
]
const operations = yield* Ref.make<ReadonlyArray<string>>([])
const result = yield* SessionStepMachine.run(firstID, {
prepare: (context) =>
Ref.update(operations, (values) => [...values, `attempt:${context.assistantMessageID}`]).pipe(
Effect.map(() =>
SessionStepMachine.Preparation.Ready({
attempt: makeAttempt(outcomes.shift() ?? SessionStep.Outcome.Completed({ needsContinuation: false })),
}),
),
),
retry: (context) => Ref.update(operations, (values) => [...values, `retry:${context.assistantMessageID}`]),
publishSynthetic: Effect.void,
})
expect(result).toBe(false)
expect(yield* Ref.get(operations)).toEqual([`attempt:${firstID}`, `retry:${firstID}`, `attempt:${firstID}`])
}),
)
it.effect("continues partial output only after retry and synthetic publication", () =>
Effect.gen(function* () {
const outcomes: Array<SessionStep.Outcome> = [
SessionStep.Outcome.Continue({ cause: failure, error }),
SessionStep.Outcome.Completed({ needsContinuation: false }),
]
const operations = yield* Ref.make<ReadonlyArray<string>>([])
yield* SessionStepMachine.run(firstID, {
prepare: (context) =>
Ref.update(operations, (values) => [...values, `attempt:${context.assistantMessageID}`]).pipe(
Effect.map(() =>
SessionStepMachine.Preparation.Ready({
attempt: makeAttempt(outcomes.shift() ?? SessionStep.Outcome.Completed({ needsContinuation: false })),
}),
),
),
retry: () => Ref.update(operations, (values) => [...values, "retry"]),
publishSynthetic: Ref.update(operations, (values) => [...values, "synthetic"]),
})
const observed = yield* Ref.get(operations)
expect(observed.slice(0, 3)).toEqual([`attempt:${firstID}`, "retry", "synthetic"])
expect(observed.at(3)).toStartWith("attempt:msg_")
expect(observed.at(3)).not.toBe(`attempt:${firstID}`)
}),
)
it.effect("tracks independent recovery allowances", () =>
Effect.gen(function* () {
const outcomes: Array<SessionStep.Outcome> = [
SessionStep.Outcome.RecoverFull(),
SessionStep.Outcome.Completed({ needsContinuation: false }),
SessionStep.Outcome.Completed({ needsContinuation: false }),
]
const recoveries = [false, true, false]
const attempts = yield* Ref.make<ReadonlyArray<SessionStepMachine.Context>>([])
yield* SessionStepMachine.run(firstID, {
prepare: (context) =>
Ref.update(attempts, (values) => [...values, context]).pipe(
Effect.map(() =>
SessionStepMachine.Preparation.Ready({
attempt: makeAttempt(outcomes.shift() ?? SessionStep.Outcome.Completed({ needsContinuation: false }), {
recoverOverflow: recoveries.shift(),
}),
}),
),
),
retry: () => Effect.void,
publishSynthetic: Effect.void,
})
const observed = yield* Ref.get(attempts)
expect(observed.slice(0, 2)).toEqual([
{ assistantMessageID: firstID, recoverOverflow: true, recoverContinuation: true },
{ assistantMessageID: firstID, recoverOverflow: true, recoverContinuation: false },
])
expect(observed.at(2)).toMatchObject({ recoverOverflow: false, recoverContinuation: false })
expect(observed.at(2)?.assistantMessageID).not.toBe(firstID)
}),
)
it.effect("does not begin another attempt when retry is interrupted", () =>
Effect.gen(function* () {
const retryStarted = yield* Deferred.make<void>()
const retryFinalized = yield* Deferred.make<void>()
const attempts = yield* Ref.make(0)
const machine = yield* SessionStepMachine.run(firstID, {
prepare: () =>
Ref.update(attempts, (value) => value + 1).pipe(
Effect.as(
SessionStepMachine.Preparation.Ready({
attempt: makeAttempt(SessionStep.Outcome.Retry({ cause: failure, error })),
}),
),
),
retry: () =>
Deferred.succeed(retryStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(Deferred.succeed(retryFinalized, undefined)),
),
publishSynthetic: Effect.void,
}).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(retryStarted)
yield* Fiber.interrupt(machine)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Deferred.isDone(retryFinalized)).toBe(true)
expect(yield* Ref.get(attempts)).toBe(1)
}),
)
for (const outcome of [
SessionStep.Outcome.Completed({ needsContinuation: true }),
SessionStep.Outcome.Retry({ cause: failure, error }),
SessionStep.Outcome.Continue({ cause: failure, error }),
SessionStep.Outcome.RecoverFull(),
]) {
it.effect(`cancellation during settlement prevents ${outcome._tag} from starting more work`, () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const operations = yield* Ref.make<ReadonlyArray<string>>([])
const attempt = {
...makeAttempt(outcome),
settle: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.andThen(Ref.update(operations, (values) => [...values, "settled"])),
Effect.as(outcome),
Effect.uninterruptible,
),
}
const machine = yield* SessionStepMachine.run(firstID, {
prepare: () =>
Ref.update(operations, (values) => [...values, "prepare"]).pipe(
Effect.as(SessionStepMachine.Preparation.Ready({ attempt })),
),
retry: () => Ref.update(operations, (values) => [...values, "retry"]),
publishSynthetic: Ref.update(operations, (values) => [...values, "synthetic"]),
}).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(started)
const interrupted = yield* Fiber.interrupt(machine).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(interrupted)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Ref.get(operations)).toEqual(["prepare", "settled"])
}),
)
}
it.effect("cancels provider and tools together, then closes and settles once", () =>
Effect.gen(function* () {
const providerStarted = yield* Deferred.make<void>()
const providerStopped = yield* Deferred.make<void>()
const toolStarted = yield* Deferred.make<void>()
const toolStopped = yield* Deferred.make<void>()
const operations = yield* Ref.make<ReadonlyArray<string>>([])
const calls = [{ type: "tool-call", id: "call_parallel", name: "lookup", input: {} }] as const
const pending = [...calls]
const attempt: SessionStep.Attempt = {
...makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: false }), { operations }),
observeUntilBoundary: () =>
Effect.suspend(() => {
const call = pending.shift()
if (call) return Effect.succeed(SessionStep.ProviderObservation.ToolCall({ call }))
return Deferred.succeed(providerStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(
Deferred.succeed(providerStopped, undefined).pipe(Effect.andThen(Deferred.await(toolStopped))),
),
)
}),
runTool: () =>
Deferred.succeed(toolStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(
Deferred.succeed(toolStopped, undefined).pipe(Effect.andThen(Deferred.await(providerStopped))),
),
),
settle: (settlement) =>
Effect.sync(() => {
expect(Exit.hasInterrupts(settlement.stream)).toBe(true)
expect(settlement.tools).toHaveLength(1)
expect(settlement.tools[0]?.call).toEqual(calls[0])
expect(settlement.tools.every((tool) => Exit.hasInterrupts(tool.exit))).toBe(true)
}).pipe(
Effect.andThen(Ref.update(operations, (values) => [...values, "settle"])),
Effect.as(SessionStep.Outcome.Completed({ needsContinuation: false })),
),
}
const machine = yield* SessionStepMachine.run(firstID, {
prepare: () => Effect.succeed(SessionStepMachine.Preparation.Ready({ attempt })),
retry: () => Effect.die("Unexpected retry"),
publishSynthetic: Effect.die("Unexpected continuation"),
}).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(providerStarted)
yield* Deferred.await(toolStarted)
yield* Fiber.interrupt(machine)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Ref.get(operations)).toEqual(["finish-provider", "settle"])
}),
)
it.effect("does not finalize the provider twice when cancellation races with finalization", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const operations = yield* Ref.make<ReadonlyArray<string>>([])
const attempt = {
...makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: false }), { operations }),
finishProvider: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.andThen(Ref.update(operations, (values) => [...values, "finish-provider"])),
Effect.uninterruptible,
),
}
const machine = yield* SessionStepMachine.run(firstID, {
prepare: () => Effect.succeed(SessionStepMachine.Preparation.Ready({ attempt })),
retry: () => Effect.die("Unexpected retry"),
publishSynthetic: Effect.die("Unexpected continuation"),
}).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(started)
const interrupted = yield* Fiber.interrupt(machine).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(interrupted)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Ref.get(operations)).toEqual(["read:end", "finish-provider", "settle"])
}),
)
test("cancellation awaits provider finalization and stops pending tools before settling", () => {
const definition = SessionStepMachine.definition<never, never>(firstID)
const cause = Cause.interrupt(123)
const call = { type: "tool-call", id: "call_pending", name: "lookup", input: {} } as const
const completed = { ...call, id: "call_completed" }
const state = SessionStepMachine.State.FinalizingProvider({
active: {
context: { assistantMessageID: firstID, recoverOverflow: true, recoverContinuation: true },
attempt: makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: false })),
tools: new Map([
[completed.id, { call: completed, exit: Exit.succeed(undefined) }],
[call.id, { call }],
]),
},
stream: Exit.succeed(undefined),
})
const stopping = definition.transition(state, {
_tag: "Input",
input: SessionStepMachine.Event.CancelRequested(),
cause,
})
if (stopping._tag !== "Continue") throw new Error("Expected cancellation to await owned invocations")
expect(stopping.state).toEqual({ _tag: "Stopping", from: state, cause })
expect(stopping.commands).toEqual([
{ _tag: "StopAndJoin", id: "step", ids: ["tool:call_pending"], waitFor: ["provider"] },
])
expect(
definition.transition(stopping.state, {
_tag: "Input",
input: SessionStepMachine.Event.CancelRequested(),
cause,
}),
).toEqual({ _tag: "Continue", state: stopping.state, commands: [] })
const settling = definition.transition(stopping.state, {
_tag: "InvocationsStopped",
id: "step",
exits: [
{
_tag: "InvocationExited",
id: "tool:call_pending",
generation: 1,
operation: SessionStepMachine.Operation.RunTool({ attempt: state.active.attempt, call }),
exit: Exit.interrupt(456),
},
{
_tag: "InvocationExited",
id: "provider",
generation: 2,
operation: SessionStepMachine.Operation.FinishProvider({
attempt: state.active.attempt,
stream: state.stream,
}),
exit: Exit.succeed(SessionStepMachine.Event.ProviderFinished({ exit: Exit.succeed(undefined) })),
},
],
})
if (settling._tag !== "Continue") throw new Error("Expected settlement after the joined batch")
expect(settling.state).toMatchObject({ _tag: "SettlingAttempt", stopping: cause })
expect(settling.commands).toEqual([
{
_tag: "Invoke",
id: "settlement",
operation: {
_tag: "SettleAttempt",
attempt: state.active.attempt,
settlement: {
stream: state.stream,
tools: [
{ call: completed, exit: Exit.succeed(undefined) },
{ call, exit: Exit.interrupt(456) },
],
},
},
},
])
})
for (const fixture of [
{ name: "never-started", exit: Exit.interrupt(456), replaced: false },
{
name: "queued false",
exit: Exit.succeed(SessionStepMachine.Event.OverflowRecovered({ exit: Exit.succeed(false) })),
replaced: false,
},
{
name: "queued failure",
exit: Exit.succeed(SessionStepMachine.Event.OverflowRecovered({ exit: Exit.die("Recovery failed") })),
replaced: false,
},
{
name: "queued true",
exit: Exit.succeed(SessionStepMachine.Event.OverflowRecovered({ exit: Exit.succeed(true) })),
replaced: true,
},
] as const) {
test(`cancellation reconciles ${fixture.name} overflow recovery before deciding settlement`, () => {
const definition = SessionStepMachine.definition<never, never>(firstID)
const cause = Cause.interrupt(123)
const state = SessionStepMachine.State.RecoveringOverflow({
active: {
context: { assistantMessageID: firstID, recoverOverflow: true, recoverContinuation: true },
attempt: makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: true })),
tools: new Map(),
},
stream: Exit.succeed(undefined),
})
const stopping = definition.transition(state, {
_tag: "Input",
input: SessionStepMachine.Event.CancelRequested(),
cause,
})
if (stopping._tag !== "Continue") throw new Error("Expected cancellation to await recovery")
expect(stopping.state).toEqual({ _tag: "Stopping", from: state, cause })
expect(stopping.commands).toEqual([{ _tag: "StopAndJoin", id: "step", ids: ["compaction"], waitFor: [] }])
const settled = definition.transition(stopping.state, {
_tag: "InvocationsStopped",
id: "step",
exits: [
{
_tag: "InvocationExited",
id: "compaction",
generation: 1,
operation: SessionStepMachine.Operation.RecoverOverflow({
attempt: state.active.attempt,
settlement: { stream: state.stream, tools: [] },
}),
exit: fixture.exit,
},
],
})
if (fixture.replaced) {
expect(settled).toEqual({ _tag: "Done", output: Exit.failCause(cause) })
return
}
if (settled._tag !== "Continue") throw new Error("Expected the unreplaced attempt to settle")
expect(settled.state).toEqual({ _tag: "SettlingAttempt", active: state.active, stopping: cause })
expect(settled.commands).toEqual([
{
_tag: "Invoke",
id: "settlement",
operation: {
_tag: "SettleAttempt",
attempt: state.active.attempt,
settlement: { stream: Exit.failCause(cause), tools: [] },
},
},
])
const command = settled.commands[0]
if (command?._tag !== "Invoke") throw new Error("Expected a settlement invocation")
expect(
definition.transition(settled.state, {
_tag: "InvocationExited",
id: command.id,
generation: 2,
operation: command.operation,
exit: Exit.succeed(
SessionStepMachine.Event.AttemptSettled({
exit: Exit.succeed(SessionStep.Outcome.Completed({ needsContinuation: true })),
}),
),
}),
).toEqual({ _tag: "Done", output: Exit.failCause(cause) })
})
}
for (const target of ["finishProvider", "recoverOverflow"] as const) {
it.effect(`settles once when cancellation precedes ${target} execution`, () =>
Effect.gen(function* () {
const operations = yield* Ref.make<ReadonlyArray<string>>([])
const attempt = makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: true }), { operations })
const machine = yield* Effect.withFiber((fiber) =>
SessionStepMachine.run(firstID, {
prepare: () =>
Effect.succeed(
SessionStepMachine.Preparation.Ready({
attempt: {
...attempt,
// Interrupt during construction, before the deferred invocation starts.
finishProvider: (stream) => {
if (target === "finishProvider") fiber.interruptUnsafe(123)
return attempt.finishProvider(stream)
},
recoverOverflow: (settlement) => {
if (target === "recoverOverflow") fiber.interruptUnsafe(123)
return attempt.recoverOverflow(settlement)
},
},
}),
),
retry: () => Effect.die("Unexpected retry"),
publishSynthetic: Effect.die("Unexpected continuation"),
}),
).pipe(
Effect.provideService(Scheduler.PreventSchedulerYield, true),
Effect.forkChild({ startImmediately: true }),
)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Ref.get(operations)).toEqual(["read:end", "finish-provider", "settle"])
}),
)
}
})
function makeAttempt(
outcome: SessionStep.Outcome,
options?: {
readonly events?: ReadonlyArray<LLMEvent>
readonly operations?: Ref.Ref<ReadonlyArray<string>>
readonly recoverOverflow?: boolean
},
): SessionStep.Attempt {
const events = [...(options?.events ?? [])]
const log = (value: string) =>
options?.operations ? Ref.update(options.operations, (values) => [...values, value]) : Effect.void
return {
observeUntilBoundary: () =>
Effect.gen(function* () {
const event = events.shift()
yield* log(event ? `read:${event.type}` : "read:end")
if (!event) return SessionStep.ProviderObservation.ProviderEnd()
yield* log(`publish:${event.type}`)
if (event.type !== "tool-call") return SessionStep.ProviderObservation.ProviderEnd()
return SessionStep.ProviderObservation.ToolCall({ call: event })
}),
runTool: (call) => log(`tool:${call.id}`),
finishProvider: () => log("finish-provider"),
recoverOverflow: () => Effect.succeed(options?.recoverOverflow ?? false),
settle: () => log("settle").pipe(Effect.as(outcome)),
}
}
+309 -72
View File
@@ -1,5 +1,5 @@
import { expect } from "bun:test"
import { LanguageModel, LLM, LLMEvent } from "@opencode-ai/ai"
import { AIError, LanguageModel, LLM, LLMEvent, TransportError } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
import { TestLLM } from "@opencode-ai/ai/testing"
import { Agent } from "@opencode-ai/core/agent"
@@ -11,17 +11,19 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStep } from "@opencode-ai/core/session/runner/step"
import { SessionStepMachine } from "@opencode-ai/core/session/runner/step-machine"
import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { asc, eq } from "drizzle-orm"
import { Effect, Exit, Layer } from "effect"
import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(
@@ -40,49 +42,21 @@ for (const fixture of [
] as const) {
it.effect(`settles ${fixture.finish} with tool choice ${fixture.toolChoice ?? "default"}`, () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const llm = yield* TestLLM.Test
const sessionID = Session.ID.create()
const assistantMessageID = SessionMessage.ID.create()
const start = Snapshot.ID.make("before")
const end = Snapshot.ID.make("after")
const files = [RelativePath.make("changed.ts")]
let captures = 0
let executions = 0
const steps = yield* SessionStep.make.pipe(
Effect.provide(
Layer.mock(Snapshot.Service)({
capture: () => Effect.sync(() => (captures++ === 0 ? start : end)),
files: (input) => {
expect(input).toEqual({ from: start, to: end })
return Effect.succeed(files)
},
}),
),
)
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({ id: sessionID, project_id: Project.ID.global, slug: "step", directory: "/project", version: "test" })
.run()
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "test-model", provider: "test", route: OpenAIChat.route }),
{
capabilities: { tools: true, input: ["text"], output: ["text"] },
limit: { context: 100_000, output: 1_000 },
cost: [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: { read: Money.USDPerMillionTokens.make(0.1), write: Money.USDPerMillionTokens.make(0.5) },
},
],
const s = yield* setup({
snapshot: {
capture: () => Effect.sync(() => (captures++ === 0 ? start : end)),
files: (input) => {
expect(input).toEqual({ from: start, to: end })
return Effect.succeed(files)
},
},
)
yield* llm.push(
})
yield* s.llm.push(
TestLLM.complete(
{
reason: { normalized: fixture.finish },
@@ -98,52 +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: {
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" }
}),
},
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(
@@ -152,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))),
}
})
+2 -4
View File
@@ -105,11 +105,9 @@ test("foreign typed failures settle as Tool.Error at the untrusted boundary", as
execute: () => new ForeignFailure({ message: "transport died" }) as never,
}
const exit = await Effect.runPromiseExit(execute(lying, {}, context))
expect(exit._tag).toBe("Failure")
const error = exit._tag === "Failure" ? exit.cause.reasons.find((reason) => "error" in reason)?.error : undefined
const error = await Effect.runPromise(execute(lying, {}, context).pipe(Effect.flip))
expect(error).toBeInstanceOf(Tool.Error)
expect((error as Tool.Error).message).toBe("transport died")
expect(error.message).toBe("transport died")
})
test("execute supports callable namespace tools", async () => {
+2 -2
View File
@@ -171,7 +171,7 @@ describe("Worktree", () => {
{ directory: created.directory, strategy: "git" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
expect((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
@@ -550,7 +550,7 @@ describe("Worktree", () => {
{ directory: existing, strategy: "git" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
expect((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
yield* Effect.promise(() => $`git worktree remove --force ${unchanged}`.cwd(input.root.path).quiet())
+15 -10
View File
@@ -68,7 +68,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "./component/dialog-open"
import { DialogOpen, DialogOpenKey, moveOpenSession } from "./component/dialog-open"
import { SessionTabs } from "./component/session-tabs"
import { clampSessionTabsWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "./ui/layout"
import { createPaneResize } from "./ui/pane-resize"
@@ -507,7 +507,7 @@ function App(props: { pair?: DialogPairCredentials }) {
}).catch((error) => console.error("Failed to persist TUI layout", error))
},
})
let openingOpen: Promise<SessionInfo[]> | undefined
const [openSessions, setOpenSessions] = createSignal<SessionInfo[]>([])
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
// without having to open the status panel. Tracking the last alerted status avoids re-toasting
// the same problem on every refresh while still re-alerting if the state changes.
@@ -719,14 +719,12 @@ function App(props: { pair?: DialogPairCredentials }) {
title: "Open session or project",
category: "Session",
slash: { name: "open", aliases: ["projects", "project"] },
run: async () => {
if (dialog.key === DialogOpenKey || openingOpen) return
const previous = dialog.stack.at(-1)
openingOpen = loadDialogOpen(data, client)
const sessions = await openingOpen
openingOpen = undefined
if (dialog.stack.at(-1) !== previous) return
dialog.replace(() => <DialogOpen sessions={sessions} />, undefined, { key: DialogOpenKey, size: "large" })
run: () => {
if (dialog.key === DialogOpenKey) return
dialog.replace(() => <DialogOpen sessions={openSessions()} onLoad={setOpenSessions} />, undefined, {
key: DialogOpenKey,
size: "large",
})
},
},
...Array.from({ length: 9 }, (_, i) => ({
@@ -1213,7 +1211,14 @@ function App(props: { pair?: DialogPairCredentials }) {
})
})
event.on("session.moved", (evt) => {
setOpenSessions((sessions) =>
sessions.map((session) => (session.id !== evt.data.sessionID ? session : moveOpenSession(session, evt))),
)
})
event.on("session.deleted", (evt) => {
setOpenSessions((sessions) => sessions.filter((session) => session.id !== evt.data.sessionID))
if (route.data.type === "session" && route.data.sessionID === evt.data.sessionID) {
const title = active?.id === evt.data.sessionID ? active.title : undefined
route.navigate({ type: "home" })
+73 -17
View File
@@ -1,5 +1,5 @@
import { createMemo, createResource, createSignal } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { createMemo, createResource, createSignal, onCleanup, Show } from "solid-js"
import type { OpenCodeEvent, SessionInfo } from "@opencode-ai/client"
import { useTerminalDimensions } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
import { dialogWidth, useDialog } from "../ui/dialog"
@@ -25,18 +25,7 @@ export const DialogOpenKey = Symbol("DialogOpen")
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
export async function loadDialogOpen(data: ReturnType<typeof useData>, client: ReturnType<typeof useClient>) {
const [, sessions] = await Promise.all([
data.project.sync().catch(() => {}),
client.api.session
.list({ limit: 50, order: "desc", parentID: null })
.then((response) => response.data)
.catch(() => [] as SessionInfo[]),
])
return sessions
}
export function DialogOpen(props: { sessions: SessionInfo[] }) {
export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions: SessionInfo[]) => void }) {
const dialog = useDialog()
const route = useRoute()
const data = useData()
@@ -51,6 +40,41 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
const shortcuts = Keymap.useShortcuts()
const [filter, setFilter] = createSignal("")
const [selectionMoved, setSelectionMoved] = createSignal(false)
let closed = false
onCleanup(() => {
closed = true
})
const [recent] = createResource(() => {
// A late read must not overwrite deletion or placement facts observed in flight.
const changed = new Map<string, Extract<OpenCodeEvent, { type: "session.deleted" | "session.moved" }>>()
const unsubscribe = client.event.listen((message) => {
const event = message.details
if (event.type === "session.deleted" || event.type === "session.moved") changed.set(event.data.sessionID, event)
})
onCleanup(unsubscribe)
return client.api.session
.list({ limit: 50, order: "desc", parentID: null })
.then((response) => {
if (!closed)
props.onLoad(
response.data.flatMap((session) => {
const event = changed.get(session.id)
if (!event) return [session]
if (event.type === "session.deleted") return []
return [moveOpenSession(props.sessions.find((entry) => entry.id === session.id) ?? session, event)]
}),
)
return true
})
.catch(() => false)
.finally(unsubscribe)
})
const [projects] = createResource(() =>
data.project.sync().then(
() => true,
() => false,
),
)
const [matched] = createResource(
() => {
@@ -154,12 +178,34 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
preserveSelection={selectionMoved()}
onMove={() => setSelectionMoved(true)}
onFilter={setFilter}
emptyView={
<Show when={!recent.loading && !projects.loading}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No recent sessions or projects</text>
</box>
</Show>
}
footer={
<box>
<Show when={recent.loading || projects.loading}>
<Spinner color={theme.text.subdued}>Refreshing sessions and projects...</Spinner>
</Show>
<Show when={recent() === false || projects() === false}>
<text fg={theme.text.feedback.error.default}>
Could not refresh{" "}
{recent() === false ? (projects() === false ? "sessions and projects" : "sessions") : "projects"}.
</text>
</Show>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>
{shortcuts.get("session.list")
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
: "No matches"}
{recent.loading || projects.loading || matched.loading
? "Searching sessions and projects..."
: shortcuts.get("session.list")
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
: "No matches"}
</text>
</box>
}
@@ -177,6 +223,16 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
)
}
export function moveOpenSession(session: SessionInfo, event: Extract<OpenCodeEvent, { type: "session.moved" }>) {
return {
...session,
location: event.data.location,
projectID: event.data.projectID ?? session.projectID,
subpath: event.data.subpath,
time: { ...session.time, updated: Math.max(session.time.updated, event.created) },
}
}
function timeAgo(timestamp: number) {
const minutes = Math.floor((Date.now() - timestamp) / 60_000)
if (minutes < 1) return "now"
+2 -2
View File
@@ -284,8 +284,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
selection = option
if (!moved) return
if (
(!props.preserveSelection && (props.current === undefined || props.focusCurrent === false)) ||
store.filter.length > 0
!props.preserveSelection &&
(props.current === undefined || props.focusCurrent === false || store.filter.length > 0)
)
return
scrollAfterLayout(false, option.value)
+235
View File
@@ -8,6 +8,241 @@ import path from "node:path"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const requested = Promise.withResolvers<void>()
const response = Promise.withResolvers<Response>()
const projects = Promise.withResolvers<Response>()
const refresh = Promise.withResolvers<Response>()
const events = createEventStream()
const cachedSession = {
id: "ses_cached",
title: "Cached session",
projectID: "proj_fixture",
location: { directory: "/fixture" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
}
let requests = 0
const calls = createFetch((url) => {
if (url.pathname === "/api/session") {
requests++
requested.resolve()
if (requests === 1) return response.promise
if (requests === 2 || requests === 4) return refresh.promise.then((response) => response.clone())
if (requests > 4) return new Response("Unavailable", { status: 503 })
return json({ data: [cachedSession], cursor: {} })
}
if (url.pathname === "/api/project") return projects.promise
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
await ready.promise
await setup.waitForFrame((frame) => frame.includes("commands"))
setup.mockInput.pressKey("o", { ctrl: true })
await requested.promise
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Search sessions")
expect(setup.captureCharFrame()).toContain("Refreshing")
projects.resolve(
json([
{
id: "proj_fixture",
canonical: "/fixture",
name: "Fixture project",
time: { created: 1, updated: 2 },
sandboxes: [],
},
]),
)
await setup.waitForFrame((frame) => frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
expect(requests).toBe(1)
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
response.resolve(json({ data: [{ ...cachedSession, id: "ses_disposed", title: "Disposed response" }], cursor: {} }))
await setup.renderOnce()
expect(setup.captureCharFrame()).not.toContain("Fixture project")
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Refreshing"))
expect(setup.captureCharFrame()).not.toContain("Disposed")
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Cached"))
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Cached") && frame.includes("Refreshing"))
events.emit({
id: "evt_deleted",
created: 1,
type: "session.deleted",
durable: { aggregateID: "ses_cached", seq: 1, version: 2 },
data: { sessionID: "ses_cached" },
})
await setup.waitForFrame((frame) => !frame.includes("Cached"))
refresh.resolve(json({ data: [cachedSession], cursor: {} }))
await setup.waitForFrame((frame) => frame.includes("Fixture project") && !frame.includes("Refreshing"))
expect(setup.captureCharFrame()).not.toContain("Cached")
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
expect(setup.captureCharFrame()).not.toContain("Cached")
setup.renderer.destroy()
await task
} finally {
response.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
refresh.resolve(json({ data: [], cursor: {} }))
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
test.each(["dismissed", "refreshing"])(
"Ctrl-O retains committed movement of a cached-only session while %s",
async (phase) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const refresh = Promise.withResolvers<Response>()
const metadata = Promise.withResolvers<Response>()
const destinationRequested = Promise.withResolvers<void>()
const events = createEventStream()
const cached = {
id: "ses_cached_move",
title: "Cached movement",
projectID: "proj_old",
location: { directory: "/fixture/old" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
}
let requests = 0
const locations: string[] = []
const calls = createFetch((url) => {
if (url.pathname === "/api/session") {
if (url.searchParams.has("parentID")) {
const parent = url.searchParams.get("parentID")
if (parent && parent !== "null") return json({ data: [], cursor: {} })
}
return requests++ === 0
? json({ data: [cached], cursor: {} })
: refresh.promise.then((response) => response.clone())
}
if (url.pathname === `/api/session/${cached.id}`) return metadata.promise
if (url.pathname === `/api/session/${cached.id}/message`) return json({ data: [], cursor: {} })
if (url.pathname === `/api/session/${cached.id}/inbox` || url.pathname === `/api/session/${cached.id}/permission`)
return json({ data: [] })
if (url.pathname === "/api/project")
return json(
["old", "new"].map((name) => ({
id: `proj_${name}`,
canonical: `/fixture/${name}`,
name: name === "old" ? "Old" : "New",
time: { created: 1, updated: 2 },
sandboxes: [],
})),
)
if (url.pathname === "/api/location") {
const query = url.searchParams.get("location[directory]") ?? ""
locations.push(query)
if (query.includes("/fixture/new")) {
destinationRequested.resolve()
return json({
directory: "/fixture/new",
project: { id: "proj_new", directory: "/fixture/new", canonical: "/fixture/new" },
})
}
}
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false, tabs: { enabled: false } }), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await ready.promise
await setup.waitForFrame((frame) => frame.includes("commands"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes(cached.title) && !frame.includes("Refreshing"))
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Search sessions"))
if (phase === "refreshing") {
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes(cached.title) && frame.includes("Refreshing"))
}
events.emit({
id: "evt_cached_moved",
created: 3,
type: "session.moved",
durable: { aggregateID: cached.id, seq: 1, version: 1 },
data: { sessionID: cached.id, location: { directory: "/fixture/new" }, projectID: "proj_new" },
})
if (phase === "dismissed") {
setup.mockInput.pressKey("o", { ctrl: true })
refresh.resolve(new Response("Unavailable", { status: 503 }))
await setup.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
}
if (phase === "refreshing") {
await setup.waitForFrame((frame) =>
frame.split("\n").some((line) => line.includes(cached.title) && line.includes("New")),
)
refresh.resolve(json({ data: [cached], cursor: {} }))
await setup.waitForFrame((frame) => frame.includes(cached.title) && !frame.includes("Refreshing"))
}
await setup.waitForFrame((frame) =>
frame.split("\n").some((line) => line.includes(cached.title) && line.includes("New")),
)
expect(
setup
.captureCharFrame()
.split("\n")
.find((line) => line.includes(cached.title)),
).toContain("New")
locations.length = 0
setup.mockInput.pressEnter()
await destinationRequested.promise
expect(locations.some((query) => query.includes("/fixture/old"))).toBe(false)
} finally {
refresh.resolve(json({ data: [], cursor: {} }))
metadata.resolve(json({ data: { ...cached, projectID: "proj_new", location: { directory: "/fixture/new" } } }))
setup.renderer.destroy()
await task
await server.stop()
}
},
)
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const titles: string[] = []
+255 -16
View File
@@ -1,10 +1,13 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { once } from "node:events"
import { CliRenderEvents, TextAttributes } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "../../../src/component/dialog-open"
import { createSignal, onMount } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { DialogOpen, DialogOpenKey } from "../../../src/component/dialog-open"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider, useClient } from "../../../src/context/client"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider, useLocation } from "../../../src/context/location"
@@ -85,7 +88,7 @@ test("finds and opens an exact session ID outside the recent list", async () =>
expect(fixture.route.data).toEqual({ type: "session", sessionID })
expect(fixture.location.ref).toEqual(remote)
} finally {
fixture.dispose()
await fixture.dispose()
}
})
@@ -131,7 +134,7 @@ test("shows the current project and opens its root", async () => {
}
})
test("waits for sessions before showing the populated picker", async () => {
test("shows projects while sessions refresh and preserves the selected project", async () => {
let resolveSessions!: (response: Response) => void
const sessions = new Promise<Response>((resolve) => (resolveSessions = resolve))
const fixture = await renderOpen((url) => {
@@ -157,8 +160,9 @@ test("waits for sessions before showing the populated picker", async () => {
})
try {
await fixture.app.renderOnce()
expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and projects")
await fixture.app.waitForFrame((frame) => frame.includes("Second project") && frame.includes("Refreshing"))
expect(fixture.app.captureCharFrame()).toContain("Search sessions and projects")
fixture.app.mockInput.pressArrow("down")
resolveSessions(
json({
@@ -177,8 +181,6 @@ test("waits for sessions before showing the populated picker", async () => {
}),
)
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Second project"))
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
@@ -188,6 +190,240 @@ test("waits for sessions before showing the populated picker", async () => {
}
})
test.each([false, true])("keeps a filtered selection visible after refresh with query reset %s", async (reset) => {
const sessions = Promise.withResolvers<Response>()
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return sessions.promise
if (url.pathname === "/api/project")
return json([
{
id: "proj_first",
canonical: "/tmp/opencode/first",
name: "First shared project",
time: { created: 1, updated: 2 },
sandboxes: [],
},
{
id: "proj_second",
canonical: "/tmp/opencode/second",
name: "Second shared project",
time: { created: 1, updated: 1 },
sandboxes: [],
},
])
return undefined
})
const selectedTitle = () =>
fixture.app
.captureSpans()
.lines.flatMap((line) => line.spans)
.filter((span) => span.attributes & TextAttributes.BOLD)
.map((span) => span.text)
.join("")
try {
await fixture.app.waitForFrame((frame) => frame.includes("Second shared project") && frame.includes("Refreshing"))
await fixture.app.mockInput.typeText("shared")
await fixture.app.waitForFrame(() => selectedTitle().includes("First shared project"))
fixture.app.mockInput.pressArrow("down")
await fixture.app.waitForFrame(() => selectedTitle().includes("Second shared project"))
sessions.resolve(
json({
data: Array.from({ length: 12 }, (_, index) => ({
...recentSession,
id: `ses_shared_${index}`,
title: "shared",
time: { created: 1, updated: index + 3 },
})),
cursor: {},
}),
)
await fixture.app.waitForFrame((frame) => frame.includes("Open") && !frame.includes("Refreshing"))
// Selection reveal runs on FRAME; the following paint must show the selected row.
const frame = once(fixture.app.renderer, CliRenderEvents.FRAME)
fixture.app.renderer.requestRender()
await frame
expect(fixture.app.captureCharFrame()).toContain("Second shared project")
expect(selectedTitle()).toContain("Second shared project")
if (reset) {
await fixture.app.mockInput.typeText(" project")
await fixture.app.waitForFrame(() => selectedTitle().includes("First shared project"))
expect(fixture.app.captureCharFrame()).toContain("Second shared project")
expect(selectedTitle()).not.toContain("Second shared project")
}
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(fixture.route.data).toEqual({
type: "home",
location: { directory: `/tmp/opencode/${reset ? "first" : "second"}` },
})
} finally {
sessions.resolve(json({ data: [], cursor: {} }))
await fixture.dispose()
}
})
const recentSession = {
id: "ses_recent",
projectID: "proj_recent",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: "Recent session",
location: { directory: "/fixture" },
}
test("sessions remain selectable while projects are still loading", async () => {
const projects = Promise.withResolvers<Response>()
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return json({ data: [recentSession], cursor: {} })
if (url.pathname === "/api/project") return projects.promise
return undefined
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Refreshing"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
} finally {
projects.resolve(json([]))
await fixture.dispose()
}
})
test("shows hydrated sessions immediately without waiting for either read", async () => {
const sessions = Promise.withResolvers<Response>()
const projects = Promise.withResolvers<Response>()
const fixture = await renderOpen(
(url) => {
if (url.pathname === "/api/session") return sessions.promise
if (url.pathname === "/api/project") return projects.promise
return undefined
},
({ data }) => data.session.remember(recentSession),
)
try {
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Refreshing"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
} finally {
sessions.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
await fixture.dispose()
}
})
test("keeps an uncached moved session in the first successful refresh", async () => {
const response = Promise.withResolvers<Response>()
const destination = { directory: "/fixture/destination" }
const fixture = await renderOpen((url) => (url.pathname === "/api/session" ? response.promise : undefined))
try {
await fixture.app.waitForFrame((frame) => frame.includes("Refreshing"))
fixture.emit({
id: "evt_uncached_move",
created: 3,
type: "session.moved",
durable: { aggregateID: recentSession.id, seq: 1, version: 1 },
data: { sessionID: recentSession.id, location: destination, projectID: "proj_destination" },
})
// The following event supplies an ordered-stream receipt barrier without hydrating metadata.
fixture.emit({
id: "evt_move_received",
created: 4,
type: "session.execution.started",
durable: { aggregateID: recentSession.id, seq: 2, version: 1 },
data: { sessionID: recentSession.id },
})
await fixture.app.waitFor(() => fixture.data.session.status(recentSession.id) === "running")
expect(fixture.data.session.get(recentSession.id)).toBeUndefined()
response.resolve(
json({
data: [
{ ...recentSession, location: destination, projectID: "proj_destination", time: { created: 1, updated: 3 } },
],
cursor: {},
}),
)
await fixture.app.waitForFrame((frame) => frame.includes(recentSession.title) && !frame.includes("Refreshing"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
expect(fixture.location.ref).toEqual(destination)
} finally {
response.resolve(json({ data: [], cursor: {} }))
await fixture.dispose()
}
})
test("keeps the previous recent list usable when reopening fails to refresh", async () => {
let requests = 0
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session")
return requests++ === 0
? json({ data: [recentSession], cursor: {} })
: new Response("Unavailable", { status: 503 })
return undefined
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && !frame.includes("Refreshing"))
fixture.app.mockInput.pressEscape()
await fixture.app.waitForFrame((frame) => !frame.includes("Recent session"))
fixture.open()
await fixture.app.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
expect(fixture.app.captureCharFrame()).toContain("Recent session")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
} finally {
await fixture.dispose()
}
})
test("shows an initial loading shell instead of reporting an empty list", async () => {
const sessions = Promise.withResolvers<Response>()
const projects = Promise.withResolvers<Response>()
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return sessions.promise
if (url.pathname === "/api/project") return projects.promise
return undefined
})
try {
await fixture.app.waitForFrame(
(frame) => frame.includes("Search sessions and projects") && frame.includes("Refreshing"),
)
expect(fixture.app.captureCharFrame()).not.toContain("No items available")
await fixture.app.mockInput.typeText("missing")
await fixture.app.waitForFrame((frame) => frame.includes("Searching sessions and projects"))
expect(fixture.app.captureCharFrame()).not.toContain("No matches")
sessions.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
await fixture.app.waitForFrame((frame) => frame.includes("No matches") && !frame.includes("Refreshing"))
} finally {
sessions.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
await fixture.dispose()
}
})
test("reports both refresh failures while keeping hydrated sessions usable", async () => {
const fixture = await renderOpen(
(url) => {
if (url.pathname === "/api/session" || url.pathname === "/api/project")
return new Response("Unavailable", { status: 503 })
return undefined
},
({ data }) => data.session.remember(recentSession),
)
try {
await fixture.app.waitForFrame((frame) => frame.includes("Could not refresh sessions and projects"))
expect(fixture.app.captureCharFrame()).toContain("Recent session")
} finally {
await fixture.dispose()
}
})
test("option arrows jump between sections", async () => {
const handler: FetchHandler = (url) => {
if (url.pathname === "/api/session")
@@ -291,20 +527,21 @@ async function renderOpen(
let location!: ReturnType<typeof useLocation>
let data!: ReturnType<typeof useData>
let storage!: ReturnType<typeof useStorage>
let open!: () => void
function Probe() {
const dialog = useDialog()
const client = useClient()
const [sessions, setSessions] = createSignal<SessionInfo[]>([])
route = useRoute()
location = useLocation()
data = useData()
storage = useStorage()
onMount(
() =>
void Promise.all([beforeOpen?.({ data, location }), loadDialogOpen(data, client)]).then(([, sessions]) =>
dialog.replace(() => <DialogOpen sessions={sessions} />, undefined, { key: DialogOpenKey, size: "large" }),
),
)
open = () =>
dialog.replace(() => <DialogOpen sessions={sessions()} onLoad={setSessions} />, undefined, {
key: DialogOpenKey,
size: "large",
})
onMount(() => void Promise.resolve(beforeOpen?.({ data, location })).then(open))
return null
}
@@ -344,6 +581,8 @@ async function renderOpen(
return {
app,
emit: events.emit,
open: () => open(),
get route() {
return route
},
@@ -32,7 +32,6 @@ for (const orientation of ["horizontal", "vertical"] as const) {
await using temporary = await tmpdir()
const [status, setStatus] = createSignal<SessionTabsStatus>(EMPTY_SESSION_TAB_STATUS)
const [active, setActive] = createSignal("second")
const [animations, setAnimations] = createSignal(false)
const [newTab, setNewTab] = createSignal(false)
const [preview, setPreview] = createSignal(false)
const settings: Info = { tabs: { enabled: true } }
@@ -91,11 +90,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
<ToastProvider>
<DialogProvider>
<box width="100%" height="100%">
<SessionTabs
controller={controller}
orientation={orientation}
animations={animations()}
/>
<SessionTabs controller={controller} orientation={orientation} animations={false} />
</box>
</DialogProvider>
</ToastProvider>
@@ -141,7 +136,6 @@ for (const orientation of ["horizontal", "vertical"] as const) {
}
for (const attention of ["question", "permission"] as const) {
setAnimations(false)
setActive("second")
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention })
await app.renderOnce()
@@ -171,84 +165,39 @@ for (const orientation of ["horizontal", "vertical"] as const) {
const dim = glow()
expect(dim).toBeGreaterThan(0)
expect(dim).toBeLessThan(full)
setActive("second")
await app.renderOnce()
setAnimations(true)
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
setActive("first")
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
await app.waitForFrame(() => glow() > dim && glow() < full)
await app.waitForFrame(() => glow() === dim, { maxPasses: 60 })
setActive("second")
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
await app.waitForFrame(() => glow() > dim && glow() < full)
await app.waitForFrame(() => glow() === full, { maxPasses: 60 })
setStatus(EMPTY_SESSION_TAB_STATUS)
await app.renderOnce()
expect(app.renderer.root.liveCount).toBeGreaterThan(0)
}
const glyph = "\u2022"
for (const unread of ["activity", "error"] as const) {
setAnimations(false)
setActive("second")
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
await app.renderOnce()
setAnimations(true)
setStatus({ ...EMPTY_SESSION_TAB_STATUS, unread })
await app.renderOnce()
const color = () =>
app
.captureSpans()
.lines.flatMap((line) => line.spans)
.find((span) => span.text.trim() === glyph)?.fg
expect(color()?.toInts()).toEqual(
const color = app
.captureSpans()
.lines.flatMap((line) => line.spans)
.find((span) => span.text.trim() === glyph)?.fg
expect(color?.toInts()).toEqual(
(unread === "error" ? theme.text.feedback.error.default : theme.text.status.unread).toInts(),
)
const brightness = () => {
const value = color()
return value ? value.r + value.g + value.b : undefined
}
const initial = brightness()!
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
await app.renderOnce()
expect(active()).toBe("first")
expect(status().unread).toBeUndefined()
expect(app.captureCharFrame()).toContain(`${glyph} First`)
await app.waitForFrame((frame) => frame.includes(`${glyph} First`) && (brightness() ?? -1) > initial)
const peak = brightness()!
await app.waitForFrame((frame) => frame.includes(`${glyph} First`) && (brightness() ?? Infinity) < peak)
await app.waitForFrame((frame) => frame.includes(" First"), { maxPasses: 60 })
expect(app.captureCharFrame()).toContain(" First")
expect(app.captureCharFrame()).not.toContain(`${glyph} First`)
}
setAnimations(false)
setStatus({ ...EMPTY_SESSION_TAB_STATUS, unread: "activity" })
await app.renderOnce()
setAnimations(true)
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
await app.waitForFrame((frame) => SPINNER_FRAMES.slice(1).some((glyph) => frame.includes(`${glyph} First`)))
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention: "question" })
await app.waitForFrame((frame) => frame.includes("? First"))
await config.update((draft) => {
draft.tabs.indicators = "numbers"
})
await app.waitForFrame((frame) => frame.includes("1 First") && frame.includes("2 Second"))
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
await app.renderOnce()
expect(app.captureCharFrame()).toContain("1 First")
await config.update((draft) => {
draft.tabs.indicators = "status"
})
await app.waitForFrame((frame) => SPINNER_FRAMES.some((glyph) => frame.includes(`${glyph} First`)))
await app.waitForFrame((frame) => frame.includes(`${SPINNER_FRAMES[0]} First`))
setAnimations(false)
setStatus(EMPTY_SESSION_TAB_STATUS)
setActive("second")
await app.renderOnce()
+1
View File
@@ -193,6 +193,7 @@ const layer = Layer.effect(
}
const tree = yield* reify({ dir, add: [pkg] })
if (isMutable(parsed)) refreshed.add(pkg)
const first = tree.edgesOut.values().next().value?.to
if (!first) {
const installed = yield* installedName(pkg, dir, parsedName)
+9 -7
View File
@@ -40,13 +40,15 @@ Project-specific configuration can use either form:
/home/user/projects/my-app/.opencode/opencode.json(c)
```
When OpenCode starts, it searches for configuration files from the current
directory upward to the project root. It merges direct `opencode.json(c)` files
from the project root toward the current directory, then does the same for
files inside `.opencode` directories. A `.opencode` config therefore overrides
every direct config, even when the direct config is closer to the current
directory. Avoid mixing the two forms across one project hierarchy unless this
precedence is intentional.
During ordinary project discovery, OpenCode searches for configuration files
from the current Location directory through every ancestor to the filesystem
root, including directories above the detected project or repository root. It
merges direct `opencode.json(c)` files from the farthest ancestor toward the
current directory, then does the same for files inside `.opencode` directories.
A discovered `.opencode` config therefore overrides every discovered direct
config, even when the direct config is closer to the current directory. Avoid
mixing the two forms across one directory hierarchy unless this precedence is
intentional.
For example, consider a monorepo with OpenCode started from
`/home/user/projects/acme/packages/web`:
@@ -231,6 +231,18 @@ Use permission actions to hide or deny a server's tools without stopping its con
}
```
## Session context
When OpenCode invokes an MCP tool on behalf of a session, it includes the invoking
session's ID in `CallToolRequest.params._meta.sessionID`. This applies to direct tool
calls and Code Mode over both stdio and Streamable HTTP.
The ID is request metadata, not a tool argument, so it does not appear in the
model-visible tool schema. Treat it as an opaque correlation value: it identifies the
invoking OpenCode session rather than the MCP transport session, can be absent for
calls without session context, and must not be used by itself for authentication or
authorization. Remote MCP servers receive the raw ID and may log or retain it.
## Manage servers
OpenCode interfaces can add servers to project or global configuration, list
+4 -3
View File
@@ -93,9 +93,10 @@ opencode2 plugin add 'github:acme/plugins#main::path:packages/opencode-plugin'
Branches, tags, complete commit hashes, and npm's `::path:` repository-subdirectory selectors are supported. Configure
local paths directly; tarball and npm alias targets are not accepted by `plugin add`.
Changes under watched config directories reload automatically. On server startup, OpenCode refreshes unpinned package and
Git plugins once, then uses that result for the lifetime of the server. Exact npm versions and full Git commit hashes stay
pinned. Changes to unwatched local dependencies may still require restarting OpenCode.
Changes under watched config directories reload automatically. Server startup loads cached package plugins immediately,
then refreshes unpinned npm and Git plugins in the background. A refreshed package becomes active the next time the server
starts. Exact npm versions and full Git commit hashes stay pinned. Changes to unwatched local dependencies may still require
restarting OpenCode.
```sh
touch .opencode/plugins/concise.ts