feat: structured context handoff between parent and child tasks (Phase 3c)

This commit is contained in:
Roo Code 2026-05-12 14:38:25 +00:00
parent fe976ec29a
commit edebbae2cc
13 changed files with 618 additions and 2 deletions

View file

@ -0,0 +1,51 @@
import { contextHandoffSummarySchema } from "../context-handoff.js"
describe("ContextHandoffSummary schema", () => {
it("validates a complete summary", () => {
const summary = {
mode: "code",
filesModified: ["src/app.ts", "src/utils.ts"],
filesRead: ["src/config.ts"],
commandsExecuted: ["npm test"],
toolUsageCounts: { write_to_file: 2, read_file: 1 },
apiRequestCount: 5,
result: "Task completed successfully",
}
const result = contextHandoffSummarySchema.safeParse(summary)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.filesModified).toEqual(["src/app.ts", "src/utils.ts"])
expect(result.data.mode).toBe("code")
}
})
it("accepts minimal summary with only result", () => {
const summary = { result: "Done" }
const result = contextHandoffSummarySchema.safeParse(summary)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.filesModified).toEqual([])
expect(result.data.filesRead).toEqual([])
expect(result.data.commandsExecuted).toEqual([])
expect(result.data.toolUsageCounts).toEqual({})
expect(result.data.apiRequestCount).toBe(0)
}
})
it("rejects summary without result", () => {
const summary = { mode: "code", filesModified: [] }
const result = contextHandoffSummarySchema.safeParse(summary)
expect(result.success).toBe(false)
})
it("applies defaults for optional array fields", () => {
const summary = { result: "Done", mode: "debug" }
const result = contextHandoffSummarySchema.safeParse(summary)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.mode).toBe("debug")
expect(result.data.filesModified).toEqual([])
expect(result.data.commandsExecuted).toEqual([])
}
})
})

View file

@ -0,0 +1,25 @@
import { DEFAULT_MODES } from "../mode.js"
describe("Orchestrator context handoff prompt", () => {
const orchestratorMode = DEFAULT_MODES.find((m: { slug: string }) => m.slug === "orchestrator")
it("should have an orchestrator mode", () => {
expect(orchestratorMode).toBeDefined()
})
it("should include context handoff guidance in customInstructions", () => {
expect(orchestratorMode!.customInstructions).toContain("structured context handoff summary")
})
it("should mention files modified in context handoff guidance", () => {
expect(orchestratorMode!.customInstructions).toContain("files modified")
})
it("should mention passing context to subsequent subtasks", () => {
expect(orchestratorMode!.customInstructions).toContain("subsequent subtasks")
})
it("should mention identifying potential conflicts", () => {
expect(orchestratorMode!.customInstructions).toContain("potential conflicts")
})
})

View file

@ -0,0 +1,31 @@
import { z } from "zod"
/**
* ContextHandoffSummary
*
* Structured summary of what a subtask accomplished during execution.
* Automatically collected when a subtask completes via attempt_completion
* and passed back to the parent task alongside the freeform result string.
*
* This gives the parent (typically the Orchestrator) structured visibility
* into the child's work without requiring the child to manually enumerate
* every file it touched or command it ran.
*/
export const contextHandoffSummarySchema = z.object({
/** Mode the subtask ran in (e.g., "code", "debug", "architect") */
mode: z.string().optional(),
/** Files that were created or modified by the subtask */
filesModified: z.array(z.string()).default([]),
/** Files that were read (but not modified) by the subtask */
filesRead: z.array(z.string()).default([]),
/** Shell commands that were executed by the subtask */
commandsExecuted: z.array(z.string()).default([]),
/** Count of each tool type used (e.g., { write_to_file: 3, read_file: 5 }) */
toolUsageCounts: z.record(z.string(), z.number()).default({}),
/** Total number of API requests made during the subtask */
apiRequestCount: z.number().default(0),
/** The freeform completion result from attempt_completion */
result: z.string(),
})
export type ContextHandoffSummary = z.infer<typeof contextHandoffSummarySchema>

View file

