diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2fc93c482521..d860c5048433 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -128,7 +128,6 @@ export const layer = Layer.effect( cancel: (sessionID: SessionID) => cancel(sessionID), resolvePromptParts: (template: string) => resolvePromptParts(template), prompt: (input: PromptInput) => prompt(input).pipe(Effect.catch(Effect.die)), - loop: (input: LoopInput) => loop(input), } satisfies TaskPromptOps }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6ef6d39a65a5..d7f7de778e00 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -7,7 +7,6 @@ import { GlobTool } from "./glob" import { GrepTool } from "./grep" import { ReadTool } from "./read" import { TaskTool } from "./task" -import { TaskStatusTool } from "./task_status" import { TodoWriteTool } from "./todo" import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" @@ -53,7 +52,6 @@ import { Skill } from "../skill" import { Permission } from "@/permission" import { Reference } from "@/reference/reference" import { BackgroundJob } from "@/background/job" -import { SessionStatus } from "@/session/status" import { RuntimeFlags } from "@/effect/runtime-flags" const log = Log.create({ service: "tool.registry" }) @@ -91,7 +89,6 @@ export const layer: Layer.Layer< | Agent.Service | Skill.Service | Session.Service - | SessionStatus.Service | BackgroundJob.Service | Provider.Service | Git.Service @@ -119,7 +116,6 @@ export const layer: Layer.Layer< const invalid = yield* InvalidTool const task = yield* TaskTool - const taskStatus = yield* TaskStatusTool const read = yield* ReadTool const question = yield* QuestionTool const todo = yield* TodoWriteTool @@ -235,7 +231,6 @@ export const layer: Layer.Layer< edit: Tool.init(edit), write: Tool.init(writetool), task: Tool.init(task), - task_status: Tool.init(taskStatus), fetch: Tool.init(webfetch), todo: Tool.init(todo), search: Tool.init(websearch), @@ -260,7 +255,6 @@ export const layer: Layer.Layer< tool.edit, tool.write, tool.task, - ...(flags.experimentalBackgroundSubagents ? [tool.task_status] : []), tool.fetch, tool.todo, tool.search, @@ -385,7 +379,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Skill.defaultLayer), Layer.provide(Agent.defaultLayer), Layer.provide(Session.defaultLayer), - Layer.provide(Layer.mergeAll(SessionStatus.defaultLayer, BackgroundJob.defaultLayer)), + Layer.provide(BackgroundJob.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(Layer.mergeAll(Git.defaultLayer, RepositoryCache.defaultLayer)), Layer.provide(Reference.defaultLayer), diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index fece68800b06..864400c1c162 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -2,17 +2,14 @@ import * as Tool from "./tool" import DESCRIPTION from "./task.txt" import { ToolJsonSchema } from "./json-schema" import { BackgroundJob } from "@/background/job" -import { Bus } from "@/bus" import { Session } from "@/session/session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" import { deriveSubagentSessionPermission } from "../agent/subagent-permissions" import type { SessionPrompt } from "../session/prompt" -import { SessionStatus } from "@/session/status" import { Config } from "@/config/config" -import { TuiEvent } from "@/cli/cmd/tui/event" -import { Cause, Effect, Exit, Option, Schema, Scope } from "effect" +import { Cause, Effect, Exit, Schema, Scope } from "effect" import { EffectBridge } from "@/effect/bridge" import { RuntimeFlags } from "@/effect/runtime-flags" @@ -20,7 +17,6 @@ export interface TaskPromptOps { cancel(sessionID: SessionID): Effect.Effect resolvePromptParts(template: string): Effect.Effect prompt(input: SessionPrompt.PromptInput): Effect.Effect - loop(input: SessionPrompt.LoopInput): Effect.Effect } const id = "task" @@ -28,12 +24,14 @@ const BACKGROUND_DESCRIPTION = [ "", "", [ - "Background mode: background=true launches the subagent asynchronously.", - "Use task_status(task_id=..., wait=false) to poll, or wait=true to block until done.", + "Background mode: background=true launches the subagent asynchronously and returns immediately.", + "Foreground is the default; use it when you need the result before continuing.", + "Use background only for independent work that can run while you continue elsewhere.", + "You will be notified automatically when it finishes.", ].join(" "), ].join("\n") -const BaseParameters = Schema.Struct({ +const BaseParameterFields = { description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }), prompt: Schema.String.annotate({ description: "The task for the agent to perform" }), subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }), @@ -42,40 +40,32 @@ const BaseParameters = Schema.Struct({ "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", }), command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }), -}) +} + +const BaseParameters = Schema.Struct(BaseParameterFields) export const Parameters = Schema.Struct({ - description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }), - prompt: Schema.String.annotate({ description: "The task for the agent to perform" }), - subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }), - task_id: Schema.optional(Schema.String).annotate({ - description: - "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", - }), - command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }), + ...BaseParameterFields, background: Schema.optional(Schema.Boolean).annotate({ - description: "When true, launch the subagent in the background and return immediately", + description: "Run the agent in the background. You will be notified when it completes.", }), }) function output(sessionID: SessionID, text: string) { - return [ - `task_id: ${sessionID} (for resuming to continue this task if needed)`, - "", - "", - text, - "", - ].join("\n") + return [``, "", text, "", ""].join( + "\n", + ) } function backgroundOutput(sessionID: SessionID) { return [ - `task_id: ${sessionID} (for polling this task with task_status)`, - "state: running", - "", + ``, + "Background task started", "", - "Background task started. Continue your current work and call task_status when you need the result.", + "Background task started. You will be notified automatically when it finishes; do not poll for progress.", + "Do not duplicate its work. Continue only with non-overlapping work, or stop if there is nothing else useful to do.", "", + "", ].join("\n") } @@ -90,9 +80,14 @@ function backgroundMessage(input: { input.state === "completed" ? `Background task completed: ${input.description}` : `Background task failed: ${input.description}` - return [title, `task_id: ${input.sessionID}`, `state: ${input.state}`, "", `<${tag}>`, input.text, ``].join( - "\n", - ) + return [ + ``, + `${title}`, + `<${tag}>`, + input.text, + ``, + "", + ].join("\n") } function errorText(error: unknown) { @@ -105,11 +100,9 @@ export const TaskTool = Tool.define( Effect.gen(function* () { const agent = yield* Agent.Service const background = yield* BackgroundJob.Service - const bus = yield* Bus.Service const config = yield* Config.Service const sessions = yield* Session.Service const scope = yield* Scope.Scope - const status = yield* SessionStatus.Service const flags = yield* RuntimeFlags.Service const run = Effect.fn("TaskTool.execute")(function* ( @@ -141,9 +134,8 @@ export const TaskTool = Tool.define( return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)) } - const taskID = params.task_id - const session = taskID - ? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) + const session = params.task_id + ? yield* sessions.get(SessionID.make(params.task_id)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) : undefined const parent = yield* sessions.get(ctx.sessionID) const parentAgent = parent.agent @@ -189,7 +181,6 @@ export const TaskTool = Tool.define( const ops = ctx.extra?.promptOps as TaskPromptOps if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra")) - const runCancel = yield* EffectBridge.make() const runTask = Effect.fn("TaskTool.runTask")(function* () { const parts = yield* ops.resolvePromptParts(params.prompt) @@ -211,68 +202,34 @@ export const TaskTool = Tool.define( return result.parts.findLast((item) => item.type === "text")?.text ?? "" }) - const resumeWhenIdle: (input: { userID: MessageID; state: "completed" | "error" }) => Effect.Effect = - Effect.fn("TaskTool.resumeWhenIdle")(function* (input: { userID: MessageID; state: "completed" | "error" }) { - const latest = yield* sessions - .findMessage(ctx.sessionID, (item) => item.info.role === "user") - .pipe(Effect.orDie) - if (Option.isNone(latest)) return - if (latest.value.info.id !== input.userID) return - if ((yield* status.get(ctx.sessionID)).type !== "idle") { - yield* Effect.sleep("300 millis") - return yield* resumeWhenIdle(input) - } - yield* bus.publish(TuiEvent.ToastShow, { - title: input.state === "completed" ? "Background task complete" : "Background task failed", - message: - input.state === "completed" - ? `Background task "${params.description}" finished. Resuming the main thread.` - : `Background task "${params.description}" failed. Resuming the main thread.`, - variant: input.state === "completed" ? "success" : "error", - duration: 5000, - }) - yield* ops - .loop({ sessionID: ctx.sessionID }) - .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true })) - }) - - const continueIfIdle = Effect.fn("TaskTool.continueIfIdle")(function* (input: { - userID: MessageID - state: "completed" | "error" - }) { - yield* resumeWhenIdle(input).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true })) - }) - const inject = Effect.fn("TaskTool.injectBackgroundResult")(function* ( state: "completed" | "error", text: string, ) { const currentParent = yield* sessions.get(ctx.sessionID) - const message = yield* ops.prompt({ - sessionID: ctx.sessionID, - noReply: true, - agent: currentParent.agent ?? ctx.agent, - parts: [ - { - type: "text", - synthetic: true, - text: backgroundMessage({ - sessionID: nextSession.id, - description: params.description, - state, - text, - }), - }, - ], - }) - yield* continueIfIdle({ userID: message.info.id, state }) + yield* ops + .prompt({ + sessionID: ctx.sessionID, + agent: currentParent.agent ?? ctx.agent, + parts: [ + { + type: "text", + synthetic: true, + text: backgroundMessage({ + sessionID: nextSession.id, + description: params.description, + state, + text, + }), + }, + ], + }) + .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true })) }) const existing = yield* background.get(nextSession.id) if (existing?.status === "running") { - return yield* Effect.fail( - new Error(`Task ${nextSession.id} is already running. Use task_status to check progress.`), - ) + return yield* Effect.fail(new Error(`Task ${nextSession.id} is already running.`)) } if (runInBackground) { @@ -302,6 +259,7 @@ export const TaskTool = Tool.define( } } + const runCancel = yield* EffectBridge.make() const cancel = ops.cancel(nextSession.id) function onAbort() { diff --git a/packages/opencode/src/tool/task_status.ts b/packages/opencode/src/tool/task_status.ts deleted file mode 100644 index b458b4fc45fa..000000000000 --- a/packages/opencode/src/tool/task_status.ts +++ /dev/null @@ -1,179 +0,0 @@ -import * as Tool from "./tool" -import DESCRIPTION from "./task_status.txt" -import { BackgroundJob } from "@/background/job" -import { Session } from "@/session/session" -import { MessageV2 } from "@/session/message-v2" -import { SessionID } from "@/session/schema" -import { SessionStatus } from "@/session/status" -import { PositiveInt } from "@opencode-ai/core/schema" -import { RuntimeFlags } from "@/effect/runtime-flags" -import { Effect, Option, Schema } from "effect" - -const DEFAULT_TIMEOUT = 60_000 -const POLL_MS = 300 - -const Parameters = Schema.Struct({ - task_id: SessionID.annotate({ description: "The task_id returned by the task tool" }), - wait: Schema.optional(Schema.Boolean).annotate({ - description: "When true, wait until the task reaches a terminal state or timeout", - }), - timeout_ms: Schema.optional(PositiveInt).annotate({ - description: "Maximum milliseconds to wait when wait=true (default: 60000)", - }), -}) - -type State = BackgroundJob.Status -type InspectResult = { state: State; text: string } - -function format(input: { taskID: SessionID; state: State; text: string }) { - const tag = input.state === "completed" || input.state === "running" ? "task_result" : "task_error" - return [`task_id: ${input.taskID}`, `state: ${input.state}`, "", `<${tag}>`, input.text, ``].join("\n") -} - -function errorText(error: NonNullable) { - const data = Reflect.get(error, "data") - const message = data && typeof data === "object" ? Reflect.get(data, "message") : undefined - if (typeof message === "string" && message) return message - return error.name -} - -function inspectMessage(message: MessageV2.WithParts): InspectResult | undefined { - if (message.info.role !== "assistant") return - const text = message.parts.findLast((part) => part.type === "text")?.text ?? "" - if (message.info.error) return { state: "error", text: text || errorText(message.info.error) } - if (message.info.finish && !["tool-calls", "unknown"].includes(message.info.finish)) - return { state: "completed", text } - return { state: "running", text: text || "Task is still running." } -} - -export const TaskStatusTool = Tool.define( - "task_status", - Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service - const sessions = yield* Session.Service - const status = yield* SessionStatus.Service - const flags = yield* RuntimeFlags.Service - - const inspect: (taskID: SessionID) => Effect.Effect = Effect.fn("TaskStatusTool.inspect")(function* ( - taskID: SessionID, - ) { - const job = yield* jobs.get(taskID) - if (job) { - return { - state: job.status, - text: - job.output ?? - job.error ?? - (job.status === "running" - ? "Task is still running." - : job.status === "cancelled" - ? "Task was cancelled." - : ""), - } - } - - const current = yield* status.get(taskID) - if (current.type === "busy" || current.type === "retry") { - return { - state: "running", - text: current.type === "retry" ? `Task is retrying: ${current.message}` : "Task is still running.", - } - } - - const latestAssistant = yield* sessions - .findMessage(taskID, (item) => item.info.role === "assistant") - .pipe(Effect.orDie) - if (Option.isSome(latestAssistant)) { - const latest = inspectMessage(latestAssistant.value) - if (!latest) return { state: "error", text: "Task is not running in this process." } - if (latest.state === "running") - return { state: "error", text: "Task is not running in this process and has no final output." } - return latest - } - return { state: "error", text: "Task is not running in this process and has not produced output." } - }) - - const waitForTerminal: ( - taskID: SessionID, - timeout: number, - ) => Effect.Effect<{ result: InspectResult; timedOut: boolean }> = Effect.fn("TaskStatusTool.waitForTerminal")( - function* (taskID: SessionID, timeout: number) { - const result = yield* inspect(taskID) - if (result.state !== "running") return { result, timedOut: false } - if (timeout <= 0) return { result, timedOut: true } - const sleep = Math.min(POLL_MS, timeout) - yield* Effect.sleep(`${sleep} millis`) - return yield* waitForTerminal(taskID, timeout - sleep) - }, - ) - - const run = Effect.fn("TaskStatusTool.execute")(function* ( - params: Schema.Schema.Type, - _ctx: Tool.Context, - ) { - if (!flags.experimentalBackgroundSubagents) { - return yield* Effect.fail(new Error("task_status requires OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true")) - } - - const session = yield* sessions.get(params.task_id).pipe(Effect.catchCause(() => Effect.succeed(undefined))) - if (!session) { - return { - title: "Task status", - metadata: { - task_id: params.task_id, - state: "error" as const, - timed_out: false, - }, - output: format({ - taskID: params.task_id, - state: "error", - text: `Task not found: ${params.task_id}`, - }), - } - } - - const waited = - params.wait === true - ? yield* jobs.wait({ id: params.task_id, timeout: params.timeout_ms ?? DEFAULT_TIMEOUT }) - : { info: yield* jobs.get(params.task_id), timedOut: false } - const inspected = waited.info - ? { - result: { - state: waited.info.status, - text: - waited.info.output ?? - waited.info.error ?? - (waited.info.status === "running" ? "Task is still running." : ""), - }, - timedOut: waited.timedOut, - } - : params.wait === true - ? yield* waitForTerminal(params.task_id, params.timeout_ms ?? DEFAULT_TIMEOUT) - : { result: yield* inspect(params.task_id), timedOut: false } - const text = inspected.timedOut - ? `Timed out after ${params.timeout_ms ?? DEFAULT_TIMEOUT}ms while waiting for task completion.` - : inspected.result.text - - return { - title: "Task status", - metadata: { - task_id: params.task_id, - state: inspected.result.state, - timed_out: inspected.timedOut, - }, - output: format({ - taskID: params.task_id, - state: inspected.result.state, - text, - }), - } - }) - - return { - description: DESCRIPTION, - parameters: Parameters, - execute: (params: Schema.Schema.Type, ctx: Tool.Context) => - run(params, ctx).pipe(Effect.orDie), - } - }), -) diff --git a/packages/opencode/src/tool/task_status.txt b/packages/opencode/src/tool/task_status.txt deleted file mode 100644 index ed6fa727b2a9..000000000000 --- a/packages/opencode/src/tool/task_status.txt +++ /dev/null @@ -1,13 +0,0 @@ -Poll the status of a background subagent task launched with the task tool. - -Use this for tasks started with `task(background=true)`. - -Parameters: -- `task_id` (required): the task session id returned by the task tool -- `wait` (optional): when true, wait for completion -- `timeout_ms` (optional): max wait duration in milliseconds when `wait=true` - -Returns compact, parseable output: -- `task_id` -- `state` (`running`, `completed`, `error`, or `cancelled`) -- `...` or `...` containing final output, error summary, or current progress text diff --git a/packages/opencode/test/cli/run/entry.body.test.ts b/packages/opencode/test/cli/run/entry.body.test.ts index e65fd016590c..a33bb0e0dbd2 100644 --- a/packages/opencode/test/cli/run/entry.body.test.ts +++ b/packages/opencode/test/cli/run/entry.body.test.ts @@ -235,11 +235,11 @@ describe("run entry body", () => { }, title: "", output: [ - "task_id: child-1 (for resuming to continue this task if needed)", - "", + '', "", "# Findings\n\n- Footer stays live", "", + "", ].join("\n"), metadata: { sessionId: "child-1", @@ -265,11 +265,11 @@ describe("run entry body", () => { }, title: "", output: [ - "task_id: child-1 (for resuming to continue this task if needed)", - "", + '', "", "", "", + "", ].join("\n"), metadata: { sessionId: "child-1", diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts index da196b7e1020..0d8b297a3524 100644 --- a/packages/opencode/test/cli/run/scrollback.surface.test.ts +++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts @@ -938,8 +938,7 @@ test("renders promoted task markdown without a leading blank row", async () => { subagent_type: "explore", }, output: [ - "task_id: child-1 (for resuming to continue this task if needed)", - "", + '', "", "Location: `/tmp/run.ts`", "", @@ -947,6 +946,7 @@ test("renders promoted task markdown without a leading blank row", async () => { "- Local interactive mode", "- Attach mode", "", + "", ].join("\n"), metadata: { sessionId: "child-1", diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 9fede8175929..1be32979ddfe 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -320,7 +320,7 @@ 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", + "description": "Run the agent in the background. You will be notified when it completes.", "type": "boolean", }, "command": { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index d3549e66f340..25c50678adc3 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -99,9 +99,6 @@ const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer)) const scout = testEffect( Layer.mergeAll(registryLayer({ flags: { experimentalScout: true } }), node, Agent.defaultLayer), ) -const background = testEffect( - Layer.mergeAll(registryLayer({ flags: { experimentalBackgroundSubagents: true } }), node, Agent.defaultLayer), -) const withBrokenPlugin = testEffect( Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer), ) @@ -131,7 +128,7 @@ describe("tool.registry", () => { }), ) - it.instance("hides task_status unless experimental background subagents are enabled", () => + it.instance("does not expose task_status", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service const ids = yield* registry.ids() @@ -157,15 +154,6 @@ describe("tool.registry", () => { }), ) - background.instance("shows task_status when experimental background subagents are enabled", () => - Effect.gen(function* () { - 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 2b7d001572a0..17e7fbea614f 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -92,7 +92,6 @@ 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")), } } @@ -237,7 +236,7 @@ describe("tool.task", () => { 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}`) + expect(result.output).toContain(``) expect(seen?.sessionID).toBe(child.id) }), ) @@ -307,7 +306,6 @@ 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 @@ -371,7 +369,7 @@ describe("tool.task", () => { 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}`) + expect(result.output).toContain(``) expect(seen?.sessionID).toBe(result.metadata.sessionId) }), ) @@ -511,7 +509,7 @@ describe("tool.task", () => { const job = yield* jobs.get(result.metadata.sessionId) expect(result.metadata.background).toBe(true) - expect(result.output).toContain("state: running") + expect(result.output).toContain(`state="running"`) expect(job?.status).toBe("running") }), ) @@ -549,10 +547,9 @@ describe("tool.task", () => { }), ) - background.instance("background task completion does not wait for the parent resume loop", () => + background.instance("background task completion does not wait for the parent async prompt", () => Effect.gen(function* () { const jobs = yield* BackgroundJob.Service - const sessions = yield* Session.Service const { chat, assistant } = yield* seed() const tool = yield* TaskTool const def = yield* tool.init() @@ -573,27 +570,7 @@ describe("tool.task", () => { 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, + input.sessionID === chat.id ? Effect.never : Effect.succeed(reply(input, "background done")), } satisfies TaskPromptOps, }, messages: [], diff --git a/packages/opencode/test/tool/task_status.test.ts b/packages/opencode/test/tool/task_status.test.ts deleted file mode 100644 index 23bd49c616c7..000000000000 --- a/packages/opencode/test/tool/task_status.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -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 { RuntimeFlags } from "@/effect/runtime-flags" -import { disposeAllInstances } from "../fixture/fixture" -import { testEffect } from "../lib/effect" - -afterEach(async () => { - await disposeAllInstances() -}) - -const layer = (flags: Partial = {}) => - Layer.mergeAll( - Agent.defaultLayer, - BackgroundJob.defaultLayer, - Bus.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Session.defaultLayer, - SessionStatus.defaultLayer, - Truncate.defaultLayer, - RuntimeFlags.layer(flags), - ) - -const it = testEffect(layer({ experimentalBackgroundSubagents: true })) - -describe("tool.task_status", () => { - it.instance("returns completed background job output", () => - Effect.gen(function* () { - 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* () { - 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) - }), - ) -})