fix(core): await initial plugin readiness (#35755)
This commit is contained in:
parent
09553d46e0
commit
5b39972947
13 changed files with 484 additions and 84 deletions
|
|
@ -5,29 +5,22 @@ import { Catalog } from "./catalog"
|
||||||
import { CommandV2 } from "./command"
|
import { CommandV2 } from "./command"
|
||||||
import { Config } from "./config"
|
import { Config } from "./config"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { LayerNode } from "./effect/layer-node"
|
||||||
import { makeLocationNode, Node } from "./effect/app-node"
|
import { Node } from "./effect/app-node"
|
||||||
import { httpClient } from "./effect/app-node-platform"
|
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { FileMutation } from "./file-mutation"
|
import { FileMutation } from "./file-mutation"
|
||||||
import { FileSystem } from "./filesystem"
|
import { FileSystem } from "./filesystem"
|
||||||
import { FileSystemSearch } from "./filesystem/search"
|
import { FileSystemSearch } from "./filesystem/search"
|
||||||
import { FSUtil } from "./fs-util"
|
|
||||||
import { Generate } from "./generate"
|
import { Generate } from "./generate"
|
||||||
import { Form } from "./form"
|
import { Form } from "./form"
|
||||||
import { Global } from "./global"
|
|
||||||
import { LocationWatcher } from "./filesystem/location-watcher"
|
|
||||||
import { Image } from "./image"
|
import { Image } from "./image"
|
||||||
|
import { LocationWatcher } from "./filesystem/location-watcher"
|
||||||
import { Integration } from "./integration"
|
import { Integration } from "./integration"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { LocationMutation } from "./location-mutation"
|
import { LocationMutation } from "./location-mutation"
|
||||||
import { LocationServiceMap } from "./location-service-map"
|
import { LocationServiceMap } from "./location-service-map"
|
||||||
import { MCP } from "./mcp/index"
|
import { MCP } from "./mcp/index"
|
||||||
import { ModelsDev } from "./models-dev"
|
|
||||||
import { Npm } from "./npm"
|
|
||||||
import { PermissionV2 } from "./permission"
|
import { PermissionV2 } from "./permission"
|
||||||
import { PluginV2 } from "./plugin"
|
import { PluginV2 } from "./plugin"
|
||||||
import { PluginRuntime } from "./plugin/runtime"
|
|
||||||
import { SdkPlugins } from "./plugin/sdk"
|
|
||||||
import { PluginSupervisor } from "./plugin/supervisor"
|
import { PluginSupervisor } from "./plugin/supervisor"
|
||||||
import { ProjectCopy } from "./project/copy"
|
import { ProjectCopy } from "./project/copy"
|
||||||
import { Pty } from "./pty"
|
import { Pty } from "./pty"
|
||||||
|
|
@ -35,7 +28,6 @@ import { QuestionV2 } from "./question"
|
||||||
import { Shell } from "./shell"
|
import { Shell } from "./shell"
|
||||||
import { Reference } from "./reference"
|
import { Reference } from "./reference"
|
||||||
import { ReferenceGuidance } from "./reference/guidance"
|
import { ReferenceGuidance } from "./reference/guidance"
|
||||||
import { Ripgrep } from "./ripgrep"
|
|
||||||
import { SessionRunnerLLM } from "./session/runner/llm"
|
import { SessionRunnerLLM } from "./session/runner/llm"
|
||||||
import { SessionRunnerModel } from "./session/runner/model"
|
import { SessionRunnerModel } from "./session/runner/model"
|
||||||
import { SessionCompaction } from "./session/compaction"
|
import { SessionCompaction } from "./session/compaction"
|
||||||
|
|
@ -51,49 +43,11 @@ import { SessionInstructions } from "./session/instructions"
|
||||||
import { McpTool } from "./tool/mcp"
|
import { McpTool } from "./tool/mcp"
|
||||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||||
import { ToolRegistry } from "./tool/registry"
|
import { ToolRegistry } from "./tool/registry"
|
||||||
import { WebSearchTool } from "./tool/websearch"
|
|
||||||
import { ToolOutputStore } from "./tool-output-store"
|
import { ToolOutputStore } from "./tool-output-store"
|
||||||
import { Vcs } from "./vcs"
|
import { Vcs } from "./vcs"
|
||||||
|
|
||||||
export { LocationServiceMap } from "./location-service-map"
|
export { LocationServiceMap } from "./location-service-map"
|
||||||
|
|
||||||
const pluginSupervisorNode = makeLocationNode({
|
|
||||||
service: PluginSupervisor.Service,
|
|
||||||
layer: PluginSupervisor.layer,
|
|
||||||
deps: [
|
|
||||||
PluginV2.node,
|
|
||||||
SdkPlugins.node,
|
|
||||||
AgentV2.node,
|
|
||||||
Catalog.node,
|
|
||||||
CommandV2.node,
|
|
||||||
Config.node,
|
|
||||||
EventV2.node,
|
|
||||||
FileMutation.node,
|
|
||||||
FileSystem.node,
|
|
||||||
FSUtil.node,
|
|
||||||
Global.node,
|
|
||||||
httpClient,
|
|
||||||
Image.node,
|
|
||||||
Integration.node,
|
|
||||||
Location.node,
|
|
||||||
LocationMutation.node,
|
|
||||||
ModelsDev.node,
|
|
||||||
Npm.node,
|
|
||||||
PermissionV2.node,
|
|
||||||
PluginRuntime.node,
|
|
||||||
Form.node,
|
|
||||||
ReadToolFileSystem.node,
|
|
||||||
Reference.node,
|
|
||||||
Ripgrep.node,
|
|
||||||
SessionInstructions.node,
|
|
||||||
SessionTodo.node,
|
|
||||||
Shell.node,
|
|
||||||
SkillV2.node,
|
|
||||||
ToolRegistry.toolsNode,
|
|
||||||
WebSearchTool.configNode,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const locationServiceNodes = [
|
const locationServiceNodes = [
|
||||||
Location.node,
|
Location.node,
|
||||||
Config.node,
|
Config.node,
|
||||||
|
|
@ -104,7 +58,7 @@ const locationServiceNodes = [
|
||||||
Catalog.node,
|
Catalog.node,
|
||||||
AISDK.node,
|
AISDK.node,
|
||||||
PluginV2.node,
|
PluginV2.node,
|
||||||
pluginSupervisorNode,
|
PluginSupervisor.node,
|
||||||
ProjectCopy.node,
|
ProjectCopy.node,
|
||||||
ProjectCopy.refreshNode,
|
ProjectCopy.refreshNode,
|
||||||
FileSystemSearch.node,
|
FileSystemSearch.node,
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,42 @@ export * as PluginSupervisor from "./supervisor"
|
||||||
|
|
||||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
|
import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
import { Event } from "@opencode-ai/schema/config"
|
import { Event } from "@opencode-ai/schema/config"
|
||||||
import { Context, Effect, Fiber, Layer, Option, Schema, Semaphore, Stream } from "effect"
|
import { Context, Deferred, Effect, Layer, Option, Schema, Semaphore, Stream } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath, pathToFileURL } from "url"
|
import { fileURLToPath, pathToFileURL } from "url"
|
||||||
|
import { AgentV2 } from "../agent"
|
||||||
|
import { Catalog } from "../catalog"
|
||||||
|
import { CommandV2 } from "../command"
|
||||||
import { Config } from "../config"
|
import { Config } from "../config"
|
||||||
import { ConfigPlugin } from "../config/plugin"
|
import { ConfigPlugin } from "../config/plugin"
|
||||||
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
|
import { httpClient } from "../effect/app-node-platform"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
|
import { FileMutation } from "../file-mutation"
|
||||||
|
import { FileSystem } from "../filesystem"
|
||||||
|
import { Form } from "../form"
|
||||||
import { FSUtil } from "../fs-util"
|
import { FSUtil } from "../fs-util"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { Image } from "../image"
|
||||||
|
import { Integration } from "../integration"
|
||||||
import { Location } from "../location"
|
import { Location } from "../location"
|
||||||
|
import { LocationMutation } from "../location-mutation"
|
||||||
|
import { ModelsDev } from "../models-dev"
|
||||||
import { Npm } from "../npm"
|
import { Npm } from "../npm"
|
||||||
|
import { PermissionV2 } from "../permission"
|
||||||
import { PluginV2 } from "../plugin"
|
import { PluginV2 } from "../plugin"
|
||||||
import { PluginPromise } from "../plugin/promise"
|
import { PluginPromise } from "../plugin/promise"
|
||||||
|
import { Reference } from "../reference"
|
||||||
|
import { Ripgrep } from "../ripgrep"
|
||||||
|
import { SessionInstructions } from "../session/instructions"
|
||||||
|
import { SessionTodo } from "../session/todo"
|
||||||
|
import { Shell } from "../shell"
|
||||||
|
import { SkillV2 } from "../skill"
|
||||||
|
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
||||||
|
import { ToolRegistry } from "../tool/registry"
|
||||||
|
import { WebSearchTool } from "../tool/websearch"
|
||||||
import { PluginInternal } from "./internal"
|
import { PluginInternal } from "./internal"
|
||||||
|
import { PluginRuntime } from "./runtime"
|
||||||
import { SdkPlugins } from "./sdk"
|
import { SdkPlugins } from "./sdk"
|
||||||
|
|
||||||
const PluginModule = Schema.Struct({
|
const PluginModule = Schema.Struct({
|
||||||
|
|
@ -246,7 +270,8 @@ const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interfa
|
||||||
})
|
})
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly ready: Effect.Effect<void>
|
/** Wait for the initial plugin generation and startup updates to settle. */
|
||||||
|
readonly flush: Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||||
|
|
@ -259,35 +284,96 @@ const layer = Layer.effect(
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const lock = Semaphore.makeUnsafe(1)
|
const lock = Semaphore.makeUnsafe(1)
|
||||||
const reload = Effect.fn("PluginSupervisor.reload")(() =>
|
const ready = yield* Deferred.make<void>()
|
||||||
lock.withPermit(
|
let observed = 0
|
||||||
|
let applied = -1
|
||||||
|
|
||||||
|
const activate = Effect.fn("PluginSupervisor.activate")(function* (target: number) {
|
||||||
|
yield* lock.withPermit(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
if (applied >= target) return
|
||||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||||
const internal = yield* PluginInternal.list()
|
const internal = yield* PluginInternal.list()
|
||||||
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
||||||
const pre = [...internal.pre, ...sdk.all()]
|
const pre = [...internal.pre, ...sdk.all()]
|
||||||
// Read the current layered config before resolving plugin directives and packages.
|
const operations = yield* scan(yield* config.entries())
|
||||||
const entries = yield* config.entries()
|
|
||||||
const operations = yield* scan(entries)
|
|
||||||
// Apply config operations and load enabled package plugins into one ordered generation.
|
// Apply config operations and load enabled package plugins into one ordered generation.
|
||||||
const plugins = yield* resolve(pre, internal.post, operations)
|
const plugins = yield* resolve(pre, internal.post, operations)
|
||||||
// Replace the active generation in one scoped, batched activation.
|
// Replace the active generation in one scoped, batched activation.
|
||||||
yield* registry.activate(plugins)
|
yield* registry.activate(plugins)
|
||||||
|
applied = target
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
)
|
})
|
||||||
yield* events.subscribe([Event.Updated, SdkPlugins.Updated]).pipe(
|
const updates = yield* events
|
||||||
Stream.runForEach(() =>
|
.subscribe([Event.Updated, SdkPlugins.Updated])
|
||||||
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
|
.pipe(Stream.toQueue({ capacity: 1, strategy: "sliding" }))
|
||||||
|
const signals = yield* Stream.concat(
|
||||||
|
Stream.succeed(0),
|
||||||
|
Stream.fromQueue(updates).pipe(Stream.mapEffect(() => Effect.sync(() => ++observed))),
|
||||||
|
).pipe(Stream.broadcast({ capacity: 1, strategy: "sliding", replay: 1 }))
|
||||||
|
const attempt = (target: number) =>
|
||||||
|
activate(target).pipe(
|
||||||
|
Effect.map(() => observed === target),
|
||||||
|
Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }).pipe(Effect.as(false))),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* signals.pipe(
|
||||||
|
Stream.runForEach((target) =>
|
||||||
|
activate(target).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
|
||||||
),
|
),
|
||||||
Effect.forkScoped({ startImmediately: true }),
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
)
|
)
|
||||||
const fiber = yield* reload().pipe(
|
yield* signals.pipe(
|
||||||
Effect.withSpan("PluginSupervisor.boot"),
|
Stream.debounce("100 millis"),
|
||||||
|
Stream.mapEffect(attempt),
|
||||||
|
Stream.filter((settled) => settled),
|
||||||
|
Stream.take(1),
|
||||||
|
Stream.runDrain,
|
||||||
|
Effect.andThen(Deferred.succeed(ready, undefined)),
|
||||||
Effect.forkScoped({ startImmediately: true }),
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
)
|
)
|
||||||
return Service.of({ ready: Fiber.join(fiber) })
|
return Service.of({ flush: Deferred.await(ready) })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const nodeLayer = layer as Layer.Layer<Service, never, PluginInternal.Requirements>
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer: nodeLayer,
|
||||||
|
deps: [
|
||||||
|
PluginV2.node,
|
||||||
|
SdkPlugins.node,
|
||||||
|
AgentV2.node,
|
||||||
|
Catalog.node,
|
||||||
|
CommandV2.node,
|
||||||
|
Config.node,
|
||||||
|
EventV2.node,
|
||||||
|
FileMutation.node,
|
||||||
|
FileSystem.node,
|
||||||
|
FSUtil.node,
|
||||||
|
Global.node,
|
||||||
|
httpClient,
|
||||||
|
Image.node,
|
||||||
|
Integration.node,
|
||||||
|
Location.node,
|
||||||
|
LocationMutation.node,
|
||||||
|
ModelsDev.node,
|
||||||
|
Npm.node,
|
||||||
|
PermissionV2.node,
|
||||||
|
PluginRuntime.node,
|
||||||
|
Form.node,
|
||||||
|
ReadToolFileSystem.node,
|
||||||
|
Reference.node,
|
||||||
|
Ripgrep.node,
|
||||||
|
SessionInstructions.node,
|
||||||
|
SessionTodo.node,
|
||||||
|
Shell.node,
|
||||||
|
SkillV2.node,
|
||||||
|
ToolRegistry.toolsNode,
|
||||||
|
WebSearchTool.configNode,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
export { layer }
|
export { layer }
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
|
|
@ -12,6 +13,15 @@ export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AgentNotFoundError extends Schema.TaggedErrorClass<AgentNotFoundError>()("Session.AgentNotFoundError", {
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
agent: Agent.ID,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `Agent not found: "${this.agent}"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class StepFailedError extends Schema.TaggedErrorClass<StepFailedError>()("Session.StepFailedError", {
|
export class StepFailedError extends Schema.TaggedErrorClass<StepFailedError>()("Session.StepFailedError", {
|
||||||
error: SessionError.Error,
|
error: SessionError.Error,
|
||||||
}) {
|
}) {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ export * as SessionRunner from "./index"
|
||||||
import type { LLMError } from "@opencode-ai/llm"
|
import type { LLMError } from "@opencode-ai/llm"
|
||||||
import { Context, Effect } from "effect"
|
import { Context, Effect } from "effect"
|
||||||
import { SessionSchema } from "../schema"
|
import { SessionSchema } from "../schema"
|
||||||
import type { MessageDecodeError, StepFailedError, UserInterruptedError } from "../error"
|
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error"
|
||||||
import { SessionRunnerModel } from "./model"
|
import { SessionRunnerModel } from "./model"
|
||||||
import type { Instructions } from "../../instructions/index"
|
import type { Instructions } from "../../instructions/index"
|
||||||
import type { ToolOutputStore } from "../../tool-output-store"
|
import type { ToolOutputStore } from "../../tool-output-store"
|
||||||
|
|
@ -12,6 +12,7 @@ export type RunError =
|
||||||
| LLMError
|
| LLMError
|
||||||
| SessionRunnerModel.Error
|
| SessionRunnerModel.Error
|
||||||
| MessageDecodeError
|
| MessageDecodeError
|
||||||
|
| AgentNotFoundError
|
||||||
| StepFailedError
|
| StepFailedError
|
||||||
| UserInterruptedError
|
| UserInterruptedError
|
||||||
| Instructions.InitializationBlocked
|
| Instructions.InitializationBlocked
|
||||||
|
|
|
||||||
|
|
@ -48,11 +48,12 @@ import { SessionRunnerSystemPrompt } from "./system-prompt"
|
||||||
import { Snapshot } from "../../snapshot"
|
import { Snapshot } from "../../snapshot"
|
||||||
import { makeLocationNode } from "../../effect/app-node"
|
import { makeLocationNode } from "../../effect/app-node"
|
||||||
import { llmClient } from "../../effect/app-node-platform"
|
import { llmClient } from "../../effect/app-node-platform"
|
||||||
import { StepFailedError, UserInterruptedError } from "../error"
|
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "../error"
|
||||||
import { toSessionError } from "../to-session-error"
|
import { toSessionError } from "../to-session-error"
|
||||||
import { SessionRunnerRetry } from "./retry"
|
import { SessionRunnerRetry } from "./retry"
|
||||||
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
|
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
|
||||||
import { PluginHooks } from "../../plugin/hooks"
|
import { PluginHooks } from "../../plugin/hooks"
|
||||||
|
import { PluginSupervisor } from "../../plugin/supervisor"
|
||||||
|
|
||||||
type StepTokens = {
|
type StepTokens = {
|
||||||
readonly input: number
|
readonly input: number
|
||||||
|
|
@ -149,6 +150,7 @@ const layer = Layer.effect(
|
||||||
const db = (yield* Database.Service).db
|
const db = (yield* Database.Service).db
|
||||||
const compaction = yield* SessionCompaction.Service
|
const compaction = yield* SessionCompaction.Service
|
||||||
const title = yield* SessionTitle.Service
|
const title = yield* SessionTitle.Service
|
||||||
|
const plugins = yield* PluginSupervisor.Service
|
||||||
// Title generation is a side effect of the first step; it must not delay step continuation.
|
// Title generation is a side effect of the first step; it must not delay step continuation.
|
||||||
// Tracked per process so repeated wakes before the second user message arrives don't
|
// Tracked per process so repeated wakes before the second user message arrives don't
|
||||||
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
|
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
|
||||||
|
|
@ -209,7 +211,10 @@ const layer = Layer.effect(
|
||||||
const session = yield* getSession(sessionID)
|
const session = yield* getSession(sessionID)
|
||||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||||
return yield* Effect.interrupt
|
return yield* Effect.interrupt
|
||||||
|
yield* plugins.flush
|
||||||
const agent = yield* agents.select(session.agent)
|
const agent = yield* agents.select(session.agent)
|
||||||
|
const agentInfo = agent.info
|
||||||
|
if (!agentInfo) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||||
// Establish what the model knows before admitting what the user said, so
|
// Establish what the model knows before admitting what the user said, so
|
||||||
// a blocked first step leaves pending inputs untouched.
|
// a blocked first step leaves pending inputs untouched.
|
||||||
const checkpoint = yield* InstructionCheckpoint.prepare(
|
const checkpoint = yield* InstructionCheckpoint.prepare(
|
||||||
|
|
@ -218,9 +223,6 @@ const layer = Layer.effect(
|
||||||
loadInstructions(agent, session.id),
|
loadInstructions(agent, session.id),
|
||||||
session.id,
|
session.id,
|
||||||
)
|
)
|
||||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error | UserInterruptedError>()
|
|
||||||
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error | UserInterruptedError>> = []
|
|
||||||
let needsContinuation = false
|
|
||||||
let currentStep = step
|
let currentStep = step
|
||||||
if (promotion) {
|
if (promotion) {
|
||||||
let promoted = 0
|
let promoted = 0
|
||||||
|
|
@ -236,18 +238,15 @@ const layer = Layer.effect(
|
||||||
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
|
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
|
||||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
||||||
const context = entries.map((entry) => entry.message)
|
const context = entries.map((entry) => entry.message)
|
||||||
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
const isLastStep = agentInfo.steps !== undefined && currentStep >= agentInfo.steps
|
||||||
const toolMaterialization = isLastStep
|
const toolMaterialization = isLastStep
|
||||||
? undefined
|
? undefined
|
||||||
: yield* tools.materialize({ permissions: agent.info?.permissions, model })
|
: yield* tools.materialize({ permissions: agentInfo.permissions, model })
|
||||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||||
const request = LLM.request({
|
const request = LLM.request({
|
||||||
model,
|
model,
|
||||||
providerOptions: { openai: { promptCacheKey } },
|
providerOptions: { openai: { promptCacheKey } },
|
||||||
system: [
|
system: [agentInfo.system ? agentInfo.system : SessionRunnerSystemPrompt.provider(model), checkpoint.baseline]
|
||||||
agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model),
|
|
||||||
checkpoint.baseline,
|
|
||||||
]
|
|
||||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||||
.map(SystemPart.make),
|
.map(SystemPart.make),
|
||||||
messages: [
|
messages: [
|
||||||
|
|
@ -257,6 +256,9 @@ const layer = Layer.effect(
|
||||||
tools: toolMaterialization?.definitions ?? [],
|
tools: toolMaterialization?.definitions ?? [],
|
||||||
toolChoice: isLastStep ? "none" : undefined,
|
toolChoice: isLastStep ? "none" : undefined,
|
||||||
})
|
})
|
||||||
|
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error | UserInterruptedError>()
|
||||||
|
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error | UserInterruptedError>> = []
|
||||||
|
let needsContinuation = false
|
||||||
const availableTools = new Map(request.tools.map((tool) => [tool.name, tool]))
|
const availableTools = new Map(request.tools.map((tool) => [tool.name, tool]))
|
||||||
const requestEvent: SessionHooks["request"] = {
|
const requestEvent: SessionHooks["request"] = {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
|
|
@ -436,7 +438,7 @@ const layer = Layer.effect(
|
||||||
if (
|
if (
|
||||||
SessionRunnerRetry.isRetryable(llmFailure) &&
|
SessionRunnerRetry.isRetryable(llmFailure) &&
|
||||||
!publisher.hasRetryEvidence() &&
|
!publisher.hasRetryEvidence() &&
|
||||||
(agent.info?.steps === undefined || currentStep < agent.info.steps)
|
(agentInfo.steps === undefined || currentStep < agentInfo.steps)
|
||||||
) {
|
) {
|
||||||
return yield* new SessionRunnerRetry.RetryableFailure({
|
return yield* new SessionRunnerRetry.RetryableFailure({
|
||||||
cause: llmFailure,
|
cause: llmFailure,
|
||||||
|
|
@ -700,5 +702,6 @@ export const node = makeLocationNode({
|
||||||
Config.node,
|
Config.node,
|
||||||
Snapshot.node,
|
Snapshot.node,
|
||||||
Database.node,
|
Database.node,
|
||||||
|
PluginSupervisor.node,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { PermissionV2 } from "../permission"
|
||||||
import { QuestionV2 } from "../question"
|
import { QuestionV2 } from "../question"
|
||||||
import { Integration } from "../integration"
|
import { Integration } from "../integration"
|
||||||
import { ToolOutputStore } from "../tool-output-store"
|
import { ToolOutputStore } from "../tool-output-store"
|
||||||
import { StepFailedError, UserInterruptedError } from "./error"
|
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./error"
|
||||||
import { SessionRunnerModel } from "./runner/model"
|
import { SessionRunnerModel } from "./runner/model"
|
||||||
|
|
||||||
export function toSessionError(cause: unknown): SessionError.Error {
|
export function toSessionError(cause: unknown): SessionError.Error {
|
||||||
|
|
@ -41,6 +41,7 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||||
if (cause instanceof ToolFailure)
|
if (cause instanceof ToolFailure)
|
||||||
return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error)
|
return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error)
|
||||||
if (cause instanceof StepFailedError) return cause.error
|
if (cause instanceof StepFailedError) return cause.error
|
||||||
|
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
|
||||||
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
|
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
|
||||||
if (
|
if (
|
||||||
cause instanceof SessionRunnerModel.ModelNotSelectedError ||
|
cause instanceof SessionRunnerModel.ModelNotSelectedError ||
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,7 @@ describe("PluginSupervisor config", () => {
|
||||||
|
|
||||||
const ready = Effect.fnUntraced(function* () {
|
const ready = Effect.fnUntraced(function* () {
|
||||||
const supervisor = yield* PluginSupervisor.Service
|
const supervisor = yield* PluginSupervisor.Service
|
||||||
yield* supervisor.ready
|
yield* supervisor.flush
|
||||||
})
|
})
|
||||||
|
|
||||||
function withLocation<A, E, R>(
|
function withLocation<A, E, R>(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { describe, expect } from "bun:test"
|
||||||
import { Config } from "@opencode-ai/schema/config"
|
import { Config } from "@opencode-ai/schema/config"
|
||||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect"
|
import { Context, DateTime, Deferred, Effect, Equal, Fiber, Hash, RcMap, Schema, Stream } from "effect"
|
||||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
|
@ -54,7 +54,7 @@ describe("LocationServiceMap", () => {
|
||||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||||
const read = Effect.gen(function* () {
|
const read = Effect.gen(function* () {
|
||||||
const supervisor = yield* PluginSupervisor.Service
|
const supervisor = yield* PluginSupervisor.Service
|
||||||
yield* supervisor.ready
|
yield* supervisor.flush
|
||||||
const agents = yield* AgentV2.Service
|
const agents = yield* AgentV2.Service
|
||||||
return yield* agents.get(id)
|
return yield* agents.get(id)
|
||||||
})
|
})
|
||||||
|
|
@ -67,6 +67,268 @@ describe("LocationServiceMap", () => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
itWithSdk.live("waits for explorer activation to complete", () =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const started = yield* Deferred.make<void>()
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
const sdk = yield* SdkPlugins.Service
|
||||||
|
yield* sdk.register(
|
||||||
|
EffectPlugin.define({
|
||||||
|
id: "blocked-initial-activation",
|
||||||
|
effect: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release))),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const locations = yield* LocationServiceMap.Service
|
||||||
|
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||||
|
yield* Deferred.await(started)
|
||||||
|
|
||||||
|
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||||
|
Effect.provide(context),
|
||||||
|
Effect.forkChild,
|
||||||
|
)
|
||||||
|
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
yield* Fiber.join(flushFiber)
|
||||||
|
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||||
|
Effect.provide(context),
|
||||||
|
Effect.timeout("1 second"),
|
||||||
|
)
|
||||||
|
|
||||||
|
const explorer = yield* Effect.gen(function* () {
|
||||||
|
const agents = yield* AgentV2.Service
|
||||||
|
return yield* agents.resolve("explore")
|
||||||
|
}).pipe(Effect.provide(context))
|
||||||
|
|
||||||
|
expect(explorer).toBeDefined()
|
||||||
|
expect(explorer?.permissions.length).toBeGreaterThan(0)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
itWithSdk.live("reruns activation for SDK plugins registered during startup", () =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const firstStarted = yield* Deferred.make<void>()
|
||||||
|
const releaseFirst = yield* Deferred.make<void>()
|
||||||
|
const secondStarted = yield* Deferred.make<void>()
|
||||||
|
const releaseSecond = yield* Deferred.make<void>()
|
||||||
|
const sdk = yield* SdkPlugins.Service
|
||||||
|
yield* sdk.register(
|
||||||
|
EffectPlugin.define({
|
||||||
|
id: "fixed-target-first-plugin",
|
||||||
|
effect: () =>
|
||||||
|
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const locations = yield* LocationServiceMap.Service
|
||||||
|
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||||
|
yield* Deferred.await(firstStarted)
|
||||||
|
|
||||||
|
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||||
|
Effect.provide(context),
|
||||||
|
Effect.forkChild({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
yield* sdk.register(
|
||||||
|
EffectPlugin.define({
|
||||||
|
id: "fixed-target-second-plugin",
|
||||||
|
effect: () =>
|
||||||
|
Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Deferred.succeed(releaseFirst, undefined)
|
||||||
|
yield* Deferred.await(secondStarted)
|
||||||
|
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||||
|
|
||||||
|
yield* Deferred.succeed(releaseSecond, undefined)
|
||||||
|
yield* Fiber.join(flushFiber)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
itWithSdk.live("reruns activation for Config updates during startup", () =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const activations = { count: 0 }
|
||||||
|
const file = path.join(dir.path, "opencode.json")
|
||||||
|
yield* Effect.promise(() => fs.writeFile(file, "{}"))
|
||||||
|
const firstStarted = yield* Deferred.make<void>()
|
||||||
|
const releaseFirst = yield* Deferred.make<void>()
|
||||||
|
const secondStarted = yield* Deferred.make<void>()
|
||||||
|
const releaseSecond = yield* Deferred.make<void>()
|
||||||
|
const sdk = yield* SdkPlugins.Service
|
||||||
|
yield* sdk.register(
|
||||||
|
EffectPlugin.define({
|
||||||
|
id: "blocked-config-reload",
|
||||||
|
effect: () =>
|
||||||
|
Effect.sync(() => ++activations.count).pipe(
|
||||||
|
Effect.flatMap((activation) =>
|
||||||
|
activation === 1
|
||||||
|
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst)))
|
||||||
|
: Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const locations = yield* LocationServiceMap.Service
|
||||||
|
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||||
|
yield* Deferred.await(firstStarted)
|
||||||
|
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const updated = yield* events.subscribe(Config.Event.Updated).pipe(
|
||||||
|
Stream.filter((event) => event.location?.directory === dir.path),
|
||||||
|
Stream.runHead,
|
||||||
|
Effect.forkChild({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
fs.writeFile(
|
||||||
|
file,
|
||||||
|
JSON.stringify({ plugins: [path.join(import.meta.dir, "plugin/fixtures/config-effect-plugin.ts")] }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
yield* Fiber.join(updated)
|
||||||
|
|
||||||
|
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||||
|
Effect.provide(context),
|
||||||
|
Effect.forkChild,
|
||||||
|
)
|
||||||
|
yield* Deferred.succeed(releaseFirst, undefined)
|
||||||
|
yield* Deferred.await(secondStarted)
|
||||||
|
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||||
|
yield* Deferred.succeed(releaseSecond, undefined)
|
||||||
|
yield* Fiber.join(flushFiber)
|
||||||
|
expect(activations.count).toBe(2)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
itWithSdk.live("keeps flush pending while startup updates continue", () =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const locations = yield* LocationServiceMap.Service
|
||||||
|
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||||
|
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||||
|
Effect.provide(context),
|
||||||
|
Effect.forkChild({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
|
||||||
|
yield* Effect.forEach(
|
||||||
|
Array.from({ length: 5 }),
|
||||||
|
() => events.publish(SdkPlugins.Updated, {}).pipe(Effect.andThen(Effect.sleep("50 millis"))),
|
||||||
|
{ discard: true },
|
||||||
|
)
|
||||||
|
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||||
|
yield* Fiber.join(flushFiber)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
itWithSdk.live("keeps flush open while later hot reload runs", () =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const locations = yield* LocationServiceMap.Service
|
||||||
|
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||||
|
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
|
||||||
|
|
||||||
|
const started = yield* Deferred.make<void>()
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
const completed = yield* Deferred.make<void>()
|
||||||
|
const sdk = yield* SdkPlugins.Service
|
||||||
|
yield* sdk.register(
|
||||||
|
EffectPlugin.define({
|
||||||
|
id: "post-ready-plugin",
|
||||||
|
effect: () =>
|
||||||
|
Deferred.succeed(started, undefined).pipe(
|
||||||
|
Effect.andThen(Deferred.await(release)),
|
||||||
|
Effect.andThen(Deferred.succeed(completed, undefined)),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield* Deferred.await(started)
|
||||||
|
|
||||||
|
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||||
|
Effect.provide(context),
|
||||||
|
Effect.timeout("1 second"),
|
||||||
|
)
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
yield* Deferred.await(completed)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
itWithSdk.live("does not cancel activation when a flush waiter is interrupted", () =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const started = yield* Deferred.make<void>()
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
const completed = yield* Deferred.make<void>()
|
||||||
|
const sdk = yield* SdkPlugins.Service
|
||||||
|
yield* sdk.register(
|
||||||
|
EffectPlugin.define({
|
||||||
|
id: "interrupted-waiter-plugin",
|
||||||
|
effect: () =>
|
||||||
|
Deferred.succeed(started, undefined).pipe(
|
||||||
|
Effect.andThen(Deferred.await(release)),
|
||||||
|
Effect.andThen(Deferred.succeed(completed, undefined)),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const locations = yield* LocationServiceMap.Service
|
||||||
|
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||||
|
yield* Deferred.await(started)
|
||||||
|
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||||
|
Effect.provide(context),
|
||||||
|
Effect.forkChild({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
yield* Fiber.interrupt(flushFiber)
|
||||||
|
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
yield* Deferred.await(completed)
|
||||||
|
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||||
|
Effect.provide(context),
|
||||||
|
Effect.timeout("500 millis"),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("applies ordered plugin config operations during boot", () =>
|
it.live("applies ordered plugin config operations during boot", () =>
|
||||||
Effect.acquireRelease(
|
Effect.acquireRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|
@ -79,7 +341,7 @@ describe("LocationServiceMap", () => {
|
||||||
)
|
)
|
||||||
const plugins = yield* Effect.gen(function* () {
|
const plugins = yield* Effect.gen(function* () {
|
||||||
const plugins = yield* PluginV2.Service
|
const plugins = yield* PluginV2.Service
|
||||||
yield* (yield* PluginSupervisor.Service).ready
|
yield* (yield* PluginSupervisor.Service).flush
|
||||||
return yield* plugins.list()
|
return yield* plugins.list()
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.scoped,
|
Effect.scoped,
|
||||||
|
|
@ -106,7 +368,7 @@ describe("LocationServiceMap", () => {
|
||||||
yield* Effect.gen(function* () {
|
yield* Effect.gen(function* () {
|
||||||
const registry = yield* PluginV2.Service
|
const registry = yield* PluginV2.Service
|
||||||
const supervisor = yield* PluginSupervisor.Service
|
const supervisor = yield* PluginSupervisor.Service
|
||||||
yield* supervisor.ready
|
yield* supervisor.flush
|
||||||
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
|
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
|
||||||
|
|
||||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] })))
|
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] })))
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import { Instructions } from "@opencode-ai/core/instructions"
|
||||||
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||||
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
|
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
|
||||||
|
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
|
|
@ -76,6 +77,7 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.suc
|
||||||
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
|
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||||
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
|
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||||
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
|
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
|
||||||
|
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
|
||||||
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||||
[Snapshot.node, Snapshot.noopLayer],
|
[Snapshot.node, Snapshot.noopLayer],
|
||||||
[LayerNodePlatform.llmClient, client],
|
[LayerNodePlatform.llmClient, client],
|
||||||
|
|
@ -89,6 +91,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||||
[Config.node, config],
|
[Config.node, config],
|
||||||
[PermissionV2.node, permission],
|
[PermissionV2.node, permission],
|
||||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||||
|
[PluginSupervisor.node, pluginSupervisor],
|
||||||
])
|
])
|
||||||
const execution = Layer.effect(
|
const execution = Layer.effect(
|
||||||
SessionExecution.Service,
|
SessionExecution.Service,
|
||||||
|
|
@ -137,6 +140,7 @@ const it = testEffect(
|
||||||
[ReferenceGuidance.node, referenceGuidance],
|
[ReferenceGuidance.node, referenceGuidance],
|
||||||
[Config.node, config],
|
[Config.node, config],
|
||||||
[Snapshot.node, Snapshot.noopLayer],
|
[Snapshot.node, Snapshot.noopLayer],
|
||||||
|
[PluginSupervisor.node, pluginSupervisor],
|
||||||
[SessionExecution.node, execution],
|
[SessionExecution.node, execution],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -146,6 +150,12 @@ const sessionID = SessionV2.ID.make("ses_runner_recorded")
|
||||||
describe("SessionRunnerLLM recorded", () => {
|
describe("SessionRunnerLLM recorded", () => {
|
||||||
it.effect("executes one recorded V2 prompt through the recorded HTTP transport", () =>
|
it.effect("executes one recorded V2 prompt through the recorded HTTP transport", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const agents = yield* AgentV2.Service
|
||||||
|
yield* agents.transform((draft) =>
|
||||||
|
draft.update(AgentV2.ID.make("build"), (agent) => {
|
||||||
|
agent.mode = "primary"
|
||||||
|
}),
|
||||||
|
)
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
yield* db
|
yield* db
|
||||||
.insert(ProjectTable)
|
.insert(ProjectTable)
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
|
||||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||||
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
|
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
|
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||||
import { QuestionTool } from "@opencode-ai/core/tool/question"
|
import { QuestionTool } from "@opencode-ai/core/tool/question"
|
||||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
|
|
@ -307,6 +308,13 @@ const config = Layer.succeed(
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
let pluginFlushHook = Effect.void
|
||||||
|
const pluginSupervisor = Layer.succeed(
|
||||||
|
PluginSupervisor.Service,
|
||||||
|
PluginSupervisor.Service.of({
|
||||||
|
flush: Effect.suspend(() => pluginFlushHook),
|
||||||
|
}),
|
||||||
|
)
|
||||||
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||||
[Snapshot.node, Snapshot.noopLayer],
|
[Snapshot.node, Snapshot.noopLayer],
|
||||||
[LayerNodePlatform.llmClient, client],
|
[LayerNodePlatform.llmClient, client],
|
||||||
|
|
@ -320,6 +328,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||||
[Config.node, config],
|
[Config.node, config],
|
||||||
[McpGuidance.node, mcpGuidance],
|
[McpGuidance.node, mcpGuidance],
|
||||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||||
|
[PluginSupervisor.node, pluginSupervisor],
|
||||||
])
|
])
|
||||||
const execution = Layer.effect(
|
const execution = Layer.effect(
|
||||||
SessionExecution.Service,
|
SessionExecution.Service,
|
||||||
|
|
@ -374,6 +383,7 @@ const it = testEffect(
|
||||||
[SessionExecution.node, execution],
|
[SessionExecution.node, execution],
|
||||||
[Config.node, config],
|
[Config.node, config],
|
||||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||||
|
[PluginSupervisor.node, pluginSupervisor],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -411,6 +421,7 @@ const setup = Effect.gen(function* () {
|
||||||
systemUnavailable = false
|
systemUnavailable = false
|
||||||
systemLoadHook = Effect.void
|
systemLoadHook = Effect.void
|
||||||
modelResolveHook = Effect.void
|
modelResolveHook = Effect.void
|
||||||
|
pluginFlushHook = Effect.void
|
||||||
currentModel = model
|
currentModel = model
|
||||||
skillBaselines.clear()
|
skillBaselines.clear()
|
||||||
responses = undefined
|
responses = undefined
|
||||||
|
|
@ -423,6 +434,12 @@ const setup = Effect.gen(function* () {
|
||||||
toolExecutionsReady = 5
|
toolExecutionsReady = 5
|
||||||
activeToolExecutions = 0
|
activeToolExecutions = 0
|
||||||
maxActiveToolExecutions = 0
|
maxActiveToolExecutions = 0
|
||||||
|
const agents = yield* AgentV2.Service
|
||||||
|
yield* agents.transform((draft) =>
|
||||||
|
draft.update(AgentV2.ID.make("build"), (agent) => {
|
||||||
|
agent.mode = "primary"
|
||||||
|
}),
|
||||||
|
)
|
||||||
yield* db
|
yield* db
|
||||||
.insert(ProjectTable)
|
.insert(ProjectTable)
|
||||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||||
|
|
@ -1071,10 +1088,64 @@ describe("SessionRunnerLLM", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("fails before the model request when the selected agent is unavailable", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
yield* db
|
||||||
|
.update(SessionTable)
|
||||||
|
.set({ agent: "explore" })
|
||||||
|
.where(eq(SessionTable.id, sessionID))
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Inspect files" }), resume: false })
|
||||||
|
|
||||||
|
requests.length = 0
|
||||||
|
response = []
|
||||||
|
const failure = yield* session.resume(sessionID).pipe(Effect.flip)
|
||||||
|
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
_tag: "Session.AgentNotFoundError",
|
||||||
|
sessionID,
|
||||||
|
agent: "explore",
|
||||||
|
})
|
||||||
|
expect(requests).toHaveLength(0)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("waits for initial plugin readiness before constructing the model request", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
pluginFlushHook = Deferred.await(release)
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Wait for plugins" }), resume: false })
|
||||||
|
|
||||||
|
requests.length = 0
|
||||||
|
response = []
|
||||||
|
const running = yield* session.resume(sessionID).pipe(Effect.forkChild({ startImmediately: true }))
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(0)
|
||||||
|
expect(running.pollUnsafe()).toBeUndefined()
|
||||||
|
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
yield* Fiber.join(running)
|
||||||
|
expect(requests).toHaveLength(1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("updates selected-agent skill guidance after an agent switch", () =>
|
it.effect("updates selected-agent skill guidance after an agent switch", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
|
const agents = yield* AgentV2.Service
|
||||||
|
yield* agents.transform((draft) =>
|
||||||
|
draft.update(AgentV2.ID.make("reviewer"), (agent) => {
|
||||||
|
agent.mode = "primary"
|
||||||
|
}),
|
||||||
|
)
|
||||||
skillBaselines.set(AgentV2.ID.make("build"), "Build skills")
|
skillBaselines.set(AgentV2.ID.make("build"), "Build skills")
|
||||||
yield* admit(session, "First")
|
yield* admit(session, "First")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||||
|
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||||
import { SubagentTool } from "@opencode-ai/core/tool/subagent"
|
import { SubagentTool } from "@opencode-ai/core/tool/subagent"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
|
|
@ -106,6 +107,7 @@ const it = testEffect(layer)
|
||||||
const withSubagent = (location: Location.Ref) =>
|
const withSubagent = (location: Location.Ref) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const locations = yield* LocationServiceMap.Service
|
const locations = yield* LocationServiceMap.Service
|
||||||
|
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(locations.get(location)))
|
||||||
yield* AgentV2.Service.use((agents) =>
|
yield* AgentV2.Service.use((agents) =>
|
||||||
agents.transform((draft) => {
|
agents.transform((draft) => {
|
||||||
// The caller identity used by executeTool; subagent permission asserts against it.
|
// The caller identity used by executeTool; subagent permission asserts against it.
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ const layer = Layer.effect(
|
||||||
const skillDirs = yield* skill.dirs()
|
const skillDirs = yield* skill.dirs()
|
||||||
const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length
|
const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length
|
||||||
? yield* Effect.gen(function* () {
|
? yield* Effect.gen(function* () {
|
||||||
yield* (yield* PluginSupervisor.Service).ready
|
yield* (yield* PluginSupervisor.Service).flush
|
||||||
return (yield* (yield* Reference.Service).list()).map((reference) => reference.path)
|
return (yield* (yield* Reference.Service).list()).map((reference) => reference.path)
|
||||||
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
|
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
|
||||||
: []
|
: []
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers)
|
||||||
"model.default",
|
"model.default",
|
||||||
Effect.fn(function* () {
|
Effect.fn(function* () {
|
||||||
const plugins = yield* PluginSupervisor.Service
|
const plugins = yield* PluginSupervisor.Service
|
||||||
yield* plugins.ready.pipe(
|
yield* plugins.flush.pipe(
|
||||||
Effect.timeoutOrElse({
|
Effect.timeoutOrElse({
|
||||||
duration: "5 seconds",
|
duration: "5 seconds",
|
||||||
orElse: () =>
|
orElse: () =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue