diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 868ba44672..18c6c1f7e8 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -43,6 +43,7 @@ const registryLayer = Layer.effect( Service, Effect.gen(function* () { const applications = yield* ApplicationTools.Service + const agents = yield* AgentV2.Service const resources = yield* ToolOutputStore.Service type Registration = { readonly identity: object; readonly tool: AnyTool } const local = new Map>() @@ -64,6 +65,9 @@ const registryLayer = Layer.effect( agent: input.agent, assistantMessageID: input.assistantMessageID, toolCallID: input.call.id, + agents: { + resolve: agents.resolve, + }, }).pipe( Effect.map((output) => ({ output })), Effect.catchTag("LLM.ToolFailure", (failure) => @@ -136,17 +140,18 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) { export const defaultLayer = layer.pipe( Layer.provide(ApplicationTools.layer), + Layer.provide(AgentV2.layer), Layer.provide(ToolOutputStore.defaultLayer), ) export const node = makeLocationNode({ service: Service, layer, - deps: [ApplicationTools.node, ToolOutputStore.node], + deps: [ApplicationTools.node, AgentV2.node, ToolOutputStore.node], }) export const toolsNode = makeLocationNode({ service: Tools.Service, layer, - deps: [ApplicationTools.node, ToolOutputStore.node], + deps: [ApplicationTools.node, AgentV2.node, ToolOutputStore.node], }) diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 06345d38bd..0c0c206bce 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -1,7 +1,7 @@ export * as SubagentTool from "./subagent" import { ToolFailure } from "@opencode-ai/llm" -import { DateTime, Effect, Layer, Schema } from "effect" +import { DateTime, Effect, Layer, Schema, Scope } from "effect" import { AgentV2 } from "../agent" import { BackgroundJob } from "../background-job" import { EventV2 } from "../event" @@ -10,10 +10,9 @@ import { SessionV2 } from "../session" import { SessionEvent } from "../session/event" import { SessionMessage } from "../session/message" import { SessionSchema } from "../session/schema" -import { makeLocationNode, type LocationNode } from "../effect/app-node" +import { makeGlobalNode } from "../effect/app-node" +import { ApplicationTools } from "./application-tools" import { Tool } from "./tool" -import { Tools } from "./tools" -import { ToolRegistry } from "./registry" export const name = "subagent" @@ -56,20 +55,19 @@ const parseModel = (value: string | undefined): ModelV2.Ref | undefined => { export const layer = Layer.effectDiscard( Effect.gen(function* () { - const tools = yield* Tools.Service + const tools = yield* ApplicationTools.Service const sessions = yield* SessionV2.Service const jobs = yield* BackgroundJob.Service - const agents = yield* AgentV2.Service const events = yield* EventV2.Service + 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 latestAssistantText = Effect.fn("SubagentTool.latestAssistantText")(function* (sessionID: SessionSchema.ID) { const messages = yield* sessions.messages({ sessionID, order: "desc", limit: 20 }) const assistant = messages.find( - (message) => message.type === "assistant" && message.time.completed !== undefined && message.error === undefined, + (message) => + message.type === "assistant" && message.time.completed !== undefined && message.error === undefined, ) if (assistant === undefined || assistant.type !== "assistant") return NO_TEXT const text = assistant.content @@ -83,16 +81,36 @@ export const layer = Layer.effectDiscard( parentID: SessionSchema.ID, childID: SessionSchema.ID, description: string, + state: "completed" | "error" | "cancelled", text: string, ) { yield* events.publish(SessionEvent.Synthetic, { sessionID: parentID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, - text: `\n${text}\n`, + text: `\n${text}\n`, }) }) + const injectWhenDone = Effect.fn("SubagentTool.injectWhenDone")(function* ( + parentID: SessionSchema.ID, + childID: SessionSchema.ID, + description: string, + ) { + yield* jobs.wait({ id: childID }).pipe( + Effect.flatMap((result) => { + if (result.info?.status === "completed") + return injectCompletion(parentID, childID, description, "completed", result.info.output ?? NO_TEXT) + if (result.info?.status === "error") + return injectCompletion(parentID, childID, description, "error", result.info.error ?? "Subagent failed") + if (result.info?.status === "cancelled") + return injectCompletion(parentID, childID, description, "cancelled", "Subagent cancelled") + return Effect.void + }), + Effect.forkIn(scope, { startImmediately: true }), + ) + }) + yield* tools .register({ [name]: Tool.make({ @@ -102,14 +120,16 @@ export const layer = Layer.effectDiscard( toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { - const agent = yield* agents.resolve(input.agent) - if (agent === undefined) - return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` }) + const agent = yield* context.agents.resolve(input.agent) + if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` }) if (agent.mode === "primary") return yield* new ToolFailure({ message: `Agent ${input.agent} cannot run as a subagent` }) // Precedence: explicit input model -> agent's configured model -> parent session model. - const model = parseModel(input.model) ?? agent.model + const parent = yield* sessions.get(context.sessionID).pipe( + Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })), + ) + const model = parseModel(input.model) ?? agent.model ?? parent.model const child = yield* sessions.create({ parentID: context.sessionID, @@ -134,33 +154,30 @@ export const layer = Layer.effectDiscard( id: child.id, type: name, title: input.description, - metadata: background ? { background: true } : {}, - onPromote: jobs - .wait({ id: child.id }) - .pipe( - Effect.flatMap((result) => - result.info?.status === "completed" - ? injectCompletion(context.sessionID, child.id, input.description, result.info.output ?? NO_TEXT) - : Effect.void, - ), - ), + metadata: {}, + onPromote: injectWhenDone(context.sessionID, child.id, input.description), run, }) if (background) { + if ((yield* jobs.promote(info.id)) === undefined) + yield* injectWhenDone(context.sessionID, child.id, input.description) return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED } } const result = yield* Effect.raceFirst( jobs.wait({ id: child.id }).pipe(Effect.map((waited) => waited.info)), jobs.waitForPromotion(child.id), + ).pipe( + Effect.onInterrupt(() => + Effect.all([sessions.interrupt(child.id), jobs.cancel(child.id)], { discard: true }), + ), ) if (result?.metadata?.background === true) return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED } if (result?.status === "error") return yield* new ToolFailure({ message: result.error ?? "Subagent failed" }) - if (result?.status === "cancelled") - return yield* new ToolFailure({ message: "Subagent cancelled" }) + if (result?.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" }) return { sessionID: child.id, status: "completed" as const, output: result?.output ?? NO_TEXT } }), }), @@ -169,12 +186,11 @@ export const layer = Layer.effectDiscard( }), ) -// Registered as a separate Location-scoped node rather than inside builtins, because its session -// dependencies would form a static import cycle through location-services -> tool/builtins -> session. -// Explicit annotation keeps SessionV2's type (which references LocationServiceMap) from -// expanding into the locationServices group inference and forming a type-level self-reference. -export const node: LocationNode = makeLocationNode({ +// Registered at the app root via ApplicationTools, not as a Location node: SessionV2 sits above +// LocationServiceMap, so a location-scoped subagent node would create a static dependency cycle. +// The location ToolRegistry supplies call-local agent lookup through Tool.Context. +export const node = makeGlobalNode({ name: "subagent-tool", layer, - deps: [ToolRegistry.toolsNode, SessionV2.node, AgentV2.node, BackgroundJob.node, EventV2.node], + deps: [ApplicationTools.node, SessionV2.node, BackgroundJob.node, EventV2.node], }) diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index 1d9a82e952..362b39bdcd 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -11,6 +11,9 @@ export interface Context { readonly agent: AgentV2.ID readonly assistantMessageID: SessionMessage.ID readonly toolCallID: string + readonly agents: { + readonly resolve: (id?: AgentV2.ID | string) => Effect.Effect + } } export type SchemaType = Schema.Codec diff --git a/packages/core/test/application-tools.test.ts b/packages/core/test/application-tools.test.ts index dac711f3c6..f6c7c4bf0f 100644 --- a/packages/core/test/application-tools.test.ts +++ b/packages/core/test/application-tools.test.ts @@ -19,6 +19,7 @@ const applications = ApplicationTools.layer const registry = ToolRegistry.layer.pipe( Layer.provide(permission), Layer.provide(applications), + Layer.provide(AgentV2.layer), Layer.provide(ToolOutputStore.defaultLayer), ) const it = testEffect(Layer.mergeAll(applications, registry)) @@ -66,7 +67,7 @@ describe("ApplicationTools", () => { { type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" }, ], }) - expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-opaque" }]) + expect(contexts[0]).toMatchObject({ sessionID, agent, assistantMessageID, toolCallID: "call-opaque" }) }), ) @@ -105,7 +106,7 @@ describe("ApplicationTools", () => { call: { type: "tool-call", id: "call-denied", name: "application_context", input: { query: "hello" } }, }), ).toMatchObject({ result: { type: "content" } }) - expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-denied" }]) + expect(contexts).toMatchObject([{ sessionID, agent, assistantMessageID, toolCallID: "call-denied" }]) }), ) @@ -143,7 +144,7 @@ describe("ApplicationTools", () => { ], }, }) - expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-context" }]) + expect(contexts).toMatchObject([{ sessionID, agent, assistantMessageID, toolCallID: "call-context" }]) }), ) @@ -256,8 +257,8 @@ describe("ApplicationTools", () => { call: { type: "tool-call", id: "call-first", name: "contextual", input: { query: "first" } }, }) - expect(secondContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-second" }]) - expect(firstContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-first" }]) + expect(secondContexts).toMatchObject([{ sessionID, agent, assistantMessageID, toolCallID: "call-second" }]) + expect(firstContexts).toMatchObject([{ sessionID, agent, assistantMessageID, toolCallID: "call-first" }]) }), ) @@ -284,7 +285,7 @@ describe("ApplicationTools", () => { call: { type: "tool-call", id: "call-shared", name: "shared", input: { query: "location" } }, }), ).toMatchObject({ result: { type: "content" } }) - expect(locationContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-shared" }]) + expect(locationContexts).toMatchObject([{ sessionID, agent, assistantMessageID, toolCallID: "call-shared" }]) expect(applicationContexts).toEqual([]) }), ) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 81efeb7623..6960aae613 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -102,6 +102,16 @@ describe("SessionV2.create", () => { }), ) + it.effect("inherits location from an existing parent when omitted", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const parent = yield* session.create({ location }) + const child = yield* session.create({ parentID: parent.id, title: "child" }) + + expect(child).toMatchObject({ parentID: parent.id, location }) + }), + ) + it.effect("returns the existing Session when one ID is reused with different create arguments", () => Effect.gen(function* () { const session = yield* SessionV2.Service diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index d286c29285..3f6f96b06b 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -27,7 +27,11 @@ const outputStore = Layer.mock(ToolOutputStore.Service, { ) }, }) -const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore)) +const registry = ToolRegistry.layer.pipe( + Layer.provide(ApplicationTools.layer), + Layer.provide(AgentV2.layer), + Layer.provide(outputStore), +) const it = testEffect(registry) const integrated = testEffect(Layer.mergeAll(ApplicationTools.layer, registry)) const identity = { @@ -237,7 +241,7 @@ describe("ToolRegistry", () => { ...identity, call: { type: "tool-call", id: "call-context", name: "context", input: {} }, }) - expect(contexts).toEqual([{ sessionID, ...identity, toolCallID: "call-context" }]) + expect(contexts).toMatchObject([{ sessionID, ...identity, toolCallID: "call-context" }]) }), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index c3204bf665..5c43fc6e9a 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -124,6 +124,7 @@ const applications = ApplicationTools.layer const registry = ToolRegistry.layer.pipe( Layer.provide(permission), Layer.provide(applications), + Layer.provide(AgentV2.layer), Layer.provide(ToolOutputStore.defaultLayer), ) const agents = AgentV2.layer @@ -381,7 +382,10 @@ const recordedEventTypes = (id: SessionV2.ID) => .where(eq(EventTable.aggregate_id, id)) .orderBy(asc(EventTable.seq)) .all() - .pipe(Effect.orDie, Effect.map((rows) => rows.map((row) => row.type))) + .pipe( + Effect.orDie, + Effect.map((rows) => rows.map((row) => row.type)), + ) }) const replaySessionProjection = (id: SessionV2.ID) => @@ -604,7 +608,7 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) expect(requests[0]?.tools.map((tool) => tool.name)).toContain("application_context") - expect(contexts).toEqual([ + expect(contexts).toMatchObject([ { sessionID, agent: AgentV2.ID.make("build"), diff --git a/packages/core/test/session-wait.test.ts b/packages/core/test/session-wait.test.ts new file mode 100644 index 0000000000..de100ec95f --- /dev/null +++ b/packages/core/test/session-wait.test.ts @@ -0,0 +1,66 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { ProjectV2 } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionStore } from "@opencode-ai/core/session/store" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" +import { testEffect } from "./lib/effect" + +const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) +const awaited: SessionV2.ID[] = [] +const projects = Layer.succeed( + ProjectV2.Service, + ProjectV2.Service.of({ + resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), + directories: () => Effect.succeed([]), + commit: () => Effect.void, + }), +) +const execution = Layer.succeed( + SessionExecution.Service, + SessionExecution.Service.of({ + active: Effect.succeed(new Set()), + resume: () => Effect.void, + wake: () => Effect.void, + interrupt: () => Effect.void, + awaitIdle: (sessionID) => Effect.sync(() => awaited.push(sessionID)), + }), +) +const sessions = SessionV2.layer.pipe( + Layer.provide(locationServiceMapLayer), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), + Layer.provide(projects), + Layer.provide(execution), +) +const it = testEffect( + Layer.mergeAll( + Database.defaultLayer, + EventV2.defaultLayer, + projects, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, + sessions, + ), +) + +describe("SessionV2.wait", () => { + it.effect("delegates to SessionExecution.awaitIdle", () => + Effect.gen(function* () { + awaited.length = 0 + const sessions = yield* SessionV2.Service + const session = yield* sessions.create({ location }) + + yield* sessions.wait(session.id) + + expect(awaited).toEqual([session.id]) + }), + ) +}) diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts new file mode 100644 index 0000000000..c8f6b7f642 --- /dev/null +++ b/packages/core/test/tool-subagent.test.ts @@ -0,0 +1,247 @@ +import { describe, expect } from "bun:test" +import { DateTime, Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { AgentV2 } from "@opencode-ai/core/agent" +import { BackgroundJob } from "@opencode-ai/core/background-job" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SubagentTool } from "@opencode-ai/core/tool/subagent" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" +import { executeTool, settleTool, toolIdentity } from "./lib/tool" + +const childText = "child final response" +const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") }) +const parentModel = ModelV2.Ref.make({ id: ModelV2.ID.make("parent"), providerID: ProviderV2.ID.make("test") }) +const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + +const outputSessionID = (value: unknown) => { + if (typeof value !== "object" || value === null || !("sessionID" in value) || typeof value.sessionID !== "string") { + throw new Error("Subagent output did not include a sessionID") + } + return SessionV2.ID.make(value.sessionID) +} + +const executionNode = makeGlobalNode({ + service: SessionExecution.Service, + layer: Layer.effect( + SessionExecution.Service, + EventV2.Service.use((events) => { + const completed = new Set() + const complete = Effect.fn("SubagentTest.complete")(function* (sessionID: SessionV2.ID) { + if (completed.has(sessionID)) return + completed.add(sessionID) + const assistantMessageID = SessionMessage.ID.create() + const textID = "text_subagent_test" + yield* events.publish(SessionEvent.Step.Started, { + sessionID, + assistantMessageID, + timestamp: yield* DateTime.now, + agent: AgentV2.ID.make("reviewer"), + model: childModel, + }) + yield* events.publish(SessionEvent.Text.Started, { + sessionID, + assistantMessageID, + timestamp: yield* DateTime.now, + textID, + }) + yield* events.publish(SessionEvent.Text.Ended, { + sessionID, + assistantMessageID, + timestamp: yield* DateTime.now, + textID, + text: childText, + }) + yield* events.publish(SessionEvent.Step.Ended, { + sessionID, + assistantMessageID, + timestamp: yield* DateTime.now, + finish: "stop", + cost: 0, + tokens, + }) + }) + return Effect.succeed( + SessionExecution.Service.of({ + active: Effect.succeed(new Set()), + resume: complete, + wake: () => Effect.void, + interrupt: () => Effect.void, + awaitIdle: complete, + }), + ) + }), + ), + deps: [EventV2.node], +}) + +const layer = AppNodeBuilder.build( + LayerNode.bind( + LayerNode.group([ + Database.node, + EventV2.node, + BackgroundJob.node, + ToolOutputStore.cleanupNode, + SessionV2.node, + SubagentTool.node, + LocationServiceMap.node, + ]), + SessionExecution.node, + executionNode, + ), +) + +const it = testEffect(layer) + +const withSubagent = (location: Location.Ref) => + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + yield* AgentV2.Service.use((agents) => + agents.transform((draft) => { + draft.update(AgentV2.ID.make("reviewer"), (agent) => { + agent.mode = "subagent" + agent.model = childModel + }) + draft.update(AgentV2.ID.make("fallback"), (agent) => { + agent.mode = "subagent" + }) + draft.update(AgentV2.ID.make("primary"), (agent) => { + agent.mode = "primary" + }) + }), + ).pipe(Effect.provide(locations.get(location))) + }) + +describe("SubagentTool", () => { + it.live("registers globally while resolving agents from the caller location", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + yield* withSubagent(location) + + const locations = yield* LocationServiceMap.Service + const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(location))) + expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name) + + const session = yield* SessionV2.Service + const parent = yield* session.create({ location }) + expect( + yield* executeTool(registry, { + sessionID: parent.id, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-primary", + name: SubagentTool.name, + input: { agent: "primary", description: "primary", prompt: "should fail" }, + }, + }), + ).toEqual({ type: "error", value: "Agent primary cannot run as a subagent" }) + }), + ), + ), + ) + + it.live("runs a foreground child session and returns the final assistant text", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + yield* withSubagent(location) + const sessions = yield* SessionV2.Service + const parent = yield* sessions.create({ location, model: parentModel }) + const locations = yield* LocationServiceMap.Service + const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(location))) + + const settled = yield* settleTool(registry, { + sessionID: parent.id, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-subagent", + name: SubagentTool.name, + input: { agent: "reviewer", description: "review", prompt: "review this" }, + }, + }) + + expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText }) + const child = yield* sessions.get(outputSessionID(settled.output?.structured)) + expect(child).toMatchObject({ parentID: parent.id, location, agent: "reviewer", model: childModel }) + + const fallback = yield* settleTool(registry, { + sessionID: parent.id, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-subagent-fallback", + name: SubagentTool.name, + input: { agent: "fallback", description: "fallback", prompt: "fallback" }, + }, + }) + const fallbackChild = yield* sessions.get(outputSessionID(fallback.output?.structured)) + expect(fallbackChild).toMatchObject({ parentID: parent.id, model: parentModel }) + }), + ), + ), + ) + + it.live("promotes background work and injects one synthetic parent completion", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + yield* withSubagent(location) + const sessions = yield* SessionV2.Service + const jobs = yield* BackgroundJob.Service + const parent = yield* sessions.create({ location }) + const locations = yield* LocationServiceMap.Service + const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(location))) + + const settled = yield* settleTool(registry, { + sessionID: parent.id, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-background-subagent", + name: SubagentTool.name, + input: { agent: "reviewer", description: "background review", prompt: "review", background: true }, + }, + }) + const childID = outputSessionID(settled.output?.structured) + expect(settled.output?.structured).toMatchObject({ status: "running" }) + + yield* jobs.promote(childID) + yield* Effect.yieldNow + const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic") + expect(synthetic).toHaveLength(1) + expect(synthetic[0]?.text).toContain(`