feat: enrich subtask handoff with structured context summaries

Phase 1 of #12330 - improves context handoff visibility between
parent and child tasks during delegation.

Changes:
- Add SubtaskSummary type to @roo-code/types for structured handoff data
- Create buildSubtaskSummary utility that extracts files modified/read,
  commands executed, tool usage, and todo stats from task history
- Modify AttemptCompletionTool to build structured summary on completion
- Update reopenParentFromDelegation to format enriched API history text
  so the parent LLM gets better context about what the subtask did
- Update ChatRow UI to render structured summaries with mode badge,
  file lists, command lists, and todo progress
- Add i18n translation keys for new UI elements
- Add 19 tests for buildSubtaskSummary and formatSubtaskSummaryForApi
- Backward compatible: plain-text summaries still work as before
This commit is contained in:
Roo Code 2026-05-12 02:46:26 +00:00
parent 22d845cecb
commit 09a5570b58
7 changed files with 659 additions and 8 deletions

View file

@ -29,3 +29,34 @@ export const historyItemSchema = z.object({
})
export type HistoryItem = z.infer<typeof historyItemSchema>
/**
* SubtaskSummary
*
* Structured metadata produced when a subtask completes via attempt_completion
* and hands off context back to its parent task. This enriches the handoff
* with visibility into what the subtask actually did.
*/
export const subtaskSummarySchema = z.object({
/** The completion result text from attempt_completion */
result: z.string(),
/** Mode slug the subtask ran in (e.g. "code", "architect") */
mode: z.string().optional(),
/** Files that were created or modified (write_to_file, apply_diff, insert_content) */
filesModified: z.array(z.string()).optional(),
/** Files that were read during the subtask */
filesRead: z.array(z.string()).optional(),
/** Shell commands that were executed */
commandsExecuted: z.array(z.string()).optional(),
/** Summary of tool usage counts: tool name -> number of attempts */
toolUsageSummary: z.record(z.string(), z.number()).optional(),
/** Todo list status at completion: [completed, total] */
todoStats: z
.object({
completed: z.number(),
total: z.number(),
})
.optional(),
})
export type SubtaskSummary = z.infer<typeof subtaskSummarySchema>

View file

