Просмотр исходного кода

fix(cli): scope sessions and resume flags to current workspace (#11774)

fix(cli): scope sessions to current workspace
Chris Estreich 1 месяц назад
Родитель
Сommit
81047a65b6

+ 52 - 3
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 })
 		}
 	}

+ 11 - 13
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<typeof import("@roo-code/core/cli")>()
+vi.mock("@/lib/task-history/index.js", async (importOriginal) => {
+	const actual = await importOriginal<typeof import("@/lib/task-history/index.js")>()
 	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)"])

+ 5 - 5
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<Command, "name" | "source" | "filePath" | "description"
 type ModeLike = { slug: string; name: string }
 type SessionLike = TaskSessionEntry
 type ListHostOptions = { ephemeral: boolean }
-const DEFAULT_CLI_TASK_STORAGE_PATH = path.join(os.homedir(), ".vscode-mock", "global-storage")
 
 export function parseFormat(rawFormat: string | undefined): ListFormat {
 	const format = (rawFormat ?? "json").toLowerCase()
@@ -313,10 +312,11 @@ export async function listModels(options: BaseListOptions): Promise<void> {
 
 export async function listSessions(options: BaseListOptions): Promise<void> {
 	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
 	}
 

+ 17 - 79
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<void> {
 	})
 }
 
-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!)
 				}

+ 7 - 1
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

+ 75 - 0
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<typeof import("@roo-code/core/cli")>()
+	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")
+	})
+})

+ 44 - 0
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<TaskSessionEntry[]> {
+	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
+}