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
6 changes: 6 additions & 0 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ Re-run the install script to update to the latest version:
curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
```

Or run:

```bash
roo upgrade
```

### Uninstalling

```bash
Expand Down
58 changes: 58 additions & 0 deletions apps/cli/src/agent/__tests__/extension-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,37 @@ describe("ExtensionHost", () => {
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "newTask", text: "test prompt" })
})

it("should include taskId when provided", async () => {
const host = createTestHost()
host.markWebviewReady()

const emitSpy = vi.spyOn(host, "emit")
const client = getPrivate(host, "client") as ExtensionClient

const taskPromise = host.runTask("test prompt", "task-123")

const taskCompletedEvent = {
success: true,
stateInfo: {
state: AgentLoopState.IDLE,
isWaitingForInput: false,
isRunning: false,
isStreaming: false,
requiredAction: "start_task" as const,
description: "Task completed",
},
}
setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10)

await taskPromise

expect(emitSpy).toHaveBeenCalledWith("webviewMessage", {
type: "newTask",
text: "test prompt",
taskId: "task-123",
})
})

it("should resolve when taskCompleted is emitted on client", async () => {
const host = createTestHost()
host.markWebviewReady()
Expand All @@ -525,6 +556,33 @@ describe("ExtensionHost", () => {

await expect(taskPromise).resolves.toBeUndefined()
})

it("should send showTaskWithId for resumeTask and resolve on completion", async () => {
const host = createTestHost()
host.markWebviewReady()

const emitSpy = vi.spyOn(host, "emit")
const client = getPrivate(host, "client") as ExtensionClient

const taskPromise = host.resumeTask("task-abc")

const taskCompletedEvent = {
success: true,
stateInfo: {
state: AgentLoopState.IDLE,
isWaitingForInput: false,
isRunning: false,
isStreaming: false,
requiredAction: "start_task" as const,
description: "Task completed",
},
}
setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10)

await taskPromise

expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "showTaskWithId", text: "task-abc" })
})
})

describe("initial settings", () => {
Expand Down
15 changes: 12 additions & 3 deletions apps/cli/src/agent/extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export interface ExtensionHostInterface extends IExtensionHost<ExtensionHostEven
client: ExtensionClient
activate(): Promise<void>
runTask(prompt: string, taskId?: string): Promise<void>
resumeTask(taskId: string): Promise<void>
sendToExtension(message: WebviewMessage): void
dispose(): Promise<void>
}
Expand Down Expand Up @@ -466,9 +467,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
// Task Management
// ==========================================================================

public async runTask(prompt: string, taskId?: string): Promise<void> {
this.sendToExtension({ type: "newTask", text: prompt, taskId })

private waitForTaskCompletion(): Promise<void> {
return new Promise((resolve, reject) => {
const completeHandler = () => {
cleanup()
Expand Down Expand Up @@ -509,6 +508,16 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
})
}

public async runTask(prompt: string, taskId?: string): Promise<void> {
this.sendToExtension({ type: "newTask", text: prompt, taskId })
return this.waitForTaskCompletion()
}

public async resumeTask(taskId: string): Promise<void> {
this.sendToExtension({ type: "showTaskWithId", text: taskId })
return this.waitForTaskCompletion()
}

// ==========================================================================
// Public Agent State API
// ==========================================================================
Expand Down
59 changes: 58 additions & 1 deletion apps/cli/src/commands/cli/__tests__/list.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
import { parseFormat } from "../list.js"
import * as os from "os"
import * as path from "path"

import { readTaskSessionsFromStoragePath } from "@roo-code/core/cli"

import { listSessions, parseFormat } from "../list.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("parseFormat", () => {
it("defaults to json when undefined", () => {
Expand Down Expand Up @@ -27,3 +40,47 @@ describe("parseFormat", () => {
expect(() => parseFormat("")).toThrow("Invalid format")
})
})

describe("listSessions", () => {
const storagePath = path.join(os.homedir(), ".vscode-mock", "global-storage")

beforeEach(() => {
vi.clearAllMocks()
})

const captureStdout = async (fn: () => Promise<void>): Promise<string> => {
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true)

try {
await fn()
return stdoutSpy.mock.calls.map(([chunk]) => String(chunk)).join("")
} finally {
stdoutSpy.mockRestore()
}
}

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

const output = await captureStdout(() => listSessions({ format: "json" }))

expect(readTaskSessionsFromStoragePath).toHaveBeenCalledWith(storagePath)
expect(JSON.parse(output)).toEqual({
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([
{ 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 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)"])
})
})
88 changes: 88 additions & 0 deletions apps/cli/src/commands/cli/__tests__/upgrade.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { compareVersions, getLatestCliVersion, upgrade } from "../upgrade.js"

function createFetchResponse(body: unknown, init: { ok?: boolean; status?: number } = {}): Response {
const { ok = true, status = 200 } = init
return {
ok,
status,
json: async () => body,
} as Response
}

describe("compareVersions", () => {
it("returns 1 when first version is newer", () => {
expect(compareVersions("0.2.0", "0.1.9")).toBe(1)
})

it("returns -1 when first version is older", () => {
expect(compareVersions("0.1.4", "0.1.5")).toBe(-1)
})

it("returns 0 when versions are equivalent", () => {
expect(compareVersions("v1.2.0", "1.2")).toBe(0)
})

it("supports cli tag prefixes and prerelease metadata", () => {
expect(compareVersions("cli-v1.2.3", "1.2.2")).toBe(1)
expect(compareVersions("1.2.3-beta.1", "1.2.3")).toBe(0)
})
})

describe("getLatestCliVersion", () => {
it("returns the first cli-v release tag from GitHub releases", async () => {
const fetchImpl = (async () =>
createFetchResponse([
{ tag_name: "v9.9.9" },
{ tag_name: "cli-v0.3.1" },
{ tag_name: "cli-v0.3.0" },
])) as typeof fetch

await expect(getLatestCliVersion(fetchImpl)).resolves.toBe("0.3.1")
})

it("throws when release check fails", async () => {
const fetchImpl = (async () => createFetchResponse({}, { ok: false, status: 503 })) as typeof fetch

await expect(getLatestCliVersion(fetchImpl)).rejects.toThrow("Failed to check latest version")
})
})

describe("upgrade", () => {
let logSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined)
})

afterEach(() => {
logSpy.mockRestore()
})

it("does not run installer when already up to date", async () => {
const runInstaller = vi.fn(async () => undefined)
const fetchImpl = (async () => createFetchResponse([{ tag_name: "cli-v0.1.4" }])) as typeof fetch

await upgrade({
currentVersion: "0.1.4",
fetchImpl,
runInstaller,
})

expect(runInstaller).not.toHaveBeenCalled()
expect(logSpy).toHaveBeenCalledWith("Roo CLI is already up to date.")
})

it("runs installer when a newer version is available", async () => {
const runInstaller = vi.fn(async () => undefined)
const fetchImpl = (async () => createFetchResponse([{ tag_name: "cli-v0.2.0" }])) as typeof fetch

await upgrade({
currentVersion: "0.1.4",
fetchImpl,
runInstaller,
})

expect(runInstaller).toHaveBeenCalledTimes(1)
expect(logSpy).toHaveBeenCalledWith("✓ Upgrade completed.")
})
})
1 change: 1 addition & 0 deletions apps/cli/src/commands/cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./run.js"
export * from "./list.js"
export * from "./upgrade.js"
Loading
Loading