+21

![opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



![opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



James Long
Brendan Allan
Kit Langton
opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Affan Ali
affanali2k3
Frank
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
Aiden Cline
Jay V
Dax Raad
Aarav Sareen
OpeOginni
Luke Parker
Ben Guthrie
Dax
Filip
Max Anderson
Brendan Allan
Jack
Shoubhit Dash
Dustin Deus
starptech
Aiden Cline
usrnk1
Jay
runvip
opencode
Julian Coy
Vladimir Glafirov
8c94e9005f
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com> Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Jay V <air@live.ca> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Ben Guthrie <benjee.012@gmail.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com> Co-authored-by: Max Anderson <max.a.anderson95@gmail.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: runvip <164729189+runvip@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
902 lines
28 KiB
TypeScript
902 lines
28 KiB
TypeScript
import { afterEach, describe, expect } from "bun:test"
|
|
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
|
import { Database } from "@opencode-ai/core/database/database"
|
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
|
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
|
import { Agent } from "../../src/agent/agent"
|
|
import { Job } from "@/job"
|
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
|
import { Config } from "@/config/config"
|
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
|
import { Session } from "@/session/session"
|
|
import type { SessionPrompt } from "../../src/session/prompt"
|
|
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
|
import { SessionRunState } from "@/session/run-state"
|
|
import { SessionStatus } from "@/session/status"
|
|
|
|
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
|
|
import { Truncate } from "@/tool/truncate"
|
|
import { ToolRegistry } from "@/tool/registry"
|
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
|
import { disposeAllInstances } from "../fixture/fixture"
|
|
import { pollWithTimeout, testEffect } from "../lib/effect"
|
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
|
import { ModelV2 } from "@opencode-ai/core/model"
|
|
|
|
afterEach(async () => {
|
|
await disposeAllInstances()
|
|
})
|
|
|
|
const ref = {
|
|
providerID: ProviderV2.ID.make("test"),
|
|
modelID: ModelV2.ID.make("test-model"),
|
|
}
|
|
|
|
const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|
LayerNode.compile(
|
|
LayerNode.group([
|
|
Agent.node,
|
|
Job.node,
|
|
EventV2Bridge.node,
|
|
Config.node,
|
|
CrossSpawnSpawner.node,
|
|
Session.node,
|
|
SessionProjector.node,
|
|
SessionRunState.node,
|
|
SessionStatus.node,
|
|
Truncate.node,
|
|
ToolRegistry.node,
|
|
Database.node,
|
|
RuntimeFlags.node,
|
|
Ripgrep.node,
|
|
]),
|
|
[[RuntimeFlags.node, RuntimeFlags.layer(flags)]],
|
|
)
|
|
|
|
const it = testEffect(layer())
|
|
const background = testEffect(layer({ experimentalBackgroundSubagents: true }))
|
|
|
|
function defer<T>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void
|
|
const promise = new Promise<T>((done) => {
|
|
resolve = done
|
|
})
|
|
return { promise, resolve }
|
|
}
|
|
|
|
const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
|
|
const session = yield* Session.Service
|
|
const chat = yield* session.create({ title })
|
|
const user = yield* session.updateMessage({
|
|
id: MessageID.ascending(),
|
|
role: "user",
|
|
sessionID: chat.id,
|
|
agent: "build",
|
|
model: ref,
|
|
time: { created: Date.now() },
|
|
})
|
|
const assistant: SessionV1.Assistant = {
|
|
id: MessageID.ascending(),
|
|
role: "assistant",
|
|
parentID: user.id,
|
|
sessionID: chat.id,
|
|
mode: "build",
|
|
agent: "build",
|
|
cost: 0,
|
|
path: { cwd: "/tmp", root: "/tmp" },
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
modelID: ref.modelID,
|
|
providerID: ref.providerID,
|
|
variant: "xhigh",
|
|
time: { created: Date.now() },
|
|
}
|
|
yield* session.updateMessage(assistant)
|
|
return { chat, assistant }
|
|
})
|
|
|
|
function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps {
|
|
return {
|
|
cancel: () => Effect.void,
|
|
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
|
|
prompt: (input) =>
|
|
Effect.sync(() => {
|
|
opts?.onPrompt?.(input)
|
|
return reply(input, opts?.text ?? "done")
|
|
}),
|
|
}
|
|
}
|
|
|
|
function reply(input: SessionPrompt.PromptInput, text: string): SessionV1.WithParts {
|
|
const id = MessageID.ascending()
|
|
return {
|
|
info: {
|
|
id,
|
|
role: "assistant",
|
|
parentID: input.messageID ?? MessageID.ascending(),
|
|
sessionID: input.sessionID,
|
|
mode: input.agent ?? "general",
|
|
agent: input.agent ?? "general",
|
|
cost: 0,
|
|
path: { cwd: "/tmp", root: "/tmp" },
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
modelID: input.model?.modelID ?? ref.modelID,
|
|
providerID: input.model?.providerID ?? ref.providerID,
|
|
time: { created: Date.now() },
|
|
finish: "stop",
|
|
},
|
|
parts: [
|
|
{
|
|
id: PartID.ascending(),
|
|
messageID: id,
|
|
sessionID: input.sessionID,
|
|
type: "text",
|
|
text,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
describe("tool.task", () => {
|
|
it.instance(
|
|
"description sorts subagents by name and is stable across calls",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const agent = yield* Agent.Service
|
|
const build = yield* agent.get("build")
|
|
const registry = yield* ToolRegistry.Service
|
|
const get = Effect.fnUntraced(function* () {
|
|
const tools = yield* registry.tools({ ...ref, agent: build })
|
|
return tools.find((tool) => tool.id === TaskTool.id)?.description ?? ""
|
|
})
|
|
const first = yield* get()
|
|
const second = yield* get()
|
|
|
|
expect(first).toBe(second)
|
|
|
|
const alpha = first.indexOf("- alpha: Alpha agent")
|
|
const explore = first.indexOf("- explore:")
|
|
const general = first.indexOf("- general:")
|
|
const zebra = first.indexOf("- zebra: Zebra agent")
|
|
|
|
expect(alpha).toBeGreaterThan(-1)
|
|
expect(explore).toBeGreaterThan(alpha)
|
|
expect(general).toBeGreaterThan(explore)
|
|
expect(zebra).toBeGreaterThan(general)
|
|
}),
|
|
{
|
|
config: {
|
|
agent: {
|
|
zebra: {
|
|
description: "Zebra agent",
|
|
mode: "subagent",
|
|
},
|
|
alpha: {
|
|
description: "Alpha agent",
|
|
mode: "subagent",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
it.instance(
|
|
"description hides denied subagents for the caller",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const agent = yield* Agent.Service
|
|
const build = yield* agent.get("build")
|
|
const registry = yield* ToolRegistry.Service
|
|
const description =
|
|
(yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? ""
|
|
|
|
expect(description).toContain("- alpha: Alpha agent")
|
|
expect(description).not.toContain("- zebra: Zebra agent")
|
|
}),
|
|
{
|
|
config: {
|
|
permission: {
|
|
task: {
|
|
"*": "allow",
|
|
zebra: "deny",
|
|
},
|
|
},
|
|
agent: {
|
|
zebra: {
|
|
description: "Zebra agent",
|
|
mode: "subagent",
|
|
},
|
|
alpha: {
|
|
description: "Alpha agent",
|
|
mode: "subagent",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
it.instance("execute resumes an existing task session from task_id", () =>
|
|
Effect.gen(function* () {
|
|
const sessions = yield* Session.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
let seen: SessionPrompt.PromptInput | undefined
|
|
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
task_id: child.id,
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: { promptOps },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
const kids = yield* sessions.children(chat.id)
|
|
expect(kids).toHaveLength(1)
|
|
expect(kids[0]?.id).toBe(child.id)
|
|
expect(result.metadata.sessionId).toBe(child.id)
|
|
expect(result.output).toContain(`<task id="${child.id}" state="completed">`)
|
|
expect(seen?.sessionID).toBe(child.id)
|
|
expect(seen?.variant).toBe("xhigh")
|
|
}),
|
|
)
|
|
|
|
it.instance("execute asks by default and skips checks when bypassed", () =>
|
|
Effect.gen(function* () {
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
const calls: unknown[] = []
|
|
const promptOps = stubOps()
|
|
|
|
const exec = (extra?: Record<string, any>) =>
|
|
def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: { promptOps, ...extra },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: (input) =>
|
|
Effect.sync(() => {
|
|
calls.push(input)
|
|
}),
|
|
},
|
|
)
|
|
|
|
yield* exec()
|
|
yield* exec({ bypassAgentCheck: true })
|
|
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]).toEqual({
|
|
permission: "task",
|
|
patterns: ["general"],
|
|
always: ["*"],
|
|
metadata: {
|
|
description: "inspect bug",
|
|
subagent_type: "general",
|
|
},
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.instance("execute cancels child session when abort signal fires", () =>
|
|
Effect.gen(function* () {
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
const ready = defer<SessionPrompt.PromptInput>()
|
|
const cancelled = defer<SessionID>()
|
|
const abort = new AbortController()
|
|
const promptOps: TaskPromptOps = {
|
|
cancel: (sessionID) =>
|
|
Effect.sync(() => {
|
|
cancelled.resolve(sessionID)
|
|
}),
|
|
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
|
|
prompt: (input) =>
|
|
Effect.promise(() => {
|
|
ready.resolve(input)
|
|
return cancelled.promise
|
|
}).pipe(Effect.as(reply(input, "cancelled"))),
|
|
}
|
|
|
|
const fiber = yield* def
|
|
.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: abort.signal,
|
|
extra: { promptOps },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
.pipe(Effect.forkChild)
|
|
|
|
const input = yield* Effect.promise(() => ready.promise)
|
|
abort.abort()
|
|
expect(yield* Effect.promise(() => cancelled.promise)).toBe(input.sessionID)
|
|
|
|
const exit = yield* Fiber.await(fiber)
|
|
expect(Exit.isSuccess(exit)).toBe(true)
|
|
}),
|
|
)
|
|
|
|
it.instance("execute creates a child when task_id does not exist", () =>
|
|
Effect.gen(function* () {
|
|
const sessions = yield* Session.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
let seen: SessionPrompt.PromptInput | undefined
|
|
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
task_id: "ses_missing",
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: { promptOps },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
const kids = yield* sessions.children(chat.id)
|
|
expect(kids).toHaveLength(1)
|
|
expect(kids[0]?.id).toBe(result.metadata.sessionId)
|
|
expect(result.metadata.sessionId).not.toBe("ses_missing")
|
|
expect(result.output).toContain(`<task id="${result.metadata.sessionId}" state="completed">`)
|
|
expect(seen?.sessionID).toBe(result.metadata.sessionId)
|
|
}),
|
|
)
|
|
|
|
it.instance(
|
|
"execute shapes child permissions for task, todowrite, and primary tools",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const sessions = yield* Session.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
let seen: SessionPrompt.PromptInput | undefined
|
|
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "reviewer",
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: { promptOps },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
const child = yield* sessions.get(result.metadata.sessionId)
|
|
expect(child.parentID).toBe(chat.id)
|
|
expect(child.agent).toBe("reviewer")
|
|
expect(child.permission).toEqual([
|
|
{
|
|
permission: "todowrite",
|
|
pattern: "*",
|
|
action: "deny",
|
|
},
|
|
{
|
|
permission: "bash",
|
|
pattern: "*",
|
|
action: "deny",
|
|
},
|
|
{
|
|
permission: "read",
|
|
pattern: "*",
|
|
action: "deny",
|
|
},
|
|
])
|
|
expect(seen?.tools).toBeUndefined()
|
|
}),
|
|
{
|
|
config: {
|
|
agent: {
|
|
reviewer: {
|
|
mode: "subagent",
|
|
permission: {
|
|
task: "allow",
|
|
},
|
|
},
|
|
},
|
|
experimental: {
|
|
primary_tools: ["bash", "read"],
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
it.instance("rejects background execution when the experiment is disabled", () =>
|
|
Effect.gen(function* () {
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
|
|
const exit = yield* def
|
|
.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: { promptOps: stubOps() },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
.pipe(Effect.exit)
|
|
|
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
}),
|
|
)
|
|
|
|
it.instance("backgrounds a running foreground task without restarting it", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* Job.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
const ready = yield* Deferred.make<void>()
|
|
const done = yield* Deferred.make<void>()
|
|
const injected = yield* Deferred.make<SessionPrompt.PromptInput>()
|
|
let runs = 0
|
|
const promptOps: TaskPromptOps = {
|
|
cancel: () => Effect.void,
|
|
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
|
|
prompt: (input) => {
|
|
if (input.sessionID === chat.id) {
|
|
return Deferred.succeed(injected, input).pipe(Effect.as(reply(input, "injected")))
|
|
}
|
|
return Effect.gen(function* () {
|
|
runs += 1
|
|
yield* Deferred.succeed(ready, undefined)
|
|
yield* Deferred.await(done)
|
|
return reply(input, "background done")
|
|
})
|
|
},
|
|
}
|
|
|
|
const fiber = yield* def
|
|
.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: { promptOps },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
.pipe(Effect.forkChild)
|
|
|
|
yield* Deferred.await(ready)
|
|
const job = (yield* jobs.list())[0]
|
|
expect(job).toBeDefined()
|
|
if (!job) throw new Error("task job not found")
|
|
expect(job.metadata?.parentSessionId).toBe(chat.id)
|
|
yield* pollWithTimeout(
|
|
jobs
|
|
.backgroundAll({ sessionID: chat.id, type: "task" })
|
|
.pipe(Effect.map((backgrounded) => (backgrounded.length > 0 ? backgrounded : undefined))),
|
|
"task never blocked the parent session",
|
|
)
|
|
|
|
const result = yield* Fiber.join(fiber)
|
|
expect(result.metadata.background).toBe(true)
|
|
expect(result.output).toContain(`state="running"`)
|
|
expect((yield* jobs.get(result.metadata.sessionId))?.status).toBe("running")
|
|
expect(runs).toBe(1)
|
|
|
|
yield* Deferred.succeed(done, undefined)
|
|
expect((yield* jobs.wait({ id: result.metadata.sessionId })).info?.output).toBe("background done")
|
|
expect((yield* Deferred.await(injected)).parts[0]?.type).toBe("text")
|
|
expect(runs).toBe(1)
|
|
}),
|
|
)
|
|
|
|
background.instance("execute launches background tasks without waiting for completion", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* Job.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: {
|
|
promptOps: {
|
|
...stubOps(),
|
|
prompt: () => Effect.never,
|
|
} satisfies TaskPromptOps,
|
|
},
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
const job = yield* jobs.get(result.metadata.sessionId)
|
|
expect(result.metadata.background).toBe(true)
|
|
expect(result.output).toContain(`state="running"`)
|
|
expect(job?.status).toBe("running")
|
|
}),
|
|
)
|
|
|
|
background.instance("running task_id reports the existing background task", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* Job.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
const first = defer<void>()
|
|
const injected = defer<SessionPrompt.PromptInput>()
|
|
let prompts = 0
|
|
const promptOps: TaskPromptOps = {
|
|
...stubOps(),
|
|
prompt: (input) => {
|
|
if (input.sessionID === chat.id) {
|
|
injected.resolve(input)
|
|
return Effect.succeed(reply(input, "done"))
|
|
}
|
|
prompts++
|
|
return Effect.promise(() => first.promise).pipe(Effect.as(reply(input, "first done")))
|
|
},
|
|
}
|
|
const context = {
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: { promptOps },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
}
|
|
|
|
const started = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
context,
|
|
)
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "add investigation scope",
|
|
prompt: "also inspect cancellation",
|
|
subagent_type: "general",
|
|
task_id: started.metadata.sessionId,
|
|
},
|
|
context,
|
|
)
|
|
|
|
expect(result.metadata.sessionId).toBe(started.metadata.sessionId)
|
|
expect(result.metadata.background).toBe(true)
|
|
expect(result.output).toContain("Background task already running")
|
|
expect(prompts).toBe(1)
|
|
first.resolve()
|
|
const waited = yield* jobs.wait({ id: started.metadata.sessionId, timeout: 1_000 })
|
|
expect(waited.info?.status).toBe("completed")
|
|
expect(waited.info?.output).toBe("first done")
|
|
const notification = yield* Effect.promise(() => injected.promise)
|
|
expect(notification.variant).toBe("xhigh")
|
|
expect(notification.parts[0]?.type).toBe("text")
|
|
if (notification.parts[0]?.type === "text") expect(notification.parts[0].text).toContain("first done")
|
|
}),
|
|
)
|
|
|
|
background.instance("background tasks complete through the job service", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* Job.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: { promptOps: stubOps({ text: "background done" }) },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
|
|
expect(waited.timedOut).toBe(false)
|
|
expect(waited.info?.status).toBe("completed")
|
|
expect(waited.info?.output).toBe("background done")
|
|
}),
|
|
)
|
|
|
|
background.instance("background task completion does not wait for the parent async prompt", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* Job.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: {
|
|
promptOps: {
|
|
...stubOps({ text: "background done" }),
|
|
prompt: (input) =>
|
|
input.sessionID === chat.id ? Effect.never : Effect.succeed(reply(input, "background done")),
|
|
} satisfies TaskPromptOps,
|
|
},
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
|
|
expect(waited.timedOut).toBe(false)
|
|
expect(waited.info?.status).toBe("completed")
|
|
}),
|
|
)
|
|
|
|
background.instance("removing the parent session cancels running background tasks", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* Job.Service
|
|
const sessions = yield* Session.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: {
|
|
promptOps: {
|
|
...stubOps(),
|
|
prompt: () => Effect.never,
|
|
} satisfies TaskPromptOps,
|
|
},
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
yield* sessions.remove(chat.id)
|
|
const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
|
|
expect(waited.timedOut).toBe(false)
|
|
expect(waited.info?.status).toBe("cancelled")
|
|
}),
|
|
)
|
|
|
|
background.instance("removing the child task session cancels its running background task", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* Job.Service
|
|
const sessions = yield* Session.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: {
|
|
promptOps: {
|
|
...stubOps(),
|
|
prompt: () => Effect.never,
|
|
} satisfies TaskPromptOps,
|
|
},
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
yield* sessions.remove(result.metadata.sessionId)
|
|
const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
|
|
expect(waited.timedOut).toBe(false)
|
|
expect(waited.info?.status).toBe("cancelled")
|
|
}),
|
|
)
|
|
|
|
background.instance("cancelling the parent run cancels running background tasks", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* Job.Service
|
|
const runState = yield* SessionRunState.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: {
|
|
promptOps: {
|
|
...stubOps(),
|
|
prompt: () => Effect.never,
|
|
} satisfies TaskPromptOps,
|
|
},
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
yield* runState.cancel(chat.id)
|
|
const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
|
|
expect(waited.timedOut).toBe(false)
|
|
expect(waited.info?.status).toBe("cancelled")
|
|
}),
|
|
)
|
|
|
|
it.instance("cancelling a child run cancels its own pre-runner task job", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* Job.Service
|
|
const runState = yield* SessionRunState.Service
|
|
const sessions = yield* Session.Service
|
|
const { chat } = yield* seed()
|
|
const child = yield* sessions.create({ parentID: chat.id, title: "child" })
|
|
|
|
yield* jobs.start({
|
|
id: child.id,
|
|
type: "task",
|
|
metadata: { parentSessionId: chat.id, sessionId: child.id },
|
|
run: Effect.never,
|
|
})
|
|
|
|
yield* runState.cancel(child.id)
|
|
|
|
expect((yield* jobs.get(child.id))?.status).toBe("cancelled")
|
|
}),
|
|
)
|
|
|
|
it.instance("cancelling a parent run recursively cancels descendant background tasks", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* Job.Service
|
|
const runState = yield* SessionRunState.Service
|
|
const sessions = yield* Session.Service
|
|
const { chat } = yield* seed()
|
|
const child = yield* sessions.create({ parentID: chat.id, title: "child" })
|
|
const grandchild = yield* sessions.create({ parentID: child.id, title: "grandchild" })
|
|
|
|
yield* jobs.start({
|
|
id: child.id,
|
|
type: "task",
|
|
metadata: { parentSessionId: chat.id, sessionId: child.id },
|
|
run: Effect.never,
|
|
})
|
|
yield* jobs.start({
|
|
id: grandchild.id,
|
|
type: "task",
|
|
metadata: { parentSessionId: child.id, sessionId: grandchild.id },
|
|
run: Effect.never,
|
|
})
|
|
|
|
yield* runState.cancel(chat.id)
|
|
|
|
expect((yield* jobs.get(child.id))?.status).toBe("cancelled")
|
|
expect((yield* jobs.get(grandchild.id))?.status).toBe("cancelled")
|
|
}),
|
|
)
|
|
})
|