test(core): cover task interruption semantics
This commit is contained in:
parent
70ab2e2aea
commit
73be8f7658
2 changed files with 62 additions and 50 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<void>()
|
||||
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<void>()
|
||||
|
|
@ -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<SessionV2.Interface, "create" | "get" | "interrupt" | "messages" | "prompt" | "resume"> {
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue