diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index d6c1bc45d8..9fede81759 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -319,6 +319,10 @@ exports[`tool parameters JSON Schema (wire shape) task 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { + "background": { + "description": "When true, launch the subagent in the background and return immediately", + "type": "boolean", + }, "command": { "description": "The command that triggered this task", "type": "string", diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 8b2dc9a74d..3a124be81b 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -235,6 +235,10 @@ describe("tool parameters", () => { const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general" }) expect(parsed.subagent_type).toBe("general") }) + test("accepts optional background flag", () => { + const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general", background: true }) + expect(parsed.background).toBe(true) + }) test("rejects missing prompt", () => { expect(accepts(Task, { description: "d", subagent_type: "general" })).toBe(false) }) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 5ee56300c4..3720492afe 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -15,7 +15,9 @@ import { Question } from "@/question" import { Todo } from "@/session/todo" import { Skill } from "@/skill" import { Agent } from "@/agent/agent" +import { BackgroundJob } from "@/background/job" import { Session } from "@/session/session" +import { SessionStatus } from "@/session/status" import { Provider } from "@/provider/provider" import { Git } from "@/git" import { LSP } from "@/lsp/lsp" @@ -32,6 +34,7 @@ import { ToolJsonSchema } from "@/tool/json-schema" const node = CrossSpawnSpawner.defaultLayer const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT +const originalBackgroundAgents = Flag.OPENCODE_EXPERIMENTAL_BACKGROUND_AGENTS const configLayer = TestConfig.layer({ directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), }) @@ -44,6 +47,7 @@ const registryLayer = ToolRegistry.layer.pipe( Layer.provide(Skill.defaultLayer), Layer.provide(Agent.defaultLayer), Layer.provide(Session.defaultLayer), + Layer.provide(Layer.mergeAll(SessionStatus.defaultLayer, BackgroundJob.defaultLayer)), Layer.provide(Provider.defaultLayer), Layer.provide(Git.defaultLayer), Layer.provide(Reference.defaultLayer), @@ -62,6 +66,7 @@ const it = testEffect(Layer.mergeAll(registryLayer, node, Agent.defaultLayer)) afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_SCOUT = originalExperimentalScout + Flag.OPENCODE_EXPERIMENTAL_BACKGROUND_AGENTS = originalBackgroundAgents await disposeAllInstances() }) @@ -88,6 +93,41 @@ describe("tool.registry", () => { }), ) + it.instance("hides task_status unless experimental background agents are enabled", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_BACKGROUND_AGENTS = false + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + + expect(ids).not.toContain("task_status") + }), + ) + + it.instance("hides task background parameter unless experimental background agents are enabled", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_BACKGROUND_AGENTS = false + const registry = yield* ToolRegistry.Service + const agent = yield* Agent.Service + const build = yield* agent.get("build") + const task = (yield* registry.tools({ providerID: ProviderID.opencode, modelID: ModelID.make("test"), agent: build })).find( + (tool) => tool.id === "task", + ) + + expect(task?.jsonSchema).toBeDefined() + expect((task?.jsonSchema?.properties as Record | undefined)?.background).toBeUndefined() + }), + ) + + it.instance("shows task_status when experimental background agents are enabled", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_BACKGROUND_AGENTS = true + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + + expect(ids).toContain("task_status") + }), + ) + it.instance("loads tools from .opencode/tool (singular)", () => Effect.gen(function* () { const test = yield* TestInstance diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index f75fcf84b8..5aa6f5f560 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,20 +1,28 @@ import { afterEach, describe, expect } from "bun:test" import { Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "../../src/agent/agent" +import { BackgroundJob } from "@/background/job" +import { Bus } from "@/bus" import { Config } from "@/config/config" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Session } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" 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 { ModelID, ProviderID } from "../../src/provider/schema" import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" +import { Flag } from "@opencode-ai/core/flag/flag" import { disposeAllInstances } from "../fixture/fixture" import { testEffect } from "../lib/effect" +const originalBackgroundAgents = Flag.OPENCODE_EXPERIMENTAL_BACKGROUND_AGENTS + afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_BACKGROUND_AGENTS = originalBackgroundAgents await disposeAllInstances() }) @@ -26,9 +34,13 @@ const ref = { const it = testEffect( Layer.mergeAll( Agent.defaultLayer, + BackgroundJob.defaultLayer, + Bus.defaultLayer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer, Session.defaultLayer, + SessionRunState.defaultLayer, + SessionStatus.defaultLayer, Truncate.defaultLayer, ToolRegistry.defaultLayer, ), @@ -80,6 +92,7 @@ function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; opts?.onPrompt?.(input) return reply(input, opts?.text ?? "done") }), + loop: (input) => Effect.succeed(reply({ sessionID: input.sessionID, parts: [] }, opts?.text ?? "done")), } } @@ -294,6 +307,7 @@ describe("tool.task", () => { ready.resolve(input) return cancelled.promise }).pipe(Effect.as(reply(input, "cancelled"))), + loop: (input) => Effect.succeed(reply({ sessionID: input.sessionID, parts: [] }, "done")), } const fiber = yield* def @@ -432,4 +446,318 @@ describe("tool.task", () => { }, }, ) + + it.instance("rejects background execution when the experiment is disabled", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_BACKGROUND_AGENTS = false + 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("execute launches background tasks without waiting for completion", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_BACKGROUND_AGENTS = true + const jobs = yield* BackgroundJob.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") + }), + ) + + it.instance("background tasks complete through the background job service", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_BACKGROUND_AGENTS = true + const jobs = yield* BackgroundJob.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") + }), + ) + + it.instance("background task completion does not wait for the parent resume loop", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_BACKGROUND_AGENTS = true + const jobs = yield* BackgroundJob.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({ text: "background done" }), + prompt: (input) => + input.noReply + ? Effect.gen(function* () { + const user = yield* sessions.updateMessage({ + id: input.messageID ?? MessageID.ascending(), + role: "user", + sessionID: input.sessionID, + agent: input.agent ?? "build", + model: input.model ?? ref, + time: { created: Date.now() }, + }) + const parts = input.parts.map((part) => ({ + ...part, + id: part.id ?? PartID.ascending(), + messageID: user.id, + sessionID: input.sessionID, + })) + yield* Effect.forEach(parts, (part) => sessions.updatePart(part), { discard: true }) + return { info: user, parts } + }) + : Effect.succeed(reply(input, "background done")), + loop: () => Effect.never, + } 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") + }), + ) + + it.instance("removing the parent session cancels running background tasks", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_BACKGROUND_AGENTS = true + const jobs = yield* BackgroundJob.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") + }), + ) + + it.instance("removing the child task session cancels its running background task", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_BACKGROUND_AGENTS = true + const jobs = yield* BackgroundJob.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") + }), + ) + + it.instance("cancelling the parent run cancels running background tasks", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_BACKGROUND_AGENTS = true + const jobs = yield* BackgroundJob.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 parent run recursively cancels descendant background tasks", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.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") + }), + ) }) diff --git a/packages/opencode/test/tool/task_status.test.ts b/packages/opencode/test/tool/task_status.test.ts new file mode 100644 index 0000000000..448f24e766 --- /dev/null +++ b/packages/opencode/test/tool/task_status.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Agent } from "@/agent/agent" +import { BackgroundJob } from "@/background/job" +import { Bus } from "@/bus" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Session } from "@/session/session" +import { MessageID } from "@/session/schema" +import { SessionStatus } from "@/session/status" +import { TaskStatusTool } from "@/tool/task_status" +import { Truncate } from "@/tool/truncate" +import { Flag } from "@opencode-ai/core/flag/flag" +import { disposeAllInstances } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const originalBackgroundAgents = Flag.OPENCODE_EXPERIMENTAL_BACKGROUND_AGENTS + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_BACKGROUND_AGENTS = originalBackgroundAgents + await disposeAllInstances() +}) + +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + BackgroundJob.defaultLayer, + Bus.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Session.defaultLayer, + SessionStatus.defaultLayer, + Truncate.defaultLayer, + ), +) + +describe("tool.task_status", () => { + it.instance("returns completed background job output", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_BACKGROUND_AGENTS = true + const jobs = yield* BackgroundJob.Service + const sessions = yield* Session.Service + const tool = yield* TaskStatusTool + const def = yield* tool.init() + const chat = yield* sessions.create({}) + + yield* jobs.start({ id: chat.id, type: "task", run: Effect.succeed("all done") }) + + const result = yield* def.execute( + { task_id: chat.id, wait: true, timeout_ms: 1_000 }, + { + sessionID: chat.id, + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.output).toContain("state: completed") + expect(result.output).toContain("all done") + expect(result.metadata.timed_out).toBe(false) + }), + ) + + it.instance("wait=true times out while the background job is running", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_BACKGROUND_AGENTS = true + const jobs = yield* BackgroundJob.Service + const sessions = yield* Session.Service + const tool = yield* TaskStatusTool + const def = yield* tool.init() + const chat = yield* sessions.create({}) + + yield* jobs.start({ id: chat.id, type: "task", run: Effect.never }) + + const result = yield* def.execute( + { task_id: chat.id, wait: true, timeout_ms: 50 }, + { + sessionID: chat.id, + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.output).toContain("state: running") + expect(result.output).toContain("Timed out after 50ms") + expect(result.metadata.timed_out).toBe(true) + }), + ) +})