From 74a269af7d39b609ac5b208e0f130e8cfaa5ecf3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 6 Jun 2026 21:29:20 -0400 Subject: [PATCH 1/5] feat(core): add v2 background task tool --- packages/core/src/public/opencode.ts | 24 ++- packages/core/src/public/session.ts | 6 + packages/core/src/session.ts | 2 + packages/core/src/tool/task.ts | 117 +++++++++++++ packages/core/test/public-opencode.test.ts | 39 ++--- packages/core/test/session-create.test.ts | 4 +- packages/core/test/tool-task.test.ts | 185 +++++++++++++++++++++ 7 files changed, 352 insertions(+), 25 deletions(-) create mode 100644 packages/core/src/tool/task.ts create mode 100644 packages/core/test/tool-task.test.ts diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts index 80c69bdb06..91b58fc2f8 100644 --- a/packages/core/src/public/opencode.ts +++ b/packages/core/src/public/opencode.ts @@ -2,6 +2,7 @@ export * as OpenCode from "./opencode" import { Context, Effect, Layer } from "effect" import { Catalog } from "../catalog" +import { AgentV2 } from "../agent" import { Database } from "../database/database" import { EventV2 } from "../event" import { LocationServiceMap } from "../location-layer" @@ -12,12 +13,13 @@ import * as SessionExecutionLocal from "../session/execution/local" import { SessionProjector } from "../session/projector" import { SessionStore } from "../session/store" import { ApplicationTools } from "../tool/application-tools" +import { TaskTool } from "../tool/task" import { Session } from "./session" import { Tool } from "./tool" export interface Interface { - readonly sessions: Session.Interface - readonly tools: Tool.Interface + readonly session: Session.Interface + readonly tool: Tool.Interface } /** Intentional public native API for Effect applications embedding OpenCode. */ @@ -77,7 +79,7 @@ const SessionsLayer = Layer.merge( Layer.orDie, ), SessionModelValidationLayer, -).pipe(Layer.provide(LocationServicesLayer)) +).pipe(Layer.provideMerge(LocationServicesLayer)) const ApplicationToolsLayer = ApplicationTools.layer // TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence. @@ -85,14 +87,24 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const sessions = yield* SessionV2.Service + const locations = yield* LocationServiceMap const tools = yield* ApplicationTools.Service const validation = yield* SessionModelValidation + yield* tools.attach({ + task: yield* TaskTool.make(sessions, (location, id) => + AgentV2.Service.pipe( + Effect.flatMap((agents) => agents.get(id)), + Effect.provide(locations.get(location)), + ), + ), + }) return Service.of({ - tools: { register: tools.register }, - sessions: { + tool: { register: tools.register }, + session: { create: (input) => sessions.create({ id: input.id, + parentID: input.parentID, agent: input.agent, model: input.model, location: input.location, @@ -111,7 +123,9 @@ export const layer = Layer.effect( sessionID: input.sessionID, prompt: input.prompt, delivery: input.delivery, + resume: input.resume, }), + resume: sessions.resume, messages: (input) => sessions.messages({ sessionID: input.sessionID, diff --git a/packages/core/src/public/session.ts b/packages/core/src/public/session.ts index 6c61aff3b6..1c1f4d9380 100644 --- a/packages/core/src/public/session.ts +++ b/packages/core/src/public/session.ts @@ -9,6 +9,7 @@ import { SessionEvent } from "../session/event" import { SessionInput } from "../session/input" import { SessionMessage } from "../session/message" import { Prompt } from "../session/prompt" +import type { SessionRunner } from "../session/runner" import { Agent } from "./agent" import { Location } from "./location" import { Model } from "./model" @@ -65,6 +66,7 @@ export { MessageDecodeError } export interface CreateInput { readonly id?: ID + readonly parentID?: ID readonly agent?: Agent.ID readonly model?: Model.Ref readonly location: Location.Ref @@ -75,6 +77,8 @@ export interface PromptInput { readonly sessionID: ID readonly prompt: Prompt readonly delivery?: Delivery + /** Admit durably without scheduling execution. */ + readonly resume?: boolean } export interface SwitchModelInput { @@ -107,6 +111,8 @@ export interface Interface { readonly get: (sessionID: ID) => Effect.Effect readonly list: (input?: ListInput) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect + /** Explicitly drain one Session and wait for the current execution chain to settle. */ + readonly resume: (sessionID: ID) => Effect.Effect readonly switchModel: ( input: SwitchModelInput, ) => Effect.Effect diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 8ea4593304..32d011dfb4 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -71,6 +71,7 @@ export type ListInput = typeof ListInput.Type type CreateInput = { id?: SessionSchema.ID + parentID?: SessionSchema.ID agent?: AgentV2.ID model?: ModelV2.Ref location: Location.Ref @@ -216,6 +217,7 @@ export const layer = Layer.effect( slug: Slug.create(), version: InstallationVersion, projectID: project.id, + parentID: input.parentID, directory: input.location.directory, path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"), workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined, diff --git a/packages/core/src/tool/task.ts b/packages/core/src/tool/task.ts new file mode 100644 index 0000000000..bf1ad982cd --- /dev/null +++ b/packages/core/src/tool/task.ts @@ -0,0 +1,117 @@ +export * as TaskTool from "./task" + +import { ToolFailure } from "@opencode-ai/llm" +import { Cause, Effect, Schema, Scope } from "effect" +import { AgentV2 } from "../agent" +import { Location } from "../location" +import { SessionV2 } from "../session" +import { SessionMessage } from "../session/message" +import { Prompt } from "../session/prompt" +import { Tool } from "./tool" + +export const Parameters = Schema.Struct({ + description: Schema.String.annotate({ description: "A short description of the task" }), + prompt: Schema.String.annotate({ description: "The task for the agent to perform" }), + subagent_type: Schema.String.annotate({ description: "The specialized agent to use" }), + background: Schema.optional(Schema.Boolean).annotate({ + description: "Return immediately and notify the parent Session when the task finishes", + }), +}) + +export const Success = Schema.Struct({ + sessionID: SessionV2.ID, + status: Schema.Literals(["running", "completed"]), + output: Schema.String.pipe(Schema.optional), +}) + +type Sessions = Pick + +export const make = Effect.fn("TaskTool.make")(function* ( + sessions: Sessions, + resolveAgent: (location: Location.Ref, id: AgentV2.ID) => Effect.Effect, +) { + const scope = yield* Scope.Scope + + return Tool.make({ + description: + "Delegate focused work to a specialized child agent. Foreground calls wait for the result; background calls return immediately and notify this Session when complete.", + input: Parameters, + output: Success, + execute: (parameters, context) => + Effect.gen(function* () { + const parent = yield* sessions.get(context.sessionID) + const agent = yield* resolveAgent(parent.location, AgentV2.ID.make(parameters.subagent_type)) + if (!agent || (agent.mode !== "subagent" && agent.mode !== "all") || agent.hidden) + return yield* new ToolFailure({ message: `Unknown subagent: ${parameters.subagent_type}` }) + const child = yield* sessions.create({ + parentID: parent.id, + location: parent.location, + agent: agent.id, + model: agent.model ?? parent.model, + }) + + const run = Effect.gen(function* () { + yield* sessions.prompt({ + sessionID: child.id, + prompt: new Prompt({ text: parameters.prompt }), + delivery: "steer", + resume: false, + }) + yield* sessions.resume(child.id) + const messages = yield* sessions.messages({ sessionID: child.id, order: "desc", limit: 1 }) + const assistant = messages.find( + (message): message is SessionMessage.Assistant => message.type === "assistant" && !!message.time.completed, + ) + if (!assistant) return "" + return assistant.content + .filter((part): part is SessionMessage.AssistantText => part.type === "text") + .map((part) => part.text) + .join("\n") + }).pipe(Effect.onInterrupt(() => sessions.interrupt(child.id))) + + if (parameters.background !== true) { + const output = yield* run.pipe( + Effect.mapError((error) => new ToolFailure({ message: `Task failed: ${String(error)}`, error })), + ) + return { sessionID: child.id, status: "completed" as const, output } + } + + yield* run.pipe( + Effect.matchCauseEffect({ + onSuccess: (output) => notify("completed", output), + onFailure: (cause) => notify("error", String(Cause.squash(cause))), + }), + Effect.tapCause((cause) => Effect.logError("Background task notification failed", Cause.squash(cause))), + Effect.ignore, + Effect.forkIn(scope, { startImmediately: true }), + ) + return { sessionID: child.id, status: "running" as const } + + function notify(state: "completed" | "error", text: string) { + const tag = state === "completed" ? "task_result" : "task_error" + return sessions.prompt({ + sessionID: parent.id, + prompt: new Prompt({ + text: `\nBackground task ${state}: ${parameters.description}\n<${tag}>\n${text}\n\n`, + }), + delivery: "steer", + }) + } + }).pipe( + Effect.mapError((error) => + error instanceof ToolFailure + ? error + : new ToolFailure({ message: `Unable to run task: ${String(error)}`, error }), + ), + ), + toModelOutput: ({ output }) => [ + { + type: "text", + text: + output.status === "running" + ? `\nThe task is working in the background. You will be notified automatically when it finishes.\n` + : `\n\n${output.output ?? ""}\n\n`, + }, + ], + }) +}) diff --git a/packages/core/test/public-opencode.test.ts b/packages/core/test/public-opencode.test.ts index c5f90e92c4..acd679307d 100644 --- a/packages/core/test/public-opencode.test.ts +++ b/packages/core/test/public-opencode.test.ts @@ -13,9 +13,9 @@ describe("public native OpenCode API", () => { Effect.gen(function* () { const opencode = yield* OpenCode.Service - expect(Object.keys(opencode).sort()).toEqual(["sessions", "tools"]) + expect(Object.keys(opencode).sort()).toEqual(["session", "tool"]) - expect(Object.keys(opencode.sessions).sort()).toEqual([ + expect(Object.keys(opencode.session).sort()).toEqual([ "context", "create", "events", @@ -25,12 +25,13 @@ describe("public native OpenCode API", () => { "message", "messages", "prompt", + "resume", "switchModel", ]) expect(Session.ID.create()).toStartWith("ses_") expect(Session.MessageID.create()).toStartWith("msg_") - expect(yield* opencode.sessions.list()).toBeArray() - yield* opencode.tools.register({ + expect(yield* opencode.session.list()).toBeArray() + yield* opencode.tool.register({ public_tool: Tool.make({ description: "Public tool", input: Schema.Struct({}), @@ -52,14 +53,14 @@ describe("public native OpenCode API", () => { const opencode = yield* OpenCode.Service const sessionID = Session.ID.make("ses_public_switch_available") const model = ref({ variant: "fast" }) - yield* opencode.sessions.create({ + yield* opencode.session.create({ id: sessionID, location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }), }) - yield* opencode.sessions.switchModel({ sessionID, model }) + yield* opencode.session.switchModel({ sessionID, model }) - expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model) + expect((yield* opencode.session.get(sessionID)).model).toEqual(model) }), ), ), @@ -77,27 +78,27 @@ describe("public native OpenCode API", () => { const opencode = yield* OpenCode.Service const availableID = Session.ID.make("ses_public_switch_exact_available") const disabledID = Session.ID.make("ses_public_switch_exact_disabled") - yield* opencode.sessions.create({ + yield* opencode.session.create({ id: availableID, location: Location.Ref.make({ directory: AbsolutePath.make(available.path) }), }) - yield* opencode.sessions.create({ + yield* opencode.session.create({ id: disabledID, location: Location.Ref.make({ directory: AbsolutePath.make(disabled.path) }), }) - yield* opencode.sessions.switchModel({ sessionID: availableID, model: ref({ variant: "default" }) }) - const disabledError = yield* opencode.sessions + yield* opencode.session.switchModel({ sessionID: availableID, model: ref({ variant: "default" }) }) + const disabledError = yield* opencode.session .switchModel({ sessionID: disabledID, model: ref() }) .pipe(Effect.flip) - const missingError = yield* opencode.sessions + const missingError = yield* opencode.session .switchModel({ sessionID: disabledID, model: ref({ id: "missing" }) }) .pipe(Effect.flip) expect(disabledError).toBeInstanceOf(Session.ModelUnavailableError) expect(missingError).toBeInstanceOf(Session.ModelUnavailableError) - expect((yield* opencode.sessions.get(availableID)).model).toEqual(ref({ variant: "default" })) - expect((yield* opencode.sessions.get(disabledID)).model).toBeUndefined() + expect((yield* opencode.session.get(availableID)).model).toEqual(ref({ variant: "default" })) + expect((yield* opencode.session.get(disabledID)).model).toBeUndefined() }), ), ), @@ -114,18 +115,18 @@ describe("public native OpenCode API", () => { const opencode = yield* OpenCode.Service const sessionID = Session.ID.make("ses_public_switch_variant") const selected = ref({ variant: "fast" }) - yield* opencode.sessions.create({ + yield* opencode.session.create({ id: sessionID, location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }), }) - yield* opencode.sessions.switchModel({ sessionID, model: selected }) + yield* opencode.session.switchModel({ sessionID, model: selected }) - const error = yield* opencode.sessions + const error = yield* opencode.session .switchModel({ sessionID, model: ref({ variant: "unknown" }) }) .pipe(Effect.flip) expect(error).toBeInstanceOf(Session.VariantUnavailableError) - expect((yield* opencode.sessions.get(sessionID)).model).toEqual(selected) + expect((yield* opencode.session.get(sessionID)).model).toEqual(selected) }), ), ), @@ -135,7 +136,7 @@ describe("public native OpenCode API", () => { Effect.gen(function* () { const opencode = yield* OpenCode.Service const sessionID = Session.ID.make("ses_public_switch_missing") - const error = yield* opencode.sessions + const error = yield* opencode.session .switchModel({ sessionID, model: Schema.decodeUnknownSync(Model.Ref)({ id: "claude-sonnet-4-5", providerID: "anthropic" }), diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 3551ec52f3..0beb568266 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -94,6 +94,7 @@ describe("SessionV2.create", () => { it.effect("stores supplied immutable create attributes", () => Effect.gen(function* () { const session = yield* SessionV2.Service + const parentID = SessionV2.ID.make("ses_parent") const workspaceID = WorkspaceV2.ID.make("wrk_test") const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), @@ -104,10 +105,11 @@ describe("SessionV2.create", () => { expect( yield* session.create({ location: Location.Ref.make({ directory: location.directory, workspaceID }), + parentID, agent: AgentV2.ID.make("build"), model, }), - ).toMatchObject({ location: { directory: location.directory, workspaceID }, agent: "build", model }) + ).toMatchObject({ parentID, location: { directory: location.directory, workspaceID }, agent: "build", model }) }), ) diff --git a/packages/core/test/tool-task.test.ts b/packages/core/test/tool-task.test.ts new file mode 100644 index 0000000000..2989659ff3 --- /dev/null +++ b/packages/core/test/tool-task.test.ts @@ -0,0 +1,185 @@ +import { describe, expect } from "bun:test" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Location } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionInput } from "@opencode-ai/core/session/input" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { TaskTool } from "@opencode-ai/core/tool/task" +import { DateTime, Deferred, Effect, Layer, Stream } from "effect" +import { testEffect } from "./lib/effect" + +const parentID = SessionV2.ID.make("ses_task_parent") +const childID = SessionV2.ID.make("ses_task_child") +const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) +const parent = new SessionV2.Info({ + id: parentID, + projectID: ProjectV2.ID.make("project"), + agent: AgentV2.ID.make("build"), + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + title: "Parent", + location, +}) +const child = new SessionV2.Info({ + id: childID, + parentID, + projectID: parent.projectID, + agent: parent.agent, + model: parent.model, + cost: 0, + tokens: parent.tokens, + time: parent.time, + title: "Child", + location, +}) +const assistant = new SessionMessage.Assistant({ + id: SessionMessage.ID.make("msg_task_assistant"), + type: "assistant", + agent: "explore", + model: parent.model!, + content: [new SessionMessage.AssistantText({ type: "text", id: "text", text: "Task output" })], + time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) }, +}) + +describe("TaskTool", () => { + const it = testEffect(Layer.empty) + const resolveAgent = () => Effect.succeed(AgentV2.Info.empty(AgentV2.ID.make("explore"))) + + it.effect("runs a foreground child with an admit-only steer and explicit resume", () => + Effect.gen(function* () { + const inputs: Parameters[0][] = [] + let resumed = 0 + const sessions = mockSessions({ + prompt: (input) => { + inputs.push(input) + return Effect.succeed(admission(input)) + }, + resume: () => Effect.sync(() => resumed++), + }) + const tool = yield* TaskTool.make(sessions, resolveAgent) + + const result = yield* tool.execute( + { + description: "Map auth", + prompt: "Map the authentication flow", + subagent_type: "explore", + background: false, + }, + { sessionID: parentID, id: "call_task", name: "task" }, + ) + + expect(result).toEqual({ sessionID: childID, status: "completed", output: "Task output" }) + expect(inputs).toHaveLength(1) + expect(inputs[0]).toMatchObject({ sessionID: childID, delivery: "steer", resume: false }) + expect(resumed).toBe(1) + }), + ) + + it.effect("rejects an unknown subagent before creating a child", () => + Effect.gen(function* () { + let created = false + const sessions = mockSessions({ + create: () => + Effect.sync(() => { + created = true + return child + }), + prompt: (input) => Effect.succeed(admission(input)), + resume: () => Effect.void, + }) + const tool = yield* TaskTool.make(sessions, () => Effect.succeed(undefined)) + + const error = yield* tool + .execute( + { + description: "Map auth", + prompt: "Map the authentication flow", + subagent_type: "missing", + }, + { sessionID: parentID, id: "call_task_unknown", name: "task" }, + ) + .pipe(Effect.flip) + + expect(error.message).toBe("Unknown subagent: missing") + expect(created).toBe(false) + }), + ) + + it.live("returns before background completion and steers the result into the parent", () => + Effect.gen(function* () { + const gate = yield* Deferred.make() + const notified = yield* Deferred.make[0]>() + const inputs: Parameters[0][] = [] + const sessions = mockSessions({ + prompt: (input) => { + inputs.push(input) + return input.sessionID === parentID + ? Deferred.succeed(notified, input).pipe(Effect.as(admission(input))) + : Effect.succeed(admission(input)) + }, + resume: () => Deferred.await(gate), + }) + const tool = yield* TaskTool.make(sessions, resolveAgent) + + const result = yield* tool.execute( + { + description: "Map auth", + prompt: "Map the authentication flow", + subagent_type: "explore", + background: true, + }, + { sessionID: parentID, id: "call_task_background", name: "task" }, + ) + + expect(result).toEqual({ sessionID: childID, status: "running" }) + expect(inputs).toHaveLength(1) + yield* Deferred.succeed(gate, undefined) + const notification = yield* Deferred.await(notified) + expect(notification).toMatchObject({ sessionID: parentID, delivery: "steer" }) + expect(notification.prompt.text).toContain("Background task completed: Map auth") + expect(notification.prompt.text).toContain("Task output") + }), + ) +}) + +function mockSessions(overrides: { + create?: SessionV2.Interface["create"] + prompt: SessionV2.Interface["prompt"] + resume: SessionV2.Interface["resume"] +}): SessionV2.Interface { + return { + create: overrides.create ?? (() => Effect.succeed(child)), + get: (id) => Effect.succeed(id === parentID ? parent : child), + prompt: overrides.prompt, + resume: overrides.resume, + messages: () => Effect.succeed([assistant]), + list: () => Effect.succeed([]), + message: () => Effect.succeed(undefined), + context: () => Effect.succeed([]), + events: () => Stream.die("unused"), + switchAgent: () => Effect.die("unused"), + switchModel: () => Effect.die("unused"), + shell: () => Effect.die("unused"), + skill: () => Effect.die("unused"), + compact: () => Effect.die("unused"), + wait: () => Effect.die("unused"), + interrupt: () => Effect.void, + } +} + +function admission(input: Parameters[0]) { + return new SessionInput.Admitted({ + admittedSeq: 1, + id: input.id ?? SessionMessage.ID.create(), + sessionID: input.sessionID, + prompt: input.prompt, + delivery: input.delivery ?? "steer", + timeCreated: DateTime.makeUnsafe(0), + }) +} From af22efb4e4f641ceeabd91ce30a13f8f2cc01a4a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 6 Jun 2026 21:30:54 -0400 Subject: [PATCH 2/5] refactor(core): align task tool with unified registry --- packages/core/src/public/opencode.ts | 2 +- packages/core/test/tool-task.test.ts | 50 ++++++++++++++++++---------- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts index 91b58fc2f8..b39b0d52c4 100644 --- a/packages/core/src/public/opencode.ts +++ b/packages/core/src/public/opencode.ts @@ -90,7 +90,7 @@ export const layer = Layer.effect( const locations = yield* LocationServiceMap const tools = yield* ApplicationTools.Service const validation = yield* SessionModelValidation - yield* tools.attach({ + yield* tools.register({ task: yield* TaskTool.make(sessions, (location, id) => AgentV2.Service.pipe( Effect.flatMap((agents) => agents.get(id)), diff --git a/packages/core/test/tool-task.test.ts b/packages/core/test/tool-task.test.ts index 2989659ff3..dbc38ecef3 100644 --- a/packages/core/test/tool-task.test.ts +++ b/packages/core/test/tool-task.test.ts @@ -9,6 +9,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" import { TaskTool } from "@opencode-ai/core/tool/task" +import { Tool } from "@opencode-ai/core/tool/tool" import { DateTime, Deferred, Effect, Layer, Stream } from "effect" import { testEffect } from "./lib/effect" @@ -49,7 +50,8 @@ const assistant = new SessionMessage.Assistant({ describe("TaskTool", () => { const it = testEffect(Layer.empty) - const resolveAgent = () => Effect.succeed(AgentV2.Info.empty(AgentV2.ID.make("explore"))) + const resolveAgent = (): Effect.Effect => + Effect.succeed(AgentV2.Info.empty(AgentV2.ID.make("explore"))) it.effect("runs a foreground child with an admit-only steer and explicit resume", () => Effect.gen(function* () { @@ -64,17 +66,18 @@ describe("TaskTool", () => { }) const tool = yield* TaskTool.make(sessions, resolveAgent) - const result = yield* tool.execute( + const result = yield* execute( + tool, { description: "Map auth", prompt: "Map the authentication flow", subagent_type: "explore", background: false, }, - { sessionID: parentID, id: "call_task", name: "task" }, + "call_task", ) - expect(result).toEqual({ sessionID: childID, status: "completed", output: "Task output" }) + expect(result.structured).toEqual({ sessionID: childID, status: "completed", output: "Task output" }) expect(inputs).toHaveLength(1) expect(inputs[0]).toMatchObject({ sessionID: childID, delivery: "steer", resume: false }) expect(resumed).toBe(1) @@ -95,16 +98,15 @@ describe("TaskTool", () => { }) const tool = yield* TaskTool.make(sessions, () => Effect.succeed(undefined)) - const error = yield* tool - .execute( - { - description: "Map auth", - prompt: "Map the authentication flow", - subagent_type: "missing", - }, - { sessionID: parentID, id: "call_task_unknown", name: "task" }, - ) - .pipe(Effect.flip) + const error = yield* execute( + tool, + { + description: "Map auth", + prompt: "Map the authentication flow", + subagent_type: "missing", + }, + "call_task_unknown", + ).pipe(Effect.flip) expect(error.message).toBe("Unknown subagent: missing") expect(created).toBe(false) @@ -127,17 +129,18 @@ describe("TaskTool", () => { }) const tool = yield* TaskTool.make(sessions, resolveAgent) - const result = yield* tool.execute( + const result = yield* execute( + tool, { description: "Map auth", prompt: "Map the authentication flow", subagent_type: "explore", background: true, }, - { sessionID: parentID, id: "call_task_background", name: "task" }, + "call_task_background", ) - expect(result).toEqual({ sessionID: childID, status: "running" }) + expect(result.structured).toEqual({ sessionID: childID, status: "running" }) expect(inputs).toHaveLength(1) yield* Deferred.succeed(gate, undefined) const notification = yield* Deferred.await(notified) @@ -183,3 +186,16 @@ function admission(input: Parameters[0]) { timeCreated: DateTime.makeUnsafe(0), }) } + +function execute(tool: Tool.AnyTool, input: unknown, toolCallID: string) { + return Tool.settle( + tool, + { type: "tool-call", id: toolCallID, name: "task", input }, + { + sessionID: parentID, + agent: AgentV2.ID.make("build"), + assistantMessageID: SessionMessage.ID.make("msg_task_tool"), + toolCallID, + }, + ) +} From 70ab2e2aea27d141da9f1e50d5b91ac12e621631 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 6 Jun 2026 21:41:28 -0400 Subject: [PATCH 3/5] refactor(core): use input and output task schemas --- packages/core/src/tool/task.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/tool/task.ts b/packages/core/src/tool/task.ts index bf1ad982cd..c8a47aab38 100644 --- a/packages/core/src/tool/task.ts +++ b/packages/core/src/tool/task.ts @@ -9,7 +9,7 @@ import { SessionMessage } from "../session/message" import { Prompt } from "../session/prompt" import { Tool } from "./tool" -export const Parameters = Schema.Struct({ +export const Input = Schema.Struct({ description: Schema.String.annotate({ description: "A short description of the task" }), prompt: Schema.String.annotate({ description: "The task for the agent to perform" }), subagent_type: Schema.String.annotate({ description: "The specialized agent to use" }), @@ -18,7 +18,7 @@ export const Parameters = Schema.Struct({ }), }) -export const Success = Schema.Struct({ +export const Output = Schema.Struct({ sessionID: SessionV2.ID, status: Schema.Literals(["running", "completed"]), output: Schema.String.pipe(Schema.optional), @@ -35,8 +35,8 @@ export const make = Effect.fn("TaskTool.make")(function* ( return Tool.make({ description: "Delegate focused work to a specialized child agent. Foreground calls wait for the result; background calls return immediately and notify this Session when complete.", - input: Parameters, - output: Success, + input: Input, + output: Output, execute: (parameters, context) => Effect.gen(function* () { const parent = yield* sessions.get(context.sessionID) From 73be8f7658373de2428c866245a9012b32080544 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 6 Jun 2026 21:44:32 -0400 Subject: [PATCH 4/5] test(core): cover task interruption semantics --- packages/core/src/tool/task.ts | 8 ++- packages/core/test/tool-task.test.ts | 104 ++++++++++++++------------- 2 files changed, 62 insertions(+), 50 deletions(-) diff --git a/packages/core/src/tool/task.ts b/packages/core/src/tool/task.ts index c8a47aab38..ffb59c1c36 100644 --- a/packages/core/src/tool/task.ts +++ b/packages/core/src/tool/task.ts @@ -50,6 +50,8 @@ export const make = Effect.fn("TaskTool.make")(function* ( model: agent.model ?? parent.model, }) + // TODO: Replace this fresh-child composition with Session.run, preserving admission/execution + // separation while returning the assistant response after the admitted boundary. const run = Effect.gen(function* () { yield* sessions.prompt({ sessionID: child.id, @@ -67,10 +69,11 @@ export const make = Effect.fn("TaskTool.make")(function* ( .filter((part): part is SessionMessage.AssistantText => part.type === "text") .map((part) => part.text) .join("\n") - }).pipe(Effect.onInterrupt(() => sessions.interrupt(child.id))) + }) if (parameters.background !== true) { const output = yield* run.pipe( + Effect.onInterrupt(() => sessions.interrupt(child.id)), Effect.mapError((error) => new ToolFailure({ message: `Task failed: ${String(error)}`, error })), ) return { sessionID: child.id, status: "completed" as const, output } @@ -79,7 +82,8 @@ export const make = Effect.fn("TaskTool.make")(function* ( yield* run.pipe( Effect.matchCauseEffect({ onSuccess: (output) => notify("completed", output), - onFailure: (cause) => notify("error", String(Cause.squash(cause))), + onFailure: (cause) => + Cause.hasInterruptsOnly(cause) ? Effect.void : notify("error", String(Cause.squash(cause))), }), Effect.tapCause((cause) => Effect.logError("Background task notification failed", Cause.squash(cause))), Effect.ignore, diff --git a/packages/core/test/tool-task.test.ts b/packages/core/test/tool-task.test.ts index dbc38ecef3..b1e06074a9 100644 --- a/packages/core/test/tool-task.test.ts +++ b/packages/core/test/tool-task.test.ts @@ -10,7 +10,8 @@ import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" import { TaskTool } from "@opencode-ai/core/tool/task" import { Tool } from "@opencode-ai/core/tool/tool" -import { DateTime, Deferred, Effect, Layer, Stream } from "effect" +import { DateTime, Deferred, Effect, Fiber, Layer } from "effect" +import { toolIdentity } from "./lib/tool" import { testEffect } from "./lib/effect" const parentID = SessionV2.ID.make("ses_task_parent") @@ -47,6 +48,11 @@ const assistant = new SessionMessage.Assistant({ content: [new SessionMessage.AssistantText({ type: "text", id: "text", text: "Task output" })], time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) }, }) +const input = { + description: "Map auth", + prompt: "Map the authentication flow", + subagent_type: "explore", +} describe("TaskTool", () => { const it = testEffect(Layer.empty) @@ -66,16 +72,7 @@ describe("TaskTool", () => { }) const tool = yield* TaskTool.make(sessions, resolveAgent) - const result = yield* execute( - tool, - { - description: "Map auth", - prompt: "Map the authentication flow", - subagent_type: "explore", - background: false, - }, - "call_task", - ) + const result = yield* execute(tool, { ...input, background: false }, "call_task") expect(result.structured).toEqual({ sessionID: childID, status: "completed", output: "Task output" }) expect(inputs).toHaveLength(1) @@ -98,21 +95,51 @@ describe("TaskTool", () => { }) const tool = yield* TaskTool.make(sessions, () => Effect.succeed(undefined)) - const error = yield* execute( - tool, - { - description: "Map auth", - prompt: "Map the authentication flow", - subagent_type: "missing", - }, - "call_task_unknown", - ).pipe(Effect.flip) + const error = yield* execute(tool, { ...input, subagent_type: "missing" }, "call_task_unknown").pipe(Effect.flip) expect(error.message).toBe("Unknown subagent: missing") expect(created).toBe(false) }), ) + it.live("interrupts the child when foreground waiting is interrupted", () => + Effect.gen(function* () { + const resumed = yield* Deferred.make() + const interrupts: SessionV2.ID[] = [] + const sessions = mockSessions({ + prompt: (input) => Effect.succeed(admission(input)), + resume: () => Deferred.succeed(resumed, undefined).pipe(Effect.andThen(Effect.never)), + interrupt: (sessionID) => Effect.sync(() => interrupts.push(sessionID)), + }) + const tool = yield* TaskTool.make(sessions, resolveAgent) + const fiber = yield* execute(tool, input, "call_task_interrupt").pipe(Effect.forkChild) + + yield* Deferred.await(resumed) + yield* Fiber.interrupt(fiber) + expect(interrupts).toEqual([childID]) + }), + ) + + it.live("does not notify the parent when background work is interrupted", () => + Effect.gen(function* () { + let notified = false + const sessions = mockSessions({ + prompt: (value) => { + if (value.sessionID === parentID) notified = true + return Effect.succeed(admission(value)) + }, + resume: () => Effect.interrupt, + }) + const tool = yield* TaskTool.make(sessions, resolveAgent) + + const result = yield* execute(tool, { ...input, background: true }, "call_task_background_interrupt") + + expect(result.structured).toEqual({ sessionID: childID, status: "running" }) + yield* Effect.yieldNow + expect(notified).toBe(false) + }), + ) + it.live("returns before background completion and steers the result into the parent", () => Effect.gen(function* () { const gate = yield* Deferred.make() @@ -129,16 +156,7 @@ describe("TaskTool", () => { }) const tool = yield* TaskTool.make(sessions, resolveAgent) - const result = yield* execute( - tool, - { - description: "Map auth", - prompt: "Map the authentication flow", - subagent_type: "explore", - background: true, - }, - "call_task_background", - ) + const result = yield* execute(tool, { ...input, background: true }, "call_task_background") expect(result.structured).toEqual({ sessionID: childID, status: "running" }) expect(inputs).toHaveLength(1) @@ -153,26 +171,17 @@ describe("TaskTool", () => { function mockSessions(overrides: { create?: SessionV2.Interface["create"] - prompt: SessionV2.Interface["prompt"] - resume: SessionV2.Interface["resume"] -}): SessionV2.Interface { + interrupt?: SessionV2.Interface["interrupt"] + prompt?: SessionV2.Interface["prompt"] + resume?: SessionV2.Interface["resume"] +}): Pick { return { create: overrides.create ?? (() => Effect.succeed(child)), get: (id) => Effect.succeed(id === parentID ? parent : child), - prompt: overrides.prompt, - resume: overrides.resume, + prompt: overrides.prompt ?? ((value) => Effect.succeed(admission(value))), + resume: overrides.resume ?? (() => Effect.void), messages: () => Effect.succeed([assistant]), - list: () => Effect.succeed([]), - message: () => Effect.succeed(undefined), - context: () => Effect.succeed([]), - events: () => Stream.die("unused"), - switchAgent: () => Effect.die("unused"), - switchModel: () => Effect.die("unused"), - shell: () => Effect.die("unused"), - skill: () => Effect.die("unused"), - compact: () => Effect.die("unused"), - wait: () => Effect.die("unused"), - interrupt: () => Effect.void, + interrupt: overrides.interrupt ?? (() => Effect.void), } } @@ -193,8 +202,7 @@ function execute(tool: Tool.AnyTool, input: unknown, toolCallID: string) { { type: "tool-call", id: toolCallID, name: "task", input }, { sessionID: parentID, - agent: AgentV2.ID.make("build"), - assistantMessageID: SessionMessage.ID.make("msg_task_tool"), + ...toolIdentity, toolCallID, }, ) From dc02446ff4adc1568404a59a1d8d28c23f89c292 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 6 Jun 2026 21:51:09 -0400 Subject: [PATCH 5/5] refactor(core): document task result correlation boundary --- packages/core/src/tool/task.ts | 5 +-- packages/core/test/tool-task.test.ts | 53 +++++++++++++--------------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/packages/core/src/tool/task.ts b/packages/core/src/tool/task.ts index ffb59c1c36..c5f43de810 100644 --- a/packages/core/src/tool/task.ts +++ b/packages/core/src/tool/task.ts @@ -50,8 +50,9 @@ export const make = Effect.fn("TaskTool.make")(function* ( model: agent.model ?? parent.model, }) - // TODO: Replace this fresh-child composition with Session.run, preserving admission/execution - // separation while returning the assistant response after the admitted boundary. + // TODO: Replace this fresh-child-only composition once Session execution exposes a bounded + // activity/result identity. An admission ID alone cannot correlate a response when one drain + // processes later queued work. const run = Effect.gen(function* () { yield* sessions.prompt({ sessionID: child.id, diff --git a/packages/core/test/tool-task.test.ts b/packages/core/test/tool-task.test.ts index b1e06074a9..be0c58a874 100644 --- a/packages/core/test/tool-task.test.ts +++ b/packages/core/test/tool-task.test.ts @@ -64,9 +64,9 @@ describe("TaskTool", () => { const inputs: Parameters[0][] = [] let resumed = 0 const sessions = mockSessions({ - prompt: (input) => { - inputs.push(input) - return Effect.succeed(admission(input)) + prompt: (value) => { + inputs.push(value) + return Effect.succeed(admission(value)) }, resume: () => Effect.sync(() => resumed++), }) @@ -90,8 +90,6 @@ describe("TaskTool", () => { created = true return child }), - prompt: (input) => Effect.succeed(admission(input)), - resume: () => Effect.void, }) const tool = yield* TaskTool.make(sessions, () => Effect.succeed(undefined)) @@ -102,24 +100,6 @@ describe("TaskTool", () => { }), ) - it.live("interrupts the child when foreground waiting is interrupted", () => - Effect.gen(function* () { - const resumed = yield* Deferred.make() - const interrupts: SessionV2.ID[] = [] - const sessions = mockSessions({ - prompt: (input) => Effect.succeed(admission(input)), - resume: () => Deferred.succeed(resumed, undefined).pipe(Effect.andThen(Effect.never)), - interrupt: (sessionID) => Effect.sync(() => interrupts.push(sessionID)), - }) - const tool = yield* TaskTool.make(sessions, resolveAgent) - const fiber = yield* execute(tool, input, "call_task_interrupt").pipe(Effect.forkChild) - - yield* Deferred.await(resumed) - yield* Fiber.interrupt(fiber) - expect(interrupts).toEqual([childID]) - }), - ) - it.live("does not notify the parent when background work is interrupted", () => Effect.gen(function* () { let notified = false @@ -140,17 +120,34 @@ describe("TaskTool", () => { }), ) + it.live("interrupts the child when foreground waiting is interrupted", () => + Effect.gen(function* () { + const resumed = yield* Deferred.make() + const interrupts: SessionV2.ID[] = [] + const sessions = mockSessions({ + resume: () => Deferred.succeed(resumed, undefined).pipe(Effect.andThen(Effect.never)), + interrupt: (sessionID) => Effect.sync(() => interrupts.push(sessionID)), + }) + const tool = yield* TaskTool.make(sessions, resolveAgent) + const fiber = yield* execute(tool, input, "call_task_interrupt").pipe(Effect.forkChild) + + yield* Deferred.await(resumed) + yield* Fiber.interrupt(fiber) + expect(interrupts).toEqual([childID]) + }), + ) + it.live("returns before background completion and steers the result into the parent", () => Effect.gen(function* () { const gate = yield* Deferred.make() const notified = yield* Deferred.make[0]>() const inputs: Parameters[0][] = [] const sessions = mockSessions({ - prompt: (input) => { - inputs.push(input) - return input.sessionID === parentID - ? Deferred.succeed(notified, input).pipe(Effect.as(admission(input))) - : Effect.succeed(admission(input)) + prompt: (value) => { + inputs.push(value) + return value.sessionID === parentID + ? Deferred.succeed(notified, value).pipe(Effect.as(admission(value))) + : Effect.succeed(admission(value)) }, resume: () => Deferred.await(gate), })