Skip to content

Commit de0e363

Browse files
authored
feat(cli): add session resume/history and upgrade command (#11768)
feat(cli): add session history/resume and upgrade command
1 parent e25b1f2 commit de0e363

File tree

17 files changed

+846
-17
lines changed

17 files changed

+846
-17
lines changed

apps/cli/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ Re-run the install script to update to the latest version:
4141
curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
4242
```
4343

44+
Or run:
45+
46+
```bash
47+
roo upgrade
48+
```
49+
4450
### Uninstalling
4551

4652
```bash

apps/cli/src/agent/__tests__/extension-host.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,37 @@ describe("ExtensionHost", () => {
502502
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "newTask", text: "test prompt" })
503503
})
504504

505+
it("should include taskId when provided", async () => {
506+
const host = createTestHost()
507+
host.markWebviewReady()
508+
509+
const emitSpy = vi.spyOn(host, "emit")
510+
const client = getPrivate(host, "client") as ExtensionClient
511+
512+
const taskPromise = host.runTask("test prompt", "task-123")
513+
514+
const taskCompletedEvent = {
515+
success: true,
516+
stateInfo: {
517+
state: AgentLoopState.IDLE,
518+
isWaitingForInput: false,
519+
isRunning: false,
520+
isStreaming: false,
521+
requiredAction: "start_task" as const,
522+
description: "Task completed",
523+
},
524+
}
525+
setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10)
526+
527+
await taskPromise
528+
529+
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", {
530+
type: "newTask",
531+
text: "test prompt",
532+
taskId: "task-123",
533+
})
534+
})
535+
505536
it("should resolve when taskCompleted is emitted on client", async () => {
506537
const host = createTestHost()
507538
host.markWebviewReady()
@@ -525,6 +556,33 @@ describe("ExtensionHost", () => {
525556

526557
await expect(taskPromise).resolves.toBeUndefined()
527558
})
559+
560+
it("should send showTaskWithId for resumeTask and resolve on completion", async () => {
561+
const host = createTestHost()
562+
host.markWebviewReady()
563+
564+
const emitSpy = vi.spyOn(host, "emit")
565+
const client = getPrivate(host, "client") as ExtensionClient
566+
567+
const taskPromise = host.resumeTask("task-abc")
568+
569+
const taskCompletedEvent = {
570+
success: true,
571+
stateInfo: {
572+
state: AgentLoopState.IDLE,
573+
isWaitingForInput: false,
574+
isRunning: false,
575+
isStreaming: false,
576+
requiredAction: "start_task" as const,
577+
description: "Task completed",
578+
},
579+
}
580+
setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10)
581+
582+
await taskPromise
583+
584+
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "showTaskWithId", text: "task-abc" })
585+
})
528586
})
529587

