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

fix(cli): scope sessions to current workspace
This commit is contained in:
Chris Estreich 2026-02-26 21:32:23 -08:00 committed by GitHub
parent 2f4ce367c4
commit 81047a65b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 211 additions and 101 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

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

View file

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