feat: add getTaskAsMarkdown API method for programmatic markdown export

- Add formatTaskAsMarkdown utility to convert ClineMessage arrays to markdown
- Implement getTaskAsMarkdown method in API class to retrieve task content as markdown
- Support both current active task and historical tasks by ID
- Add comprehensive tests for formatting and API functionality
- Update RooCodeAPI interface to include new method
- Fix ESLint warnings by wrapping case blocks with curly braces

This addresses issue #9858 by providing a programmatic way to access the "Copy as markdown" functionality that was previously only available through the UI.
This commit is contained in:
Roo Code 2025-12-05 22:07:41 +00:00
parent dd92453276
commit 93ffcee40d
5 changed files with 782 additions and 0 deletions

View file

@ -133,6 +133,12 @@ export interface RooCodeAPI extends EventEmitter<RooCodeAPIEvents> {
* @throws Error if the profile does not exist
*/
setActiveProfile(name: string): Promise<string | undefined>
/**
* 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<string | undefined>
}
export interface RooCodeIpcServer extends EventEmitter<IpcServerEvents> {

View file

@ -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")
})
})

View file

@ -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<any>(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<any>(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")
}

View file

@ -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<ClineProvider>
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")
})
})

View file

@ -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<RooCodeEvents> implements RooCodeAPI {
private readonly outputChannel: vscode.OutputChannel
@ -460,4 +461,46 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
await this.sidebarProvider.activateProviderProfile({ name })
return this.getActiveProfile()
}
// Task Content Management
public async getTaskAsMarkdown(taskId?: string): Promise<string | undefined> {
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
}
}
}