mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-04 21:53:57 +00:00
Compare commits
4 Commits
actual-tui
...
implement-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bd3904902 | ||
|
|
1a705cbca5 | ||
|
|
e6222529e7 | ||
|
|
2453f40d88 |
@@ -127,14 +127,40 @@ export function Session() {
|
||||
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
})
|
||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||
const localPermissions = createMemo(() => sync.data.permission[route.sessionID] ?? [])
|
||||
const localQuestions = createMemo(() => sync.data.question[route.sessionID] ?? [])
|
||||
const childSessions = createMemo(() => {
|
||||
if (session()?.parentID) return []
|
||||
return children().filter((x) => x.id !== route.sessionID)
|
||||
})
|
||||
const permissions = createMemo(() => {
|
||||
if (session()?.parentID) return []
|
||||
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
|
||||
const child = childSessions().flatMap((x) => sync.data.permission[x.id] ?? [])
|
||||
return [...localPermissions(), ...child]
|
||||
})
|
||||
const questions = createMemo(() => {
|
||||
if (session()?.parentID) return []
|
||||
return children().flatMap((x) => sync.data.question[x.id] ?? [])
|
||||
const child = childSessions().flatMap((x) => sync.data.question[x.id] ?? [])
|
||||
return [...localQuestions(), ...child]
|
||||
})
|
||||
const activeSubagents = createMemo(() =>
|
||||
childSessions().flatMap((item) => {
|
||||
const status = sync.data.session_status?.[item.id]
|
||||
if (status?.type !== "busy" && status?.type !== "retry") return []
|
||||
const count = (sync.data.message[item.id] ?? [])
|
||||
.flatMap((message) => sync.data.part[message.id] ?? [])
|
||||
.filter(
|
||||
(part) => part.type === "tool" && (part.state.status === "completed" || part.state.status === "error"),
|
||||
).length
|
||||
return [
|
||||
{
|
||||
session: item,
|
||||
status,
|
||||
count,
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
|
||||
const pending = createMemo(() => {
|
||||
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
|
||||
@@ -1115,6 +1141,29 @@ export function Session() {
|
||||
</For>
|
||||
</scrollbox>
|
||||
<box flexShrink={0}>
|
||||
<Show when={activeSubagents().length > 0}>
|
||||
<box paddingLeft={3} paddingBottom={1} gap={0}>
|
||||
<text fg={theme.text}>
|
||||
<span style={{ fg: theme.textMuted }}>Subagents</span> {activeSubagents().length} running
|
||||
<span style={{ fg: theme.textMuted }}> · {keybind.print("session_child_cycle")} open</span>
|
||||
</text>
|
||||
<For each={activeSubagents()}>
|
||||
{(item) => (
|
||||
<text
|
||||
fg={theme.textMuted}
|
||||
onMouseUp={() => {
|
||||
navigate({
|
||||
type: "session",
|
||||
sessionID: item.session.id,
|
||||
})
|
||||
}}
|
||||
>
|
||||
↳ {Locale.truncate(item.session.title, 36)} · {item.count} toolcalls
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={permissions().length > 0}>
|
||||
<PermissionPrompt request={permissions()[0]} />
|
||||
</Show>
|
||||
@@ -1122,7 +1171,7 @@ export function Session() {
|
||||
<QuestionPrompt request={questions()[0]} />
|
||||
</Show>
|
||||
<Prompt
|
||||
visible={!session()?.parentID && permissions().length === 0 && questions().length === 0}
|
||||
visible={!session()?.parentID && localPermissions().length === 0 && localQuestions().length === 0}
|
||||
ref={(r) => {
|
||||
prompt = r
|
||||
promptRef.set(r)
|
||||
@@ -1131,7 +1180,7 @@ export function Session() {
|
||||
r.set(route.initialPrompt)
|
||||
}
|
||||
}}
|
||||
disabled={permissions().length > 0 || questions().length > 0}
|
||||
disabled={localPermissions().length > 0 || localQuestions().length > 0}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
@@ -1886,22 +1935,62 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const { navigate } = useRoute()
|
||||
const local = useLocal()
|
||||
const sync = useSync()
|
||||
|
||||
const tools = createMemo(() => {
|
||||
const msgs = createMemo(() => {
|
||||
const sessionID = props.metadata.sessionId
|
||||
const msgs = sync.data.message[sessionID ?? ""] ?? []
|
||||
return msgs.flatMap((msg) =>
|
||||
return sync.data.message[sessionID ?? ""] ?? []
|
||||
})
|
||||
const tools = createMemo(() =>
|
||||
msgs().flatMap((msg) =>
|
||||
(sync.data.part[msg.id] ?? [])
|
||||
.filter((part): part is ToolPart => part.type === "tool")
|
||||
.map((part) => ({ tool: part.tool, state: part.state })),
|
||||
)
|
||||
})
|
||||
),
|
||||
)
|
||||
|
||||
const current = createMemo(() => tools().findLast((x) => x.state.status !== "pending"))
|
||||
|
||||
const isRunning = createMemo(() => props.part.state.status === "running")
|
||||
const background = createMemo(() => props.metadata.background === true)
|
||||
const status = createMemo(() => {
|
||||
const sessionID = props.metadata.sessionId
|
||||
if (!sessionID) return
|
||||
return sync.data.session_status?.[sessionID]
|
||||
})
|
||||
const counts = createMemo(() => {
|
||||
const all = tools()
|
||||
const done = all.filter((item) => item.state.status === "completed" || item.state.status === "error").length
|
||||
return {
|
||||
all: all.length,
|
||||
done,
|
||||
}
|
||||
})
|
||||
const childRunning = createMemo(() => status()?.type === "busy" || status()?.type === "retry")
|
||||
const latest = createMemo(() => {
|
||||
const user = msgs().findLast((msg) => msg.role === "user")
|
||||
const assistant = msgs().findLast((msg) => msg.role === "assistant")
|
||||
return {
|
||||
user,
|
||||
assistant,
|
||||
}
|
||||
})
|
||||
const terminal = createMemo(() => {
|
||||
const assistant = latest().assistant
|
||||
if (!assistant) return false
|
||||
const user = latest().user
|
||||
if (user && user.id > assistant.id) return false
|
||||
if (assistant.error) return true
|
||||
return !!assistant.finish && !["tool-calls", "unknown"].includes(assistant.finish)
|
||||
})
|
||||
const backgroundRunning = createMemo(() => background() && childRunning())
|
||||
const failed = createMemo(() => !!background() && terminal() && !!latest().assistant?.error)
|
||||
const statusLabel = createMemo(() => {
|
||||
if (backgroundRunning()) return "running in background"
|
||||
if (!terminal()) return "background task pending sync"
|
||||
if (failed()) return "background task failed"
|
||||
return "background task finished"
|
||||
})
|
||||
const isRunning = createMemo(() => props.part.state.status === "running" || childRunning())
|
||||
const toolLabel = createMemo(() => `${childRunning() ? counts().done : counts().all} toolcalls`)
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
@@ -1918,8 +2007,13 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
>
|
||||
<box>
|
||||
<text style={{ fg: theme.textMuted }}>
|
||||
{props.input.description} ({tools().length} toolcalls)
|
||||
{props.input.description} ({toolLabel()})
|
||||
</text>
|
||||
<Show when={background()}>
|
||||
<text style={{ fg: failed() ? theme.error : backgroundRunning() ? theme.warning : theme.textMuted }}>
|
||||
↳ {statusLabel()}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={current()}>
|
||||
{(item) => {
|
||||
const title = item().state.status === "completed" ? (item().state as any).title : ""
|
||||
|
||||
@@ -7,6 +7,7 @@ import { GrepTool } from "./grep"
|
||||
import { BatchTool } from "./batch"
|
||||
import { ReadTool } from "./read"
|
||||
import { TaskTool } from "./task"
|
||||
import { TaskStatusTool } from "./task_status"
|
||||
import { TodoWriteTool, TodoReadTool } from "./todo"
|
||||
import { WebFetchTool } from "./webfetch"
|
||||
import { WriteTool } from "./write"
|
||||
@@ -110,6 +111,7 @@ export namespace ToolRegistry {
|
||||
EditTool,
|
||||
WriteTool,
|
||||
TaskTool,
|
||||
TaskStatusTool,
|
||||
WebFetchTool,
|
||||
TodoWriteTool,
|
||||
// TodoReadTool,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MessageV2 } from "../session/message-v2"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { SessionPrompt } from "../session/prompt"
|
||||
import { SessionStatus } from "../session/status"
|
||||
import { iife } from "@/util/iife"
|
||||
import { defer } from "@/util/defer"
|
||||
import { Config } from "../config/config"
|
||||
@@ -22,8 +23,93 @@ const parameters = z.object({
|
||||
)
|
||||
.optional(),
|
||||
command: z.string().describe("The command that triggered this task").optional(),
|
||||
background: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("When true, launch the subagent in the background and return immediately"),
|
||||
})
|
||||
|
||||
function output(sessionID: string, text: string) {
|
||||
return [
|
||||
`task_id: ${sessionID} (for resuming to continue this task if needed)`,
|
||||
"",
|
||||
"<task_result>",
|
||||
text,
|
||||
"</task_result>",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function backgroundOutput(sessionID: string) {
|
||||
return [
|
||||
`task_id: ${sessionID} (for polling this task with task_status)`,
|
||||
"state: running",
|
||||
"",
|
||||
"<task_result>",
|
||||
"Background task started. Continue your current work and call task_status when you need the result.",
|
||||
"</task_result>",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function backgroundMessage(input: {
|
||||
sessionID: string
|
||||
description: string
|
||||
state: "completed" | "error"
|
||||
text: string
|
||||
}) {
|
||||
const tag = input.state === "completed" ? "task_result" : "task_error"
|
||||
const title =
|
||||
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")
|
||||
}
|
||||
|
||||
function errorText(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function resultTaskID(input: unknown) {
|
||||
if (!input || typeof input !== "object") return
|
||||
const taskID = Reflect.get(input, "task_id")
|
||||
if (typeof taskID === "string") return taskID
|
||||
}
|
||||
|
||||
function polled(input: { message: MessageV2.WithParts; taskID: string }) {
|
||||
if (input.message.info.role !== "assistant") return false
|
||||
return input.message.parts.some((part) => {
|
||||
if (part.type !== "tool") return false
|
||||
if (part.tool !== "task_status") return false
|
||||
if (part.state.status !== "completed") return false
|
||||
return resultTaskID(part.state.input) === input.taskID
|
||||
})
|
||||
}
|
||||
|
||||
async function latestUser(sessionID: string) {
|
||||
const [message] = await Session.messages({
|
||||
sessionID,
|
||||
limit: 1,
|
||||
})
|
||||
if (!message) return
|
||||
if (message.info.role !== "user") return
|
||||
return message.info.id
|
||||
}
|
||||
|
||||
async function continueParent(input: { parentID: string; userID: string; taskID: string }) {
|
||||
const message =
|
||||
SessionStatus.get(input.parentID).type === "idle"
|
||||
? undefined
|
||||
: await SessionPrompt.loop({
|
||||
sessionID: input.parentID,
|
||||
}).catch(() => undefined)
|
||||
if (message && polled({ message, taskID: input.taskID })) return
|
||||
if (SessionStatus.get(input.parentID).type !== "idle") return
|
||||
if ((await latestUser(input.parentID)) !== input.userID) return
|
||||
await SessionPrompt.loop({
|
||||
sessionID: input.parentID,
|
||||
})
|
||||
}
|
||||
|
||||
export const TaskTool = Tool.define("task", async (ctx) => {
|
||||
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
|
||||
|
||||
@@ -103,62 +189,110 @@ export const TaskTool = Tool.define("task", async (ctx) => {
|
||||
const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })
|
||||
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
|
||||
|
||||
const model = agent.model ?? {
|
||||
const parentModel = {
|
||||
modelID: msg.info.modelID,
|
||||
providerID: msg.info.providerID,
|
||||
}
|
||||
const model = agent.model ?? parentModel
|
||||
const background = params.background === true
|
||||
const metadata = {
|
||||
sessionId: session.id,
|
||||
model,
|
||||
...(background ? { background: true } : {}),
|
||||
}
|
||||
|
||||
ctx.metadata({
|
||||
title: params.description,
|
||||
metadata: {
|
||||
sessionId: session.id,
|
||||
model,
|
||||
},
|
||||
metadata,
|
||||
})
|
||||
|
||||
const messageID = Identifier.ascending("message")
|
||||
const run = async () => {
|
||||
const promptParts = await SessionPrompt.resolvePromptParts(params.prompt)
|
||||
const result = await SessionPrompt.prompt({
|
||||
messageID: Identifier.ascending("message"),
|
||||
sessionID: session.id,
|
||||
model: {
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
},
|
||||
agent: agent.name,
|
||||
tools: {
|
||||
todowrite: false,
|
||||
todoread: false,
|
||||
...(hasTaskPermission ? {} : { task: false }),
|
||||
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
|
||||
},
|
||||
parts: promptParts,
|
||||
})
|
||||
return result.parts.findLast((x) => x.type === "text")?.text ?? ""
|
||||
}
|
||||
|
||||
if (background) {
|
||||
const inject = (state: "completed" | "error", text: string) =>
|
||||
SessionPrompt.prompt({
|
||||
sessionID: ctx.sessionID,
|
||||
noReply: true,
|
||||
model: {
|
||||
modelID: parentModel.modelID,
|
||||
providerID: parentModel.providerID,
|
||||
},
|
||||
agent: ctx.agent,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text: backgroundMessage({
|
||||
sessionID: session.id,
|
||||
description: params.description,
|
||||
state,
|
||||
text,
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
void run()
|
||||
.then((text) =>
|
||||
inject("completed", text)
|
||||
.then((message) =>
|
||||
continueParent({
|
||||
parentID: ctx.sessionID,
|
||||
userID: message.info.id,
|
||||
taskID: session.id,
|
||||
}),
|
||||
)
|
||||
.catch(() => {}),
|
||||
)
|
||||
.catch((error) =>
|
||||
inject("error", errorText(error))
|
||||
.then((message) =>
|
||||
continueParent({
|
||||
parentID: ctx.sessionID,
|
||||
userID: message.info.id,
|
||||
taskID: session.id,
|
||||
}),
|
||||
)
|
||||
.catch(() => {}),
|
||||
)
|
||||
|
||||
return {
|
||||
title: params.description,
|
||||
metadata,
|
||||
output: backgroundOutput(session.id),
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
SessionPrompt.cancel(session.id)
|
||||
}
|
||||
ctx.abort.addEventListener("abort", cancel)
|
||||
using _ = defer(() => ctx.abort.removeEventListener("abort", cancel))
|
||||
const promptParts = await SessionPrompt.resolvePromptParts(params.prompt)
|
||||
|
||||
const result = await SessionPrompt.prompt({
|
||||
messageID,
|
||||
sessionID: session.id,
|
||||
model: {
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
},
|
||||
agent: agent.name,
|
||||
tools: {
|
||||
todowrite: false,
|
||||
todoread: false,
|
||||
...(hasTaskPermission ? {} : { task: false }),
|
||||
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
|
||||
},
|
||||
parts: promptParts,
|
||||
})
|
||||
|
||||
const text = result.parts.findLast((x) => x.type === "text")?.text ?? ""
|
||||
|
||||
const output = [
|
||||
`task_id: ${session.id} (for resuming to continue this task if needed)`,
|
||||
"",
|
||||
"<task_result>",
|
||||
text,
|
||||
"</task_result>",
|
||||
].join("\n")
|
||||
const text = await run()
|
||||
|
||||
return {
|
||||
title: params.description,
|
||||
metadata: {
|
||||
sessionId: session.id,
|
||||
model,
|
||||
},
|
||||
output,
|
||||
metadata,
|
||||
output: output(session.id, text),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -17,11 +17,13 @@ When NOT to use the Task tool:
|
||||
|
||||
Usage notes:
|
||||
1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
|
||||
2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. The output includes a task_id you can reuse later to continue the same subagent session.
|
||||
3. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
|
||||
4. The agent's outputs should generally be trusted
|
||||
5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands).
|
||||
6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
|
||||
2. By default, task waits for completion and returns the result immediately, along with a task_id you can reuse later to continue the same subagent session.
|
||||
3. Set background=true to launch asynchronously. In background mode, continue your current work without waiting.
|
||||
4. For background runs, use task_status(task_id=..., wait=false) to poll, or wait=true to block until done (optionally with timeout_ms).
|
||||
5. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
|
||||
6. The agent's outputs should generally be trusted
|
||||
7. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands).
|
||||
8. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
|
||||
|
||||
Example usage (NOTE: The agents below are fictional examples for illustration only - use the actual agents listed above):
|
||||
|
||||
|
||||
163
packages/opencode/src/tool/task_status.ts
Normal file
163
packages/opencode/src/tool/task_status.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./task_status.txt"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Session } from "../session"
|
||||
import { SessionStatus } from "../session/status"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
|
||||
type State = "running" | "completed" | "error"
|
||||
|
||||
const DEFAULT_TIMEOUT = 60_000
|
||||
const POLL_MS = 300
|
||||
|
||||
const parameters = z.object({
|
||||
task_id: Identifier.schema("session").describe("The task_id returned by the task tool"),
|
||||
wait: z.boolean().optional().describe("When true, wait until the task reaches a terminal state or timeout"),
|
||||
timeout_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe("Maximum milliseconds to wait when wait=true (default: 60000)"),
|
||||
})
|
||||
|
||||
function format(input: { taskID: string; state: State; text: string }) {
|
||||
return [`task_id: ${input.taskID}`, `state: ${input.state}`, "", "<task_result>", input.text, "</task_result>"].join(
|
||||
"\n",
|
||||
)
|
||||
}
|
||||
|
||||
function errorText(error: NonNullable<MessageV2.Assistant["error"]>) {
|
||||
const data = error.data as Record<string, unknown> | undefined
|
||||
const message = data?.message
|
||||
if (typeof message === "string" && message) return message
|
||||
return error.name
|
||||
}
|
||||
|
||||
async function inspect(taskID: string) {
|
||||
const status = SessionStatus.get(taskID)
|
||||
if (status.type === "busy" || status.type === "retry") {
|
||||
return {
|
||||
state: "running" as const,
|
||||
text: status.type === "retry" ? `Task is retrying: ${status.message}` : "Task is still running.",
|
||||
}
|
||||
}
|
||||
|
||||
let latestUser: MessageV2.User | undefined
|
||||
let latestAssistant:
|
||||
| {
|
||||
info: MessageV2.Assistant
|
||||
parts: MessageV2.Part[]
|
||||
}
|
||||
| undefined
|
||||
for await (const item of MessageV2.stream(taskID)) {
|
||||
if (!latestUser && item.info.role === "user") latestUser = item.info
|
||||
if (!latestAssistant && item.info.role === "assistant") {
|
||||
latestAssistant = {
|
||||
info: item.info,
|
||||
parts: item.parts,
|
||||
}
|
||||
}
|
||||
if (latestUser && latestAssistant) break
|
||||
}
|
||||
|
||||
if (!latestAssistant) {
|
||||
return {
|
||||
state: "running" as const,
|
||||
text: "Task has started but has not produced output yet.",
|
||||
}
|
||||
}
|
||||
|
||||
if (latestUser && latestUser.id > latestAssistant.info.id) {
|
||||
return {
|
||||
state: "running" as const,
|
||||
text: "Task is starting.",
|
||||
}
|
||||
}
|
||||
|
||||
const text = latestAssistant.parts.findLast((part) => part.type === "text")?.text ?? ""
|
||||
if (latestAssistant.info.error) {
|
||||
const summary = errorText(latestAssistant.info.error)
|
||||
return {
|
||||
state: "error" as const,
|
||||
text: text || summary,
|
||||
}
|
||||
}
|
||||
|
||||
const done = latestAssistant.info.finish && !["tool-calls", "unknown"].includes(latestAssistant.info.finish)
|
||||
if (done) {
|
||||
return {
|
||||
state: "completed" as const,
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: "running" as const,
|
||||
text: text || "Task is still running.",
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number, abort: AbortSignal) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (abort.aborted) {
|
||||
reject(new Error("Task status polling aborted"))
|
||||
return
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error("Task status polling aborted"))
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
abort.removeEventListener("abort", onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
|
||||
abort.addEventListener("abort", onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
export const TaskStatusTool = Tool.define("task_status", {
|
||||
description: DESCRIPTION,
|
||||
parameters,
|
||||
async execute(params, ctx) {
|
||||
await Session.get(params.task_id)
|
||||
|
||||
let result = await inspect(params.task_id)
|
||||
if (!params.wait || result.state !== "running") {
|
||||
return {
|
||||
title: "Task status",
|
||||
metadata: {
|
||||
task_id: params.task_id,
|
||||
state: result.state,
|
||||
timed_out: false,
|
||||
},
|
||||
output: format({ taskID: params.task_id, state: result.state, text: result.text }),
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = params.timeout_ms ?? DEFAULT_TIMEOUT
|
||||
const end = Date.now() + timeout
|
||||
while (Date.now() < end) {
|
||||
const left = end - Date.now()
|
||||
await sleep(Math.min(POLL_MS, left), ctx.abort)
|
||||
result = await inspect(params.task_id)
|
||||
if (result.state !== "running") break
|
||||
}
|
||||
|
||||
const done = result.state !== "running"
|
||||
const text = done ? result.text : `Timed out after ${timeout}ms while waiting for task completion.`
|
||||
return {
|
||||
title: "Task status",
|
||||
metadata: {
|
||||
task_id: params.task_id,
|
||||
state: result.state,
|
||||
timed_out: !done,
|
||||
},
|
||||
output: format({ taskID: params.task_id, state: result.state, text }),
|
||||
}
|
||||
},
|
||||
})
|
||||
13
packages/opencode/src/tool/task_status.txt
Normal file
13
packages/opencode/src/tool/task_status.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
Poll the status of a subagent task launched with the task tool.
|
||||
|
||||
Use this to check background 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`, or `error`)
|
||||
- `<task_result>...</task_result>` containing final output, error summary, or current progress text
|
||||
231
packages/opencode/test/tool/task_status.test.ts
Normal file
231
packages/opencode/test/tool/task_status.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Session } from "../../src/session"
|
||||
import { Identifier } from "../../src/id/id"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { TaskStatusTool } from "../../src/tool/task_status"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
|
||||
const ctx = {
|
||||
sessionID: "session_test",
|
||||
messageID: "message_test",
|
||||
callID: "call_test",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
async function user(sessionID: string) {
|
||||
await Session.updateMessage({
|
||||
id: Identifier.ascending("message"),
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: "test-provider",
|
||||
modelID: "test-model",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function assistant(input: { sessionID: string; text: string; error?: string }) {
|
||||
const msg = await Session.updateMessage({
|
||||
id: Identifier.ascending("message"),
|
||||
sessionID: input.sessionID,
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: Date.now(),
|
||||
completed: Date.now(),
|
||||
},
|
||||
parentID: Identifier.ascending("message"),
|
||||
modelID: "test-model",
|
||||
providerID: "test-provider",
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: {
|
||||
cwd: process.cwd(),
|
||||
root: process.cwd(),
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
finish: "stop",
|
||||
...(input.error
|
||||
? {
|
||||
error: new MessageV2.APIError({
|
||||
message: input.error,
|
||||
isRetryable: false,
|
||||
}).toObject(),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
|
||||
await Session.updatePart({
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: input.sessionID,
|
||||
messageID: msg.id,
|
||||
type: "text",
|
||||
text: input.text,
|
||||
})
|
||||
}
|
||||
|
||||
describe("tool.task_status", () => {
|
||||
test("returns running while session status is busy", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const tool = await TaskStatusTool.init()
|
||||
|
||||
SessionStatus.set(session.id, { type: "busy" })
|
||||
const result = await tool.execute({ task_id: session.id }, ctx)
|
||||
|
||||
expect(result.output).toContain("state: running")
|
||||
SessionStatus.set(session.id, { type: "idle" })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns completed with final task output", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const tool = await TaskStatusTool.init()
|
||||
|
||||
await assistant({
|
||||
sessionID: session.id,
|
||||
text: "all done",
|
||||
})
|
||||
|
||||
const result = await tool.execute({ task_id: session.id }, ctx)
|
||||
expect(result.output).toContain("state: completed")
|
||||
expect(result.output).toContain("all done")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("wait=true blocks until terminal status", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const tool = await TaskStatusTool.init()
|
||||
|
||||
SessionStatus.set(session.id, { type: "busy" })
|
||||
const transition = Bun.sleep(150).then(async () => {
|
||||
SessionStatus.set(session.id, { type: "idle" })
|
||||
await assistant({
|
||||
sessionID: session.id,
|
||||
text: "finished later",
|
||||
})
|
||||
})
|
||||
|
||||
const result = await tool.execute(
|
||||
{
|
||||
task_id: session.id,
|
||||
wait: true,
|
||||
timeout_ms: 4_000,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
await transition
|
||||
expect(result.output).toContain("state: completed")
|
||||
expect(result.output).toContain("finished later")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns error when child run fails", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const tool = await TaskStatusTool.init()
|
||||
|
||||
await assistant({
|
||||
sessionID: session.id,
|
||||
text: "",
|
||||
error: "child failed",
|
||||
})
|
||||
|
||||
const result = await tool.execute({ task_id: session.id }, ctx)
|
||||
expect(result.output).toContain("state: error")
|
||||
expect(result.output).toContain("child failed")
|
||||
expect(result.metadata.state).toBe("error")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("wait=true times out with timed_out metadata", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const tool = await TaskStatusTool.init()
|
||||
|
||||
SessionStatus.set(session.id, { type: "busy" })
|
||||
const result = await tool.execute(
|
||||
{
|
||||
task_id: session.id,
|
||||
wait: true,
|
||||
timeout_ms: 80,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.output).toContain("Timed out after 80ms")
|
||||
expect(result.metadata.timed_out).toBe(true)
|
||||
expect(result.metadata.state).toBe("running")
|
||||
SessionStatus.set(session.id, { type: "idle" })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns running for resumed task with a newer user turn", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const tool = await TaskStatusTool.init()
|
||||
|
||||
await user(session.id)
|
||||
await assistant({
|
||||
sessionID: session.id,
|
||||
text: "old done",
|
||||
})
|
||||
await user(session.id)
|
||||
|
||||
const result = await tool.execute({ task_id: session.id }, ctx)
|
||||
expect(result.output).toContain("state: running")
|
||||
expect(result.output).toContain("Task is starting.")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user