From 73be8f7658373de2428c866245a9012b32080544 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 6 Jun 2026 21:44:32 -0400 Subject: [PATCH] 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, }, )