From edebbae2ccb8ccf812be480923ed9aa3322345e3 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 14:38:25 +0000 Subject: [PATCH] feat: structured context handoff between parent and child tasks (Phase 3c) --- .../src/__tests__/context-handoff.spec.ts | 51 +++++ ...rchestrator-context-handoff-prompt.spec.ts | 25 +++ packages/types/src/context-handoff.ts | 31 +++ packages/types/src/history.ts | 2 + packages/types/src/index.ts | 1 + packages/types/src/mode.ts | 2 +- .../history-resume-delegation.spec.ts | 10 + .../nested-delegation-resume.spec.ts | 1 + .../__tests__/collectContextSummary.spec.ts | 198 ++++++++++++++++++ .../context-handoff/collectContextSummary.ts | 169 +++++++++++++++ src/core/webview/ClineProvider.ts | 45 +++- webview-ui/src/components/chat/ChatRow.tsx | 77 +++++++ webview-ui/src/i18n/locales/en/chat.json | 8 + 13 files changed, 618 insertions(+), 2 deletions(-) create mode 100644 packages/types/src/__tests__/context-handoff.spec.ts create mode 100644 packages/types/src/__tests__/orchestrator-context-handoff-prompt.spec.ts create mode 100644 packages/types/src/context-handoff.ts create mode 100644 src/core/context-handoff/__tests__/collectContextSummary.spec.ts create mode 100644 src/core/context-handoff/collectContextSummary.ts diff --git a/packages/types/src/__tests__/context-handoff.spec.ts b/packages/types/src/__tests__/context-handoff.spec.ts new file mode 100644 index 0000000000..4bfc90d57b --- /dev/null +++ b/packages/types/src/__tests__/context-handoff.spec.ts @@ -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([]) + } + }) +}) diff --git a/packages/types/src/__tests__/orchestrator-context-handoff-prompt.spec.ts b/packages/types/src/__tests__/orchestrator-context-handoff-prompt.spec.ts new file mode 100644 index 0000000000..a71f1dd4a9 --- /dev/null +++ b/packages/types/src/__tests__/orchestrator-context-handoff-prompt.spec.ts @@ -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") + }) +}) diff --git a/packages/types/src/context-handoff.ts b/packages/types/src/context-handoff.ts new file mode 100644 index 0000000000..0067a241f5 --- /dev/null +++ b/packages/types/src/context-handoff.ts @@ -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 diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index 74fef5c53e..5a7bfdfa24 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -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 }) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index fb723bc41f..1eec32bbea 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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" diff --git a/packages/types/src/mode.ts b/packages/types/src/mode.ts index 11f9a9c5da..5ac6aca071 100644 --- a/packages/types/src/mode.ts +++ b/packages/types/src/mode.ts @@ -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 diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index a78c41b7c0..868b6fa84c 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -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", diff --git a/src/__tests__/nested-delegation-resume.spec.ts b/src/__tests__/nested-delegation-resume.spec.ts index fac9a7bcad..f72d21a4a4 100644 --- a/src/__tests__/nested-delegation-resume.spec.ts +++ b/src/__tests__/nested-delegation-resume.spec.ts @@ -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)), diff --git a/src/core/context-handoff/__tests__/collectContextSummary.spec.ts b/src/core/context-handoff/__tests__/collectContextSummary.spec.ts new file mode 100644 index 0000000000..72d605d3c1 --- /dev/null +++ b/src/core/context-handoff/__tests__/collectContextSummary.spec.ts @@ -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") + }) +}) diff --git a/src/core/context-handoff/collectContextSummary.ts b/src/core/context-handoff/collectContextSummary.ts new file mode 100644 index 0000000000..64670629a2 --- /dev/null +++ b/src/core/context-handoff/collectContextSummary.ts @@ -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 = { + 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() + const filesRead = new Set() + const commandsExecuted: string[] = [] + const toolUsageCounts: Record = {} + 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") +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 1431bf8570..68cdbe8ddd 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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, } diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 3c71d1e96c..11cb3c9cdd 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -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 + 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 (
@@ -1104,6 +1123,40 @@ export const ChatRowContent = ({
  • + + + {/* Structured context handoff details */} + {contextSummary && ( +
    +
    + {t("chat:contextHandoff.title")} +
    + {contextSummary.mode && ( +
    + {t("chat:contextHandoff.mode")}:{" "} + {contextSummary.mode} +
    + )} + {contextSummary.filesModified && contextSummary.filesModified.length > 0 && ( +
    + + {t("chat:contextHandoff.filesModified")}: + +
      + {contextSummary.filesModified.map((f: string, i: number) => ( +
    • + {f} +
    • + ))} +
    +
    + )} + {contextSummary.filesRead && contextSummary.filesRead.length > 0 && ( +
    + {t("chat:contextHandoff.filesRead")}: +
      + {contextSummary.filesRead.map((f: string, i: number) => ( +
    • {f}
    • ))} @@ -1140,6 +1193,30 @@ export const ChatRowContent = ({
    ) : ( + {contextSummary.commandsExecuted && contextSummary.commandsExecuted.length > 0 && ( +
    + + {t("chat:contextHandoff.commandsExecuted")}: + +
      + {contextSummary.commandsExecuted.map((c: string, i: number) => ( +
    • + {c} +
    • + ))} +
    +
    + )} + {contextSummary.apiRequestCount !== undefined && + contextSummary.apiRequestCount > 0 && ( +
    + + {t("chat:contextHandoff.apiRequests")}: + {" "} + {contextSummary.apiRequestCount} +
    + )} +
    )} {completedChildTaskId && ( diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index fe7c42ba93..f62b969ae1 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -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" },