@ -1,5 +1,6 @@
import { z } from "zod"
import { contextHandoffSummarySchema } from "./context-handoff.js"
import { taskPermissionsSchema } from "./task-permissions.js"
/**
@ -54,6 +55,7 @@ export const historyItemSchema = z.object({
subtaskQueue: z.array(subtaskQueueItemSchema).optional(), // Remaining subtasks to execute
subtaskQueueIndex: z.number().optional(), // Current position in the original queue (0-based)
subtaskResults: z.array(subtaskResultSchema).optional(), // Results from completed queue subtasks
contextHandoffSummary: contextHandoffSummarySchema.optional(), // Structured context from completed child
taskPermissions: taskPermissionsSchema.optional(), // Permission boundaries set by parent task
})

View file

@ -11,6 +11,7 @@ export * from "./experiment.js"
export * from "./followup.js"
export * from "./git.js"
export * from "./global-settings.js"
export * from "./context-handoff.js"
export * from "./history.js"
export * from "./image-generation.js"
export * from "./ipc.js"

View file

@ -222,6 +222,6 @@ export const DEFAULT_MODES: readonly ModeConfig[] = [
description: "Coordinate tasks across multiple modes",
groups: [],
customInstructions:
'Your role is to coordinate complex workflows by delegating tasks to specialized modes. As an orchestrator, you should:\n\n1. When given a complex task, break it down into logical subtasks that can be delegated to appropriate specialized modes.\n\n2. For each subtask, use the `new_task` tool to delegate. Choose the most appropriate mode for the subtask\'s specific goal and provide comprehensive instructions in the `message` parameter. These instructions must include:\n * All necessary context from the parent task or previous subtasks required to complete the work.\n * A clearly defined scope, specifying exactly what the subtask should accomplish.\n * An explicit statement that the subtask should *only* perform the work outlined in these instructions and not deviate.\n * An instruction for the subtask to signal completion by using the `attempt_completion` tool, providing a concise yet thorough summary of the outcome in the `result` parameter, keeping in mind that this summary will be the source of truth used to keep track of what was completed on this project.\n * A statement that these specific instructions supersede any conflicting general instructions the subtask\'s mode might have.\n\n3. Track and manage the progress of all subtasks. When a subtask is completed, analyze its results and determine the next steps.\n\n4. Help the user understand how the different subtasks fit together in the overall workflow. Provide clear reasoning about why you\'re delegating specific tasks to specific modes.\n\n5. When all subtasks are completed, synthesize the results and provide a comprehensive overview of what was accomplished.\n\n6. Ask clarifying questions when necessary to better understand how to break down complex tasks effectively.\n\n7. Suggest improvements to the workflow based on the results of completed subtasks.\n\n8. When delegating subtasks, consider using the optional `permissions` parameter on `new_task` to restrict what the subtask can do. This is especially useful when:\n * The subtask should only modify files in a specific directory (use `filePatterns`, e.g. `["src/components/.*"]`).\n * The subtask should only run certain commands (use `commandPatterns`, e.g. `["npm test.*", "npm run lint"]`).\n * The subtask should be limited to specific tools (use `allowedTools`, e.g. `["read_file", "search_files"]` for read-only research tasks).\n * Certain tools should be explicitly blocked (use `deniedTools`, e.g. `["execute_command"]` to prevent shell access).\n Permissions are enforced at runtime and follow most-restrictive-wins semantics when subtasks are nested. Use them to keep subtasks focused and safe.\n\nUse subtasks to maintain clarity. If a request significantly shifts focus or requires a different expertise (mode), consider creating a subtask rather than overloading the current one.',
'Your role is to coordinate complex workflows by delegating tasks to specialized modes. As an orchestrator, you should:\n\n1. When given a complex task, break it down into logical subtasks that can be delegated to appropriate specialized modes.\n\n2. For each subtask, use the `new_task` tool to delegate. Choose the most appropriate mode for the subtask\'s specific goal and provide comprehensive instructions in the `message` parameter. These instructions must include:\n * All necessary context from the parent task or previous subtasks required to complete the work.\n * A clearly defined scope, specifying exactly what the subtask should accomplish.\n * An explicit statement that the subtask should *only* perform the work outlined in these instructions and not deviate.\n * An instruction for the subtask to signal completion by using the `attempt_completion` tool, providing a concise yet thorough summary of the outcome in the `result` parameter, keeping in mind that this summary will be the source of truth used to keep track of what was completed on this project.\n * A statement that these specific instructions supersede any conflicting general instructions the subtask\'s mode might have.\n\n3. Track and manage the progress of all subtasks. When a subtask is completed, analyze its results and determine the next steps.\n\n4. Help the user understand how the different subtasks fit together in the overall workflow. Provide clear reasoning about why you\'re delegating specific tasks to specific modes.\n\n5. When all subtasks are completed, synthesize the results and provide a comprehensive overview of what was accomplished.\n\n6. Ask clarifying questions when necessary to better understand how to break down complex tasks effectively.\n\n7. Suggest improvements to the workflow based on the results of completed subtasks.\n\n8. When delegating subtasks, consider using the optional `permissions` parameter on `new_task` to restrict what the subtask can do. This is especially useful when:\n * The subtask should only modify files in a specific directory (use `filePatterns`, e.g. `["src/components/.*"]`).\n * The subtask should only run certain commands (use `commandPatterns`, e.g. `["npm test.*", "npm run lint"]`).\n * The subtask should be limited to specific tools (use `allowedTools`, e.g. `["read_file", "search_files"]` for read-only research tasks).\n * Certain tools should be explicitly blocked (use `deniedTools`, e.g. `["execute_command"]` to prevent shell access).\n Permissions are enforced at runtime and follow most-restrictive-wins semantics when subtasks are nested. Use them to keep subtasks focused and safe.\n\nUse subtasks to maintain clarity. If a request significantly shifts focus or requires a different expertise (mode), consider creating a subtask rather than overloading the current one.\\n\\n9. When a subtask completes, you will receive a structured context handoff summary alongside the completion result. This summary includes the files modified, files read, commands executed, and tool usage counts from the subtask. Use this structured data to:\\n * Verify the subtask accomplished what was requested by checking the files modified list.\\n * Pass relevant context to subsequent subtasks (e.g., "The previous subtask modified `src/components/Button.tsx` and `src/styles/button.css`").\\n * Identify potential conflicts when multiple subtasks touch the same files.\\n * Provide accurate progress summaries to the user.',
},
] as const

View file

@ -73,6 +73,7 @@ describe("History resume delegation - parent metadata transitions", () => {
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
log: vi.fn(),
getTaskWithId,
emit: providerEmit,
getCurrentTask: vi.fn(() => ({ taskId: "child-1" })),
@ -122,6 +123,7 @@ describe("History resume delegation - parent metadata transitions", () => {
it("reopenParentFromDelegation injects subtask_result into both UI and API histories", async () => {
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/storage" } },
log: vi.fn(),
getTaskWithId: vi.fn().mockResolvedValue({
historyItem: {
id: "p1",
@ -205,6 +207,7 @@ describe("History resume delegation - parent metadata transitions", () => {
it("reopenParentFromDelegation injects tool_result when new_task tool_use exists in API history", async () => {
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/storage" } },
log: vi.fn(),
getTaskWithId: vi.fn().mockResolvedValue({
historyItem: {
id: "p-tool",
@ -291,6 +294,7 @@ describe("History resume delegation - parent metadata transitions", () => {
it("reopenParentFromDelegation injects plain text when no new_task tool_use exists in API history", async () => {
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/storage" } },
log: vi.fn(),
getTaskWithId: vi.fn().mockResolvedValue({
historyItem: {
id: "p-no-tool",
@ -351,6 +355,7 @@ describe("History resume delegation - parent metadata transitions", () => {
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
log: vi.fn(),
getTaskWithId: vi.fn().mockResolvedValue({
historyItem: {
id: "parent-2",
@ -391,6 +396,7 @@ describe("History resume delegation - parent metadata transitions", () => {
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
log: vi.fn(),
getTaskWithId: vi.fn().mockResolvedValue({
historyItem: {
id: "p3",
@ -457,6 +463,7 @@ describe("History resume delegation - parent metadata transitions", () => {
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
log: vi.fn(),
getTaskWithId: vi.fn().mockImplementation(async (id: string) => {
if (id === "parent-rpd06") {
return {
@ -527,6 +534,7 @@ describe("History resume delegation - parent metadata transitions", () => {
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
log: vi.fn(),
getTaskWithId: vi.fn().mockResolvedValue({
historyItem: {
id: "p4",
@ -580,6 +588,7 @@ describe("History resume delegation - parent metadata transitions", () => {
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
log: vi.fn(),
getTaskWithId: vi.fn().mockImplementation(async (id: string) => {
if (id === "parent-rpd02") {
return {
@ -726,6 +735,7 @@ describe("History resume delegation - parent metadata transitions", () => {
it("handles empty history gracefully when injecting synthetic messages", async () => {
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
log: vi.fn(),
getTaskWithId: vi.fn().mockResolvedValue({
historyItem: {
id: "p5",

View file

@ -138,6 +138,7 @@ describe("Nested delegation resume (A → B → C)", () => {
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
log: vi.fn(),
getTaskWithId,
emit: emitSpy,
getCurrentTask: vi.fn(() => (currentActiveId ? ({ taskId: currentActiveId } as any) : undefined)),

View file

@ -0,0 +1,198 @@
import type { ClineMessage } from "@roo-code/types"
import { collectContextSummary, formatContextSummaryForParent } from "../collectContextSummary"
describe("collectContextSummary", () => {
it("extracts files modified from tool messages", () => {
const messages: ClineMessage[] = [
{
ts: 1,
type: "ask",
ask: "tool",
text: JSON.stringify({ tool: "editedExistingFile", path: "src/app.ts" }),
},
{
ts: 2,
type: "ask",
ask: "tool",
text: JSON.stringify({ tool: "newFileCreated", path: "src/utils.ts" }),
},
]
const summary = collectContextSummary(messages, "code", "Done")
expect(summary.filesModified).toEqual(["src/app.ts", "src/utils.ts"])
expect(summary.mode).toBe("code")
expect(summary.result).toBe("Done")
})
it("extracts files read from tool messages", () => {
const messages: ClineMessage[] = [
{ ts: 1, type: "ask", ask: "tool", text: JSON.stringify({ tool: "readFile", path: "src/config.ts" }) },
]
const summary = collectContextSummary(messages, "code", "Done")
expect(summary.filesRead).toEqual(["src/config.ts"])
})
it("removes files from filesRead if they were also modified", () => {
const messages: ClineMessage[] = [
{ ts: 1, type: "ask", ask: "tool", text: JSON.stringify({ tool: "readFile", path: "src/app.ts" }) },
{
ts: 2,
type: "ask",
ask: "tool",
text: JSON.stringify({ tool: "editedExistingFile", path: "src/app.ts" }),
},
]
const summary = collectContextSummary(messages, "code", "Done")
expect(summary.filesModified).toEqual(["src/app.ts"])
expect(summary.filesRead).toEqual([])
})
it("extracts executed commands", () => {
const messages: ClineMessage[] = [
{ ts: 1, type: "ask", ask: "command", text: "npm test" },
{ ts: 2, type: "ask", ask: "command", text: "npm run build" },
]
const summary = collectContextSummary(messages, "code", "Done")
expect(summary.commandsExecuted).toEqual(["npm test", "npm run build"])
expect(summary.toolUsageCounts["execute_command"]).toBe(2)
})
it("counts API requests", () => {
const messages: ClineMessage[] = [
{ ts: 1, type: "say", say: "api_req_started" },
{ ts: 2, type: "say", say: "api_req_started" },
{ ts: 3, type: "say", say: "api_req_started" },
]
const summary = collectContextSummary(messages, "debug", "Fixed it")
expect(summary.apiRequestCount).toBe(3)
})
it("counts tool usage correctly", () => {
const messages: ClineMessage[] = [
{ ts: 1, type: "ask", ask: "tool", text: JSON.stringify({ tool: "readFile", path: "a.ts" }) },
{ ts: 2, type: "ask", ask: "tool", text: JSON.stringify({ tool: "readFile", path: "b.ts" }) },
{
ts: 3,
type: "ask",
ask: "tool",
text: JSON.stringify({ tool: "editedExistingFile", path: "c.ts" }),
},
{ ts: 4, type: "ask", ask: "tool", text: JSON.stringify({ tool: "searchFiles" }) },
]
const summary = collectContextSummary(messages, "code", "Done")
expect(summary.toolUsageCounts["read_file"]).toBe(2)
expect(summary.toolUsageCounts["write_to_file"]).toBe(1)
expect(summary.toolUsageCounts["search_files"]).toBe(1)
})
it("deduplicates modified files", () => {
const messages: ClineMessage[] = [
{
ts: 1,
type: "ask",
ask: "tool",
text: JSON.stringify({ tool: "editedExistingFile", path: "src/app.ts" }),
},
{
ts: 2,
type: "ask",
ask: "tool",
text: JSON.stringify({ tool: "editedExistingFile", path: "src/app.ts" }),
},
]
const summary = collectContextSummary(messages, "code", "Done")
expect(summary.filesModified).toEqual(["src/app.ts"])
})
it("handles empty messages array", () => {
const summary = collectContextSummary([], "code", "Nothing done")
expect(summary.filesModified).toEqual([])
expect(summary.filesRead).toEqual([])
expect(summary.commandsExecuted).toEqual([])
expect(summary.apiRequestCount).toBe(0)
expect(summary.result).toBe("Nothing done")
})
it("handles malformed tool JSON gracefully", () => {
const messages: ClineMessage[] = [
{ ts: 1, type: "ask", ask: "tool", text: "not valid json" },
{ ts: 2, type: "ask", ask: "tool", text: undefined },
]
// Should not throw
const summary = collectContextSummary(messages, "code", "Done")
expect(summary.filesModified).toEqual([])
})
it("sorts files alphabetically", () => {
const messages: ClineMessage[] = [
{
ts: 1,
type: "ask",
ask: "tool",
text: JSON.stringify({ tool: "editedExistingFile", path: "z-file.ts" }),
},
{
ts: 2,
type: "ask",
ask: "tool",
text: JSON.stringify({ tool: "editedExistingFile", path: "a-file.ts" }),
},
]
const summary = collectContextSummary(messages, "code", "Done")
expect(summary.filesModified).toEqual(["a-file.ts", "z-file.ts"])
})
})
describe("formatContextSummaryForParent", () => {
it("formats a complete summary into readable text", () => {
const summary = {
mode: "code",
filesModified: ["src/app.ts"],
filesRead: ["src/config.ts"],
commandsExecuted: ["npm test"],
toolUsageCounts: { write_to_file: 1, read_file: 1 },
apiRequestCount: 3,
result: "Task completed",
}
const formatted = formatContextSummaryForParent(summary)
expect(formatted).toContain("Result:\nTask completed")
expect(formatted).toContain("Mode: code")
expect(formatted).toContain("Files Modified:")
expect(formatted).toContain("src/app.ts")
expect(formatted).toContain("Files Read:")
expect(formatted).toContain("src/config.ts")
expect(formatted).toContain("Commands Executed:")
expect(formatted).toContain("npm test")
expect(formatted).toContain("Tool Usage:")
expect(formatted).toContain("API Requests: 3")
})
it("omits empty sections", () => {
const summary = {
mode: undefined,
filesModified: [],
filesRead: [],
commandsExecuted: [],
toolUsageCounts: {},
apiRequestCount: 0,
result: "Done",
}
const formatted = formatContextSummaryForParent(summary)
expect(formatted).toContain("Result:\nDone")
expect(formatted).not.toContain("Files Modified:")
expect(formatted).not.toContain("Files Read:")
expect(formatted).not.toContain("Commands Executed:")
expect(formatted).not.toContain("Tool Usage:")
expect(formatted).toContain("API Requests: 0")
})
})

View file

@ -0,0 +1,169 @@
import type { ClineMessage, ContextHandoffSummary } from "@roo-code/types"
import type { ClineSayTool } from "@roo-code/types"
/**
* Tool types that indicate file modifications.
*/
const FILE_MODIFY_TOOLS: ClineSayTool["tool"][] = ["editedExistingFile", "appliedDiff", "newFileCreated"]
/**
* Tool types that indicate file reads.
*/
const FILE_READ_TOOLS: ClineSayTool["tool"][] = ["readFile"]
/**
* Maps ClineSayTool tool names to canonical tool names used in toolUsageCounts.
*/
const TOOL_NAME_MAP: Record<ClineSayTool["tool"], string> = {
editedExistingFile: "write_to_file",
appliedDiff: "apply_diff",
newFileCreated: "write_to_file",
codebaseSearch: "codebase_search",
readFile: "read_file",
readCommandOutput: "read_command_output",
listFilesTopLevel: "list_files",
listFilesRecursive: "list_files",
searchFiles: "search_files",
switchMode: "switch_mode",
newTask: "new_task",
finishTask: "attempt_completion",
generateImage: "generate_image",
imageGenerated: "generate_image",
runSlashCommand: "slash_command",
updateTodoList: "update_todo_list",
skill: "skill",
}
/**
* Safely parses a JSON string from a ClineMessage's text field.
* Returns undefined if parsing fails.
*/
function safeParseToolJson(text: string | undefined): ClineSayTool | undefined {
if (!text) return undefined
try {
return JSON.parse(text) as ClineSayTool
} catch {
return undefined
}
}
/**
* Collects a structured context summary from a task's clineMessages.
*
* Scans the message history to extract:
* - Files that were modified (write_to_file, apply_diff, new file creation)
* - Files that were read
* - Shell commands that were executed
* - Tool usage counts
* - API request count
*
* @param messages - The task's clineMessages array
* @param mode - The mode the task ran in
* @param result - The freeform completion result from attempt_completion
* @returns A ContextHandoffSummary with deduplicated, sorted data
*/
export function collectContextSummary(
messages: ClineMessage[],
mode: string | undefined,
result: string,
): ContextHandoffSummary {
const filesModified = new Set<string>()
const filesRead = new Set<string>()
const commandsExecuted: string[] = []
const toolUsageCounts: Record<string, number> = {}
let apiRequestCount = 0
for (const msg of messages) {
// Count API requests
if (msg.say === "api_req_started") {
apiRequestCount++
continue
}
// Extract tool usage from "tool" ask/say messages
if (msg.ask === "tool" || msg.say === "tool") {
const toolData = safeParseToolJson(msg.text)
if (!toolData) continue
// Map to canonical tool name and count
const canonicalName = TOOL_NAME_MAP[toolData.tool]
if (canonicalName) {
toolUsageCounts[canonicalName] = (toolUsageCounts[canonicalName] || 0) + 1
}
// Track file modifications
if (FILE_MODIFY_TOOLS.includes(toolData.tool) && toolData.path) {
filesModified.add(toolData.path)
}
// Track file reads (only if not also modified)
if (FILE_READ_TOOLS.includes(toolData.tool) && toolData.path) {
filesRead.add(toolData.path)
}
// Track commands from "command" ask messages
continue
}
// Extract executed commands
if (msg.ask === "command" && msg.text) {
commandsExecuted.push(msg.text)
toolUsageCounts["execute_command"] = (toolUsageCounts["execute_command"] || 0) + 1
}
}
// Remove files from filesRead if they were also modified
for (const file of filesModified) {
filesRead.delete(file)
}
return {
mode,
filesModified: Array.from(filesModified).sort(),
filesRead: Array.from(filesRead).sort(),
commandsExecuted,
toolUsageCounts,
apiRequestCount,
result,
}
}
/**
* Formats a ContextHandoffSummary into a human-readable string
* suitable for injection into the parent's API conversation history.
*
* @param summary - The structured context summary
* @returns A formatted string with sections for each data category
*/
export function formatContextSummaryForParent(summary: ContextHandoffSummary): string {
const sections: string[] = []
sections.push(`Result:\n${summary.result}`)
if (summary.mode) {
sections.push(`Mode: ${summary.mode}`)
}
if (summary.filesModified.length > 0) {
sections.push(`Files Modified:\n${summary.filesModified.map((f) => ` - ${f}`).join("\n")}`)
}
if (summary.filesRead.length > 0) {
sections.push(`Files Read:\n${summary.filesRead.map((f) => ` - ${f}`).join("\n")}`)
}
if (summary.commandsExecuted.length > 0) {
sections.push(`Commands Executed:\n${summary.commandsExecuted.map((c) => ` - ${c}`).join("\n")}`)
}
if (Object.keys(summary.toolUsageCounts).length > 0) {
const toolLines = Object.entries(summary.toolUsageCounts)
.sort(([, a], [, b]) => b - a)
.map(([tool, count]) => ` - ${tool}: ${count}`)
sections.push(`Tool Usage:\n${toolLines.join("\n")}`)
}
sections.push(`API Requests: ${summary.apiRequestCount}`)
return sections.join("\n\n")
}

View file

@ -86,7 +86,8 @@ import { Task } from "../task/Task"
import { buildTaskContext } from "../task/TaskContextBuilder"
import { webviewMessageHandler } from "./webviewMessageHandler"
import type { ClineMessage, TodoItem, SubtaskQueueItem, TaskPermissions } from "@roo-code/types"
import type { ClineMessage, TodoItem, SubtaskQueueItem, TaskPermissions, ContextHandoffSummary } from "@roo-code/types"
import { collectContextSummary, formatContextSummaryForParent } from "../context-handoff/collectContextSummary"
import { readApiMessages, saveApiMessages, saveTaskMessages, TaskHistoryStore } from "../task-persistence"
import { readTaskMessages } from "../task-persistence/taskMessages"
import { getNonce } from "./getNonce"
@ -3213,6 +3214,42 @@ export class ClineProvider
parentApiMessages = []
}
// 1b) Collect structured context from the child's clineMessages
let contextSummary: ContextHandoffSummary | undefined
let formattedSummary = completionResultSummary
try {
let childClineMessages: ClineMessage[] = []
// Prefer in-memory messages from the current task if it's still the active child
const currentTask = this.getCurrentTask()
if (currentTask?.taskId === childTaskId && currentTask.clineMessages.length > 0) {
childClineMessages = currentTask.clineMessages
} else {
childClineMessages = await readTaskMessages({
taskId: childTaskId,
globalStoragePath,
})
}
// Get child's mode from history
let childMode: string | undefined
try {
const { historyItem: childHistory } = await this.getTaskWithId(childTaskId)
childMode = childHistory.mode
} catch {
// non-fatal
}
contextSummary = collectContextSummary(childClineMessages, childMode, completionResultSummary)
formattedSummary = formatContextSummaryForParent(contextSummary)
} catch (err) {
this.log(
`[reopenParentFromDelegation] Failed to collect context summary for child ${childTaskId} (non-fatal): ${
(err as Error)?.message ?? String(err)
}`,
)
// Fall back to unstructured summary
}
// 2) Inject synthetic records: UI subtask_result and update API tool_result
const ts = Date.now()
@ -3239,6 +3276,7 @@ export class ClineProvider
type: "say",
say: "subtask_result",
text: effectiveSummary,
text: contextSummary ? JSON.stringify(contextSummary) : completionResultSummary,
ts,
}
parentClineMessages.push(subtaskUiMessage)
@ -3272,6 +3310,8 @@ export class ClineProvider
if (block.type === "tool_result" && block.tool_use_id === toolUseId) {
// Update the existing tool_result content with enriched summary
block.content = apiResultText
// Update the existing tool_result content
block.content = `Subtask ${childTaskId} completed.\n\n${formattedSummary}`
alreadyHasToolResult = true
break
}
@ -3287,6 +3327,7 @@ export class ClineProvider
type: "tool_result" as const,
tool_use_id: toolUseId,
content: apiResultText,
content: `Subtask ${childTaskId} completed.\n\n${formattedSummary}`,
},
],
ts,
@ -3352,6 +3393,8 @@ export class ClineProvider
status: "active",
completedByChildId: childTaskId,
completionResultSummary: effectiveSummary,
completionResultSummary,
contextHandoffSummary: contextSummary,
awaitingChildId: undefined,
childIds,
}

