From 63c4ff5b8da46fea0d28a0e0742998f12338e705 Mon Sep 17 00:00:00 2001 From: cte Date: Thu, 26 Feb 2026 16:01:14 -0800 Subject: [PATCH] fix(cli): scope sessions to current workspace --- .../src/commands/cli/__tests__/list.test.ts | 24 +++--- apps/cli/src/commands/cli/list.ts | 10 +-- apps/cli/src/commands/cli/run.ts | 79 ++++--------------- .../lib/task-history/__tests__/index.test.ts | 75 ++++++++++++++++++ apps/cli/src/lib/task-history/index.ts | 44 +++++++++++ 5 files changed, 151 insertions(+), 81 deletions(-) create mode 100644 apps/cli/src/lib/task-history/__tests__/index.test.ts create mode 100644 apps/cli/src/lib/task-history/index.ts 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 d8ec6d50e8..2ff1ba1113 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -3,7 +3,6 @@ import path from "path" import { fileURLToPath } from "url" import { createElement } from "react" -import type { HistoryItem } from "@roo-code/types" import { setLogger } from "@roo-code/vscode-shim" @@ -22,8 +21,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" @@ -93,38 +92,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: () => {}, @@ -343,6 +310,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) { @@ -377,8 +356,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), }), @@ -405,16 +384,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({ @@ -459,7 +428,6 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption } hostDisposed = true - host.off("extensionWebviewMessage", onExtensionMessage) jsonEmitter?.detach() await host.dispose() } @@ -560,22 +528,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/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 +}