@ -0,0 +1,308 @@
import { buildSubtaskSummary, formatSubtaskSummaryForApi, type SubtaskContext } from "../buildSubtaskSummary"
function createContext(overrides: Partial<SubtaskContext> = {}): SubtaskContext {
return {
apiConversationHistory: [],
toolUsage: {},
todoList: undefined,
taskMode: "code",
...overrides,
}
}
describe("buildSubtaskSummary", () => {
it("should return a minimal summary with just result and mode", () => {
const context = createContext()
const summary = buildSubtaskSummary(context, "Task completed successfully")
expect(summary.result).toBe("Task completed successfully")
expect(summary.mode).toBe("code")
expect(summary.filesModified).toBeUndefined()
expect(summary.filesRead).toBeUndefined()
expect(summary.commandsExecuted).toBeUndefined()
expect(summary.toolUsageSummary).toBeUndefined()
expect(summary.todoStats).toBeUndefined()
})
it("should extract files modified from write_to_file tool_use blocks", () => {
const context = createContext({
apiConversationHistory: [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_1",
name: "write_to_file",
input: { path: "src/index.ts", content: "hello" },
},
],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }],
},
],
})
const summary = buildSubtaskSummary(context, "Done")
expect(summary.filesModified).toEqual(["src/index.ts"])
})
it("should extract files modified from apply_diff tool_use blocks", () => {
const context = createContext({
apiConversationHistory: [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_2",
name: "apply_diff",
input: { path: "src/utils.ts", diff: "--- a\n+++ b" },
},
],
},
],
})
const summary = buildSubtaskSummary(context, "Done")
expect(summary.filesModified).toEqual(["src/utils.ts"])
})
it("should extract files read from read_file tool_use blocks", () => {
const context = createContext({
apiConversationHistory: [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_3",
name: "read_file",
input: { path: "package.json" },
},
],
},
],
})
const summary = buildSubtaskSummary(context, "Done")
expect(summary.filesRead).toEqual(["package.json"])
})
it("should extract commands from execute_command tool_use blocks", () => {
const context = createContext({
apiConversationHistory: [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_4",
name: "execute_command",
input: { command: "npm test" },
},
],
},
],
})
const summary = buildSubtaskSummary(context, "Done")
expect(summary.commandsExecuted).toEqual(["npm test"])
})
it("should truncate very long commands", () => {
const longCmd = "a".repeat(200)
const context = createContext({
apiConversationHistory: [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_5",
name: "execute_command",
input: { command: longCmd },
},
],
},
],
})
const summary = buildSubtaskSummary(context, "Done")
expect(summary.commandsExecuted![0].length).toBeLessThanOrEqual(120)
expect(summary.commandsExecuted![0].endsWith("...")).toBe(true)
})
it("should deduplicate modified files", () => {
const context = createContext({
apiConversationHistory: [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_6",
name: "write_to_file",
input: { path: "src/index.ts", content: "v1" },
},
],
},
{ role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_6", content: "ok" }] },
{
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_7",
name: "apply_diff",
input: { path: "src/index.ts", diff: "diff" },
},
],
},
],
})
const summary = buildSubtaskSummary(context, "Done")
expect(summary.filesModified).toEqual(["src/index.ts"])
})
it("should include tool usage summary from toolUsage", () => {
const context = createContext({
toolUsage: {
write_to_file: { attempts: 3, failures: 0 },
read_file: { attempts: 5, failures: 1 },
} as any,
})
const summary = buildSubtaskSummary(context, "Done")
expect(summary.toolUsageSummary).toEqual({
write_to_file: 3,
read_file: 5,
})
})
it("should include todo stats when todoList is present", () => {
const context = createContext({
todoList: [
{ id: "1", task: "Do A", status: "completed" },
{ id: "2", task: "Do B", status: "completed" },
{ id: "3", task: "Do C", status: "pending" },
] as any,
})
const summary = buildSubtaskSummary(context, "Done")
expect(summary.todoStats).toEqual({ completed: 2, total: 3 })
})
it("should skip user messages when scanning for tool_use blocks", () => {
const context = createContext({
apiConversationHistory: [
{
role: "user",
content: [
{
type: "tool_result" as any,
tool_use_id: "toolu_x",
content: "ok",
},
],
},
],
})
const summary = buildSubtaskSummary(context, "Done")
expect(summary.filesModified).toBeUndefined()
expect(summary.commandsExecuted).toBeUndefined()
})
it("should handle empty conversation history", () => {
const context = createContext({ apiConversationHistory: [] })
const summary = buildSubtaskSummary(context, "Nothing happened")
expect(summary.result).toBe("Nothing happened")
expect(summary.mode).toBe("code")
})
it("should handle messages with non-array content (string content)", () => {
const context = createContext({
apiConversationHistory: [
{
role: "assistant",
content: "Just text response",
},
],
})
const summary = buildSubtaskSummary(context, "Done")
expect(summary.filesModified).toBeUndefined()
})
})
describe("formatSubtaskSummaryForApi", () => {
it("should format a minimal summary", () => {
const text = formatSubtaskSummaryForApi({ result: "All done" })
expect(text).toContain("## Result\nAll done")
})
it("should include mode section", () => {
const text = formatSubtaskSummaryForApi({ result: "Done", mode: "architect" })
expect(text).toContain("## Mode\narchitect")
})
it("should include files modified section", () => {
const text = formatSubtaskSummaryForApi({
result: "Done",
filesModified: ["src/a.ts", "src/b.ts"],
})
expect(text).toContain("## Files Modified")
expect(text).toContain("- src/a.ts")
expect(text).toContain("- src/b.ts")
})
it("should include files read section", () => {
const text = formatSubtaskSummaryForApi({
result: "Done",
filesRead: ["package.json"],
})
expect(text).toContain("## Files Read")
expect(text).toContain("- package.json")
})
it("should include commands section", () => {
const text = formatSubtaskSummaryForApi({
result: "Done",
commandsExecuted: ["npm test", "npm build"],
})
expect(text).toContain("## Commands Executed")
expect(text).toContain("- `npm test`")
expect(text).toContain("- `npm build`")
})
it("should include todo stats", () => {
const text = formatSubtaskSummaryForApi({
result: "Done",
todoStats: { completed: 3, total: 5 },
})
expect(text).toContain("## Todos\n3/5 completed")
})
it("should format a comprehensive summary with all sections", () => {
const text = formatSubtaskSummaryForApi({
result: "Implemented the feature",
mode: "code",
filesModified: ["src/feature.ts"],
filesRead: ["src/config.ts"],
commandsExecuted: ["npm test"],
todoStats: { completed: 2, total: 2 },
})
expect(text).toContain("## Result")
expect(text).toContain("## Mode")
expect(text).toContain("## Files Modified")
expect(text).toContain("## Files Read")
expect(text).toContain("## Commands Executed")
expect(text).toContain("## Todos")
})
})

