refactor(tools/background-task): split tools.ts into focused modules under 200 LOC
- Create modules/ directory with 6 focused modules:
- background-task.ts: task creation logic
- background-output.ts: output retrieval logic
- background-cancel.ts: cancellation logic
- formatters.ts: message formatting utilities
- message-processing.ts: message extraction utilities
- utils.ts: shared utility functions
- Reduce tools.ts from ~798 to ~30 lines (barrel pattern)
- Add new types to types.ts for module interfaces
- Update index.ts for clean re-exports
- Follow modular code architecture (200 LOC limit)
🤖 Generated with assistance of OhMyOpenCode
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
import type { BackgroundTask } from "../../../features/background-agent"
|
||||
import type { BackgroundOutputClient } from "../types"
|
||||
import { formatDuration, truncateText, formatMessageTime } from "./utils"
|
||||
import { extractMessages, getErrorMessage, type BackgroundOutputMessagesResult, type FullSessionMessage, extractToolResultText } from "./message-processing"
|
||||
import { consumeNewMessages } from "../../../shared/session-cursor"
|
||||
|
||||
const MAX_MESSAGE_LIMIT = 100
|
||||
const THINKING_MAX_CHARS = 2000
|
||||
|
||||
export function formatTaskStatus(task: BackgroundTask): string {
|
||||
let duration: string
|
||||
if (task.status === "pending" && task.queuedAt) {
|
||||
duration = formatDuration(task.queuedAt, undefined)
|
||||
} else if (task.startedAt) {
|
||||
duration = formatDuration(task.startedAt, task.completedAt)
|
||||
} else {
|
||||
duration = "N/A"
|
||||
}
|
||||
const promptPreview = truncateText(task.prompt, 500)
|
||||
|
||||
let progressSection = ""
|
||||
if (task.progress?.lastTool) {
|
||||
progressSection = `\n| Last tool | ${task.progress.lastTool} |`
|
||||
}
|
||||
|
||||
let lastMessageSection = ""
|
||||
if (task.progress?.lastMessage) {
|
||||
const truncated = truncateText(task.progress.lastMessage, 500)
|
||||
const messageTime = task.progress.lastMessageAt
|
||||
? task.progress.lastMessageAt.toISOString()
|
||||
: "N/A"
|
||||
lastMessageSection = `
|
||||
|
||||
## Last Message (${messageTime})
|
||||
|
||||
\`\`\`
|
||||
${truncated}
|
||||
\`\`\``
|
||||
}
|
||||
|
||||
let statusNote = ""
|
||||
if (task.status === "pending") {
|
||||
statusNote = `
|
||||
|
||||
> **Queued**: Task is waiting for a concurrency slot to become available.`
|
||||
} else if (task.status === "running") {
|
||||
statusNote = `
|
||||
|
||||
> **Note**: No need to wait explicitly - the system will notify you when this task completes.`
|
||||
} else if (task.status === "error") {
|
||||
statusNote = `
|
||||
|
||||
> **Failed**: The task encountered an error. Check the last message for details.`
|
||||
}
|
||||
|
||||
const durationLabel = task.status === "pending" ? "Queued for" : "Duration"
|
||||
|
||||
return `# Task Status
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Task ID | \`${task.id}\` |
|
||||
| Description | ${task.description} |
|
||||
| Agent | ${task.agent} |
|
||||
| Status | **${task.status}** |
|
||||
| ${durationLabel} | ${duration} |
|
||||
| Session ID | \`${task.sessionID}\` |${progressSection}
|
||||
${statusNote}
|
||||
## Original Prompt
|
||||
|
||||
\`\`\`
|
||||
${promptPreview}
|
||||
\`\`\`${lastMessageSection}`
|
||||
}
|
||||
|
||||
export async function formatTaskResult(task: BackgroundTask, client: BackgroundOutputClient): Promise<string> {
|
||||
if (!task.sessionID) {
|
||||
return `Error: Task has no sessionID`
|
||||
}
|
||||
|
||||
const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({
|
||||
path: { id: task.sessionID },
|
||||
})
|
||||
|
||||
const errorMessage = getErrorMessage(messagesResult)
|
||||
if (errorMessage) {
|
||||
return `Error fetching messages: ${errorMessage}`
|
||||
}
|
||||
|
||||
const messages = extractMessages(messagesResult)
|
||||
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return `Task Result
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)}
|
||||
Session ID: ${task.sessionID}
|
||||
|
||||
---
|
||||
|
||||
(No messages found)`
|
||||
}
|
||||
|
||||
// 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 (relevantMessages.length === 0) {
|
||||
return `Task Result
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)}
|
||||
Session ID: ${task.sessionID}
|
||||
|
||||
---
|
||||
|
||||
(No assistant or tool response found)`
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
|
||||
const newMessages = consumeNewMessages(task.sessionID, sortedMessages)
|
||||
if (newMessages.length === 0) {
|
||||
const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt)
|
||||
return `Task Result
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Duration: ${duration}
|
||||
Session ID: ${task.sessionID}
|
||||
|
||||
---
|
||||
|
||||
(No new output since last check)`
|
||||
}
|
||||
|
||||
// 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 newMessages) {
|
||||
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\n")
|
||||
|
||||
const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt)
|
||||
|
||||
return `Task Result
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Duration: ${duration}
|
||||
Session ID: ${task.sessionID}
|
||||
|
||||
---
|
||||
|
||||
${textContent || "(No text output)"}`
|
||||
}
|
||||
|
||||
export async function formatFullSession(
|
||||
task: BackgroundTask,
|
||||
client: BackgroundOutputClient,
|
||||
options: {
|
||||
includeThinking: boolean
|
||||
messageLimit?: number
|
||||
sinceMessageId?: string
|
||||
includeToolResults: boolean
|
||||
thinkingMaxChars?: number
|
||||
}
|
||||
): Promise<string> {
|
||||
if (!task.sessionID) {
|
||||
return formatTaskStatus(task)
|
||||
}
|
||||
|
||||
const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({
|
||||
path: { id: task.sessionID },
|
||||
})
|
||||
|
||||
const errorMessage = getErrorMessage(messagesResult)
|
||||
if (errorMessage) {
|
||||
return `Error fetching messages: ${errorMessage}`
|
||||
}
|
||||
|
||||
const rawMessages = extractMessages(messagesResult)
|
||||
if (!Array.isArray(rawMessages)) {
|
||||
return "Error fetching messages: invalid response"
|
||||
}
|
||||
|
||||
const sortedMessages = [...(rawMessages as FullSessionMessage[])].sort((a, b) => {
|
||||
const timeA = String(a.info?.time ?? "")
|
||||
const timeB = String(b.info?.time ?? "")
|
||||
return timeA.localeCompare(timeB)
|
||||
})
|
||||
|
||||
let filteredMessages = sortedMessages
|
||||
|
||||
if (options.sinceMessageId) {
|
||||
const index = filteredMessages.findIndex((message) => message.id === options.sinceMessageId)
|
||||
if (index === -1) {
|
||||
return `Error: since_message_id not found: ${options.sinceMessageId}`
|
||||
}
|
||||
filteredMessages = filteredMessages.slice(index + 1)
|
||||
}
|
||||
|
||||
const includeThinking = options.includeThinking
|
||||
const includeToolResults = options.includeToolResults
|
||||
const thinkingMaxChars = options.thinkingMaxChars ?? THINKING_MAX_CHARS
|
||||
|
||||
const normalizedMessages: FullSessionMessage[] = []
|
||||
for (const message of filteredMessages) {
|
||||
const parts = (message.parts ?? []).filter((part) => {
|
||||
if (part.type === "thinking" || part.type === "reasoning") {
|
||||
return includeThinking
|
||||
}
|
||||
if (part.type === "tool_result") {
|
||||
return includeToolResults
|
||||
}
|
||||
return part.type === "text"
|
||||
})
|
||||
|
||||
if (parts.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
normalizedMessages.push({ ...message, parts })
|
||||
}
|
||||
|
||||
const limit = typeof options.messageLimit === "number"
|
||||
? Math.min(options.messageLimit, MAX_MESSAGE_LIMIT)
|
||||
: undefined
|
||||
const hasMore = limit !== undefined && normalizedMessages.length > limit
|
||||
const visibleMessages = limit !== undefined
|
||||
? normalizedMessages.slice(0, limit)
|
||||
: normalizedMessages
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push("# Full Session Output")
|
||||
lines.push("")
|
||||
lines.push(`Task ID: ${task.id}`)
|
||||
lines.push(`Description: ${task.description}`)
|
||||
lines.push(`Status: ${task.status}`)
|
||||
lines.push(`Session ID: ${task.sessionID}`)
|
||||
lines.push(`Total messages: ${normalizedMessages.length}`)
|
||||
lines.push(`Returned: ${visibleMessages.length}`)
|
||||
lines.push(`Has more: ${hasMore ? "true" : "false"}`)
|
||||
lines.push("")
|
||||
lines.push("## Messages")
|
||||
|
||||
if (visibleMessages.length === 0) {
|
||||
lines.push("")
|
||||
lines.push("(No messages found)")
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
for (const message of visibleMessages) {
|
||||
const role = message.info?.role ?? "unknown"
|
||||
const agent = message.info?.agent ? ` (${message.info.agent})` : ""
|
||||
const time = formatMessageTime(message.info?.time)
|
||||
const idLabel = message.id ? ` id=${message.id}` : ""
|
||||
lines.push("")
|
||||
lines.push(`[${role}${agent}] ${time}${idLabel}`)
|
||||
|
||||
for (const part of message.parts ?? []) {
|
||||
if (part.type === "text" && part.text) {
|
||||
lines.push(part.text.trim())
|
||||
} else if (part.type === "thinking" && part.thinking) {
|
||||
lines.push(`[thinking] ${truncateText(part.thinking, thinkingMaxChars)}`)
|
||||
} else if (part.type === "reasoning" && part.text) {
|
||||
lines.push(`[thinking] ${truncateText(part.text, thinkingMaxChars)}`)
|
||||
} else if (part.type === "tool_result") {
|
||||
const toolTexts = extractToolResultText(part)
|
||||
for (const toolText of toolTexts) {
|
||||
lines.push(`[tool result] ${toolText}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
Reference in New Issue
Block a user