View file

@ -1060,6 +1060,8 @@ export const ChatRowContent = ({
// Try to parse structured summary (JSON). Falls back to plain text.
let structuredSummary: {
result?: string
// Try to parse structured context summary
let contextSummary: {
mode?: string
filesModified?: string[]
filesRead?: string[]
@ -1075,6 +1077,23 @@ export const ChatRowContent = ({
// Not JSON, use plain text rendering
}
toolUsageCounts?: Record<string, number>
apiRequestCount?: number
result?: string
} | null = null
try {
if (message.text) {
const parsed = JSON.parse(message.text)
if (parsed && typeof parsed === "object" && "result" in parsed) {
contextSummary = parsed
}
}
} catch {
// Not structured JSON - fall back to plain text display
}
const resultText = contextSummary?.result ?? message.text
return (
<div className="border-l border-muted-foreground/80 ml-2 pl-4 pt-2 pb-1 -mt-5">
<div style={headerStyle}>
@ -1104,6 +1123,40 @@ export const ChatRowContent = ({
<li
key={i}
className="text-xs text-vscode-descriptionForeground pl-2">
<MarkdownBlock markdown={resultText} />
{/* Structured context handoff details */}
{contextSummary && (
<div className="mt-2 text-xs text-vscode-descriptionForeground border border-vscode-panel-border rounded p-2 space-y-1">
<div className="font-semibold text-vscode-foreground mb-1">
{t("chat:contextHandoff.title")}
</div>
{contextSummary.mode && (
<div>
<span className="opacity-70">{t("chat:contextHandoff.mode")}:</span>{" "}
{contextSummary.mode}
</div>
)}
{contextSummary.filesModified && contextSummary.filesModified.length > 0 && (
<div>
<span className="opacity-70">
{t("chat:contextHandoff.filesModified")}:
</span>
<ul className="list-disc ml-4 mt-0.5">
{contextSummary.filesModified.map((f: string, i: number) => (
<li key={i} className="font-mono text-[11px]">
{f}
</li>
))}
</ul>
</div>
)}
{contextSummary.filesRead && contextSummary.filesRead.length > 0 && (
<div>
<span className="opacity-70">{t("chat:contextHandoff.filesRead")}:</span>
<ul className="list-disc ml-4 mt-0.5">
{contextSummary.filesRead.map((f: string, i: number) => (
<li key={i} className="font-mono text-[11px]">
{f}
</li>
))}
@ -1140,6 +1193,30 @@ export const ChatRowContent = ({
</div>
) : (
<MarkdownBlock markdown={message.text} />
{contextSummary.commandsExecuted && contextSummary.commandsExecuted.length > 0 && (
<div>
<span className="opacity-70">
{t("chat:contextHandoff.commandsExecuted")}:
</span>
<ul className="list-disc ml-4 mt-0.5">
{contextSummary.commandsExecuted.map((c: string, i: number) => (
<li key={i} className="font-mono text-[11px]">
{c}
</li>
))}
</ul>
</div>
)}
{contextSummary.apiRequestCount !== undefined &&
contextSummary.apiRequestCount > 0 && (
<div>
<span className="opacity-70">
{t("chat:contextHandoff.apiRequests")}:
</span>{" "}
{contextSummary.apiRequestCount}
</div>
)}
</div>
)}
{completedChildTaskId && (

View file

@ -320,6 +320,14 @@
"permissionAllowedTools": "Allowed tools: {{tools}}",
"permissionDeniedTools": "Denied tools: {{tools}}"
},
"contextHandoff": {
"title": "Context Handoff Summary",
"mode": "Mode",
"filesModified": "Files Modified",
"filesRead": "Files Read",
"commandsExecuted": "Commands Executed",
"apiRequests": "API Requests"
},
"questions": {
"hasQuestion": "Roo has a question"
},