mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-27 00:31:00 +00:00
remove the need for polling from experimental background agents (#29179)
This commit is contained in:
parent
775321173d
commit
dabf2dc013
11 changed files with 63 additions and 431 deletions
|
|
@ -134,7 +134,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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
resolvePromptParts(template: string): Effect.Effect<SessionPrompt.PromptInput["parts"]>
|
||||
prompt(input: SessionPrompt.PromptInput): Effect.Effect<MessageV2.WithParts>
|
||||
loop(input: SessionPrompt.LoopInput): Effect.Effect<MessageV2.WithParts>
|
||||
}
|
||||
|
||||
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)`,
|
||||
"",
|
||||
"<task_result>",
|
||||
text,
|
||||
"</task_result>",
|
||||
].join("\n")
|
||||
return [`<task id="${sessionID}" state="completed">`, "<task_result>", text, "</task_result>", "</task>"].join(
|
||||
"\n",
|
||||
)
|
||||
}
|
||||
|
||||
function backgroundOutput(sessionID: SessionID) {
|
||||
return [
|
||||
`task_id: ${sessionID} (for polling this task with task_status)`,
|
||||
"state: running",
|
||||
"",
|
||||
`<task id="${sessionID}" state="running">`,
|
||||
"<summary>Background task started</summary>",
|
||||
"<task_result>",
|
||||
"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.",
|
||||
"</task_result>",
|
||||
"</task>",
|
||||
].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, `</${tag}>`].join(
|
||||
"\n",
|
||||
)
|
||||
return [
|
||||
`<task id="${input.sessionID}" state="${input.state}">`,
|
||||
`<summary>${title}</summary>`,
|
||||
`<${tag}>`,
|
||||
input.text,
|
||||
`</${tag}>`,
|
||||
"</task>",
|
||||
].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<void> =
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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, `</${tag}>`].join("\n")
|
||||
}
|
||||
|
||||
function errorText(error: NonNullable<MessageV2.Assistant["error"]>) {
|
||||
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<InspectResult> = 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<typeof Parameters>,
|
||||
_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<typeof Parameters>, ctx: Tool.Context) =>
|
||||
run(params, ctx).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
|
@ -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`)
|
||||
- `<task_result>...</task_result>` or `<task_error>...</task_error>` containing final output, error summary, or current progress text
|
||||
|
|
@ -235,11 +235,11 @@ describe("run entry body", () => {
|
|||
},
|
||||
title: "",
|
||||
output: [
|
||||
"task_id: child-1 (for resuming to continue this task if needed)",
|
||||
"",
|
||||
'<task id="child-1" state="completed">',
|
||||
"<task_result>",
|
||||
"# Findings\n\n- Footer stays live",
|
||||
"</task_result>",
|
||||
"</task>",
|
||||
].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)",
|
||||
"",
|
||||
'<task id="child-1" state="completed">',
|
||||
"<task_result>",
|
||||
"",
|
||||
"</task_result>",
|
||||
"</task>",
|
||||
].join("\n"),
|
||||
metadata: {
|
||||
sessionId: "child-1",
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
"",
|
||||
'<task id="child-1" state="completed">',
|
||||
"<task_result>",
|
||||
"Location: `/tmp/run.ts`",
|
||||
"",
|
||||
|
|
@ -947,6 +946,7 @@ test("renders promoted task markdown without a leading blank row", async () => {
|
|||
"- Local interactive mode",
|
||||
"- Attach mode",
|
||||
"</task_result>",
|
||||
"</task>",
|
||||
].join("\n"),
|
||||
metadata: {
|
||||
sessionId: "child-1",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(`<task id="${child.id}" state="completed">`)
|
||||
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(`<task id="${result.metadata.sessionId}" state="completed">`)
|
||||
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: [],
|
||||
|
|
|
|||
|
|
@ -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<RuntimeFlags.Info> = {}) =>
|
||||
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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue