import type { OpenCodeContext } from "../../types/plugin"; import type { BackgroundAgentManager } from "../../runtime"; import type { TaskInput, CategoryConfig } from "./types"; import { log } from "../../shared/logger"; import { parseModelId } from "../../shared/model-normalization"; import { resolveModel } from "../../shared/model-resolution-pipeline"; export type MetadataCallback = (input: { title?: string; metadata?: Record; }) => void; export interface ExecutorDeps { manager: BackgroundAgentManager; client: OpenCodeContext["client"]; directory: string; sessionID?: string; messageID?: string; metadata?: MetadataCallback; /** Current delegation depth of the calling agent. Injected as depth+1 into child prompts. */ delegationDepth?: number; } /** * Builds the full prompt by appending the category context block (if defined) * to the user's prompt. This ensures each category's behavioral guidance * reaches the delegated agent. */ function buildPromptWithCategoryContext(prompt: string, config: CategoryConfig): string { if (!config.prompt_append) return prompt; return `${prompt}\n\n${config.prompt_append}`; } /** * Injects an invisible delegation depth marker into the child prompt. * The delegate-task handler reads this marker from session messages to * enforce {@link MAX_DELEGATION_DEPTH} and prevent runaway recursion. */ function injectDelegationDepth(prompt: string, currentDepth: number): string { const childDepth = currentDepth + 1; const cleanedPrompt = prompt.replace(//g, "").trim(); return `\n\n${cleanedPrompt}`; } function sanitiseValue(value: string): string { return value.replace(/[\r\n]+/g, " ").trim(); } function deriveSubagent(input: TaskInput): string { if (input.subagent_type && input.subagent_type.trim().length > 0) return sanitiseValue(input.subagent_type); return input.category; } export async function executeBackground( input: TaskInput, config: CategoryConfig, deps: ExecutorDeps, ): Promise { if (input.session_id) { return "Error: 'session_id' is not supported for background tasks. Use run_in_background=false to resume a session."; } const { manager, client, directory } = deps; const taskId = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const subagent = deriveSubagent(input); log("[delegate-task] Launching background task", { taskId, category: input.category, model: config.model, }); // Emit metadata immediately with title/subagent so the TUI can render // a meaningful card label before the child session is ready. The TUI's // onMount only fires once — if sessionId isn't available at that point // the sync never triggers, so we emit early then update with sessionId. if (deps.metadata) { deps.metadata({ title: input.description, metadata: { category: input.category, subagent_type: subagent, description: input.description, ...(deps.messageID ? { parentMessageId: deps.messageID } : {}), ...(deps.messageID ? { parent_message_id: deps.messageID } : {}), ...(config.model ? { model: config.model } : {}), }, }); } const basePrompt = buildPromptWithCategoryContext(input.prompt, config); const fullPrompt = injectDelegationDepth(basePrompt, deps.delegationDepth ?? 0); const ctx: OpenCodeContext = { client, directory } as OpenCodeContext; const task = await manager.launch(ctx, { id: taskId, prompt: fullPrompt, model: config.model, parentSessionID: deps.sessionID, title: `${sanitiseValue(input.description)} (@${subagent} subagent)`, fallbackChain: config.fallback_chain, }); // Wait briefly for the session to be created so we can update metadata // with sessionId. The TUI uses this to make the task card clickable // and to subscribe to the child session for live tool call stats. const sessionId = await waitForSessionId(manager, task.id); // Second metadata emission: add sessionId now that child session exists. if (deps.metadata) { const metadata: Record = { prompt: input.prompt, category: input.category, subagent_type: subagent, description: input.description, ...(deps.messageID ? { parentMessageId: deps.messageID } : {}), ...(deps.messageID ? { parent_message_id: deps.messageID } : {}), ...(sessionId ? { session_id: sessionId } : {}), ...(sessionId ? { sessionId } : {}), ...(config.model ? { model: config.model } : {}), }; deps.metadata({ title: input.description, metadata }); log("[delegate-task] Emitted task metadata", { taskId, sessionId }); } return formatBackgroundResult(task.id, input, config, sessionId); } const WAIT_FOR_SESSION_TIMEOUT_MS = 5_000; const WAIT_FOR_SESSION_INTERVAL_MS = 250; async function waitForSessionId( manager: BackgroundAgentManager, taskId: string, ): Promise { const deadline = Date.now() + WAIT_FOR_SESSION_TIMEOUT_MS; while (Date.now() < deadline) { const updated = manager.get(taskId); if (updated?.sessionId) return updated.sessionId; if (!updated || updated.status === "failed" || updated.status === "cancelled") return undefined; await new Promise((resolve) => setTimeout(resolve, WAIT_FOR_SESSION_INTERVAL_MS)); } return manager.get(taskId)?.sessionId; } function formatBackgroundResult( taskId: string, input: TaskInput, config: CategoryConfig, sessionId?: string, ): string { const subagent = deriveSubagent(input); const lines = [ "Background task launched.", "", `Task ID: ${taskId}`, `Status: running`, `Category: ${input.category}`, `Model: ${config.model}${config.variant ? ` (variant: ${config.variant})` : ""}`, `Description: ${sanitiseValue(input.description)}`, `Agent: ${subagent} (subagent)`, "", `Use \`background_output\` with task_id="${taskId}" to check status.`, ]; if (sessionId) { lines.push("", ...buildTaskMetadataLines(sessionId, taskId, subagent)); } return lines.join("\n"); } function buildTaskMetadataLines(sessionId: string, taskId: string, subagent: string): string[] { return [ `Session ID: ${sessionId}`, `sessionId: ${sessionId}`, ``, `session_id: ${sessionId}`, `sessionId: ${sessionId}`, `task_id: ${taskId}`, `subagent: ${subagent}`, ``, ]; } export async function executeSync( input: TaskInput, config: CategoryConfig, deps: ExecutorDeps, ): Promise { const { client, directory } = deps; log("[delegate-task] Executing sync task", { category: input.category, model: config.model, }); let sessionId: string; if (input.session_id) { sessionId = input.session_id; log("[delegate-task] Resuming existing session", { sessionId }); } else { const createResult = await client.session.create({ body: { title: `task:${input.category}:${sanitiseValue(input.description).slice(0, 50)}`, ...(deps.sessionID ? { parentID: deps.sessionID } : {}), }, query: { directory }, }); if (createResult.error) { const errorMsg = `Failed to create session: ${String(createResult.error)}`; log("[delegate-task] Session creation failed", { error: errorMsg }); return errorMsg; } sessionId = createResult.data.id; } // Emit metadata with sessionId so the TUI can make the tool card // clickable and navigate to the subagent session. if (deps.metadata) { const subagent = deriveSubagent(input); deps.metadata({ title: input.description, metadata: { prompt: input.prompt, category: input.category, subagent_type: subagent, description: input.description, ...(deps.messageID ? { parentMessageId: deps.messageID } : {}), ...(deps.messageID ? { parent_message_id: deps.messageID } : {}), session_id: sessionId, sessionId, ...(config.model ? { model: config.model } : {}), }, }); } // Resolve model using the provider-aware pipeline. const basePrompt = buildPromptWithCategoryContext(input.prompt, config); const fullPrompt = injectDelegationDepth(basePrompt, deps.delegationDepth ?? 0); const resolved = resolveModel({ override: config.model.includes("/") ? config.model : undefined, fallbackChain: config.fallback_chain, }); const parsed = parseModelId(resolved?.model ?? config.model); const promptResult = await client.session.promptAsync({ path: { id: sessionId }, body: { parts: [{ type: "text", text: fullPrompt }], ...(parsed && { model: { providerID: parsed.provider, modelID: parsed.modelId } }), }, }); if (promptResult.error) { const errorMsg = `Failed to send prompt: ${String(promptResult.error)}`; log("[delegate-task] Prompt send failed", { error: errorMsg, sessionId }); return errorMsg; } const result = await pollForResult(client, directory, sessionId); const subagent = deriveSubagent(input); const taskId = `sync_${sessionId.slice(0, 8)}`; return `${result}\n\n${buildTaskMetadataLines(sessionId, taskId, subagent).join("\n")}`; } const POLL_INTERVAL_MS = 2_000; /** * Maximum time (ms) for sync task polling. Capped at 55s to stay well * within the OpenCode tool-execution timeout (~120s), leaving headroom * for the response to be assembled and returned. */ const MAX_POLL_DURATION_MS = 55_000; async function pollForResult( client: OpenCodeContext["client"], directory: string, sessionId: string, ): Promise { const start = Date.now(); let lastMessageCount = -1; let stablePolls = 0; // This threshold is intentionally higher than poller.ts STABILITY_REQUIRED_POLLS=2. // The sync executor has no status API fallback once it returns, so we require // extra stability before treating the session as complete. const STABLE_THRESHOLD = 3; while (Date.now() - start < MAX_POLL_DURATION_MS) { const [statusResult, messagesResult] = await Promise.all([ client.session.status({ query: { directory } }), client.session.messages({ path: { id: sessionId } }), ]); const sessionStatus = statusResult.data?.[sessionId]?.type; const messages = (messagesResult.data ?? []) as SessionMessage[]; const messageCount = messages.length; if (sessionStatus === "idle") { return await fetchLastAssistantMessage(client, sessionId); } // any known terminal status other than active running states if (sessionStatus !== undefined && sessionStatus !== "busy" && sessionStatus !== "retry") { return await fetchLastAssistantMessage(client, sessionId); } // Status API may not return data for this session (undefined). // Fall back to message-count stability detection. // Keep waiting until the session has more than the initial prompt; otherwise // a single user message can look stable before the assistant replies. if (sessionStatus === undefined && messageCount > 1) { if (messageCount === lastMessageCount) { stablePolls += 1; if (stablePolls >= STABLE_THRESHOLD) { return await fetchLastAssistantMessage(client, sessionId); } } else { stablePolls = 0; } } lastMessageCount = messageCount; await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } return `Task timed out after ${MAX_POLL_DURATION_MS / 1_000}s. Session: ${sessionId}`; } /** * OpenCode messages use a structured format: { info: { role }, parts: [{ type, text }] } * NOT a flat { role, content } shape. We must handle both formats for resilience. */ type MessagePart = { type?: string; text?: string; }; type StructuredMessage = { info?: { role?: string }; parts?: MessagePart[]; }; type FlatMessage = { role?: string; content?: string; }; type SessionMessage = StructuredMessage | FlatMessage; function extractMessageRole(msg: SessionMessage): string | undefined { // Structured format: { info: { role } } if ("info" in msg && msg.info?.role) { return msg.info.role; } // Flat format fallback: { role } if ("role" in msg && typeof msg.role === "string") { return msg.role; } return undefined; } function extractMessageText(msg: SessionMessage): string | undefined { // Structured format: { parts: [{ type: "text", text: "..." }] } if ("parts" in msg && Array.isArray(msg.parts)) { const textParts = msg.parts .filter((p) => p.type === "text" && typeof p.text === "string") .map((p) => p.text!); if (textParts.length > 0) { return textParts.join("\n"); } } // Flat format fallback: { content } if ("content" in msg && typeof msg.content === "string") { return msg.content; } return undefined; } async function fetchLastAssistantMessage( client: OpenCodeContext["client"], sessionId: string, ): Promise { const messagesResult = await client.session.messages({ path: { id: sessionId }, }); const messages = (messagesResult.data ?? []) as SessionMessage[]; const lastAssistant = [...messages].reverse().find((m) => extractMessageRole(m) === "assistant"); if (!lastAssistant) { return "Task completed but no response was returned."; } return extractMessageText(lastAssistant) ?? "Task completed but no response was returned."; }