diff --git a/src/config/schema.ts b/src/config/schema.ts index e32d2ee19..07600afb4 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -296,6 +296,7 @@ export const GitMasterConfigSchema = z.object({ /** Add "Co-authored-by: Sisyphus" trailer to commit messages (default: true) */ include_co_authored_by: z.boolean().default(true), }) + export const OhMyOpenCodeConfigSchema = z.object({ $schema: z.string().optional(), disabled_mcps: z.array(AnyMcpNameSchema).optional(), diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index ccc7ddc63..6d58b2592 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -13,6 +13,7 @@ import { subagentSessions } from "../claude-code-session-state" import { getTaskToastManager } from "../task-toast-manager" const TASK_TTL_MS = 30 * 60 * 1000 +const MIN_STABILITY_TIME_MS = 10 * 1000 // Must run at least 10s before stability detection kicks in type OpencodeClient = PluginInput["client"] @@ -43,6 +44,7 @@ interface Todo { export class BackgroundManager { private tasks: Map private notifications: Map + private pendingByParent: Map> // Track pending tasks per parent for batching private client: OpencodeClient private directory: string private pollingInterval?: ReturnType @@ -51,12 +53,20 @@ export class BackgroundManager { constructor(ctx: PluginInput, config?: BackgroundTaskConfig) { this.tasks = new Map() this.notifications = new Map() + this.pendingByParent = new Map() this.client = ctx.client this.directory = ctx.directory this.concurrencyManager = new ConcurrencyManager(config) } async launch(input: LaunchInput): Promise { + log("[background-agent] launch() called with:", { + agent: input.agent, + model: input.model, + description: input.description, + parentSessionID: input.parentSessionID, + }) + if (!input.agent || input.agent.trim() === "") { throw new Error("Agent parameter is required") } @@ -106,6 +116,11 @@ export class BackgroundManager { this.tasks.set(task.id, task) this.startPolling() + // Track for batched notifications + const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set() + pending.add(task.id) + this.pendingByParent.set(input.parentSessionID, pending) + log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent }) const toastManager = getTaskToastManager() @@ -119,10 +134,21 @@ export class BackgroundManager { }) } - this.client.session.promptAsync({ + log("[background-agent] Calling prompt (fire-and-forget) for launch with:", { + sessionID, + agent: input.agent, + model: input.model, + hasSkillContent: !!input.skillContent, + promptLength: input.prompt.length, + }) + + // Use prompt() instead of promptAsync() to properly initialize agent loop (fire-and-forget) + // Include model if caller provided one (e.g., from Sisyphus category configs) + this.client.session.prompt({ path: { id: sessionID }, body: { agent: input.agent, + ...(input.model ? { model: input.model } : {}), system: input.skillContent, tools: { task: false, @@ -146,7 +172,9 @@ export class BackgroundManager { this.concurrencyManager.release(existingTask.concurrencyKey) } this.markForNotification(existingTask) - this.notifyParentSession(existingTask) + this.notifyParentSession(existingTask).catch(err => { + log("[background-agent] Failed to notify on error:", err) + }) } }) @@ -222,6 +250,11 @@ export class BackgroundManager { subagentSessions.add(input.sessionID) this.startPolling() + // Track for batched notifications (external tasks need tracking too) + const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set() + pending.add(task.id) + this.pendingByParent.set(input.parentSessionID, pending) + log("[background-agent] Registered external task:", { taskId: task.id, sessionID: input.sessionID }) return task @@ -249,6 +282,11 @@ export class BackgroundManager { this.startPolling() subagentSessions.add(existingTask.sessionID) + // Track for batched notifications (P2 fix: resumed tasks need tracking too) + const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set() + pending.add(existingTask.id) + this.pendingByParent.set(input.parentSessionID, pending) + const toastManager = getTaskToastManager() if (toastManager) { toastManager.addTask({ @@ -261,7 +299,15 @@ export class BackgroundManager { log("[background-agent] Resuming task:", { taskId: existingTask.id, sessionID: existingTask.sessionID }) - this.client.session.promptAsync({ + log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", { + sessionID: existingTask.sessionID, + agent: existingTask.agent, + promptLength: input.prompt.length, + }) + + // Note: Don't pass model in body - use agent's configured model instead + // Use prompt() instead of promptAsync() to properly initialize agent loop + this.client.session.prompt({ path: { id: existingTask.sessionID }, body: { agent: existingTask.agent, @@ -272,13 +318,15 @@ export class BackgroundManager { parts: [{ type: "text", text: input.prompt }], }, }).catch((error) => { - log("[background-agent] resume promptAsync error:", error) + log("[background-agent] resume prompt error:", error) existingTask.status = "error" const errorMessage = error instanceof Error ? error.message : String(error) existingTask.error = errorMessage existingTask.completedAt = new Date() this.markForNotification(existingTask) - this.notifyParentSession(existingTask) + this.notifyParentSession(existingTask).catch(err => { + log("[background-agent] Failed to notify on resume error:", err) + }) }) return existingTask @@ -333,7 +381,22 @@ export class BackgroundManager { const task = this.findBySession(sessionID) if (!task || task.status !== "running") return - this.checkSessionTodos(sessionID).then((hasIncompleteTodos) => { + // Edge guard: Require minimum elapsed time (5 seconds) before accepting idle + const elapsedMs = Date.now() - task.startedAt.getTime() + const MIN_IDLE_TIME_MS = 5000 + if (elapsedMs < MIN_IDLE_TIME_MS) { + log("[background-agent] Ignoring early session.idle, elapsed:", { elapsedMs, taskId: task.id }) + return + } + + // Edge guard: Verify session has actual assistant output before completing + this.validateSessionHasOutput(sessionID).then(async (hasValidOutput) => { + if (!hasValidOutput) { + log("[background-agent] Session.idle but no valid output yet, waiting:", task.id) + return + } + + const hasIncompleteTodos = await this.checkSessionTodos(sessionID) if (hasIncompleteTodos) { log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id) return @@ -342,8 +405,10 @@ export class BackgroundManager { task.status = "completed" task.completedAt = new Date() this.markForNotification(task) - this.notifyParentSession(task) + await this.notifyParentSession(task) log("[background-agent] Task completed via session.idle event:", task.id) + }).catch(err => { + log("[background-agent] Error in session.idle handler:", err) }) } @@ -384,6 +449,66 @@ export class BackgroundManager { this.notifications.delete(sessionID) } + /** + * Validates that a session has actual assistant/tool output before marking complete. + * Prevents premature completion when session.idle fires before agent responds. + */ + private async validateSessionHasOutput(sessionID: string): Promise { + try { + const response = await this.client.session.messages({ + path: { id: sessionID }, + }) + + const messages = response.data ?? [] + + // Check for at least one assistant or tool message + const hasAssistantOrToolMessage = messages.some( + (m: { info?: { role?: string } }) => + m.info?.role === "assistant" || m.info?.role === "tool" + ) + + if (!hasAssistantOrToolMessage) { + log("[background-agent] No assistant/tool messages found in session:", sessionID) + return false + } + + // Additionally check that at least one message has content (not just empty) + // OpenCode API uses different part types than Anthropic's API: + // - "reasoning" with .text property (thinking/reasoning content) + // - "tool" with .state.output property (tool call results) + // - "text" with .text property (final text output) + // - "step-start"/"step-finish" (metadata, no content) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hasContent = messages.some((m: any) => { + if (m.info?.role !== "assistant" && m.info?.role !== "tool") return false + const parts = m.parts ?? [] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return parts.some((p: any) => + // Text content (final output) + (p.type === "text" && p.text && p.text.trim().length > 0) || + // Reasoning content (thinking blocks) + (p.type === "reasoning" && p.text && p.text.trim().length > 0) || + // Tool calls (indicates work was done) + p.type === "tool" || + // Tool results (output from executed tools) - important for tool-only tasks + (p.type === "tool_result" && p.content && + (typeof p.content === "string" ? p.content.trim().length > 0 : p.content.length > 0)) + ) + }) + + if (!hasContent) { + log("[background-agent] Messages exist but no content found in session:", sessionID) + return false + } + + return true + } catch (error) { + log("[background-agent] Error validating session output:", error) + // On error, allow completion to proceed (don't block indefinitely) + return true + } + } + private clearNotificationsForTask(taskId: string): void { for (const [sessionID, tasks] of this.notifications.entries()) { const filtered = tasks.filter((t) => t.id !== taskId) @@ -411,17 +536,33 @@ export class BackgroundManager { } } - cleanup(): void { +cleanup(): void { this.stopPolling() this.tasks.clear() this.notifications.clear() + this.pendingByParent.clear() + } + + /** + * Get all running tasks (for compaction hook) + */ + getRunningTasks(): BackgroundTask[] { + return Array.from(this.tasks.values()).filter(t => t.status === "running") + } + + /** + * Get all completed tasks still in memory (for compaction hook) + */ + getCompletedTasks(): BackgroundTask[] { + return Array.from(this.tasks.values()).filter(t => t.status !== "running") } - private notifyParentSession(task: BackgroundTask): void { +private async notifyParentSession(task: BackgroundTask): Promise { const duration = this.formatDuration(task.startedAt, task.completedAt) log("[background-agent] notifyParentSession called for task:", task.id) + // Show toast notification const toastManager = getTaskToastManager() if (toastManager) { toastManager.showCompletionToast({ @@ -431,47 +572,83 @@ export class BackgroundManager { }) } - const message = `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished in ${duration}. Use background_output with task_id="${task.id}" to get results.` + // Update pending tracking and check if all tasks complete + const pendingSet = this.pendingByParent.get(task.parentSessionID) + if (pendingSet) { + pendingSet.delete(task.id) + if (pendingSet.size === 0) { + this.pendingByParent.delete(task.parentSessionID) + } + } - log("[background-agent] Sending notification to parent session:", { parentSessionID: task.parentSessionID }) + const allComplete = !pendingSet || pendingSet.size === 0 + const remainingCount = pendingSet?.size ?? 0 + + // Build notification message + const statusText = task.status === "error" ? "FAILED" : "COMPLETED" + const errorInfo = task.error ? `\n**Error:** ${task.error}` : "" + + let notification: string + if (allComplete) { + // All tasks complete - build summary + const completedTasks = Array.from(this.tasks.values()) + .filter(t => t.parentSessionID === task.parentSessionID && t.status !== "running") + .map(t => `- \`${t.id}\`: ${t.description}`) + .join("\n") + + notification = ` +[ALL BACKGROUND TASKS COMPLETE] + +**Completed:** +${completedTasks || `- \`${task.id}\`: ${task.description}`} + +Use \`background_output(task_id="")\` to retrieve each result. +` + } else { + // Individual completion - silent notification + notification = ` +[BACKGROUND TASK ${statusText}] +**ID:** \`${task.id}\` +**Description:** ${task.description} +**Duration:** ${duration}${errorInfo} + +**${remainingCount} task${remainingCount === 1 ? "" : "s"} still in progress.** You WILL be notified when ALL complete. +Do NOT poll - continue productive work. + +Use \`background_output(task_id="${task.id}")\` to retrieve this result when ready. +` + } + + // Inject notification via session.prompt with noReply + try { + await this.client.session.prompt({ + path: { id: task.parentSessionID }, + body: { + noReply: !allComplete, // Silent unless all complete + agent: task.parentAgent, + parts: [{ type: "text", text: notification }], + }, + }) + log("[background-agent] Sent notification to parent session:", { + taskId: task.id, + allComplete, + noReply: !allComplete, + }) + } catch (error) { + log("[background-agent] Failed to send notification:", error) + } + // Cleanup after retention period const taskId = task.id - setTimeout(async () => { + setTimeout(() => { if (task.concurrencyKey) { this.concurrencyManager.release(task.concurrencyKey) + task.concurrencyKey = undefined } - - try { - const body: { - agent?: string - model?: { providerID: string; modelID: string } - parts: Array<{ type: "text"; text: string }> - } = { - parts: [{ type: "text", text: message }], - } - - if (task.parentAgent !== undefined) { - body.agent = task.parentAgent - } - - if (task.parentModel?.providerID && task.parentModel?.modelID) { - body.model = { providerID: task.parentModel.providerID, modelID: task.parentModel.modelID } - } - - await this.client.session.prompt({ - path: { id: task.parentSessionID }, - body, - query: { directory: this.directory }, - }) - log("[background-agent] Successfully sent prompt to parent session:", { parentSessionID: task.parentSessionID }) - } catch (error) { - log("[background-agent] prompt failed:", String(error)) - } finally { - this.clearNotificationsForTask(taskId) - this.tasks.delete(taskId) - log("[background-agent] Removed completed task from memory:", taskId) - } - }, 200) + this.clearNotificationsForTask(taskId) + this.tasks.delete(taskId) + log("[background-agent] Removed completed task from memory:", taskId) + }, 5 * 60 * 1000) } private formatDuration(start: Date, end?: Date): string { @@ -540,15 +717,18 @@ export class BackgroundManager { for (const task of this.tasks.values()) { if (task.status !== "running") continue - try { +try { const sessionStatus = allStatuses[task.sessionID] - if (!sessionStatus) { - log("[background-agent] Session not found in status:", task.sessionID) - continue - } + // Don't skip if session not in status - fall through to message-based detection + if (sessionStatus?.type === "idle") { + // Edge guard: Validate session has actual output before completing + const hasValidOutput = await this.validateSessionHasOutput(task.sessionID) + if (!hasValidOutput) { + log("[background-agent] Polling idle but no valid output yet, waiting:", task.id) + continue + } - if (sessionStatus.type === "idle") { const hasIncompleteTodos = await this.checkSessionTodos(task.sessionID) if (hasIncompleteTodos) { log("[background-agent] Task has incomplete todos via polling, waiting:", task.id) @@ -558,7 +738,7 @@ export class BackgroundManager { task.status = "completed" task.completedAt = new Date() this.markForNotification(task) - this.notifyParentSession(task) + await this.notifyParentSession(task) log("[background-agent] Task completed via polling:", task.id) continue } @@ -599,10 +779,41 @@ export class BackgroundManager { task.progress.toolCalls = toolCalls task.progress.lastTool = lastTool task.progress.lastUpdate = new Date() - if (lastMessage) { +if (lastMessage) { task.progress.lastMessage = lastMessage task.progress.lastMessageAt = new Date() } + + // Stability detection: complete when message count unchanged for 3 polls + const currentMsgCount = messages.length + const elapsedMs = Date.now() - task.startedAt.getTime() + + if (elapsedMs >= MIN_STABILITY_TIME_MS) { + if (task.lastMsgCount === currentMsgCount) { + task.stablePolls = (task.stablePolls ?? 0) + 1 + if (task.stablePolls >= 3) { + // Edge guard: Validate session has actual output before completing + const hasValidOutput = await this.validateSessionHasOutput(task.sessionID) + if (!hasValidOutput) { + log("[background-agent] Stability reached but no valid output, waiting:", task.id) + continue + } + + const hasIncompleteTodos = await this.checkSessionTodos(task.sessionID) + if (!hasIncompleteTodos) { + task.status = "completed" + task.completedAt = new Date() + this.markForNotification(task) + await this.notifyParentSession(task) + log("[background-agent] Task completed via stability detection:", task.id) + continue + } + } + } else { + task.stablePolls = 0 + } + } + task.lastMsgCount = currentMsgCount } } catch (error) { log("[background-agent] Poll error for task:", { taskId: task.id, error }) diff --git a/src/features/background-agent/types.ts b/src/features/background-agent/types.ts index b7e68cdd7..a77766f8a 100644 --- a/src/features/background-agent/types.ts +++ b/src/features/background-agent/types.ts @@ -32,6 +32,10 @@ export interface BackgroundTask { concurrencyKey?: string /** Parent session's agent name for notification */ parentAgent?: string + /** Last message count for stability detection */ + lastMsgCount?: number + /** Number of consecutive polls with stable message count */ + stablePolls?: number } export interface LaunchInput { diff --git a/src/hooks/background-compaction/index.ts b/src/hooks/background-compaction/index.ts new file mode 100644 index 000000000..b978ee1a4 --- /dev/null +++ b/src/hooks/background-compaction/index.ts @@ -0,0 +1,85 @@ +import type { BackgroundManager } from "../../features/background-agent" + +interface CompactingInput { + sessionID: string +} + +interface CompactingOutput { + context: string[] + prompt?: string +} + +/** + * Background agent compaction hook - preserves task state during context compaction. + * + * When OpenCode compacts session context to save tokens, this hook injects + * information about running and recently completed background tasks so the + * agent doesn't lose awareness of delegated work. + */ +export function createBackgroundCompactionHook(manager: BackgroundManager) { + return { + "experimental.session.compacting": async ( + input: CompactingInput, + output: CompactingOutput + ): Promise => { + const { sessionID } = input + + // Get running tasks for this session + const running = manager.getRunningTasks() + .filter(t => t.parentSessionID === sessionID) + .map(t => ({ + id: t.id, + agent: t.agent, + description: t.description, + startedAt: t.startedAt, + })) + + // Get recently completed tasks (still in memory within 5-min retention) + const completed = manager.getCompletedTasks() + .filter(t => t.parentSessionID === sessionID) + .slice(-10) // Last 10 completed + .map(t => ({ + id: t.id, + agent: t.agent, + description: t.description, + status: t.status, + })) + + // Early exit if nothing to preserve + if (running.length === 0 && completed.length === 0) return + + const sections: string[] = [""] + + // Running tasks section + if (running.length > 0) { + sections.push("## Running Background Tasks") + sections.push("") + for (const t of running) { + const elapsed = Math.floor((Date.now() - t.startedAt.getTime()) / 1000) + sections.push(`- **\`${t.id}\`** (${t.agent}): ${t.description} [${elapsed}s elapsed]`) + } + sections.push("") + sections.push("> **Note:** You WILL be notified when tasks complete.") + sections.push("> Do NOT poll - continue productive work.") + sections.push("") + } + + // Completed tasks section + if (completed.length > 0) { + sections.push("## Recently Completed Tasks") + sections.push("") + for (const t of completed) { + const statusEmoji = t.status === "completed" ? "✅" : t.status === "error" ? "❌" : "⏱️" + sections.push(`- ${statusEmoji} **\`${t.id}\`**: ${t.description}`) + } + sections.push("") + } + + sections.push("## Retrieval") + sections.push('Use `background_output(task_id="")` to retrieve task results.') + sections.push("") + + output.context.push(sections.join("\n")) + } + } +} diff --git a/src/hooks/background-notification/index.ts b/src/hooks/background-notification/index.ts index 21944a6b3..9fcf562f2 100644 --- a/src/hooks/background-notification/index.ts +++ b/src/hooks/background-notification/index.ts @@ -9,6 +9,12 @@ interface EventInput { event: Event } +/** + * Background notification hook - handles event routing to BackgroundManager. + * + * Notifications are now delivered directly via session.prompt({ noReply }) + * from the manager, so this hook only needs to handle event routing. + */ export function createBackgroundNotificationHook(manager: BackgroundManager) { const eventHandler = async ({ event }: EventInput) => { manager.handleEvent(event) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 821c190dc..642872e90 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -14,6 +14,7 @@ export { createThinkModeHook } from "./think-mode"; export { createClaudeCodeHooksHook } from "./claude-code-hooks"; export { createRulesInjectorHook } from "./rules-injector"; export { createBackgroundNotificationHook } from "./background-notification" +export { createBackgroundCompactionHook } from "./background-compaction" export { createAutoUpdateCheckerHook } from "./auto-update-checker"; export { createAgentUsageReminderHook } from "./agent-usage-reminder"; diff --git a/src/tools/background-task/tools.ts b/src/tools/background-task/tools.ts index 1f9169378..3df7b0533 100644 --- a/src/tools/background-task/tools.ts +++ b/src/tools/background-task/tools.ts @@ -176,8 +176,13 @@ async function formatTaskResult(task: BackgroundTask, client: OpencodeClient): P // Handle both SDK response structures: direct array or wrapped in .data // eslint-disable-next-line @typescript-eslint/no-explicit-any const messages = ((messagesResult as any).data ?? messagesResult) as Array<{ - info?: { role?: string } - parts?: Array<{ type?: string; text?: string }> + info?: { role?: string; time?: string } + parts?: Array<{ + type?: string + text?: string + content?: string | Array<{ type: string; text?: string }> + name?: string + }> }> if (!Array.isArray(messages) || messages.length === 0) { @@ -193,11 +198,13 @@ Session ID: ${task.sessionID} (No messages found)` } - const assistantMessages = messages.filter( - (m) => m.info?.role === "assistant" + // Include both assistant messages AND tool messages + // Tool results (grep, glob, bash output) come from role "tool" + const relevantMessages = messages.filter( + (m) => m.info?.role === "assistant" || m.info?.role === "tool" ) - if (assistantMessages.length === 0) { + if (relevantMessages.length === 0) { return `Task Result Task ID: ${task.id} @@ -207,17 +214,46 @@ Session ID: ${task.sessionID} --- -(No assistant response found)` +(No assistant or tool response found)` } - const lastMessage = assistantMessages[assistantMessages.length - 1] - const textParts = lastMessage?.parts?.filter( - (p) => p.type === "text" - ) ?? [] - const textContent = textParts - .map((p) => p.text ?? "") + // Sort by time ascending (oldest first) to process messages in order + const sortedMessages = [...relevantMessages].sort((a, b) => { + const timeA = String((a as { info?: { time?: string } }).info?.time ?? "") + const timeB = String((b as { info?: { time?: string } }).info?.time ?? "") + return timeA.localeCompare(timeB) + }) + + // Extract content from ALL messages, not just the last one + // Tool results may be in earlier messages while the final message is empty + const extractedContent: string[] = [] + + for (const message of sortedMessages) { + for (const part of message.parts ?? []) { + // Handle both "text" and "reasoning" parts (thinking models use "reasoning") + if ((part.type === "text" || part.type === "reasoning") && part.text) { + extractedContent.push(part.text) + } else if (part.type === "tool_result") { + // Tool results contain the actual output from tool calls + const toolResult = part as { content?: string | Array<{ type: string; text?: string }> } + if (typeof toolResult.content === "string" && toolResult.content) { + extractedContent.push(toolResult.content) + } else if (Array.isArray(toolResult.content)) { + // Handle array of content blocks + for (const block of toolResult.content) { + // Handle both "text" and "reasoning" parts (thinking models use "reasoning") + if ((block.type === "text" || block.type === "reasoning") && block.text) { + extractedContent.push(block.text) + } + } + } + } + } + } + + const textContent = extractedContent .filter((text) => text.length > 0) - .join("\n") + .join("\n\n") const duration = formatDuration(task.startedAt, task.completedAt) diff --git a/src/tools/call-omo-agent/tools.ts b/src/tools/call-omo-agent/tools.ts index d1ff9a71c..b30e2286b 100644 --- a/src/tools/call-omo-agent/tools.ts +++ b/src/tools/call-omo-agent/tools.ts @@ -170,23 +170,59 @@ async function executeSync( const messages = messagesResult.data log(`[call_omo_agent] Got ${messages.length} messages`) + // Include both assistant messages AND tool messages + // Tool results (grep, glob, bash output) come from role "tool" // eslint-disable-next-line @typescript-eslint/no-explicit-any - const lastAssistantMessage = messages - .filter((m: any) => m.info.role === "assistant") - .sort((a: any, b: any) => (b.info.time?.created || 0) - (a.info.time?.created || 0))[0] + const relevantMessages = messages.filter( + (m: any) => m.info?.role === "assistant" || m.info?.role === "tool" + ) - if (!lastAssistantMessage) { - log(`[call_omo_agent] No assistant message found`) + if (relevantMessages.length === 0) { + log(`[call_omo_agent] No assistant or tool messages found`) log(`[call_omo_agent] All messages:`, JSON.stringify(messages, null, 2)) - return `Error: No assistant response found\n\n\nsession_id: ${sessionID}\n` + return `Error: No assistant or tool response found\n\n\nsession_id: ${sessionID}\n` } - log(`[call_omo_agent] Found assistant message with ${lastAssistantMessage.parts.length} parts`) + log(`[call_omo_agent] Found ${relevantMessages.length} relevant messages`) + // Sort by time ascending (oldest first) to process messages in order // eslint-disable-next-line @typescript-eslint/no-explicit-any - const textParts = lastAssistantMessage.parts.filter((p: any) => p.type === "text") - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const responseText = textParts.map((p: any) => p.text).join("\n") + const sortedMessages = [...relevantMessages].sort((a: any, b: any) => { + const timeA = a.info?.time?.created ?? 0 + const timeB = b.info?.time?.created ?? 0 + return timeA - timeB + }) + + // Extract content from ALL messages, not just the last one + // Tool results may be in earlier messages while the final message is empty + const extractedContent: string[] = [] + + for (const message of sortedMessages) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + for (const part of (message as any).parts ?? []) { + // Handle both "text" and "reasoning" parts (thinking models use "reasoning") + if ((part.type === "text" || part.type === "reasoning") && part.text) { + extractedContent.push(part.text) + } else if (part.type === "tool_result") { + // Tool results contain the actual output from tool calls + const toolResult = part as { content?: string | Array<{ type: string; text?: string }> } + if (typeof toolResult.content === "string" && toolResult.content) { + extractedContent.push(toolResult.content) + } else if (Array.isArray(toolResult.content)) { + // Handle array of content blocks + for (const block of toolResult.content) { + if ((block.type === "text" || block.type === "reasoning") && block.text) { + extractedContent.push(block.text) + } + } + } + } + } + } + + const responseText = extractedContent + .filter((text) => text.length > 0) + .join("\n\n") log(`[call_omo_agent] Got response, length: ${responseText.length}`) diff --git a/src/tools/sisyphus-task/tools.ts b/src/tools/sisyphus-task/tools.ts index dc22a3097..cfe0b5ba6 100644 --- a/src/tools/sisyphus-task/tools.ts +++ b/src/tools/sisyphus-task/tools.ts @@ -221,6 +221,33 @@ Use \`background_output\` with task_id="${task.id}" to check progress.` return `❌ Failed to send resume prompt: ${errorMessage}\n\nSession ID: ${args.resume}` } + // Wait for message stability after prompt completes + const POLL_INTERVAL_MS = 500 + const MIN_STABILITY_TIME_MS = 5000 + const STABILITY_POLLS_REQUIRED = 3 + const pollStart = Date.now() + let lastMsgCount = 0 + let stablePolls = 0 + + while (Date.now() - pollStart < 60000) { + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) + + const elapsed = Date.now() - pollStart + if (elapsed < MIN_STABILITY_TIME_MS) continue + + const messagesCheck = await client.session.messages({ path: { id: args.resume } }) + const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array + const currentMsgCount = msgs.length + + if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) { + stablePolls++ + if (stablePolls >= STABILITY_POLLS_REQUIRED) break + } else { + stablePolls = 0 + lastMsgCount = currentMsgCount + } + } + const messagesResult = await client.session.messages({ path: { id: args.resume }, }) @@ -250,7 +277,8 @@ Use \`background_output\` with task_id="${task.id}" to check progress.` return `❌ No assistant response found.\n\nSession ID: ${args.resume}` } - const textParts = lastMessage?.parts?.filter((p) => p.type === "text") ?? [] + // Extract text from both "text" and "reasoning" parts (thinking models use "reasoning") + const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? [] const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n") const duration = formatDuration(startTime) @@ -390,13 +418,13 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id metadata: { sessionId: sessionID, category: args.category, sync: true }, }) - // Use promptAsync to avoid changing main session's active state + // Use fire-and-forget prompt() - awaiting causes JSON parse errors with thinking models + // Note: Don't pass model in body - use agent's configured model instead let promptError: Error | undefined - await client.session.promptAsync({ + client.session.prompt({ path: { id: sessionID }, body: { agent: agentToUse, - model: categoryModel, system: systemContent, tools: { task: false, @@ -408,6 +436,9 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id promptError = error instanceof Error ? error : new Error(String(error)) }) + // Small delay to let the prompt start + await new Promise(resolve => setTimeout(resolve, 100)) + if (promptError) { if (toastManager && taskId !== undefined) { toastManager.removeTask(taskId) @@ -419,21 +450,63 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id return `❌ Failed to send prompt: ${errorMessage}\n\nSession ID: ${sessionID}` } - // Poll for session completion + // Poll for session completion with stability detection + // The session may show as "idle" before messages appear, so we also check message stability const POLL_INTERVAL_MS = 500 const MAX_POLL_TIME_MS = 10 * 60 * 1000 + const MIN_STABILITY_TIME_MS = 10000 // Minimum 10s before accepting completion + const STABILITY_POLLS_REQUIRED = 3 const pollStart = Date.now() + let lastMsgCount = 0 + let stablePolls = 0 while (Date.now() - pollStart < MAX_POLL_TIME_MS) { await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) + // Check for async errors that may have occurred after the initial 100ms delay + // TypeScript doesn't understand async mutation, so we cast to check + const asyncError = promptError as Error | undefined + if (asyncError) { + if (toastManager && taskId !== undefined) { + toastManager.removeTask(taskId) + } + const errorMessage = asyncError.message + if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) { + return `❌ Agent "${agentToUse}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\nSession ID: ${sessionID}` + } + return `❌ Failed to send prompt: ${errorMessage}\n\nSession ID: ${sessionID}` + } + const statusResult = await client.session.status() const allStatuses = (statusResult.data ?? {}) as Record const sessionStatus = allStatuses[sessionID] - // Break if session is idle OR no longer in status (completed and removed) - if (!sessionStatus || sessionStatus.type === "idle") { - break + // If session is actively running, reset stability + if (sessionStatus && sessionStatus.type !== "idle") { + stablePolls = 0 + lastMsgCount = 0 + continue + } + + // Session is idle or not in status - check message stability + const elapsed = Date.now() - pollStart + if (elapsed < MIN_STABILITY_TIME_MS) { + continue // Don't accept completion too early + } + + // Get current message count + const messagesCheck = await client.session.messages({ path: { id: sessionID } }) + const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array + const currentMsgCount = msgs.length + + if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) { + stablePolls++ + if (stablePolls >= STABILITY_POLLS_REQUIRED) { + break // Messages stable for 3 polls - task complete + } + } else { + stablePolls = 0 + lastMsgCount = currentMsgCount } } @@ -459,7 +532,8 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id return `❌ No assistant response found.\n\nSession ID: ${sessionID}` } - const textParts = lastMessage?.parts?.filter((p) => p.type === "text") ?? [] + // Extract text from both "text" and "reasoning" parts (thinking models use "reasoning") + const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? [] const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n") const duration = formatDuration(startTime) diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts index abb6d1c1a..24e0f548c 100644 --- a/src/tools/skill/tools.ts +++ b/src/tools/skill/tools.ts @@ -194,4 +194,4 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition }) } -export const skill = createSkillTool() +export const skill: ToolDefinition = createSkillTool() diff --git a/src/tools/slashcommand/tools.ts b/src/tools/slashcommand/tools.ts index 335d4428b..4866a6765 100644 --- a/src/tools/slashcommand/tools.ts +++ b/src/tools/slashcommand/tools.ts @@ -249,4 +249,4 @@ export function createSlashcommandTool(options: SlashcommandToolOptions = {}): T } // Default instance for backward compatibility (lazy loading) -export const slashcommand = createSlashcommandTool() +export const slashcommand: ToolDefinition = createSlashcommandTool()