diff --git a/apps/cli/src/agent/output-manager.ts b/apps/cli/src/agent/output-manager.ts index 0863546f6c..805b090925 100644 --- a/apps/cli/src/agent/output-manager.ts +++ b/apps/cli/src/agent/output-manager.ts @@ -85,6 +85,12 @@ export class OutputManager { */ private currentlyStreamingTs: number | null = null + /** + * Track whether a say:completion_result has been streamed, + * so the subsequent ask:completion_result doesn't duplicate the text. + */ + private completionResultStreamed = false + /** * Track first partial logs (for debugging first/last pattern). */ @@ -197,6 +203,7 @@ export class OutputManager { this.displayedMessages.clear() this.streamedContent.clear() this.currentlyStreamingTs = null + this.completionResultStreamed = false this.loggedFirstPartial.clear() this.streamingState.next({ ts: null, isStreaming: false }) } @@ -248,8 +255,13 @@ export class OutputManager { this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete) break - // Note: completion_result is an "ask" type, not a "say" type. - // It is handled via the TaskCompleted event in extension-host.ts + case "completion_result": + // completion_result can arrive as both a "say" (with streamed text) + // and an "ask" (handled via TaskCompleted in extension-host.ts). + // Stream the say variant here; the ask variant is handled by + // outputCompletionResult which will skip if already displayed. + this.outputCompletionSayMessage(ts, text, isPartial, alreadyDisplayedComplete) + break case "error": if (!alreadyDisplayedComplete) { @@ -401,13 +413,50 @@ export class OutputManager { } } + /** + * Output a say:completion_result message (streamed text of the completion). + * The subsequent ask:completion_result is handled by outputCompletionResult. + */ + private outputCompletionSayMessage( + ts: number, + text: string, + isPartial: boolean, + alreadyDisplayedComplete: boolean | undefined, + ): void { + if (isPartial && text) { + this.streamContent(ts, text, "[assistant]") + this.displayedMessages.set(ts, { ts, text, partial: true }) + this.completionResultStreamed = true + } else if (!isPartial && text && !alreadyDisplayedComplete) { + const streamed = this.streamedContent.get(ts) + + if (streamed) { + if (text.length > streamed.text.length && text.startsWith(streamed.text)) { + const delta = text.slice(streamed.text.length) + this.writeRaw(delta) + } + this.finishStream(ts) + } else { + this.output("\n[assistant]", text) + } + + this.displayedMessages.set(ts, { ts, text, partial: false }) + this.completionResultStreamed = true + } + } + /** * Output completion message (called from TaskCompleted handler). */ outputCompletionResult(ts: number, text: string): void { const previousDisplay = this.displayedMessages.get(ts) if (!previousDisplay || previousDisplay.partial) { - this.output("\n[task complete]", text || "") + if (this.completionResultStreamed) { + // Text was already streamed via say:completion_result. + this.output("\n[task complete]") + } else { + this.output("\n[task complete]", text || "") + } this.displayedMessages.set(ts, { ts, text: text || "", partial: false }) } } diff --git a/apps/cli/src/commands/cli/__tests__/list.test.ts b/apps/cli/src/commands/cli/__tests__/list.test.ts index e5b1eeabb7..5058b8e8d8 100644 --- a/apps/cli/src/commands/cli/__tests__/list.test.ts +++ b/apps/cli/src/commands/cli/__tests__/list.test.ts @@ -1,15 +1,12 @@ -import * as os from "os" -import * as path from "path" - -import { readTaskSessionsFromStoragePath } from "@roo-code/core/cli" +import { readWorkspaceTaskSessions } from "@/lib/task-history/index.js" import { listSessions, parseFormat } from "../list.js" -vi.mock("@roo-code/core/cli", async (importOriginal) => { - const actual = await importOriginal() +vi.mock("@/lib/task-history/index.js", async (importOriginal) => { + const actual = await importOriginal() return { ...actual, - readTaskSessionsFromStoragePath: vi.fn(), + readWorkspaceTaskSessions: vi.fn(), } }) @@ -42,7 +39,7 @@ describe("parseFormat", () => { }) describe("listSessions", () => { - const storagePath = path.join(os.homedir(), ".vscode-mock", "global-storage") + const workspacePath = process.cwd() beforeEach(() => { vi.clearAllMocks() @@ -60,25 +57,26 @@ describe("listSessions", () => { } it("uses the CLI runtime storage path and prints JSON output", async () => { - vi.mocked(readTaskSessionsFromStoragePath).mockResolvedValue([ + vi.mocked(readWorkspaceTaskSessions).mockResolvedValue([ { id: "s1", task: "Task 1", ts: 1_700_000_000_000, mode: "code" }, ]) - const output = await captureStdout(() => listSessions({ format: "json" })) + const output = await captureStdout(() => listSessions({ format: "json", workspace: workspacePath })) - expect(readTaskSessionsFromStoragePath).toHaveBeenCalledWith(storagePath) + expect(readWorkspaceTaskSessions).toHaveBeenCalledWith(workspacePath) expect(JSON.parse(output)).toEqual({ + workspace: workspacePath, sessions: [{ id: "s1", task: "Task 1", ts: 1_700_000_000_000, mode: "code" }], }) }) it("prints tab-delimited text output with ISO timestamps and formatted titles", async () => { - vi.mocked(readTaskSessionsFromStoragePath).mockResolvedValue([ + vi.mocked(readWorkspaceTaskSessions).mockResolvedValue([ { id: "s1", task: "Task 1", ts: Date.UTC(2024, 0, 1, 0, 0, 0) }, { id: "s2", task: " ", ts: Date.UTC(2024, 0, 1, 1, 0, 0) }, ]) - const output = await captureStdout(() => listSessions({ format: "text" })) + const output = await captureStdout(() => listSessions({ format: "text", workspace: workspacePath })) const lines = output.trim().split("\n") expect(lines).toEqual(["s1\t2024-01-01T00:00:00.000Z\tTask 1", "s2\t2024-01-01T01:00:00.000Z\t(untitled)"]) diff --git a/apps/cli/src/commands/cli/list.ts b/apps/cli/src/commands/cli/list.ts index 8c8d5ae93a..31898c59cd 100644 --- a/apps/cli/src/commands/cli/list.ts +++ b/apps/cli/src/commands/cli/list.ts @@ -1,15 +1,15 @@ import fs from "fs" -import os from "os" import path from "path" import { fileURLToPath } from "url" import pWaitFor from "p-wait-for" -import { readTaskSessionsFromStoragePath, type TaskSessionEntry } from "@roo-code/core/cli" +import type { TaskSessionEntry } from "@roo-code/core/cli" import type { Command, ModelRecord, WebviewMessage } from "@roo-code/types" import { getProviderDefaultModelId } from "@roo-code/types" import { ExtensionHost, type ExtensionHostOptions } from "@/agent/index.js" +import { readWorkspaceTaskSessions } from "@/lib/task-history/index.js" import { loadToken } from "@/lib/storage/index.js" import { getDefaultExtensionPath } from "@/lib/utils/extension.js" import { getApiKeyFromEnv } from "@/lib/utils/provider.js" @@ -33,7 +33,6 @@ type CommandLike = Pick { export async function listSessions(options: BaseListOptions): Promise { const format = parseFormat(options.format) - const sessions = await readTaskSessionsFromStoragePath(DEFAULT_CLI_TASK_STORAGE_PATH) + const workspacePath = resolveWorkspacePath(options.workspace) + const sessions = await readWorkspaceTaskSessions(workspacePath) if (format === "json") { - outputJson({ sessions }) + outputJson({ workspace: workspacePath, sessions }) return } diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index b9d690e395..f2c0e03912 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -5,7 +5,6 @@ import { fileURLToPath } from "url" import { createElement } from "react" import pWaitFor from "p-wait-for" -import type { HistoryItem } from "@roo-code/types" import { setLogger } from "@roo-code/vscode-shim" import { @@ -23,8 +22,8 @@ import { JsonEventEmitter } from "@/agent/json-event-emitter.js" import { createClient } from "@/lib/sdk/index.js" import { loadToken, loadSettings } from "@/lib/storage/index.js" +import { readWorkspaceTaskSessions, resolveWorkspaceResumeSessionId } from "@/lib/task-history/index.js" import { isRecord } from "@/lib/utils/guards.js" -import { arePathsEqual } from "@/lib/utils/path.js" import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js" import { runOnboarding } from "@/lib/utils/onboarding.js" import { getDefaultExtensionPath } from "@/lib/utils/extension.js" @@ -105,38 +104,6 @@ async function warmRooModels(host: ExtensionHost): Promise { }) } -function extractTaskHistoryFromMessage(message: unknown): HistoryItem[] | undefined { - if (!isRecord(message)) { - return undefined - } - - if (message.type === "state") { - const state = isRecord(message.state) ? message.state : undefined - if (Array.isArray(state?.taskHistory)) { - return state.taskHistory as HistoryItem[] - } - } - - if (message.type === "taskHistoryUpdated" && Array.isArray(message.taskHistory)) { - return message.taskHistory as HistoryItem[] - } - - return undefined -} - -function getMostRecentTaskIdInWorkspace(taskHistory: HistoryItem[], workspacePath: string): string | undefined { - const workspaceTasks = taskHistory.filter( - (item) => typeof item.workspace === "string" && arePathsEqual(item.workspace, workspacePath), - ) - - if (workspaceTasks.length === 0) { - return undefined - } - - const sorted = [...workspaceTasks].sort((a, b) => b.ts - a.ts) - return sorted[0]?.id -} - export async function run(promptArg: string | undefined, flagOptions: FlagOptions) { setLogger({ info: () => {}, @@ -360,6 +327,18 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption } const useStdinPromptStream = flagOptions.stdinPromptStream + let resolvedResumeSessionId: string | undefined + + if (isResumeRequested) { + const workspaceSessions = await readWorkspaceTaskSessions(effectiveWorkspacePath) + try { + resolvedResumeSessionId = resolveWorkspaceResumeSessionId(workspaceSessions, requestedSessionId) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`[CLI] Error: ${message}`) + process.exit(1) + } + } if (!isTuiEnabled) { if (!prompt && !useStdinPromptStream && !isResumeRequested) { @@ -394,8 +373,8 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption createElement(App, { ...extensionHostOptions, initialPrompt: prompt, - initialSessionId: requestedSessionId, - continueSession: shouldContinueSession, + initialSessionId: resolvedResumeSessionId, + continueSession: false, version: VERSION, createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts), }), @@ -422,16 +401,6 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption let keepAliveInterval: NodeJS.Timeout | undefined let isShuttingDown = false let hostDisposed = false - let taskHistorySnapshot: HistoryItem[] = [] - - const onExtensionMessage = (message: unknown) => { - const taskHistory = extractTaskHistoryFromMessage(message) - if (taskHistory) { - taskHistorySnapshot = taskHistory - } - } - - host.on("extensionWebviewMessage", onExtensionMessage) const jsonEmitter = useJsonOutput ? new JsonEventEmitter({ @@ -497,7 +466,6 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption } hostDisposed = true - host.off("extensionWebviewMessage", onExtensionMessage) jsonEmitter?.detach() await host.dispose() } @@ -594,22 +562,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption } if (isResumeRequested) { - const resolvedSessionId = - requestedSessionId || - getMostRecentTaskIdInWorkspace(taskHistorySnapshot, effectiveWorkspacePath) - - if (requestedSessionId && taskHistorySnapshot.length > 0) { - const hasRequestedTask = taskHistorySnapshot.some((item) => item.id === requestedSessionId) - if (!hasRequestedTask) { - throw new Error(`Session not found in task history: ${requestedSessionId}`) - } - } - - if (!resolvedSessionId) { - throw new Error("No previous tasks found to continue in this workspace.") - } - - await bootstrapResumeForStdinStream(host, resolvedSessionId) + await bootstrapResumeForStdinStream(host, resolvedResumeSessionId!) } await runStdinStreamMode({ @@ -621,22 +574,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption }) } else { if (isResumeRequested) { - const resolvedSessionId = - requestedSessionId || - getMostRecentTaskIdInWorkspace(taskHistorySnapshot, effectiveWorkspacePath) - - if (requestedSessionId && taskHistorySnapshot.length > 0) { - const hasRequestedTask = taskHistorySnapshot.some((item) => item.id === requestedSessionId) - if (!hasRequestedTask) { - throw new Error(`Session not found in task history: ${requestedSessionId}`) - } - } - - if (!resolvedSessionId) { - throw new Error("No previous tasks found to continue in this workspace.") - } - - await host.resumeTask(resolvedSessionId) + await host.resumeTask(resolvedResumeSessionId!) } else { await host.runTask(prompt!) } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index e11953c357..de614737d5 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -20,6 +20,8 @@ program .name("roo") .description("Roo Code CLI - starts an interactive session by default, use -p/--print for non-interactive output") .version(VERSION) + .enablePositionalOptions() + .passThroughOptions() program .argument("[prompt]", "Your prompt") @@ -65,7 +67,11 @@ program ) .action(run) -const listCommand = program.command("list").description("List commands, modes, models, or sessions") +const listCommand = program + .command("list") + .description("List commands, modes, models, or sessions") + .enablePositionalOptions() + .passThroughOptions() const applyListOptions = (command: Command) => command diff --git a/apps/cli/src/lib/task-history/__tests__/index.test.ts b/apps/cli/src/lib/task-history/__tests__/index.test.ts new file mode 100644 index 0000000000..58b0692b2b --- /dev/null +++ b/apps/cli/src/lib/task-history/__tests__/index.test.ts @@ -0,0 +1,75 @@ +import { readTaskSessionsFromStoragePath } from "@roo-code/core/cli" + +import { + filterSessionsForWorkspace, + getDefaultCliTaskStoragePath, + readWorkspaceTaskSessions, + resolveWorkspaceResumeSessionId, +} from "../index.js" + +vi.mock("@roo-code/core/cli", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readTaskSessionsFromStoragePath: vi.fn(), + } +}) + +describe("task history workspace helpers", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("filters sessions to the current workspace and sorts newest first", () => { + const result = filterSessionsForWorkspace( + [ + { id: "a", task: "A", ts: 10, workspace: "/workspace/project" }, + { id: "b", task: "B", ts: 30, workspace: "/workspace/project/" }, + { id: "c", task: "C", ts: 20, workspace: "/workspace/other" }, + { id: "d", task: "D", ts: 40 }, + ], + "/workspace/project", + ) + + expect(result.map((session) => session.id)).toEqual(["b", "a"]) + }) + + it("reads from storage path and applies workspace filtering", async () => { + vi.mocked(readTaskSessionsFromStoragePath).mockResolvedValue([ + { id: "a", task: "A", ts: 10, workspace: "/workspace/project" }, + { id: "b", task: "B", ts: 30, workspace: "/workspace/other" }, + ]) + + const result = await readWorkspaceTaskSessions("/workspace/project", "/custom/storage") + + expect(readTaskSessionsFromStoragePath).toHaveBeenCalledWith("/custom/storage") + expect(result).toEqual([{ id: "a", task: "A", ts: 10, workspace: "/workspace/project" }]) + }) + + it("returns the expected default CLI storage path", () => { + expect(getDefaultCliTaskStoragePath()).toContain(".vscode-mock") + expect(getDefaultCliTaskStoragePath()).toContain("global-storage") + }) + + it("resolves explicit session id only when it exists in current workspace sessions", () => { + const sessions = [ + { id: "a", task: "A", ts: 10, workspace: "/workspace/project" }, + { id: "b", task: "B", ts: 20, workspace: "/workspace/project" }, + ] + + expect(resolveWorkspaceResumeSessionId(sessions, "a")).toBe("a") + expect(() => resolveWorkspaceResumeSessionId(sessions, "missing")).toThrow( + "Session not found in current workspace", + ) + }) + + it("resolves continue to most recent session and errors when no sessions exist", () => { + const sessions = [ + { id: "newer", task: "Newer", ts: 30, workspace: "/workspace/project" }, + { id: "older", task: "Older", ts: 10, workspace: "/workspace/project" }, + ] + + expect(resolveWorkspaceResumeSessionId(sessions)).toBe("newer") + expect(() => resolveWorkspaceResumeSessionId([])).toThrow("No previous tasks found to continue") + }) +}) diff --git a/apps/cli/src/lib/task-history/index.ts b/apps/cli/src/lib/task-history/index.ts new file mode 100644 index 0000000000..3be2d45d4c --- /dev/null +++ b/apps/cli/src/lib/task-history/index.ts @@ -0,0 +1,44 @@ +import os from "os" +import path from "path" + +import { readTaskSessionsFromStoragePath, type TaskSessionEntry } from "@roo-code/core/cli" + +import { arePathsEqual } from "@/lib/utils/path.js" + +const DEFAULT_CLI_TASK_STORAGE_PATH = path.join(os.homedir(), ".vscode-mock", "global-storage") + +export function getDefaultCliTaskStoragePath(): string { + return DEFAULT_CLI_TASK_STORAGE_PATH +} + +export function filterSessionsForWorkspace(sessions: TaskSessionEntry[], workspacePath: string): TaskSessionEntry[] { + return sessions + .filter((session) => typeof session.workspace === "string" && arePathsEqual(session.workspace, workspacePath)) + .sort((a, b) => b.ts - a.ts) +} + +export async function readWorkspaceTaskSessions( + workspacePath: string, + storagePath = DEFAULT_CLI_TASK_STORAGE_PATH, +): Promise { + const sessions = await readTaskSessionsFromStoragePath(storagePath) + return filterSessionsForWorkspace(sessions, workspacePath) +} + +export function resolveWorkspaceResumeSessionId(sessions: TaskSessionEntry[], requestedSessionId?: string): string { + if (requestedSessionId) { + const hasRequestedSession = sessions.some((session) => session.id === requestedSessionId) + if (!hasRequestedSession) { + throw new Error(`Session not found in current workspace: ${requestedSessionId}`) + } + + return requestedSessionId + } + + const mostRecentSessionId = sessions[0]?.id + if (!mostRecentSessionId) { + throw new Error("No previous tasks found to continue in this workspace.") + } + + return mostRecentSessionId +}