diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index e61e1e61067..406b6a95552 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -133,6 +133,12 @@ export interface RooCodeAPI extends EventEmitter { * @throws Error if the profile does not exist */ setActiveProfile(name: string): Promise + /** + * Gets the task content formatted as markdown. + * @param taskId Optional task ID. If not provided, uses the current active task. + * @returns The task content formatted as markdown, or undefined if no task is found. + */ + getTaskAsMarkdown(taskId?: string): Promise } export interface RooCodeIpcServer extends EventEmitter { diff --git a/src/core/task/__tests__/formatTaskAsMarkdown.spec.ts b/src/core/task/__tests__/formatTaskAsMarkdown.spec.ts new file mode 100644 index 00000000000..6b5699e80b3 --- /dev/null +++ b/src/core/task/__tests__/formatTaskAsMarkdown.spec.ts @@ -0,0 +1,344 @@ +import { describe, it, expect } from "vitest" +import type { ClineMessage } from "@roo-code/types" +import { formatTaskAsMarkdown } from "../formatTaskAsMarkdown" + +describe("formatTaskAsMarkdown", () => { + it("should return empty string for empty messages array", () => { + const result = formatTaskAsMarkdown([]) + expect(result).toBe("") + }) + + it("should return empty string for null/undefined messages", () => { + expect(formatTaskAsMarkdown(null as any)).toBe("") + expect(formatTaskAsMarkdown(undefined as any)).toBe("") + }) + + it("should format user feedback messages", () => { + const messages: ClineMessage[] = [ + { + ts: 1000, + type: "say", + say: "user_feedback", + text: "Hello, can you help me?", + }, + ] + const result = formatTaskAsMarkdown(messages) + expect(result).toContain("## Human") + expect(result).toContain("Hello, can you help me?") + }) + + it("should format assistant text messages", () => { + const messages: ClineMessage[] = [ + { + ts: 1000, + type: "say", + say: "text", + text: "Sure, I can help you with that.", + }, + ] + const result = formatTaskAsMarkdown(messages) + expect(result).toContain("## Assistant") + expect(result).toContain("Sure, I can help you with that.") + }) + + it("should format error messages", () => { + const messages: ClineMessage[] = [ + { + ts: 1000, + type: "say", + say: "error", + text: "An error occurred", + }, + ] + const result = formatTaskAsMarkdown(messages) + expect(result).toContain("## Error") + expect(result).toContain("```\nAn error occurred\n```") + }) + + it("should format completion result messages", () => { + const messages: ClineMessage[] = [ + { + ts: 1000, + type: "say", + say: "completion_result", + text: "Task completed successfully!", + }, + ] + const result = formatTaskAsMarkdown(messages) + expect(result).toContain("## Task Completed") + expect(result).toContain("Task completed successfully!") + }) + + it("should format reasoning messages with blockquote", () => { + const messages: ClineMessage[] = [ + { + ts: 1000, + type: "say", + say: "reasoning", + text: "I need to analyze this\nStep by step", + }, + ] + const result = formatTaskAsMarkdown(messages) + expect(result).toContain("## Reasoning") + expect(result).toContain("> I need to analyze this\n> Step by step") + }) + + it("should format command execution messages", () => { + const messages: ClineMessage[] = [ + { + ts: 1000, + type: "ask", + ask: "command", + text: "npm install", + }, + ] + const result = formatTaskAsMarkdown(messages) + expect(result).toContain("## Command Execution") + expect(result).toContain("```bash\nnpm install\n```") + }) + + it("should format tool use messages for file operations", () => { + const messages: ClineMessage[] = [ + { + ts: 1000, + type: "ask", + ask: "tool", + text: JSON.stringify({ + tool: "readFile", + path: "src/index.ts", + }), + }, + { + ts: 1001, + type: "ask", + ask: "tool", + text: JSON.stringify({ + tool: "newFileCreated", + path: "src/new.ts", + content: "const x = 1;", + }), + }, + { + ts: 1002, + type: "ask", + ask: "tool", + text: JSON.stringify({ + tool: "editedExistingFile", + path: "src/existing.ts", + diff: "- old line\n+ new line", + }), + }, + ] + const result = formatTaskAsMarkdown(messages) + expect(result).toContain("## Reading File") + expect(result).toContain("**Path:** `src/index.ts`") + expect(result).toContain("## Creating File") + expect(result).toContain("**Path:** `src/new.ts`") + expect(result).toContain("## Editing File") + expect(result).toContain("```diff\n- old line\n+ new line\n```") + }) + + it("should format updateTodoList tool messages", () => { + const messages: ClineMessage[] = [ + { + ts: 1000, + type: "ask", + ask: "tool", + text: JSON.stringify({ + tool: "updateTodoList", + todos: ["- [ ] Task 1", "- [x] Task 2", "- [ ] Task 3"], + }), + }, + ] + const result = formatTaskAsMarkdown(messages) + expect(result).toContain("## Updated TODO List") + expect(result).toContain("- [ ] Task 1") + expect(result).toContain("- [x] Task 2") + expect(result).toContain("- [ ] Task 3") + }) + + it("should format follow-up questions", () => { + const messages: ClineMessage[] = [ + { + ts: 1000, + type: "ask", + ask: "followup", + text: JSON.stringify({ + question: "Which framework would you like to use?", + suggest: ["React", "Vue", "Angular"], + }), + }, + ] + const result = formatTaskAsMarkdown(messages) + expect(result).toContain("## Question") + expect(result).toContain("Which framework would you like to use?") + }) + + it("should handle messages with images", () => { + const messages: ClineMessage[] = [ + { + ts: 1000, + type: "say", + say: "user_feedback", + text: "Here's a screenshot", + images: ["data:image/png;base64,abc123", "data:image/png;base64,def456"], + }, + ] + const result = formatTaskAsMarkdown(messages) + expect(result).toContain("## Human") + expect(result).toContain("Here's a screenshot") + expect(result).toContain("*[2 image(s) attached]*") + }) + + it("should separate multiple messages with dividers", () => { + const messages: ClineMessage[] = [ + { + ts: 1000, + type: "say", + say: "user_feedback", + text: "First message", + }, + { + ts: 1001, + type: "say", + say: "text", + text: "Second message", + }, + { + ts: 1002, + type: "say", + say: "user_feedback", + text: "Third message", + }, + ] + const result = formatTaskAsMarkdown(messages) + expect(result).toContain( + "## Human\n\nFirst message\n\n---\n\n## Assistant\n\nSecond message\n\n---\n\n## Human\n\nThird message", + ) + }) + + it("should skip api_req_started and api_req_finished messages", () => { + const messages: ClineMessage[] = [ + { + ts: 1000, + type: "say", + say: "api_req_started", + text: '{"cost": 0.01}', + }, + { + ts: 1001, + type: "say", + say: "api_req_finished", + text: "finished", + }, + { + ts: 1002, + type: "say", + say: "text", + text: "Actual content", + }, + ] + const result = formatTaskAsMarkdown(messages) + expect(result).not.toContain("api_req_started") + expect(result).not.toContain("api_req_finished") + expect(result).toContain("## Assistant") + expect(result).toContain("Actual content") + }) + + it("should format file listing operations", () => { + const messages: ClineMessage[] = [ + { + ts: 1000, + type: "ask", + ask: "tool", + text: JSON.stringify({ + tool: "listFilesTopLevel", + path: "/src", + content: "file1.ts\nfile2.ts", + }), + }, + { + ts: 1001, + type: "ask", + ask: "tool", + text: JSON.stringify({ + tool: "listFilesRecursive", + path: "/src", + content: "src/\n file1.ts\n subfolder/\n file2.ts", + }), + }, + ] + const result = formatTaskAsMarkdown(messages) + expect(result).toContain("## Listing Files") + expect(result).toContain("## Listing Files (Recursive)") + expect(result).toContain("**Path:** `/src`") + }) + + it("should format search operations", () => { + const messages: ClineMessage[] = [ + { + ts: 1000, + type: "ask", + ask: "tool", + text: JSON.stringify({ + tool: "searchFiles", + regex: "TODO", + path: "/src", + content: "Found 3 matches", + }), + }, + ] + const result = formatTaskAsMarkdown(messages) + expect(result).toContain("## Searching Files") + expect(result).toContain("**Pattern:** `TODO`") + expect(result).toContain("**Path:** `/src`") + expect(result).toContain("```\nFound 3 matches\n```") + }) + + it("should format mode switch operations", () => { + const messages: ClineMessage[] = [ + { + ts: 1000, + type: "ask", + ask: "tool", + text: JSON.stringify({ + tool: "switchMode", + mode: "architect", + reason: "Need to plan the architecture first", + }), + }, + ] + const result = formatTaskAsMarkdown(messages) + expect(result).toContain("## Switching Mode") + expect(result).toContain("**Mode:** architect") + expect(result).toContain("**Reason:** Need to plan the architecture first") + }) + + it("should handle malformed JSON gracefully", () => { + const messages: ClineMessage[] = [ + { + ts: 1000, + type: "ask", + ask: "tool", + text: "{ invalid json }", + }, + ] + const result = formatTaskAsMarkdown(messages) + // Should not throw and should return empty for this message + expect(result).toBe("") + }) + + it("should handle unknown message types with generic formatting", () => { + const messages: ClineMessage[] = [ + { + ts: 1000, + type: "say", + say: "unknown_type" as any, + text: "Some unknown content", + }, + ] + const result = formatTaskAsMarkdown(messages) + expect(result).toContain("## unknown_type") + expect(result).toContain("Some unknown content") + }) +}) diff --git a/src/core/task/formatTaskAsMarkdown.ts b/src/core/task/formatTaskAsMarkdown.ts new file mode 100644 index 00000000000..0669abff423 --- /dev/null +++ b/src/core/task/formatTaskAsMarkdown.ts @@ -0,0 +1,222 @@ +import type { ClineMessage } from "@roo-code/types" +import { safeJsonParse } from "../../shared/safeJsonParse" + +/** + * Formats task messages as markdown, similar to how the UI displays them. + * This follows the same formatting logic used in the webview UI components. + * @param messages Array of ClineMessage objects from a task + * @returns Formatted markdown string + */ +export function formatTaskAsMarkdown(messages: ClineMessage[]): string { + if (!messages || messages.length === 0) { + return "" + } + + const markdownParts: string[] = [] + + for (const message of messages) { + const messageMarkdown = formatMessageAsMarkdown(message) + if (messageMarkdown) { + markdownParts.push(messageMarkdown) + } + } + + return markdownParts.join("\n\n---\n\n") +} + +function formatMessageAsMarkdown(message: ClineMessage): string { + const parts: string[] = [] + + // Handle different message types + if (message.type === "say") { + switch (message.say) { + case "user_feedback": + parts.push("## Human") + if (message.text) { + parts.push(message.text) + } + if (message.images && message.images.length > 0) { + parts.push(`*[${message.images.length} image(s) attached]*`) + } + break + + case "text": + parts.push("## Assistant") + if (message.text) { + parts.push(message.text) + } + break + + case "error": + parts.push("## Error") + if (message.text) { + parts.push(`\`\`\`\n${message.text}\n\`\`\``) + } + break + + case "completion_result": + parts.push("## Task Completed") + if (message.text) { + parts.push(message.text) + } + break + + case "api_req_started": + // Skip API request started messages in markdown export + return "" + + case "api_req_finished": + // Skip API request finished messages in markdown export + return "" + + case "reasoning": + parts.push("## Reasoning") + if (message.text) { + parts.push(`> ${message.text.split("\n").join("\n> ")}`) + } + break + + default: + // For other message types, include them with a generic header + if (message.text) { + parts.push(`## ${message.say}`) + parts.push(message.text) + } + } + } else if (message.type === "ask") { + switch (message.ask) { + case "tool": { + const tool = safeJsonParse(message.text) + if (tool) { + parts.push(formatToolUseAsMarkdown(tool, "ask")) + } + break + } + + case "command": + parts.push("## Command Execution") + if (message.text) { + parts.push(`\`\`\`bash\n${message.text}\n\`\`\``) + } + break + + case "completion_result": + parts.push("## Task Completion Request") + if (message.text) { + parts.push(message.text) + } + break + + case "followup": + parts.push("## Question") + if (message.text) { + const followUpData = safeJsonParse(message.text) + if (followUpData?.question) { + parts.push(followUpData.question) + } else { + parts.push(message.text) + } + } + break + + default: + // For other ask types, include them with a generic header + if (message.text) { + parts.push(`## ${message.ask}`) + parts.push(message.text) + } + } + } + + return parts.join("\n\n") +} + +function formatToolUseAsMarkdown(tool: any, messageType: "ask" | "say"): string { + const parts: string[] = [] + const isRequest = messageType === "ask" + + switch (tool.tool) { + case "readFile": + parts.push(`## ${isRequest ? "Reading" : "Read"} File`) + if (tool.path) { + parts.push(`**Path:** \`${tool.path}\``) + } + if (!isRequest && tool.content) { + parts.push(`\`\`\`\n${tool.content}\n\`\`\``) + } + break + + case "newFileCreated": + parts.push(`## ${isRequest ? "Creating" : "Created"} File`) + if (tool.path) { + parts.push(`**Path:** \`${tool.path}\``) + } + if (tool.content) { + parts.push(`\`\`\`\n${tool.content}\n\`\`\``) + } + break + + case "editedExistingFile": + case "appliedDiff": + parts.push(`## ${isRequest ? "Editing" : "Edited"} File`) + if (tool.path) { + parts.push(`**Path:** \`${tool.path}\``) + } + if (tool.diff || tool.content) { + parts.push(`\`\`\`diff\n${tool.diff || tool.content}\n\`\`\``) + } + break + + case "listFilesTopLevel": + case "listFilesRecursive": { + const recursive = tool.tool === "listFilesRecursive" + parts.push(`## ${isRequest ? "Listing" : "Listed"} Files${recursive ? " (Recursive)" : ""}`) + if (tool.path) { + parts.push(`**Path:** \`${tool.path}\``) + } + if (tool.content) { + parts.push(`\`\`\`\n${tool.content}\n\`\`\``) + } + break + } + + case "searchFiles": + parts.push(`## ${isRequest ? "Searching" : "Searched"} Files`) + if (tool.regex) { + parts.push(`**Pattern:** \`${tool.regex}\``) + } + if (tool.path) { + parts.push(`**Path:** \`${tool.path}\``) + } + if (tool.content) { + parts.push(`\`\`\`\n${tool.content}\n\`\`\``) + } + break + + case "updateTodoList": + parts.push(`## Updated TODO List`) + if (tool.todos && Array.isArray(tool.todos)) { + parts.push(tool.todos.join("\n")) + } + break + + case "switchMode": + parts.push(`## ${isRequest ? "Switching" : "Switched"} Mode`) + if (tool.mode) { + parts.push(`**Mode:** ${tool.mode}`) + } + if (tool.reason) { + parts.push(`**Reason:** ${tool.reason}`) + } + break + + default: + // For other tools, include a generic format + parts.push(`## Tool: ${tool.tool}`) + if (tool.content) { + parts.push(`\`\`\`\n${tool.content}\n\`\`\``) + } + } + + return parts.join("\n\n") +} diff --git a/src/extension/__tests__/api.getTaskAsMarkdown.spec.ts b/src/extension/__tests__/api.getTaskAsMarkdown.spec.ts new file mode 100644 index 00000000000..e760eee7a12 --- /dev/null +++ b/src/extension/__tests__/api.getTaskAsMarkdown.spec.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { API } from "../api" +import type { ClineProvider } from "../../core/webview/ClineProvider" + +// Mock the formatTaskAsMarkdown function +vi.mock("../../core/task/formatTaskAsMarkdown", () => ({ + formatTaskAsMarkdown: vi.fn((messages) => { + if (!messages || messages.length === 0) return "" + return messages.map((m: any) => `${m.type}: ${m.text}`).join("\n") + }), +})) + +// Mock the readTaskMessages function +vi.mock("../../core/task-persistence/taskMessages", () => ({ + readTaskMessages: vi.fn(), +})) + +describe("API.getTaskAsMarkdown", () => { + let api: API + let mockProvider: Partial + let mockOutputChannel: any + let mockReadTaskMessages: any + + beforeEach(async () => { + // Import and get the mocked function + const { readTaskMessages } = await import("../../core/task-persistence/taskMessages") + mockReadTaskMessages = vi.mocked(readTaskMessages) + + mockOutputChannel = { + appendLine: vi.fn(), + } + + mockProvider = { + context: {} as any, + getCurrentTask: vi.fn(), + getTaskWithId: vi.fn(), + contextProxy: { + globalStorageUri: { + fsPath: "/mock/storage/path", + }, + } as any, + // Mock the event emitter methods that API constructor uses + on: vi.fn(), + off: vi.fn(), + } + + api = new API(mockOutputChannel, mockProvider as ClineProvider) + }) + + it("should return undefined when no taskId provided and no current task", async () => { + vi.mocked(mockProvider.getCurrentTask!).mockReturnValue(undefined) + + const result = await api.getTaskAsMarkdown() + + expect(result).toBeUndefined() + }) + + it("should return formatted markdown for current task when no taskId provided", async () => { + const mockMessages = [ + { ts: 1000, type: "say", say: "user_feedback", text: "Hello" }, + { ts: 1001, type: "say", say: "text", text: "Hi there" }, + ] + + vi.mocked(mockProvider.getCurrentTask!).mockReturnValue({ + clineMessages: mockMessages, + } as any) + + const result = await api.getTaskAsMarkdown() + + expect(result).toBeDefined() + expect(result).toContain("say: Hello") + expect(result).toContain("say: Hi there") + }) + + it("should return formatted markdown for specific task when taskId provided", async () => { + const mockMessages = [{ ts: 1000, type: "say", say: "user_feedback", text: "Task specific message" }] + + // Mock readTaskMessages to return the messages + mockReadTaskMessages.mockResolvedValue(mockMessages) + + const result = await api.getTaskAsMarkdown("task-123") + + expect(result).toBeDefined() + expect(result).toContain("say: Task specific message") + expect(mockReadTaskMessages).toHaveBeenCalledWith({ + taskId: "task-123", + globalStoragePath: "/mock/storage/path", + }) + }) + + it("should return undefined when specific task messages not found", async () => { + // Mock readTaskMessages to throw an error + mockReadTaskMessages.mockRejectedValue(new Error("Task messages not found")) + + const result = await api.getTaskAsMarkdown("non-existent-task") + + expect(result).toBeUndefined() + // Note: log method won't be called because logging is disabled in tests + expect(mockReadTaskMessages).toHaveBeenCalledWith({ + taskId: "non-existent-task", + globalStoragePath: "/mock/storage/path", + }) + }) + + it("should return undefined when task has no messages", async () => { + // Mock readTaskMessages to return empty array + mockReadTaskMessages.mockResolvedValue([]) + + const result = await api.getTaskAsMarkdown("task-123") + + expect(result).toBeUndefined() + }) + + it("should handle errors gracefully and return undefined", async () => { + // Mock readTaskMessages to throw an unexpected error + mockReadTaskMessages.mockImplementation(() => { + throw new Error("Unexpected error") + }) + + const result = await api.getTaskAsMarkdown("task-123") + + expect(result).toBeUndefined() + // Note: log method won't be called because logging is disabled in tests + // Just verify the method handled the error gracefully + expect(mockReadTaskMessages).toHaveBeenCalledWith({ + taskId: "task-123", + globalStoragePath: "/mock/storage/path", + }) + }) + + it("should work with current task that has many messages", async () => { + const mockMessages = Array.from({ length: 50 }, (_, i) => ({ + ts: 1000 + i, + type: "say", + say: i % 2 === 0 ? "user_feedback" : "text", + text: `Message ${i}`, + })) + + vi.mocked(mockProvider.getCurrentTask!).mockReturnValue({ + clineMessages: mockMessages, + } as any) + + const result = await api.getTaskAsMarkdown() + + expect(result).toBeDefined() + expect(result).toContain("Message 0") + expect(result).toContain("Message 49") + }) + + it("should work with task loaded from disk with many messages", async () => { + const mockMessages = Array.from({ length: 100 }, (_, i) => ({ + ts: 2000 + i, + type: "say", + say: "text", + text: `Stored message ${i}`, + })) + + // Mock readTaskMessages to return many messages + mockReadTaskMessages.mockResolvedValue(mockMessages) + + const result = await api.getTaskAsMarkdown("task-with-many-messages") + + expect(result).toBeDefined() + expect(result).toContain("Stored message 0") + expect(result).toContain("Stored message 99") + }) +}) diff --git a/src/extension/api.ts b/src/extension/api.ts index a9e11b4dbe5..7574ec91bbc 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -24,6 +24,7 @@ import { IpcServer } from "@roo-code/ipc" import { Package } from "../shared/package" import { ClineProvider } from "../core/webview/ClineProvider" import { openClineInNewTab } from "../activate/registerCommands" +import { formatTaskAsMarkdown } from "../core/task/formatTaskAsMarkdown" export class API extends EventEmitter implements RooCodeAPI { private readonly outputChannel: vscode.OutputChannel @@ -460,4 +461,46 @@ export class API extends EventEmitter implements RooCodeAPI { await this.sidebarProvider.activateProviderProfile({ name }) return this.getActiveProfile() } + + // Task Content Management + + public async getTaskAsMarkdown(taskId?: string): Promise { + try { + let messages: any[] | undefined + + if (taskId) { + // Get messages for a specific task from history + // The messages are stored separately on disk, we need to load them + const { readTaskMessages } = await import("../core/task-persistence/taskMessages") + const globalStoragePath = this.sidebarProvider.contextProxy.globalStorageUri.fsPath + + try { + messages = await readTaskMessages({ + taskId, + globalStoragePath, + }) + } catch (error) { + // Task messages not found or error reading them + this.log(`[API] getTaskAsMarkdown: Failed to read messages for task ${taskId}: ${error}`) + return undefined + } + } else { + // Get messages from the current active task + const currentTask = this.sidebarProvider.getCurrentTask() + if (currentTask) { + messages = currentTask.clineMessages + } + } + + if (!messages || messages.length === 0) { + return undefined + } + + // Format the messages as markdown + return formatTaskAsMarkdown(messages) + } catch (error) { + this.log(`[API] getTaskAsMarkdown failed: ${error instanceof Error ? error.message : String(error)}`) + return undefined + } + } }