View file

@ -0,0 +1,189 @@
import type { SubtaskSummary } from "@roo-code/types"
import type { ToolUsage } from "@roo-code/types"
import type { TodoItem } from "@roo-code/types"
import type Anthropic from "@anthropic-ai/sdk"
/**
* File-modifying tool names. When these appear as tool_use blocks in the
* API conversation history, the first positional argument (typically `path`)
* is extracted as a modified file.
*/
const FILE_WRITE_TOOLS = new Set(["write_to_file", "apply_diff", "insert_content"])
/**
* File-reading tool names.
*/
const FILE_READ_TOOLS = new Set(["read_file", "search_files", "list_files", "list_code_definition_names"])
/**
* Extract a file path from a tool_use input object.
* Native tool calls store params as structured objects with a `path` field.
*/
function extractPath(input: Record<string, unknown>): string | undefined {
if (typeof input.path === "string" && input.path.length > 0) {
return input.path
}
return undefined
}
/**
* Extract a command string from a tool_use input for execute_command.
*/
function extractCommand(input: Record<string, unknown>): string | undefined {
if (typeof input.command === "string" && input.command.length > 0) {
const cmd = input.command
return cmd.length > 120 ? cmd.slice(0, 117) + "..." : cmd
}
return undefined
}
/**
* Minimal interface representing the data we need from a Task instance.
* Using an interface avoids importing the full Task class (circular deps).
*/
export interface SubtaskContext {
apiConversationHistory: Anthropic.MessageParam[]
toolUsage: ToolUsage
todoList?: TodoItem[]
taskMode: string
}
/**
* Builds a structured SubtaskSummary from task context.
*
* This scans the task's API conversation history to extract:
* - Files modified (write_to_file, apply_diff, insert_content)
* - Files read (read_file, search_files, etc.)
* - Commands executed (execute_command)
* - Tool usage summary (from toolUsage)
* - Todo completion stats (from todoList)
*
* The result text comes from attempt_completion and is passed in separately.
*/
export function buildSubtaskSummary(context: SubtaskContext, completionResult: string): SubtaskSummary {
const filesModified = new Set<string>()
const filesRead = new Set<string>()
const commandsExecuted: string[] = []
// Scan API conversation history for tool_use blocks
for (const message of context.apiConversationHistory) {
if (message.role !== "assistant" || !Array.isArray(message.content)) {
continue
}
for (const block of message.content as Anthropic.ContentBlockParam[]) {
if (block.type !== "tool_use") {
continue
}
const toolBlock = block as Anthropic.ToolUseBlockParam
const toolName = toolBlock.name
const input = (toolBlock.input ?? {}) as Record<string, unknown>
if (FILE_WRITE_TOOLS.has(toolName)) {
const path = extractPath(input)
if (path) {
filesModified.add(path)
}
} else if (FILE_READ_TOOLS.has(toolName)) {
const path = extractPath(input)
if (path) {
filesRead.add(path)
}
} else if (toolName === "execute_command") {
const cmd = extractCommand(input)
if (cmd) {
commandsExecuted.push(cmd)
}
}
}
}
// Build tool usage summary from toolUsage
const toolUsageSummary: Record<string, number> = {}
if (context.toolUsage) {
for (const [toolName, usage] of Object.entries(context.toolUsage)) {
const u = usage as { attempts: number; failures: number } | undefined
if (u && u.attempts > 0) {
toolUsageSummary[toolName] = u.attempts
}
}
}
// Build todo stats
let todoStats: SubtaskSummary["todoStats"]
if (context.todoList && context.todoList.length > 0) {
const completed = context.todoList.filter((t: TodoItem) => t.status === "completed").length
todoStats = { completed, total: context.todoList.length }
}
const summary: SubtaskSummary = {
result: completionResult,
mode: context.taskMode,
}
if (filesModified.size > 0) {
summary.filesModified = Array.from(filesModified)
}
if (filesRead.size > 0) {
summary.filesRead = Array.from(filesRead)
}
if (commandsExecuted.length > 0) {
summary.commandsExecuted = commandsExecuted
}
if (Object.keys(toolUsageSummary).length > 0) {
summary.toolUsageSummary = toolUsageSummary
}
if (todoStats) {
summary.todoStats = todoStats
}
return summary
}
/**
* Formats a SubtaskSummary into a human-readable string suitable for
* injection into the parent's API history (tool_result content).
* This enriched format gives the parent LLM much better context about
* what the subtask accomplished.
*/
export function formatSubtaskSummaryForApi(summary: SubtaskSummary): string {
const sections: string[] = []
// Result section (always present)
sections.push(`## Result\n${summary.result}`)
// Mode
if (summary.mode) {
sections.push(`## Mode\n${summary.mode}`)
}
// Files modified
if (summary.filesModified && summary.filesModified.length > 0) {
const fileList = summary.filesModified.map((f: string) => `- ${f}`).join("\n")
sections.push(`## Files Modified\n${fileList}`)
}
// Files read
if (summary.filesRead && summary.filesRead.length > 0) {
const fileList = summary.filesRead.map((f: string) => `- ${f}`).join("\n")
sections.push(`## Files Read\n${fileList}`)
}
// Commands executed
if (summary.commandsExecuted && summary.commandsExecuted.length > 0) {
const cmdList = summary.commandsExecuted.map((c: string) => `- \`${c}\``).join("\n")
sections.push(`## Commands Executed\n${cmdList}`)
}
// Todo stats
if (summary.todoStats) {
sections.push(`## Todos\n${summary.todoStats.completed}/${summary.todoStats.total} completed`)
}
return sections.join("\n\n")
}

View file

@ -7,6 +7,7 @@ import { formatResponse } from "../prompts/responses"
import { Package } from "../../shared/package"
import type { ToolUse } from "../../shared/tools"
import { t } from "../../i18n"
import { buildSubtaskSummary } from "../task/buildSubtaskSummary"
import { BaseTool, ToolCallbacks } from "./BaseTool"
@ -168,10 +169,31 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
pushToolResult("")
// Build a structured summary of what this subtask accomplished.
// This enriches the handoff with files changed, tools used, etc.
// Wrapped in try/catch: if summary building fails (e.g. mode not initialized),
// we fall back to the plain result string for backward compatibility.
let completionResultSummary: string
try {
const summary = buildSubtaskSummary(
{
apiConversationHistory: task.apiConversationHistory,
toolUsage: task.toolUsage,
todoList: task.todoList ?? undefined,
taskMode: task.taskMode,
},
result,
)
completionResultSummary = JSON.stringify(summary)
} catch {
// Fallback: use plain result text if structured summary cannot be built
completionResultSummary = result
}
await provider.reopenParentFromDelegation({
parentTaskId: task.parentTaskId!,
childTaskId: task.taskId,
completionResultSummary: result,
completionResultSummary,
})
return "delegated"

View file

@ -92,6 +92,8 @@ import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { REQUESTY_BASE_URL } from "../../shared/utils/requesty"
import { validateAndFixToolResultIds } from "../task/validateToolResultIds"
import { formatSubtaskSummaryForApi } from "../task/buildSubtaskSummary"
import type { SubtaskSummary } from "@roo-code/types"
/**
* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@ -3183,6 +3185,21 @@ export class ClineProvider
if (!Array.isArray(parentClineMessages)) parentClineMessages = []
if (!Array.isArray(parentApiMessages)) parentApiMessages = []
// Try to parse completionResultSummary as a structured SubtaskSummary (JSON).
// If it's not valid JSON, treat it as a plain-text result for backward compatibility.
let parsedSummary: SubtaskSummary | undefined
let apiResultText: string
try {
parsedSummary = JSON.parse(completionResultSummary) as SubtaskSummary
// Use the enriched format for API history so the parent LLM gets structured context
apiResultText = `Subtask ${childTaskId} completed.\n\n${formatSubtaskSummaryForApi(parsedSummary)}`
} catch {
// Not JSON - plain text result (backward compatible path)
apiResultText = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`
}
// For the UI message, pass the raw completionResultSummary (JSON or plain text).
// The webview ChatRow component will detect JSON and render structured data.
const subtaskUiMessage: ClineMessage = {
type: "say",
say: "subtask_result",
@ -3218,8 +3235,8 @@ export class ClineProvider
if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) {
for (const block of lastMsg.content) {
if (block.type === "tool_result" && block.tool_use_id === toolUseId) {
// Update the existing tool_result content
block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`
// Update the existing tool_result content with enriched summary
block.content = apiResultText
alreadyHasToolResult = true
break
}
@ -3234,7 +3251,7 @@ export class ClineProvider
{
type: "tool_result" as const,
tool_use_id: toolUseId,
content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`,
content: apiResultText,
},
],
ts,
@ -3257,7 +3274,7 @@ export class ClineProvider
content: [
{
type: "text" as const,
text: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`,
text: apiResultText,
},
],
ts,

View file

@ -1020,16 +1020,95 @@ export const ChatRowContent = ({
showCopyButton={true}
/>
)
case "subtask_result":
case "subtask_result": {
// Get the child task ID that produced this result
const completedChildTaskId = currentTaskItem?.completedByChildId
// Try to parse structured summary (JSON). Falls back to plain text.
let structuredSummary: {
result?: string
mode?: string
filesModified?: string[]
filesRead?: string[]
commandsExecuted?: string[]
toolUsageSummary?: Record<string, number>
todoStats?: { completed: number; total: number }
} | null = null
try {
if (message.text?.startsWith("{")) {
structuredSummary = JSON.parse(message.text)
}
} catch {
// Not JSON, use plain text rendering
}
return (
<div className="border-l border-muted-foreground/80 ml-2 pl-4 pt-2 pb-1 -mt-5">
<div style={headerStyle}>
<span style={{ fontWeight: "bold" }}>{t("chat:subtasks.resultContent")}</span>
<Check className="size-3" />
</div>
<MarkdownBlock markdown={message.text} />
{structuredSummary ? (
<div className="text-sm">
{structuredSummary.mode && (
<div className="mb-2">
<span className="inline-block text-xs px-1.5 py-0.5 rounded border border-vscode-dropdown-border/50 text-vscode-descriptionForeground">
{structuredSummary.mode}
</span>
</div>
)}
{structuredSummary.result && <MarkdownBlock markdown={structuredSummary.result} />}
{structuredSummary.filesModified && structuredSummary.filesModified.length > 0 && (
<div className="mt-2">
<div className="text-xs font-semibold text-vscode-descriptionForeground mb-1">
{t("chat:subtasks.filesModified")}
</div>
<ul className="list-none m-0 p-0">
{structuredSummary.filesModified.map((f: string, i: number) => (
<li
key={i}
className="text-xs text-vscode-descriptionForeground pl-2">
{f}
</li>
))}
</ul>
</div>
)}
{structuredSummary.commandsExecuted &&
structuredSummary.commandsExecuted.length > 0 && (
<div className="mt-2">
<div className="text-xs font-semibold text-vscode-descriptionForeground mb-1">
{t("chat:subtasks.commandsExecuted")}
</div>
<ul className="list-none m-0 p-0">
{structuredSummary.commandsExecuted.map((c: string, i: number) => (
<li
key={i}
className="text-xs text-vscode-descriptionForeground pl-2 font-mono">
{c}
</li>
))}
</ul>
</div>
)}
{structuredSummary.todoStats && (
<div className="mt-2 text-xs text-vscode-descriptionForeground">
{t("chat:subtasks.todoStats", {
completed: structuredSummary.todoStats.completed,
total: structuredSummary.todoStats.total,
})}
</div>
)}
</div>
) : (
<MarkdownBlock markdown={message.text} />
)}
{completedChildTaskId && (
<button
className="cursor-pointer flex gap-1 items-center mt-2 text-vscode-descriptionForeground hover:text-vscode-descriptionForeground hover:underline font-normal"
@ -1042,6 +1121,7 @@ export const ChatRowContent = ({
)}
</div>
)
}
case "reasoning":
return (
<ReasoningBlock

View file

@ -309,7 +309,11 @@
"resultContent": "Subtask completed",
"defaultResult": "Please continue to the next task.",
"completionInstructions": "You can review the results and suggest any corrections or next steps. If everything looks good, confirm to return the result to the parent task.",
"goToSubtask": "View task"
"goToSubtask": "View task",
"filesModified": "Files Modified",
"filesRead": "Files Read",
"commandsExecuted": "Commands Executed",
"todoStats": "Todos: {{completed}}/{{total}} completed"
},
"questions": {
"hasQuestion": "Roo has a question"