Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 52 additions & 3 deletions apps/cli/src/agent/output-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand Down Expand Up @@ -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 })
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 })
}
}
Expand Down
24 changes: 11 additions & 13 deletions apps/cli/src/commands/cli/__tests__/list.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
}
})

Expand Down Expand Up @@ -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()
Expand All @@ -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)"])
Expand Down
10 changes: 5 additions & 5 deletions apps/cli/src/commands/cli/list.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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()
Expand Down Expand Up @@ -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
}

Expand Down
96 changes: 17 additions & 79 deletions apps/cli/src/commands/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"
Expand Down Expand Up @@ -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: () => {},
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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),
}),
Expand All @@ -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({
Expand Down Expand Up @@ -497,7 +466,6 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
}

hostDisposed = true
host.off("extensionWebviewMessage", onExtensionMessage)
jsonEmitter?.detach()
await host.dispose()
}
Expand Down Expand Up @@ -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({
Expand All @@ -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!)
}
Expand Down
8 changes: 7 additions & 1 deletion apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading