Skip to content

Commit 81047a6

Browse files
authored
fix(cli): scope sessions and resume flags to current workspace (#11774)
fix(cli): scope sessions to current workspace
1 parent 2f4ce36 commit 81047a6

File tree

7 files changed

+211
-101
lines changed

7 files changed

+211
-101
lines changed

apps/cli/src/agent/output-manager.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ export class OutputManager {
8585
*/
8686
private currentlyStreamingTs: number | null = null
8787

88+
/**
89+
* Track whether a say:completion_result has been streamed,
90+
* so the subsequent ask:completion_result doesn't duplicate the text.
91+
*/
92+
private completionResultStreamed = false
93+
8894
/**
8995
* Track first partial logs (for debugging first/last pattern).
9096
*/
@@ -197,6 +203,7 @@ export class OutputManager {
197203
this.displayedMessages.clear()
198204
this.streamedContent.clear()
199205
this.currentlyStreamingTs = null
206+
this.completionResultStreamed = false
200207
this.loggedFirstPartial.clear()
201208
this.streamingState.next({ ts: null, isStreaming: false })
202209
}
@@ -248,8 +255,13 @@ export class OutputManager {
248255
this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete)
249256
break
250257

251-
// Note: completion_result is an "ask" type, not a "say" type.
252-
// It is handled via the TaskCompleted event in extension-host.ts
258+
case "completion_result":
259+
// completion_result can arrive as both a "say" (with streamed text)
260+
// and an "ask" (handled via TaskCompleted in extension-host.ts).
261+
// Stream the say variant here; the ask variant is handled by
262+
// outputCompletionResult which will skip if already displayed.
263+
this.outputCompletionSayMessage(ts, text, isPartial, alreadyDisplayedComplete)
264+
break
253265

254266
case "error":
255267
if (!alreadyDisplayedComplete) {
@@ -401,13 +413,50 @@ export class OutputManager {
401413
}
402414
}
403415

416+
/**
417+
* Output a say:completion_result message (streamed text of the completion).
418+
* The subsequent ask:completion_result is handled by outputCompletionResult.
419+
*/
420+
private outputCompletionSayMessage(
421+
ts: number,
422+
text: string,
423+
isPartial: boolean,
424+
alreadyDisplayedComplete: boolean | undefined,
425+
): void {
426+
if (isPartial && text) {
427+
this.streamContent(ts, text, "[assistant]")
428+
this.displayedMessages.set(ts, { ts, text, partial: true })
429+
this.completionResultStreamed = true
430+
} else if (!isPartial && text && !alreadyDisplayedComplete) {
431+
const streamed = this.streamedContent.get(ts)
432+
433+
if (streamed) {
434+
if (text.length > streamed.text.length && text.startsWith(streamed.text)) {
435+
const delta = text.slice(streamed.text.length)
436+
this.writeRaw(delta)
437+
}
438+
this.finishStream(ts)
439+
} else {
440+
this.output("\n[assistant]", text)
441+
}
442+
443+
this.displayedMessages.set(ts, { ts, text, partial: false })
444+
this.completionResultStreamed = true
445+
}
446+
}
447+
404448
/**
405449
* Output completion message (called from TaskCompleted handler).
406450
*/
407451
outputCompletionResult(ts: number, text: string): void {
408452
const previousDisplay = this.displayedMessages.get(ts)
409453
if (!previousDisplay || previousDisplay.partial) {
410-
this.output("\n[task complete]", text || "")
454+
if (this.completionResultStreamed) {
455+
// Text was already streamed via say:completion_result.
456+
this.output("\n[task complete]")
457+
} else {
458+
this.output("\n[task complete]", text || "")
459+
}
411460
this.displayedMessages.set(ts, { ts, text: text || "", partial: false })
412461
}
413462
}

apps/cli/src/commands/cli/__tests__/list.test.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
1-
import * as os from "os"
2-
import * as path from "path"
3-
4-
import { readTaskSessionsFromStoragePath } from "@roo-code/core/cli"
1+
import { readWorkspaceTaskSessions } from "@/lib/task-history/index.js"
52

63
import { listSessions, parseFormat } from "../list.js"
74

8-
vi.mock("@roo-code/core/cli", async (importOriginal) => {
9-
const actual = await importOriginal<typeof import("@roo-code/core/cli")>()
5+
vi.mock("@/lib/task-history/index.js", async (importOriginal) => {
6+
const actual = await importOriginal<typeof import("@/lib/task-history/index.js")>()
107
return {
118
...actual,
12-
readTaskSessionsFromStoragePath: vi.fn(),
9+
readWorkspaceTaskSessions: vi.fn(),
1310
}
1411
})
1512

@@ -42,7 +39,7 @@ describe("parseFormat", () => {
4239
})
4340

4441
describe("listSessions", () => {
45-
const storagePath = path.join(os.homedir(), ".vscode-mock", "global-storage")
42+
const workspacePath = process.cwd()
4643

4744
beforeEach(() => {
4845
vi.clearAllMocks()
@@ -60,25 +57,26 @@ describe("listSessions", () => {
6057
}
6158

6259
it("uses the CLI runtime storage path and prints JSON output", async () => {
63-
vi.mocked(readTaskSessionsFromStoragePath).mockResolvedValue([
60+
vi.mocked(readWorkspaceTaskSessions).mockResolvedValue([
6461
{ id: "s1", task: "Task 1", ts: 1_700_000_000_000, mode: "code" },
6562
])
6663

67-
const output = await captureStdout(() => listSessions({ format: "json" }))
64+
const output = await captureStdout(() => listSessions({ format: "json", workspace: workspacePath }))
6865

69-
expect(readTaskSessionsFromStoragePath).toHaveBeenCalledWith(storagePath)
66+
expect(readWorkspaceTaskSessions).toHaveBeenCalledWith(workspacePath)
7067
expect(JSON.parse(output)).toEqual({
68+
workspace: workspacePath,
7169
sessions: [{ id: "s1", task: "Task 1", ts: 1_700_000_000_000, mode: "code" }],
7270
})
7371
})
7472

7573
it("prints tab-delimited text output with ISO timestamps and formatted titles", async () => {
76-
vi.mocked(readTaskSessionsFromStoragePath).mockResolvedValue([
74+
vi.mocked(readWorkspaceTaskSessions).mockResolvedValue([
7775
{ id: "s1", task: "Task 1", ts: Date.UTC(2024, 0, 1, 0, 0, 0) },
7876
{ id: "s2", task: " ", ts: Date.UTC(2024, 0, 1, 1, 0, 0) },
7977
])
8078

81-
const output = await captureStdout(() => listSessions({ format: "text" }))
79+
const output = await captureStdout(() => listSessions({ format: "text", workspace: workspacePath }))
8280
const lines = output.trim().split("\n")
8381

8482
expect(lines).toEqual(["s1\t2024-01-01T00:00:00.000Z\tTask 1", "s2\t2024-01-01T01:00:00.000Z\t(untitled)"])

apps/cli/src/commands/cli/list.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import fs from "fs"
2-
import os from "os"
32
import path from "path"
43
import { fileURLToPath } from "url"
54

65
import pWaitFor from "p-wait-for"
76

8-
import { readTaskSessionsFromStoragePath, type TaskSessionEntry } from "@roo-code/core/cli"
7+
import type { TaskSessionEntry } from "@roo-code/core/cli"
98
import type { Command, ModelRecord, WebviewMessage } from "@roo-code/types"
109
import { getProviderDefaultModelId } from "@roo-code/types"
1110

1211
import { ExtensionHost, type ExtensionHostOptions } from "@/agent/index.js"
12+
import { readWorkspaceTaskSessions } from "@/lib/task-history/index.js"
1313
import { loadToken } from "@/lib/storage/index.js"
1414
import { getDefaultExtensionPath } from "@/lib/utils/extension.js"
1515
import { getApiKeyFromEnv } from "@/lib/utils/provider.js"
@@ -33,7 +33,6 @@ type CommandLike = Pick<Command, "name" | "source" | "filePath" | "description"
3333
type ModeLike = { slug: string; name: string }
3434
type SessionLike = TaskSessionEntry
3535
type ListHostOptions = { ephemeral: boolean }
36-
const DEFAULT_CLI_TASK_STORAGE_PATH = path.join(os.homedir(), ".vscode-mock", "global-storage")
3736

3837
export function parseFormat(rawFormat: string | undefined): ListFormat {
3938
const format = (rawFormat ?? "json").toLowerCase()
@@ -313,10 +312,11 @@ export async function listModels(options: BaseListOptions): Promise<void> {
313312

314313
export async function listSessions(options: BaseListOptions): Promise<void> {
315314
const format = parseFormat(options.format)
316-
const sessions = await readTaskSessionsFromStoragePath(DEFAULT_CLI_TASK_STORAGE_PATH)
315+
const workspacePath = resolveWorkspacePath(options.workspace)
316+
const sessions = await readWorkspaceTaskSessions(workspacePath)
317317

318318
if (format === "json") {
319-
outputJson({ sessions })
319+
outputJson({ workspace: workspacePath, sessions })
320320
return
321321
}
322322

apps/cli/src/commands/cli/run.ts

Lines changed: 17 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { fileURLToPath } from "url"
55
import { createElement } from "react"
66
import pWaitFor from "p-wait-for"
77

8-
import type { HistoryItem } from "@roo-code/types"
98
import { setLogger } from "@roo-code/vscode-shim"
109

1110
import {
@@ -23,8 +22,8 @@ import { JsonEventEmitter } from "@/agent/json-event-emitter.js"
2322

2423
import { createClient } from "@/lib/sdk/index.js"
2524
import { loadToken, loadSettings } from "@/lib/storage/index.js"
25+
import { readWorkspaceTaskSessions, resolveWorkspaceResumeSessionId } from "@/lib/task-history/index.js"
2626
import { isRecord } from "@/lib/utils/guards.js"
27-
import { arePathsEqual } from "@/lib/utils/path.js"
2827
import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js"
2928
import { runOnboarding } from "@/lib/utils/onboarding.js"
3029
import { getDefaultExtensionPath } from "@/lib/utils/extension.js"
@@ -105,38 +104,6 @@ async function warmRooModels(host: ExtensionHost): Promise<void> {
105104
})
106105
}
107106

108-
function extractTaskHistoryFromMessage(message: unknown): HistoryItem[] | undefined {
109-
if (!isRecord(message)) {
110-
return undefined
111-
}
112-
113-
if (message.type === "state") {
114-
const state = isRecord(message.state) ? message.state : undefined
115-
if (Array.isArray(state?.taskHistory)) {
116-
return state.taskHistory as HistoryItem[]
117-
}
118-
}
119-
120-
if (message.type === "taskHistoryUpdated" && Array.isArray(message.taskHistory)) {
121-
return message.taskHistory as HistoryItem[]
122-
}
123-
124-
return undefined
125-
}
126-
127-
function getMostRecentTaskIdInWorkspace(taskHistory: HistoryItem[], workspacePath: string): string | undefined {
128-
const workspaceTasks = taskHistory.filter(
129-
(item) => typeof item.workspace === "string" && arePathsEqual(item.workspace, workspacePath),
130-
)
131-
132-
if (workspaceTasks.length === 0) {
133-
return undefined
134-
}
135-
136-
const sorted = [...workspaceTasks].sort((a, b) => b.ts - a.ts)
137-
return sorted[0]?.id
138-
}
139-
140107
export async function run(promptArg: string | undefined, flagOptions: FlagOptions) {
141108
setLogger({
142109
info: () => {},
@@ -360,6 +327,18 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
360327
}
361328

362329
const useStdinPromptStream = flagOptions.stdinPromptStream
330+
let resolvedResumeSessionId: string | undefined
331+
332+
if (isResumeRequested) {
333+
const workspaceSessions = await readWorkspaceTaskSessions(effectiveWorkspacePath)
334+
try {
335+
resolvedResumeSessionId = resolveWorkspaceResumeSessionId(workspaceSessions, requestedSessionId)
336+
} catch (error) {
337+
const message = error instanceof Error ? error.message : String(error)
338+
console.error(`[CLI] Error: ${message}`)
339+
process.exit(1)
340+
}
341+
}
363342

364343
if (!isTuiEnabled) {
365344
if (!prompt && !useStdinPromptStream && !isResumeRequested) {
@@ -394,8 +373,8 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
394373
createElement(App, {
395374
...extensionHostOptions,
396375
initialPrompt: prompt,
397-
initialSessionId: requestedSessionId,
398-
continueSession: shouldContinueSession,
376+
initialSessionId: resolvedResumeSessionId,
377+
continueSession: false,
399378
version: VERSION,
400379
createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts),
401380
}),
@@ -422,16 +401,6 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
422401
let keepAliveInterval: NodeJS.Timeout | undefined
423402
let isShuttingDown = false
424403
let hostDisposed = false
425-
let taskHistorySnapshot: HistoryItem[] = []
426-
427-
const onExtensionMessage = (message: unknown) => {
428-
const taskHistory = extractTaskHistoryFromMessage(message)
429-
if (taskHistory) {
430-
taskHistorySnapshot = taskHistory
431-
}
432-
}
433-
434-
host.on("extensionWebviewMessage", onExtensionMessage)
435404

436405
const jsonEmitter = useJsonOutput
437406
? new JsonEventEmitter({
@@ -497,7 +466,6 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
497466
}
498467

499468
hostDisposed = true
500-
host.off("extensionWebviewMessage", onExtensionMessage)
501469
jsonEmitter?.detach()
502470
await host.dispose()
503471
}
@@ -594,22 +562,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
594562
}
595563

596564
if (isResumeRequested) {
597-
const resolvedSessionId =
598-
requestedSessionId ||
599-
getMostRecentTaskIdInWorkspace(taskHistorySnapshot, effectiveWorkspacePath)
600-
601-
if (requestedSessionId && taskHistorySnapshot.length > 0) {
602-
const hasRequestedTask = taskHistorySnapshot.some((item) => item.id === requestedSessionId)
603-
if (!hasRequestedTask) {
604-
throw new Error(`Session not found in task history: ${requestedSessionId}`)
605-
}
606-
}
607-
608-
if (!resolvedSessionId) {
609-
throw new Error("No previous tasks found to continue in this workspace.")
610-
}
611-
612-
await bootstrapResumeForStdinStream(host, resolvedSessionId)
565+
await bootstrapResumeForStdinStream(host, resolvedResumeSessionId!)
613566
}
614567

615568
await runStdinStreamMode({
@@ -621,22 +574,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
621574
})
622575
} else {
623576
if (isResumeRequested) {
624-
const resolvedSessionId =
625-
requestedSessionId ||
626-
getMostRecentTaskIdInWorkspace(taskHistorySnapshot, effectiveWorkspacePath)
627-
628-
if (requestedSessionId && taskHistorySnapshot.length > 0) {
629-
const hasRequestedTask = taskHistorySnapshot.some((item) => item.id === requestedSessionId)
630-
if (!hasRequestedTask) {
631-
throw new Error(`Session not found in task history: ${requestedSessionId}`)
632-
}
633-
}
634-
635-
if (!resolvedSessionId) {
636-
throw new Error("No previous tasks found to continue in this workspace.")
637-
}
638-
639-
await host.resumeTask(resolvedSessionId)
577+
await host.resumeTask(resolvedResumeSessionId!)
640578
} else {
641579
await host.runTask(prompt!)
642580
}

apps/cli/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ program
2020
.name("roo")
2121
.description("Roo Code CLI - starts an interactive session by default, use -p/--print for non-interactive output")
2222
.version(VERSION)
23+
.enablePositionalOptions()
24+
.passThroughOptions()
2325

2426
program
2527
.argument("[prompt]", "Your prompt")
@@ -65,7 +67,11 @@ program
6567
)
6668
.action(run)
6769

68-
const listCommand = program.command("list").description("List commands, modes, models, or sessions")
70+
const listCommand = program
71+
.command("list")
72+
.description("List commands, modes, models, or sessions")
73+
.enablePositionalOptions()
74+
.passThroughOptions()
6975

7076
const applyListOptions = (command: Command) =>
7177
command

0 commit comments

Comments
 (0)