mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 12:06:22 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ed2b1b545 | ||
|
|
c25b2119f6 | ||
|
|
8ebb94a8d1 | ||
|
|
8cbe3fb785 | ||
|
|
36d3c5e76f | ||
|
|
01a3b677aa |
@@ -56,6 +56,35 @@ import { AbsolutePath } from "./schema.js"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map.js"
|
||||
|
||||
/**
|
||||
* Engine tier: the tags consumed from OUTSIDE the graph by the drain and by
|
||||
* session operations, plus the registries that form the configuration surface.
|
||||
* Everything else the engine needs (SessionContext, ModelRequest, Permission,
|
||||
* ModelResolver, ...) is internal wiring reached through dependency closure
|
||||
* during compile, where replacements can substitute capability sources.
|
||||
* `locationServiceNodes` below stays the composed full graph — its list order
|
||||
* is semantic (compile provide-merges in order), so the tier is named
|
||||
* alongside, not split out.
|
||||
*/
|
||||
const sessionEngineNodes = [
|
||||
// drain entry (execution.ts runs the runner; its layer wires the spine internally)
|
||||
SessionRunnerLLM.node,
|
||||
// prompt admission (session.ts attachment resize + skill mentions) and readiness
|
||||
PluginSupervisor.node,
|
||||
Image.node,
|
||||
Skill.node,
|
||||
// configuration surface: populated from values instead of discovery
|
||||
Tool.node,
|
||||
Agent.node,
|
||||
Catalog.node,
|
||||
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
|
||||
|
||||
export const sessionEngineGroup = LayerNode.group<typeof sessionEngineNodes>(sessionEngineNodes)
|
||||
|
||||
/** What a session drain and its operations require. `LocationServices` is a superset. */
|
||||
export type SessionEngine = LayerNode.Output<typeof sessionEngineGroup>
|
||||
export type SessionEngineError = LayerNode.Error<typeof sessionEngineGroup>
|
||||
|
||||
const locationServiceNodes = [
|
||||
Location.node,
|
||||
Environment.node,
|
||||
@@ -112,6 +141,27 @@ export const locationServices = LayerNode.group<typeof locationServiceNodes>(loc
|
||||
export type LocationServices = LayerNode.Output<typeof locationServices>
|
||||
export type LocationError = LayerNode.Error<typeof locationServices>
|
||||
|
||||
// Compile-time guard: the engine tier must remain a subset of the full graph.
|
||||
const _sessionEngineIsSubset: [SessionEngine] extends [LocationServices] ? true : never = true
|
||||
void _sessionEngineIsSubset
|
||||
|
||||
/**
|
||||
* Compile a Location graph with its global nodes hoisted out. Replacements
|
||||
* must be applied during hoist, not afterward: replacements can introduce new
|
||||
* tagged dependencies (Location.boundNode depends on Project), and the hoist
|
||||
* walk is the only pass that can still slice those back out. Callers must
|
||||
* thread the application root's replacements through so hoisted globals
|
||||
* compile to the same Layer references the root built and memoization dedupes
|
||||
* them instead of constructing second instances.
|
||||
*/
|
||||
export function compileWithHoistedGlobals<A, E>(
|
||||
root: LayerNode.Node<A, E, LayerNode.Tag | undefined>,
|
||||
replacements: LayerNode.Replacements,
|
||||
): Layer.Layer<A, E> {
|
||||
const sliced = LayerNode.hoist(root, Node.tags.values.global, replacements)
|
||||
return LayerNode.compile(sliced.node).pipe(Layer.fresh, Layer.provide(LayerNode.compile(sliced.hoisted)))
|
||||
}
|
||||
|
||||
export function buildLocationServiceMap(
|
||||
replacements: LayerNode.Replacements = [],
|
||||
): Layer.Layer<LocationServiceMap.Service> {
|
||||
@@ -129,14 +179,8 @@ export function buildLocationServiceMap(
|
||||
(ref: Location.Ref) => {
|
||||
const startedAt = performance.now()
|
||||
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
|
||||
// Apply replacements during hoist, not afterward: replacements can
|
||||
// introduce new tagged dependencies (Location.boundNode depends on
|
||||
// Project), and the hoist walk is the only pass that can still slice
|
||||
// those back out.
|
||||
const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements)
|
||||
|
||||
return LayerNode.compile(location.node).pipe(
|
||||
Layer.fresh,
|
||||
return compileWithHoistedGlobals(locationServices, allReplacements).pipe(
|
||||
Layer.tap(() =>
|
||||
Effect.logInfo("location services booted", {
|
||||
directory: ref.directory,
|
||||
@@ -144,7 +188,6 @@ export function buildLocationServiceMap(
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
}),
|
||||
),
|
||||
Layer.provide(LayerNode.compile(location.hoisted)),
|
||||
)
|
||||
},
|
||||
{
|
||||
|
||||
@@ -60,6 +60,9 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/McpInstructions") {}
|
||||
|
||||
/** For environments without MCP: no server guidance to load. */
|
||||
export const noop = Layer.succeed(Service, Service.of({ load: () => Effect.succeed(Instructions.empty) }))
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as PluginSupervisor from "./supervisor-service.js"
|
||||
|
||||
import { Context, Effect } from "effect"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
|
||||
/**
|
||||
* Dependency-only supervisor seam. Keep this module free of implementation
|
||||
@@ -12,3 +12,6 @@ export interface Interface {
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||
|
||||
/** For values-constructed environments: no plugin generations exist, so flush settles immediately. */
|
||||
export const noop = Layer.succeed(Service, Service.of({ flush: Effect.void }))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * as PluginSupervisor from "./supervisor.js"
|
||||
export { Service, type Interface } from "./supervisor-service.js"
|
||||
export { noop, Service, type Interface } from "./supervisor-service.js"
|
||||
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Cause, Effect, Latch, Layer, Stream } from "effect"
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
export * as SessionEngine from "./session-engine.js"
|
||||
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Agent } from "./agent.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
import { Location } from "./location.js"
|
||||
import { McpInstructions } from "./mcp/instructions.js"
|
||||
import { McpTool } from "./tool/mcp.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor.js"
|
||||
import { Session } from "./session.js"
|
||||
import { SessionEngineBindings } from "./session/engine-bindings.js"
|
||||
import { SessionRunnerModel } from "./session/runner/model.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { Snapshot } from "./snapshot.js"
|
||||
import { Tool } from "./tool.js"
|
||||
import { compileWithHoistedGlobals, sessionEngineGroup, type SessionEngine, type SessionEngineError } from "./location-services.js"
|
||||
import type { AbsolutePath } from "./schema.js"
|
||||
|
||||
/**
|
||||
* Values-constructed session environment: the engine tier of the location
|
||||
* graph, booted without discovery, plugins, or MCP. Capabilities arrive
|
||||
* through the same draft APIs plugins use, so registry invariants (hook
|
||||
* wiring, image normalization, permission gating) hold by construction.
|
||||
*/
|
||||
export interface Options {
|
||||
readonly directory: AbsolutePath
|
||||
/**
|
||||
* Fixed model for every drain in this environment, bypassing catalog
|
||||
* resolution (SessionRunnerModel.resolved is the values-side constructor).
|
||||
* Omit to resolve through the populated catalog instead.
|
||||
*/
|
||||
readonly model?: SessionRunnerModel.Resolved
|
||||
/** Capture filesystem snapshots around attempts. Defaults to false. */
|
||||
readonly snapshots?: boolean
|
||||
readonly tools?: (draft: Tool.Draft) => void
|
||||
readonly agents?: (draft: Agent.Draft) => void
|
||||
readonly catalog?: (draft: Catalog.Draft) => void
|
||||
}
|
||||
|
||||
type PromptOptions = Omit<Parameters<Session.Interface["prompt"]>[0], "sessionID">
|
||||
type SessionOptions = Omit<Parameters<Session.Interface["create"]>[0], "location" | "parentID">
|
||||
|
||||
export interface SessionHandle {
|
||||
readonly id: SessionSchema.ID
|
||||
readonly prompt: (input: PromptOptions) => ReturnType<Session.Interface["prompt"]>
|
||||
readonly interrupt: (input?: { readonly continue?: boolean }) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export interface Handle {
|
||||
/**
|
||||
* Ensure a durable session and bind it to this environment. Reusing a
|
||||
* Session ID adopts the existing Session (creation args are ignored then),
|
||||
* so reconnection after a restart is the same call with the same ID. The
|
||||
* binding lives until the environment's scope closes; drains resolve the
|
||||
* bound graph instead of the Session's Location graph.
|
||||
*/
|
||||
readonly session: (input?: SessionOptions) => Effect.Effect<SessionHandle, Session.NotFoundError>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly make: (options: Options) => Effect.Effect<Handle, SessionEngineError, Scope.Scope>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionEngine") {}
|
||||
|
||||
/**
|
||||
* Captures the application root's MemoMap at construction (the same trick
|
||||
* LayerMap.make uses), so each environment's hoisted global nodes dedupe
|
||||
* against the running Database, Bus, and SessionStore instead of building
|
||||
* second instances. The engine subtree itself builds fresh per environment.
|
||||
*
|
||||
* Like buildLocationServiceMap, the layer must receive the application
|
||||
* root's replacements: hoisted globals otherwise compile their original
|
||||
* implementations and a composed root (test harness, embedded host) would
|
||||
* build second, differently-configured instances.
|
||||
*/
|
||||
const layerWith = (base: LayerNode.Replacements) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const memoMap = Layer.CurrentMemoMap.forkOrCreate(yield* Effect.context<never>())
|
||||
const bindings = yield* SessionEngineBindings.Service
|
||||
const sessions = yield* Session.Service
|
||||
|
||||
const make = Effect.fn("SessionEngine.make")(function* (options: Options) {
|
||||
const scope = yield* Effect.scope
|
||||
const location = Location.Ref.make({ directory: options.directory })
|
||||
// Later entries win in the replacement map, so environment-specific
|
||||
// substitutions override same-node entries from the application root.
|
||||
const replacements: LayerNode.Replacements = [
|
||||
...base,
|
||||
[Location.node, Location.boundNode(location)],
|
||||
[PluginSupervisor.node, PluginSupervisor.noop],
|
||||
[McpTool.node, McpTool.noop],
|
||||
[McpInstructions.node, McpInstructions.noop],
|
||||
...(options.snapshots === true ? [] : [[Snapshot.node, Snapshot.noopLayer] as const]),
|
||||
...(options.model === undefined
|
||||
? []
|
||||
: [[SessionRunnerModel.node, SessionRunnerModel.fixed(options.model)] as const]),
|
||||
]
|
||||
const context = yield* Layer.buildWithMemoMap(
|
||||
compileWithHoistedGlobals(sessionEngineGroup, replacements),
|
||||
memoMap,
|
||||
scope,
|
||||
)
|
||||
|
||||
const populate = Effect.gen(function* () {
|
||||
const tools = options.tools
|
||||
if (tools) yield* Tool.Service.use((service) => service.transform(tools))
|
||||
const agents = options.agents
|
||||
if (agents) yield* Agent.Service.use((service) => service.transform(agents))
|
||||
const catalog = options.catalog
|
||||
if (catalog) yield* Catalog.Service.use((service) => service.transform(catalog))
|
||||
})
|
||||
yield* populate.pipe(Effect.provide(context), Effect.provideService(Scope.Scope, scope))
|
||||
|
||||
const session = Effect.fn("SessionEngine.session")(function* (input?: SessionOptions) {
|
||||
// Create-or-adopt: ID reuse returns the existing durable Session, and the
|
||||
// binding outranks its recorded Location even if the directories differ.
|
||||
const info = yield* sessions.create({ ...input, location })
|
||||
// Bind in the environment's scope: teardown must unbind every session so
|
||||
// drains fall back to the Location graph instead of a torn-down context.
|
||||
yield* bindings.bind(info.id, context).pipe(Effect.provideService(Scope.Scope, scope))
|
||||
return {
|
||||
id: info.id,
|
||||
prompt: (promptInput: PromptOptions) => sessions.prompt({ ...promptInput, sessionID: info.id }),
|
||||
interrupt: (interruptInput?: { readonly continue?: boolean }) =>
|
||||
sessions.interrupt(info.id, interruptInput),
|
||||
} as const
|
||||
})
|
||||
|
||||
return { session } as const
|
||||
})
|
||||
|
||||
return Service.of({ make })
|
||||
}),
|
||||
)
|
||||
|
||||
/** Thread the application root's replacements through, mirroring buildLocationServiceMap. */
|
||||
export const configured = (replacements: LayerNode.Replacements = []) =>
|
||||
makeGlobalNode({ service: Service, layer: layerWith(replacements), deps: [SessionEngineBindings.node, Session.node] })
|
||||
|
||||
export const node = configured()
|
||||
@@ -0,0 +1,48 @@
|
||||
export * as SessionEngineBindings from "./engine-bindings.js"
|
||||
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import type { SessionEngine } from "../location-services.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
|
||||
/**
|
||||
* Process-local map from Session ID to a values-constructed engine graph.
|
||||
* Execution resolves a bound context before falling back to the Session's
|
||||
* Location graph, so tier-2 sessions drain against caller-supplied
|
||||
* capabilities while every other session is untouched.
|
||||
*/
|
||||
export interface Interface {
|
||||
/** Bind until the enclosing scope closes. Rebinding the same ID replaces the previous binding. */
|
||||
readonly bind: (
|
||||
id: SessionSchema.ID,
|
||||
context: Context.Context<SessionEngine>,
|
||||
) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly get: (id: SessionSchema.ID) => Context.Context<SessionEngine> | undefined
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionEngineBindings") {}
|
||||
|
||||
export const layer = Layer.sync(Service, () => {
|
||||
// Entries wrap the context so release identity is per bind call: binding the
|
||||
// same context twice from different scopes must not let the first release
|
||||
// tear down the survivor's entry.
|
||||
const map = new Map<SessionSchema.ID, { readonly context: Context.Context<SessionEngine> }>()
|
||||
return Service.of({
|
||||
bind: (id, context) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const entry = { context }
|
||||
map.set(id, entry)
|
||||
return entry
|
||||
}),
|
||||
(entry) =>
|
||||
Effect.sync(() => {
|
||||
// A later rebind owns the entry now; do not tear it down.
|
||||
if (map.get(id) === entry) map.delete(id)
|
||||
}),
|
||||
).pipe(Effect.asVoid),
|
||||
get: (id) => map.get(id)?.context,
|
||||
})
|
||||
})
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
@@ -6,6 +6,7 @@ import { Database } from "../database/database.js"
|
||||
import { Job } from "../job.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionEngineBindings } from "./engine-bindings.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionRunCoordinator } from "./run-coordinator.js"
|
||||
import { SessionRunner } from "./runner/index.js"
|
||||
@@ -52,6 +53,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const bindings = yield* SessionEngineBindings.Service
|
||||
const bus = yield* Bus.Service
|
||||
const jobs = yield* Job.Service
|
||||
const db = (yield* Database.Service).db
|
||||
@@ -83,22 +85,30 @@ export const layer = Layer.effect(
|
||||
continuation?: SessionRunner.Continuation,
|
||||
promotable: SessionInbox.Promotable = "input",
|
||||
): Effect.Effect<void, SessionRunner.RunError> {
|
||||
const loop = (
|
||||
force: boolean,
|
||||
continuation?: SessionRunner.Continuation,
|
||||
): Effect.Effect<void, SessionRunner.RunError, SessionRunner.Service> =>
|
||||
SessionRunner.Service.use((runner) => runner.drain({ sessionID, force, continuation, promotable })).pipe(
|
||||
Effect.flatMap((result) => (result._tag === "Complete" ? Effect.void : loop(false, result.continuation))),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
// The environment is resolved once and pinned for the whole busy period, so a
|
||||
// binding change never switches environments between continuations. A bound
|
||||
// values-constructed environment outranks the Session's Location graph and
|
||||
// implies the Session exists, since binding follows durable creation.
|
||||
const bound = bindings.get(sessionID)
|
||||
if (bound) return yield* loop(force, continuation).pipe(Effect.provide(bound))
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
const result = yield* SessionRunner.Service.use((runner) =>
|
||||
runner.drain({ sessionID, force, continuation, promotable }),
|
||||
).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
|
||||
),
|
||||
)
|
||||
if (result._tag === "Complete") return
|
||||
return yield* drain(sessionID, false, result.continuation, promotable)
|
||||
})
|
||||
return yield* loop(force, continuation).pipe(Effect.provide(locations.get(session.location)))
|
||||
}).pipe(
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
|
||||
),
|
||||
)
|
||||
}
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
||||
started: (sessionID) =>
|
||||
@@ -170,7 +180,7 @@ export const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node],
|
||||
deps: [SessionStore.node, LocationServiceMap.node, SessionEngineBindings.node, Bus.node, Database.node, Job.node],
|
||||
})
|
||||
|
||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||
|
||||
@@ -67,6 +67,10 @@ export const resolved = (
|
||||
limit: options.limit,
|
||||
})
|
||||
|
||||
/** Layer resolving every session to one fixed model, bypassing the catalog. Test or embedding seam. */
|
||||
export const fixed = (resolved: Resolved) =>
|
||||
Layer.succeed(Service, Service.of({ resolve: () => Effect.succeed(resolved) }))
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -22,10 +22,12 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface Draft {
|
||||
readonly add: (tool: Tool.Info) => void
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: (
|
||||
callback: (draft: { readonly add: (tool: Tool.Info) => void }) => void,
|
||||
) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly transform: (callback: (draft: Draft) => void) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/McpTool") {}
|
||||
|
||||
/** For environments without MCP: registration settles immediately. */
|
||||
export const noop = Layer.succeed(Service, Service.of({ flush: Effect.void }))
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { mkdtemp } from "fs/promises"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import { LanguageModel } from "@opencode-ai/ai"
|
||||
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEngine } from "@opencode-ai/core/session-engine"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const testLLM = TestLLM.layer()
|
||||
// The environment's engine graph compiles the scripted client from the same
|
||||
// Layer references the application root uses, so the shared MemoMap yields
|
||||
// one TestLLM instance for both pushes and drains.
|
||||
const scriptedClient = TestLLM.clientLayer.pipe(Layer.provide(testLLM))
|
||||
const shared: LayerNode.Replacements = [
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[LayerNodePlatform.llmClient, scriptedClient],
|
||||
]
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
SessionExecution.node,
|
||||
Session.node,
|
||||
SessionEngine.node,
|
||||
]),
|
||||
[...shared, [SessionEngine.node, SessionEngine.configured(shared)]],
|
||||
).pipe(Layer.provideMerge(testLLM)),
|
||||
)
|
||||
|
||||
const model = SessionRunnerModel.resolved(
|
||||
LanguageModel.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }),
|
||||
{
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
},
|
||||
)
|
||||
|
||||
const executions: string[] = []
|
||||
const echo = {
|
||||
name: "echo",
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
options: { codemode: false as const },
|
||||
execute: ({ text }: { text: string }) =>
|
||||
Effect.sync(() => {
|
||||
executions.push(text)
|
||||
return { output: { text }, content: text }
|
||||
}),
|
||||
}
|
||||
|
||||
describe("SessionEngine", () => {
|
||||
it.effect("drains a durable session against a values-constructed environment", () =>
|
||||
Effect.gen(function* () {
|
||||
executions.length = 0
|
||||
const directory = AbsolutePath.make(
|
||||
yield* Effect.promise(() => mkdtemp(path.join(tmpdir(), "session-engine-"))),
|
||||
)
|
||||
const envs = yield* SessionEngine.Service
|
||||
const env = yield* envs.make({
|
||||
directory,
|
||||
model,
|
||||
agents: (draft) => {
|
||||
draft.update(Agent.defaultID, () => {})
|
||||
draft.default(Agent.defaultID)
|
||||
},
|
||||
tools: (draft) => draft.add(echo),
|
||||
})
|
||||
const session = yield* env.session()
|
||||
|
||||
yield* TestLLM.push(TestLLM.tool("call_1", "echo", { text: "hello" }), TestLLM.text("done", "out_1"))
|
||||
yield* session.prompt({ text: "use echo", resume: false })
|
||||
const sessions = yield* Session.Service
|
||||
yield* sessions.resume(session.id)
|
||||
|
||||
// The values tool executed inside the real drain.
|
||||
expect(executions).toEqual(["hello"])
|
||||
|
||||
// The drain produced durable assistant history containing the scripted reply.
|
||||
const messages = yield* sessions.messages({ sessionID: session.id })
|
||||
const assistant = messages.filter((message) => message.type === "assistant")
|
||||
expect(assistant.length).toBeGreaterThan(0)
|
||||
const text = assistant
|
||||
.flatMap((message) => message.content)
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("\n")
|
||||
expect(text).toContain("done")
|
||||
|
||||
// Reconnect: the same call with the same ID adopts the existing Session.
|
||||
const reconnected = yield* env.session({ id: session.id, title: "ignored on adoption" })
|
||||
expect(reconnected.id).toBe(session.id)
|
||||
expect((yield* sessions.messages({ sessionID: reconnected.id })).length).toBe(messages.length)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEngineBindings } from "@opencode-ai/core/session/engine-bindings"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { UserInterruptedError } from "@opencode-ai/core/session/error"
|
||||
@@ -1171,6 +1172,7 @@ function buildExecution(
|
||||
Layer.provide(Layer.succeed(Bus.Service, bus)),
|
||||
Layer.provide(Layer.succeed(SessionStore.Service, store)),
|
||||
Layer.provide(Layer.succeed(Job.Service, jobs)),
|
||||
Layer.provide(SessionEngineBindings.layer),
|
||||
Layer.provide(locations),
|
||||
),
|
||||
scope,
|
||||
|
||||
Reference in New Issue
Block a user