mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5cf9081c9 | |||
| ac5203f5a3 | |||
| c64043481d | |||
| caa0be1f95 |
@@ -50,6 +50,7 @@ import { Global } from "@opencode-ai/util/global"
|
|||||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||||
import { KeyedMutex } from "./effect/keyed-mutex.js"
|
import { KeyedMutex } from "./effect/keyed-mutex.js"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
import { Subagent } from "./subagent.js"
|
||||||
|
|
||||||
// get project -> project.locations
|
// get project -> project.locations
|
||||||
//
|
//
|
||||||
@@ -608,14 +609,33 @@ const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments })
|
const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments })
|
||||||
|
|
||||||
// TODO(v2 commands): decide whether command-level subtask/background execution belongs in v2 commands.
|
const agents = yield* Agent.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||||
|
const commandAgent = command.agent ? yield* agents.get(command.agent) : undefined
|
||||||
|
const model = command.model ?? commandAgent?.model ?? input.model ?? session.model
|
||||||
|
if (commandAgent?.mode === "subagent") {
|
||||||
|
const childAgent = command.agent ?? Agent.ID.make("general")
|
||||||
|
const title = command.description ?? input.command
|
||||||
|
const run = yield* Subagent.run({
|
||||||
|
runtime: { session: result, job: jobs },
|
||||||
|
scope,
|
||||||
|
parentID: input.sessionID,
|
||||||
|
agent: childAgent,
|
||||||
|
title,
|
||||||
|
prompt: evaluated.text,
|
||||||
|
id: input.id,
|
||||||
|
model,
|
||||||
|
files: input.files,
|
||||||
|
agents: input.agents,
|
||||||
|
skills: input.skills,
|
||||||
|
delivery: input.delivery,
|
||||||
|
background: true,
|
||||||
|
notifyStarted: true,
|
||||||
|
metadata: { source: "command", command: input.command, parentID: input.sessionID },
|
||||||
|
})
|
||||||
|
return run.admitted
|
||||||
|
}
|
||||||
|
|
||||||
const agent = command.agent ?? input.agent
|
const agent = command.agent ?? input.agent
|
||||||
const commandAgent = yield* Effect.gen(function* () {
|
|
||||||
if (!command.agent) return undefined
|
|
||||||
const agents = yield* Agent.Service.pipe(Effect.provide(locations.get(session.location)))
|
|
||||||
return yield* agents.get(Agent.ID.make(command.agent))
|
|
||||||
})
|
|
||||||
const model = command.model ?? commandAgent?.model ?? input.model
|
|
||||||
if (agent !== undefined && session.agent !== Agent.ID.make(agent))
|
if (agent !== undefined && session.agent !== Agent.ID.make(agent))
|
||||||
yield* result.switchAgent({ sessionID: input.sessionID, agent: Agent.ID.make(agent) })
|
yield* result.switchAgent({ sessionID: input.sessionID, agent: Agent.ID.make(agent) })
|
||||||
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
|
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
export * as Subagent from "./subagent.js"
|
||||||
|
|
||||||
|
import { Effect, Scope } from "effect"
|
||||||
|
import type { Agent } from "./agent.js"
|
||||||
|
import type { Job } from "./job.js"
|
||||||
|
import type { Model } from "./model.js"
|
||||||
|
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||||
|
import type { Session } from "./session.js"
|
||||||
|
import type { SessionInbox } from "./session/inbox.js"
|
||||||
|
import type { SessionMessage } from "./session/message.js"
|
||||||
|
import type { SessionSchema } from "./session/schema.js"
|
||||||
|
|
||||||
|
const NO_TEXT = "Subagent completed without a text response."
|
||||||
|
|
||||||
|
export const backgroundStarted = (sessionID: SessionSchema.ID) =>
|
||||||
|
[
|
||||||
|
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes.`,
|
||||||
|
"DO NOT sleep, poll for progress, ask the subagent for status, or duplicate this subagent's work; avoid working with the same files or topics it is using.",
|
||||||
|
"Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
export type Runtime = {
|
||||||
|
readonly session: Pick<Session.Interface, "create" | "messages" | "prompt" | "resume" | "interrupt" | "synthetic">
|
||||||
|
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Input {
|
||||||
|
readonly runtime: Runtime
|
||||||
|
readonly scope: Scope.Scope
|
||||||
|
readonly parentID: SessionSchema.ID
|
||||||
|
readonly agent: Agent.ID
|
||||||
|
readonly title: string
|
||||||
|
readonly prompt: string
|
||||||
|
readonly id?: SessionMessage.ID
|
||||||
|
readonly model?: Model.Ref
|
||||||
|
readonly files?: PromptInput.Prompt["files"]
|
||||||
|
readonly agents?: PromptInput.Prompt["agents"]
|
||||||
|
readonly skills?: PromptInput.Prompt["skills"]
|
||||||
|
readonly delivery?: SessionInbox.Delivery
|
||||||
|
readonly background: boolean
|
||||||
|
readonly notifyStarted?: boolean
|
||||||
|
readonly progress?: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||||
|
readonly metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const run = Effect.fn("Subagent.run")(function* (input: Input) {
|
||||||
|
const child = yield* input.runtime.session.create({
|
||||||
|
parentID: input.parentID,
|
||||||
|
title: input.title,
|
||||||
|
agent: input.agent,
|
||||||
|
model: input.model,
|
||||||
|
})
|
||||||
|
yield* input.progress?.(child.id) ?? Effect.void
|
||||||
|
const admitted = yield* input.runtime.session.prompt({
|
||||||
|
id: input.id,
|
||||||
|
sessionID: child.id,
|
||||||
|
text: input.prompt,
|
||||||
|
files: input.files,
|
||||||
|
agents: input.agents,
|
||||||
|
skills: input.skills,
|
||||||
|
delivery: input.delivery,
|
||||||
|
resume: false,
|
||||||
|
})
|
||||||
|
const info = yield* input.runtime.job.start({
|
||||||
|
id: child.id,
|
||||||
|
type: "subagent",
|
||||||
|
title: input.title,
|
||||||
|
metadata: input.metadata,
|
||||||
|
run: Effect.gen(function* () {
|
||||||
|
yield* input.runtime.session.resume(child.id)
|
||||||
|
const messages = yield* input.runtime.session.messages({ sessionID: child.id, order: "desc", limit: 20 })
|
||||||
|
const assistant = messages.find(
|
||||||
|
(message) =>
|
||||||
|
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
|
||||||
|
)
|
||||||
|
if (assistant === undefined || assistant.type !== "assistant") return NO_TEXT
|
||||||
|
const text = assistant.content
|
||||||
|
.filter((part): part is Extract<typeof part, { type: "text" }> => part.type === "text")
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join("")
|
||||||
|
return text.length > 0 ? text : NO_TEXT
|
||||||
|
}).pipe(Effect.onInterrupt(() => input.runtime.session.interrupt(child.id))),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (input.background) {
|
||||||
|
yield* input.runtime.job.background(info.id)
|
||||||
|
if (input.notifyStarted)
|
||||||
|
yield* notify(input, child.id, "running", backgroundStarted(child.id)).pipe(
|
||||||
|
Effect.catchTag("Session.SyntheticConflictError", Effect.die),
|
||||||
|
)
|
||||||
|
yield* notifyWhenDone(input, child.id)
|
||||||
|
return { sessionID: child.id, status: "running" as const, output: backgroundStarted(child.id), admitted }
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = yield* input.runtime.job
|
||||||
|
.block({ id: child.id, sessionID: input.parentID })
|
||||||
|
.pipe(
|
||||||
|
Effect.onInterrupt(() =>
|
||||||
|
Effect.all([input.runtime.session.interrupt(child.id), input.runtime.job.cancel(child.id)], { discard: true }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (result?.type === "backgrounded") {
|
||||||
|
yield* notifyWhenDone(input, child.id)
|
||||||
|
return { sessionID: child.id, status: "running" as const, output: backgroundStarted(child.id), admitted }
|
||||||
|
}
|
||||||
|
if (result?.info.status === "error")
|
||||||
|
return { sessionID: child.id, status: "error" as const, output: result.info.error ?? "Subagent failed", admitted }
|
||||||
|
if (result?.info.status === "cancelled")
|
||||||
|
return { sessionID: child.id, status: "cancelled" as const, output: "Subagent cancelled", admitted }
|
||||||
|
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT, admitted }
|
||||||
|
})
|
||||||
|
|
||||||
|
function notifyWhenDone(input: Input, childID: SessionSchema.ID) {
|
||||||
|
return input.runtime.job.wait({ id: childID }).pipe(
|
||||||
|
Effect.flatMap((result) => {
|
||||||
|
if (result.info?.status === "completed") return notify(input, childID, "completed", result.info.output ?? NO_TEXT)
|
||||||
|
if (result.info?.status === "error")
|
||||||
|
return notify(input, childID, "error", result.info.error ?? "Subagent failed")
|
||||||
|
if (result.info?.status === "cancelled") return notify(input, childID, "cancelled", "Subagent cancelled")
|
||||||
|
return Effect.void
|
||||||
|
}),
|
||||||
|
Effect.catchTag("Session.SyntheticConflictError", Effect.die),
|
||||||
|
Effect.forkIn(input.scope, { startImmediately: true }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(
|
||||||
|
input: Input,
|
||||||
|
childID: SessionSchema.ID,
|
||||||
|
state: "running" | "completed" | "error" | "cancelled",
|
||||||
|
text: string,
|
||||||
|
) {
|
||||||
|
return input.runtime.session.synthetic({
|
||||||
|
sessionID: input.parentID,
|
||||||
|
text: `<subagent id="${childID}" state="${state}" description="${input.title}">\n${text}\n</subagent>`,
|
||||||
|
description: input.title,
|
||||||
|
metadata: { source: "subagent", ...input.metadata, childID, agent: input.agent, state },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,17 +8,10 @@ import { Config } from "../../config.js"
|
|||||||
import { PluginRuntime } from "../../plugin/runtime.js"
|
import { PluginRuntime } from "../../plugin/runtime.js"
|
||||||
import { Permission } from "../../permission.js"
|
import { Permission } from "../../permission.js"
|
||||||
import { SessionSchema } from "../../session/schema.js"
|
import { SessionSchema } from "../../session/schema.js"
|
||||||
|
import { Subagent } from "../../subagent.js"
|
||||||
|
|
||||||
export const name = "subagent"
|
export const name = "subagent"
|
||||||
|
|
||||||
const NO_TEXT = "Subagent completed without a text response."
|
|
||||||
const backgroundStarted = (sessionID: SessionSchema.ID) =>
|
|
||||||
[
|
|
||||||
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes.`,
|
|
||||||
"DO NOT sleep, poll for progress, ask the subagent for status, or duplicate this subagent's work; avoid working with the same files or topics it is using.",
|
|
||||||
"Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.",
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
export const Input = Schema.Struct({
|
export const Input = Schema.Struct({
|
||||||
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
|
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
|
||||||
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
|
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
|
||||||
@@ -51,65 +44,6 @@ export const Plugin = {
|
|||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
// Concatenate the child's final completed assistant text. Distinguishes "completed with no
|
|
||||||
// text" (generic string) from "failed" (the run effect fails, surfaced as a job error).
|
|
||||||
const latestAssistantText = Effect.fn("SubagentTool.latestAssistantText")(function* (sessionID: SessionSchema.ID) {
|
|
||||||
const messages = yield* runtime.session.messages({ sessionID, order: "desc", limit: 20 })
|
|
||||||
const assistant = messages.find(
|
|
||||||
(message) =>
|
|
||||||
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
|
|
||||||
)
|
|
||||||
if (assistant === undefined || assistant.type !== "assistant") return NO_TEXT
|
|
||||||
const text = assistant.content
|
|
||||||
.filter((part): part is Extract<typeof part, { type: "text" }> => part.type === "text")
|
|
||||||
.map((part) => part.text)
|
|
||||||
.join("")
|
|
||||||
return text.length > 0 ? text : NO_TEXT
|
|
||||||
})
|
|
||||||
|
|
||||||
const injectCompletion = Effect.fn("SubagentTool.injectCompletion")(function* (
|
|
||||||
parentID: SessionSchema.ID,
|
|
||||||
childID: SessionSchema.ID,
|
|
||||||
agent: string,
|
|
||||||
description: string,
|
|
||||||
state: "completed" | "error" | "cancelled",
|
|
||||||
text: string,
|
|
||||||
) {
|
|
||||||
yield* runtime.session.synthetic({
|
|
||||||
sessionID: parentID,
|
|
||||||
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
|
|
||||||
description,
|
|
||||||
metadata: { source: "subagent", childID, agent, state },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* (
|
|
||||||
parentID: SessionSchema.ID,
|
|
||||||
childID: SessionSchema.ID,
|
|
||||||
agent: string,
|
|
||||||
description: string,
|
|
||||||
) {
|
|
||||||
yield* runtime.job.wait({ id: childID }).pipe(
|
|
||||||
Effect.flatMap((result) => {
|
|
||||||
if (result.info?.status === "completed")
|
|
||||||
return injectCompletion(parentID, childID, agent, description, "completed", result.info.output ?? NO_TEXT)
|
|
||||||
if (result.info?.status === "error")
|
|
||||||
return injectCompletion(
|
|
||||||
parentID,
|
|
||||||
childID,
|
|
||||||
agent,
|
|
||||||
description,
|
|
||||||
"error",
|
|
||||||
result.info.error ?? "Subagent failed",
|
|
||||||
)
|
|
||||||
if (result.info?.status === "cancelled")
|
|
||||||
return injectCompletion(parentID, childID, agent, description, "cancelled", "Subagent cancelled")
|
|
||||||
return Effect.void
|
|
||||||
}),
|
|
||||||
Effect.forkIn(scope, { startImmediately: true }),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add({
|
draft.add({
|
||||||
@@ -163,76 +97,21 @@ export const Plugin = {
|
|||||||
})
|
})
|
||||||
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
|
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
|
||||||
|
|
||||||
// Model selection is policy/config/session state, not an LLM-facing tool argument.
|
const output = yield* Subagent.run({
|
||||||
const model = agent.model ?? parent.model
|
runtime,
|
||||||
const child = yield* runtime.session
|
scope,
|
||||||
.create({
|
parentID: context.sessionID,
|
||||||
parentID: context.sessionID,
|
agent: agent.id,
|
||||||
title: input.description,
|
|
||||||
agent: Agent.ID.make(input.agent),
|
|
||||||
model,
|
|
||||||
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
|
|
||||||
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const background = input.background === true
|
|
||||||
yield* context.progress({
|
|
||||||
metadata: { sessionID: child.id, status: "running" },
|
|
||||||
})
|
|
||||||
|
|
||||||
const run = Effect.gen(function* () {
|
|
||||||
// The child session owns its agent/model (set at create); prompt only admits input.
|
|
||||||
yield* runtime.session.prompt({
|
|
||||||
sessionID: child.id,
|
|
||||||
text: ["You are a subagent spawned by another session.", input.prompt].join("\n"),
|
|
||||||
resume: false,
|
|
||||||
})
|
|
||||||
yield* runtime.session.resume(child.id)
|
|
||||||
return yield* latestAssistantText(child.id)
|
|
||||||
}).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id)))
|
|
||||||
|
|
||||||
const info = yield* runtime.job.start({
|
|
||||||
id: child.id,
|
|
||||||
type: name,
|
|
||||||
title: input.description,
|
title: input.description,
|
||||||
metadata: {},
|
prompt: ["You are a subagent spawned by another session.", input.prompt].join("\n"),
|
||||||
run,
|
model: agent.model ?? parent.model,
|
||||||
})
|
background: input.background === true,
|
||||||
|
progress: (sessionID) =>
|
||||||
if (background) {
|
context.progress({ metadata: { sessionID, status: "running" } }).pipe(Effect.asVoid),
|
||||||
yield* runtime.job.background(info.id)
|
}).pipe(Effect.mapError((error) => new ToolFailure({ message: error.message, error })))
|
||||||
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
|
if (output.status === "error" || output.status === "cancelled")
|
||||||
return {
|
return yield* new ToolFailure({ message: output.output })
|
||||||
sessionID: child.id,
|
return output
|
||||||
status: "running" as const,
|
|
||||||
output: backgroundStarted(child.id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
|
|
||||||
Effect.onInterrupt(() =>
|
|
||||||
Effect.all([runtime.session.interrupt(child.id), runtime.job.cancel(child.id)], {
|
|
||||||
discard: true,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (result?.type === "backgrounded") {
|
|
||||||
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
|
|
||||||
return {
|
|
||||||
sessionID: child.id,
|
|
||||||
status: "running" as const,
|
|
||||||
output: backgroundStarted(child.id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (result?.info.status === "error")
|
|
||||||
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
|
|
||||||
if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" })
|
|
||||||
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
|
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.map((output) => ({
|
Effect.map((output) => ({
|
||||||
output,
|
output,
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer, LayerMap } from "effect"
|
||||||
|
import { Agent } from "@opencode-ai/core/agent"
|
||||||
|
import { Bus } from "@opencode-ai/core/bus"
|
||||||
|
import { Command } from "@opencode-ai/core/command"
|
||||||
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { Job } from "@opencode-ai/core/job"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||||
|
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||||
|
import { Model } from "@opencode-ai/core/model"
|
||||||
|
import { Project } from "@opencode-ai/core/project"
|
||||||
|
import { Provider } from "@opencode-ai/core/provider"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { Session } from "@opencode-ai/core/session"
|
||||||
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
|
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||||
|
const model = Model.Ref.make({ id: Model.ID.make("sonnet"), providerID: Provider.ID.make("anthropic") })
|
||||||
|
const reviewer = Agent.ID.make("reviewer")
|
||||||
|
const commands = Layer.mock(Command.Service, {
|
||||||
|
get: (name) => {
|
||||||
|
if (name === "review")
|
||||||
|
return Effect.succeed(
|
||||||
|
Command.Info.make({
|
||||||
|
name,
|
||||||
|
template: "Review this",
|
||||||
|
description: "review changes",
|
||||||
|
agent: reviewer,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return Effect.succeed(undefined)
|
||||||
|
},
|
||||||
|
evaluate: () => Effect.succeed({ text: "Review this" }),
|
||||||
|
})
|
||||||
|
const agents = Layer.mock(Agent.Service, {
|
||||||
|
get: (id) =>
|
||||||
|
Effect.succeed(
|
||||||
|
id === reviewer ? Agent.Info.make({ ...Agent.Info.default(id), mode: "subagent", model }) : undefined,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const locations = Layer.effect(
|
||||||
|
LocationServiceMap.Service,
|
||||||
|
LayerMap.make(
|
||||||
|
() =>
|
||||||
|
// This endpoint only needs the Location-scoped Command and Agent services.
|
||||||
|
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||||
|
Layer.merge(commands, agents) as unknown as Layer.Layer<LocationServices>,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const projects = Layer.mock(Project.Service, {
|
||||||
|
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||||
|
})
|
||||||
|
const execution = Layer.succeed(
|
||||||
|
SessionExecution.Service,
|
||||||
|
SessionExecution.Service.of({
|
||||||
|
active: Effect.succeed(new Set()),
|
||||||
|
resume: () => Effect.never,
|
||||||
|
wake: () => Effect.void,
|
||||||
|
interrupt: () => Effect.void,
|
||||||
|
awaitIdle: () => Effect.void,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const it = testEffect(
|
||||||
|
AppNodeBuilder.build(
|
||||||
|
LayerNode.group([Database.node, Bus.node, Job.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||||
|
[
|
||||||
|
[LocationServiceMap.node, locations],
|
||||||
|
[Project.node, projects],
|
||||||
|
[SessionExecution.node, execution],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
describe("Session.command", () => {
|
||||||
|
it.effect("runs commands targeting subagent-mode agents in background child sessions", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const sessions = yield* Session.Service
|
||||||
|
const parent = yield* sessions.create({ location, model })
|
||||||
|
|
||||||
|
const admitted = yield* sessions.command({ sessionID: parent.id, command: "review" })
|
||||||
|
const children = yield* sessions.list({ parentID: parent.id })
|
||||||
|
|
||||||
|
expect(children.data).toHaveLength(1)
|
||||||
|
expect(children.data[0]).toMatchObject({
|
||||||
|
parentID: parent.id,
|
||||||
|
title: "review changes",
|
||||||
|
agent: reviewer,
|
||||||
|
model,
|
||||||
|
})
|
||||||
|
expect(admitted).toMatchObject({ sessionID: children.data[0]!.id, payload: { text: "Review this" } })
|
||||||
|
expect(yield* Job.Service.use((jobs) => jobs.get(children.data[0]!.id))).toMatchObject({
|
||||||
|
id: children.data[0]!.id,
|
||||||
|
type: "subagent",
|
||||||
|
status: "running",
|
||||||
|
})
|
||||||
|
expect(yield* sessions.inbox(parent.id)).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "synthetic",
|
||||||
|
payload: expect.objectContaining({ text: expect.stringContaining(children.data[0]!.id) }),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user