530588
describe("initial settings", () => {

apps/cli/src/agent/extension-host.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export interface ExtensionHostInterface extends IExtensionHost<ExtensionHostEven
108108
client: ExtensionClient
109109
activate(): Promise<void>
110110
runTask(prompt: string, taskId?: string): Promise<void>
111+
resumeTask(taskId: string): Promise<void>
111112
sendToExtension(message: WebviewMessage): void
112113
dispose(): Promise<void>
113114
}
@@ -466,9 +467,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
466467
// Task Management
467468
// ==========================================================================
468469

469-
public async runTask(prompt: string, taskId?: string): Promise<void> {
470-
this.sendToExtension({ type: "newTask", text: prompt, taskId })
471-
470+
private waitForTaskCompletion(): Promise<void> {
472471
return new Promise((resolve, reject) => {
473472
const completeHandler = () => {
474473
cleanup()
@@ -509,6 +508,16 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
509508
})
510509
}
511510

511+
public async runTask(prompt: string, taskId?: string): Promise<void> {
512+
this.sendToExtension({ type: "newTask", text: prompt, taskId })
513+
return this.waitForTaskCompletion()
514+
}
515+
516+
public async resumeTask(taskId: string): Promise<void> {
517+
this.sendToExtension({ type: "showTaskWithId", text: taskId })
518+
return this.waitForTaskCompletion()
519+
}
520+
512521
// ==========================================================================
513522
// Public Agent State API
514523
// ==========================================================================

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

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
1-
import { parseFormat } from "../list.js"
1+
import * as os from "os"
2+
import * as path from "path"
3+
4+
import { readTaskSessionsFromStoragePath } from "@roo-code/core/cli"
5+
6+
import { listSessions, parseFormat } from "../list.js"
7+
8+
vi.mock("@roo-code/core/cli", async (importOriginal) => {
9+
const actual = await importOriginal<typeof import("@roo-code/core/cli")>()
10+
return {
11+
...actual,
12+
readTaskSessionsFromStoragePath: vi.fn(),
13+
}
14+
})
215

316
describe("parseFormat", () => {
417
it("defaults to json when undefined", () => {
@@ -27,3 +40,47 @@ describe("parseFormat", () => {
2740
expect(() => parseFormat("")).toThrow("Invalid format")
2841
})
2942
})
43+
44+
describe("listSessions", () => {
45+
const storagePath = path.join(os.homedir(), ".vscode-mock", "global-storage")
46+
47+
beforeEach(() => {
48+
vi.clearAllMocks()
49+
})
50+
51+
const captureStdout = async (fn: () => Promise<void>): Promise<string> => {
52+
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
53+
54+
try {
55+
await fn()
56+
return stdoutSpy.mock.calls.map(([chunk]) => String(chunk)).join("")
57+
} finally {
58+
stdoutSpy.mockRestore()
59+
}
60+
}
61+
62+
it("uses the CLI runtime storage path and prints JSON output", async () => {
63+
vi.mocked(readTaskSessionsFromStoragePath).mockResolvedValue([
64+
{ id: "s1", task: "Task 1", ts: 1_700_000_000_000, mode: "code" },
65+
])
66+
67+
const output = await captureStdout(() => listSessions({ format: "json" }))
68+
69+
expect(readTaskSessionsFromStoragePath).toHaveBeenCalledWith(storagePath)
70+
expect(JSON.parse(output)).toEqual({
71+
sessions: [{ id: "s1", task: "Task 1", ts: 1_700_000_000_000, mode: "code" }],
72+
})
73+
})
74+
75+
it("prints tab-delimited text output with ISO timestamps and formatted titles", async () => {
76+
vi.mocked(readTaskSessionsFromStoragePath).mockResolvedValue([
77+
{ id: "s1", task: "Task 1", ts: Date.UTC(2024, 0, 1, 0, 0, 0) },
78+
{ id: "s2", task: " ", ts: Date.UTC(2024, 0, 1, 1, 0, 0) },
79+
])
80+
81+
const output = await captureStdout(() => listSessions({ format: "text" }))
82+
const lines = output.trim().split("\n")
83+
84+
expect(lines).toEqual(["s1\t2024-01-01T00:00:00.000Z\tTask 1", "s2\t2024-01-01T01:00:00.000Z\t(untitled)"])
85+
})
86+
})
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { compareVersions, getLatestCliVersion, upgrade } from "../upgrade.js"
2+
3+
function createFetchResponse(body: unknown, init: { ok?: boolean; status?: number } = {}): Response {
4+
const { ok = true, status = 200 } = init
5+
return {
6+
ok,
7+
status,
8+
json: async () => body,
9+
} as Response
10+
}
11+
12+
describe("compareVersions", () => {
13+
it("returns 1 when first version is newer", () => {
14+
expect(compareVersions("0.2.0", "0.1.9")).toBe(1)
15+
})
16+
17+
it("returns -1 when first version is older", () => {
18+
expect(compareVersions("0.1.4", "0.1.5")).toBe(-1)
19+
})
20+
21+
it("returns 0 when versions are equivalent", () => {
22+
expect(compareVersions("v1.2.0", "1.2")).toBe(0)
23+
})
24+
25+
it("supports cli tag prefixes and prerelease metadata", () => {
26+
expect(compareVersions("cli-v1.2.3", "1.2.2")).toBe(1)
27+
expect(compareVersions("1.2.3-beta.1", "1.2.3")).toBe(0)
28+
})
29+
})
30+
31+
describe("getLatestCliVersion", () => {
32+
it("returns the first cli-v release tag from GitHub releases", async () => {
33+
const fetchImpl = (async () =>
34+
createFetchResponse([
35+
{ tag_name: "v9.9.9" },
36+
{ tag_name: "cli-v0.3.1" },
37+
{ tag_name: "cli-v0.3.0" },
38+
])) as typeof fetch
39+
40+
await expect(getLatestCliVersion(fetchImpl)).resolves.toBe("0.3.1")
41+
})
42+
43+
it("throws when release check fails", async () => {
44+
const fetchImpl = (async () => createFetchResponse({}, { ok: false, status: 503 })) as typeof fetch
45+
46+
await expect(getLatestCliVersion(fetchImpl)).rejects.toThrow("Failed to check latest version")
47+
})
48+
})
49+
50+
describe("upgrade", () => {
51+
let logSpy: ReturnType<typeof vi.spyOn>
52+
53+
beforeEach(() => {
54+
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined)
55+
})
56+
57+
afterEach(() => {
58+
logSpy.mockRestore()
59+
})
60+
61+
it("does not run installer when already up to date", async () => {
62+
const runInstaller = vi.fn(async () => undefined)
63+
const fetchImpl = (async () => createFetchResponse([{ tag_name: "cli-v0.1.4" }])) as typeof fetch
64+
65+
await upgrade({
66+
currentVersion: "0.1.4",
67+
fetchImpl,
68+
runInstaller,
69+
})
70+
71+
expect(runInstaller).not.toHaveBeenCalled()
72+
expect(logSpy).toHaveBeenCalledWith("Roo CLI is already up to date.")
73+
})
74+
75+
it("runs installer when a newer version is available", async () => {
76+
const runInstaller = vi.fn(async () => undefined)
77+
const fetchImpl = (async () => createFetchResponse([{ tag_name: "cli-v0.2.0" }])) as typeof fetch
78+
79+
await upgrade({
80+
currentVersion: "0.1.4",
81+
fetchImpl,
82+
runInstaller,
83+
})
84+
85+
expect(runInstaller).toHaveBeenCalledTimes(1)
86+
expect(logSpy).toHaveBeenCalledWith("✓ Upgrade completed.")
87+
})
88+
})

apps/cli/src/commands/cli/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from "./run.js"
22
export * from "./list.js"
3+
export * from "./upgrade.js"

0 commit comments

Comments
 (0)