From 6526ae0770b9263667ec6366bfb026508ccb955a Mon Sep 17 00:00:00 2001 From: cte Date: Wed, 4 Mar 2026 10:26:02 -0800 Subject: [PATCH] feat(cli): add create-with-session-id support rename public task id flag to --create-with-session-id validate session ids as UUIDs for create/resume and stdin start.taskId add integration coverage for create+resume loading correct session --- apps/cli/README.md | 51 +-- ...session-id-resume-loads-correct-session.ts | 364 ++++++++++++++++++ .../cli/__tests__/parse-stdin-command.test.ts | 56 +++ apps/cli/src/commands/cli/run.ts | 35 +- apps/cli/src/commands/cli/stdin-stream.ts | 32 +- apps/cli/src/index.ts | 3 +- apps/cli/src/lib/utils/session-id.ts | 5 + apps/cli/src/types/types.ts | 1 + apps/cli/src/ui/App.tsx | 3 + apps/cli/src/ui/hooks/useExtensionHost.ts | 11 +- packages/types/src/__tests__/cli.test.ts | 12 + packages/types/src/cli.ts | 6 + 12 files changed, 548 insertions(+), 31 deletions(-) create mode 100644 apps/cli/scripts/integration/cases/create-with-session-id-resume-loads-correct-session.ts create mode 100644 apps/cli/src/lib/utils/session-id.ts diff --git a/apps/cli/README.md b/apps/cli/README.md index d1fc3b2f5e2..8dec1f3a1c6 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -113,15 +113,21 @@ Use `--print` for non-interactive execution and machine-readable output: ```bash # Prompt is required roo --print "Summarize this repository" + +# Create a new task with a specific session ID (UUID) +roo --print --create-with-session-id 018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87 "Summarize this repository" ``` ### Stdin Stream Mode (`--stdin-prompt-stream`) For programmatic control (one process, multiple prompts), use `--stdin-prompt-stream` with `--print`. -Send one prompt per line via stdin: +Send NDJSON commands via stdin: ```bash -printf '1+1=?\n10!=?\n' | roo --print --stdin-prompt-stream --output-format stream-json +printf '{"command":"start","requestId":"1","prompt":"1+1=?"}\n' | roo --print --stdin-prompt-stream --output-format stream-json + +# Optional: provide taskId per start command +printf '{"command":"start","requestId":"1","taskId":"018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87","prompt":"1+1=?"}\n' | roo --print --stdin-prompt-stream --output-format stream-json ``` ### Roo Code Cloud Authentication @@ -170,26 +176,27 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo ## Options -| Option | Description | Default | -| --------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------- | -| `[prompt]` | Your prompt (positional argument, optional) | None | -| `--prompt-file ` | Read prompt from a file instead of command line argument | None | -| `-w, --workspace ` | Workspace path to operate in | Current directory | -| `-p, --print` | Print response and exit (non-interactive mode) | `false` | -| `--stdin-prompt-stream` | Read prompts from stdin (one prompt per line, requires `--print`) | `false` | -| `-e, --extension ` | Path to the extension bundle directory | Auto-detected | -| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` | -| `-a, --require-approval` | Require manual approval before actions execute | `false` | -| `-k, --api-key ` | API key for the LLM provider | From env var | -| `--provider ` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) | -| `-m, --model ` | Model to use | `anthropic/claude-opus-4.6` | -| `--mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | -| `--terminal-shell ` | Absolute shell path for inline terminal command execution | Auto-detected shell | -| `-r, --reasoning-effort ` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` | -| `--consecutive-mistake-limit ` | Consecutive error/repetition limit before guidance prompt (`0` disables the limit) | `10` | -| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` | -| `--oneshot` | Exit upon task completion | `false` | -| `--output-format ` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` | +| Option | Description | Default | +| --------------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------- | +| `[prompt]` | Your prompt (positional argument, optional) | None | +| `--prompt-file ` | Read prompt from a file instead of command line argument | None | +| `--create-with-session-id ` | Create a new task using the provided session ID (UUID) | None | +| `-w, --workspace ` | Workspace path to operate in | Current directory | +| `-p, --print` | Print response and exit (non-interactive mode) | `false` | +| `--stdin-prompt-stream` | Read NDJSON control commands from stdin (requires `--print`) | `false` | +| `-e, --extension ` | Path to the extension bundle directory | Auto-detected | +| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` | +| `-a, --require-approval` | Require manual approval before actions execute | `false` | +| `-k, --api-key ` | API key for the LLM provider | From env var | +| `--provider ` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) | +| `-m, --model ` | Model to use | `anthropic/claude-opus-4.6` | +| `--mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | +| `--terminal-shell ` | Absolute shell path for inline terminal command execution | Auto-detected shell | +| `-r, --reasoning-effort ` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` | +| `--consecutive-mistake-limit ` | Consecutive error/repetition limit before guidance prompt (`0` disables the limit) | `10` | +| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` | +| `--oneshot` | Exit upon task completion | `false` | +| `--output-format ` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` | ## Auth Commands diff --git a/apps/cli/scripts/integration/cases/create-with-session-id-resume-loads-correct-session.ts b/apps/cli/scripts/integration/cases/create-with-session-id-resume-loads-correct-session.ts new file mode 100644 index 00000000000..cbefd265258 --- /dev/null +++ b/apps/cli/scripts/integration/cases/create-with-session-id-resume-loads-correct-session.ts @@ -0,0 +1,364 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" +import readline from "readline" +import { fileURLToPath } from "url" +import { randomUUID } from "crypto" + +import { execa } from "execa" +import type { TaskSessionEntry } from "@roo-code/core/cli" + +type StreamEvent = { + type?: string + subtype?: string + requestId?: string + command?: string + taskId?: string + content?: string + code?: string + success?: boolean + done?: boolean +} + +const RESUME_TIMEOUT_MS = 180_000 +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +function parseStreamEvent(line: string): StreamEvent | null { + const trimmed = line.trim() + + if (!trimmed.startsWith("{")) { + return null + } + + try { + return JSON.parse(trimmed) as StreamEvent + } catch { + return null + } +} + +async function listSessions(cliRoot: string, workspacePath: string): Promise { + const result = await execa("pnpm", ["dev", "list", "sessions", "--workspace", workspacePath, "--format", "json"], { + cwd: cliRoot, + reject: false, + }) + + if (result.exitCode !== 0) { + throw new Error(`list sessions failed with exit code ${result.exitCode}: ${result.stderr || result.stdout}`) + } + + const stdoutLines = result.stdout.split("\n") + const jsonStartIndex = stdoutLines.findIndex((line) => line.trim().startsWith("{")) + if (jsonStartIndex === -1) { + throw new Error(`list sessions output did not contain JSON payload: ${result.stdout}`) + } + + const jsonPayload = stdoutLines.slice(jsonStartIndex).join("\n").trim() + + let parsed: unknown + try { + parsed = JSON.parse(jsonPayload) + } catch (error) { + throw new Error( + `failed to parse list sessions output as JSON: ${error instanceof Error ? error.message : String(error)}`, + ) + } + + if ( + typeof parsed !== "object" || + parsed === null || + !("sessions" in parsed) || + !Array.isArray((parsed as { sessions?: unknown }).sessions) + ) { + throw new Error("list sessions output missing sessions array") + } + + return (parsed as { sessions: TaskSessionEntry[] }).sessions +} + +async function createSessionWithCustomId( + cliRoot: string, + workspacePath: string, + sessionId: string, + prompt: string, +): Promise { + const result = await execa( + "pnpm", + [ + "dev", + "--print", + "--provider", + "roo", + "--output-format", + "stream-json", + "--workspace", + workspacePath, + "--create-with-session-id", + sessionId, + prompt, + ], + { + cwd: cliRoot, + reject: false, + }, + ) + + if (result.exitCode !== 0) { + throw new Error( + `create-with-session-id failed for ${sessionId} with exit code ${result.exitCode}: ${result.stderr || result.stdout}`, + ) + } + + const lines = result.stdout.split("\n") + const events = lines.map(parseStreamEvent).filter((event): event is StreamEvent => Boolean(event)) + const errorEvent = events.find((event) => event.type === "error") + + if (errorEvent) { + throw new Error( + `create-with-session-id emitted error for ${sessionId}: code=${errorEvent.code ?? "none"} content=${errorEvent.content ?? ""}`, + ) + } + + const completion = events.find((event) => event.type === "result" && event.done === true) + if (!completion) { + throw new Error(`create-with-session-id did not emit final result for ${sessionId}`) + } + + if (completion.success !== true) { + throw new Error(`create-with-session-id completed unsuccessfully for ${sessionId}`) + } +} + +async function resumeSessionAndSendMarker( + cliRoot: string, + workspacePath: string, + sessionId: string, + messageToken: string, +): Promise { + const pingRequestId = `ping-${Date.now()}` + const messageRequestId = `message-${Date.now()}` + const shutdownRequestId = `shutdown-${Date.now()}` + + const messagePrompt = `Resume marker token: ${messageToken}. Reply with exactly "ack-${messageToken}".` + + const child = execa( + "pnpm", + [ + "dev", + "--print", + "--stdin-prompt-stream", + "--provider", + "roo", + "--output-format", + "stream-json", + "--workspace", + workspacePath, + "--session-id", + sessionId, + ], + { + cwd: cliRoot, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + reject: false, + forceKillAfterDelay: 2_000, + }, + ) + + child.stderr?.on("data", (chunk) => { + process.stderr.write(chunk) + }) + + let pingSent = false + let messageSent = false + let shutdownSent = false + let sawMessageControlDone = false + let sawUserTurnWithMarker = false + let shutdownTaskId: string | undefined + let handlerError: Error | null = null + let timedOut = false + + const sendCommand = (command: { command: "ping" | "message" | "shutdown"; requestId: string; prompt?: string }) => { + if (!child.stdin || child.stdin.destroyed) { + return + } + child.stdin.write(`${JSON.stringify(command)}\n`) + } + + const timeout = setTimeout(() => { + timedOut = true + handlerError = new Error( + `timed out resuming session ${sessionId} (pingSent=${pingSent}, messageSent=${messageSent}, sawMessageControlDone=${sawMessageControlDone}, sawUserTurnWithMarker=${sawUserTurnWithMarker})`, + ) + child.kill("SIGTERM") + }, RESUME_TIMEOUT_MS) + + const rl = readline.createInterface({ + input: child.stdout!, + crlfDelay: Infinity, + }) + + rl.on("line", (line) => { + process.stdout.write(`${line}\n`) + + const event = parseStreamEvent(line) + if (!event) { + return + } + + if (event.type === "system" && event.subtype === "init" && !pingSent) { + pingSent = true + sendCommand({ command: "ping", requestId: pingRequestId }) + return + } + + if ( + event.type === "control" && + event.subtype === "done" && + event.command === "ping" && + event.requestId === pingRequestId && + !messageSent + ) { + messageSent = true + sendCommand({ + command: "message", + requestId: messageRequestId, + prompt: messagePrompt, + }) + return + } + + if ( + event.type === "control" && + event.subtype === "error" && + event.command === "message" && + event.requestId === messageRequestId + ) { + handlerError = new Error( + `message command failed while resuming ${sessionId}: code=${event.code ?? "unknown"} content=${event.content ?? ""}`, + ) + child.kill("SIGTERM") + return + } + + if ( + event.type === "control" && + event.subtype === "done" && + event.command === "message" && + event.requestId === messageRequestId + ) { + sawMessageControlDone = true + return + } + + if (event.type === "user" && event.requestId === messageRequestId && event.content?.includes(messageToken)) { + sawUserTurnWithMarker = true + + if (!shutdownSent) { + shutdownSent = true + sendCommand({ command: "shutdown", requestId: shutdownRequestId }) + } + return + } + + if ( + event.type === "control" && + (event.subtype === "ack" || event.subtype === "done") && + event.command === "shutdown" && + event.requestId === shutdownRequestId && + typeof event.taskId === "string" + ) { + shutdownTaskId = event.taskId + return + } + + if (event.type === "control" && event.subtype === "error" && event.requestId !== shutdownRequestId) { + handlerError = new Error( + `unexpected control error while resuming ${sessionId}: command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`, + ) + child.kill("SIGTERM") + return + } + }) + + const result = await child + clearTimeout(timeout) + rl.close() + + if (handlerError) { + throw handlerError + } + + if (timedOut) { + throw new Error(`stream resume for ${sessionId} timed out`) + } + + if (result.exitCode !== 0) { + throw new Error(`stream resume for ${sessionId} exited non-zero: ${result.exitCode}`) + } + + if (!sawMessageControlDone) { + throw new Error(`did not observe message control completion while resuming ${sessionId}`) + } + + if (!sawUserTurnWithMarker) { + throw new Error(`did not observe resumed user marker turn while resuming ${sessionId}`) + } + + if (shutdownTaskId !== sessionId) { + throw new Error( + `shutdown taskId did not match resumed session (expected=${sessionId}, actual=${shutdownTaskId ?? "none"})`, + ) + } +} + +async function main() { + const cliRoot = process.env.ROO_CLI_ROOT + ? path.resolve(process.env.ROO_CLI_ROOT) + : path.resolve(__dirname, "../../..") + const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "roo-cli-create-session-id-")) + + const firstSessionId = randomUUID() + const secondSessionId = randomUUID() + const firstMarker = `FIRST-MARKER-${Date.now()}` + const secondMarker = `SECOND-MARKER-${Date.now()}` + + try { + await createSessionWithCustomId( + cliRoot, + workspacePath, + firstSessionId, + `Create first session marker ${firstMarker}. Reply with exactly "ok-${firstMarker}".`, + ) + await createSessionWithCustomId( + cliRoot, + workspacePath, + secondSessionId, + `Create second session marker ${secondMarker}. Reply with exactly "ok-${secondMarker}".`, + ) + + const initialSessions = await listSessions(cliRoot, workspacePath) + if (!initialSessions.some((session) => session.id === firstSessionId)) { + throw new Error(`session list missing first custom session id ${firstSessionId}`) + } + if (!initialSessions.some((session) => session.id === secondSessionId)) { + throw new Error(`session list missing second custom session id ${secondSessionId}`) + } + + const resumeMarkerForFirst = `resume-first-${Date.now()}` + await resumeSessionAndSendMarker(cliRoot, workspacePath, firstSessionId, resumeMarkerForFirst) + + const resumeMarkerForSecond = `resume-second-${Date.now()}` + await resumeSessionAndSendMarker(cliRoot, workspacePath, secondSessionId, resumeMarkerForSecond) + + console.log(`[PASS] created and resumed custom sessions: ${firstSessionId}, ${secondSessionId}`) + } finally { + await fs.rm(workspacePath, { recursive: true, force: true }) + } +} + +main().catch((error) => { + console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/apps/cli/src/commands/cli/__tests__/parse-stdin-command.test.ts b/apps/cli/src/commands/cli/__tests__/parse-stdin-command.test.ts index 408ab1bc6e9..3656ac6ce18 100644 --- a/apps/cli/src/commands/cli/__tests__/parse-stdin-command.test.ts +++ b/apps/cli/src/commands/cli/__tests__/parse-stdin-command.test.ts @@ -10,6 +10,24 @@ describe("parseStdinStreamCommand", () => { expect(result).toEqual({ command: "start", requestId: "req-1", prompt: "hello" }) }) + it("parses a start command with taskId", () => { + const result = parseStdinStreamCommand( + JSON.stringify({ + command: "start", + requestId: "req-task-id", + prompt: "hello", + taskId: "018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87", + }), + 1, + ) + expect(result).toEqual({ + command: "start", + requestId: "req-task-id", + prompt: "hello", + taskId: "018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87", + }) + }) + it("parses a message command", () => { const result = parseStdinStreamCommand( JSON.stringify({ command: "message", requestId: "req-2", prompt: "follow up" }), @@ -129,6 +147,44 @@ describe("parseStdinStreamCommand", () => { ) }) + it("throws when start taskId is empty, not a string, or not a UUID", () => { + expect(() => + parseStdinStreamCommand( + JSON.stringify({ + command: "start", + requestId: "req-start-task-id-empty", + prompt: "hello", + taskId: " ", + }), + 1, + ), + ).toThrow('"start" taskId must be a non-empty string') + + expect(() => + parseStdinStreamCommand( + JSON.stringify({ + command: "start", + requestId: "req-start-task-id-num", + prompt: "hello", + taskId: 123, + }), + 1, + ), + ).toThrow('"start" taskId must be a non-empty string') + + expect(() => + parseStdinStreamCommand( + JSON.stringify({ + command: "start", + requestId: "req-start-task-id-invalid-format", + prompt: "hello", + taskId: "task-123", + }), + 1, + ), + ).toThrow('"start" taskId must be a valid UUID') + }) + it("throws when message command has empty prompt", () => { expect(() => parseStdinStreamCommand(JSON.stringify({ command: "message", requestId: "req", prompt: " " }), 1), diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index 2e723599271..62760919e7e 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -28,6 +28,7 @@ import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js" import { runOnboarding } from "@/lib/utils/onboarding.js" import { validateTerminalShellPath } from "@/lib/utils/shell.js" import { getDefaultExtensionPath } from "@/lib/utils/extension.js" +import { isValidSessionId } from "@/lib/utils/session-id.js" import { VERSION } from "@/lib/utils/version.js" import { ExtensionHost, ExtensionHostOptions } from "@/agent/index.js" @@ -126,11 +127,32 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption } const requestedSessionId = flagOptions.sessionId?.trim() + const requestedCreateSessionId = flagOptions.createWithSessionId?.trim() const shouldContinueSession = flagOptions.continue const isResumeRequested = Boolean(requestedSessionId || shouldContinueSession) + if (flagOptions.createWithSessionId !== undefined && !requestedCreateSessionId) { + console.error("[CLI] Error: --create-with-session-id requires a non-empty session id") + process.exit(1) + } + if (flagOptions.sessionId !== undefined && !requestedSessionId) { - console.error("[CLI] Error: --session-id requires a non-empty task id") + console.error("[CLI] Error: --session-id requires a non-empty session id") + process.exit(1) + } + + if (requestedCreateSessionId && !isValidSessionId(requestedCreateSessionId)) { + console.error("[CLI] Error: --create-with-session-id must be a valid UUID session id") + process.exit(1) + } + + if (requestedSessionId && !isValidSessionId(requestedSessionId)) { + console.error("[CLI] Error: --session-id must be a valid UUID session id") + process.exit(1) + } + + if (requestedCreateSessionId && isResumeRequested) { + console.error("[CLI] Error: cannot use --create-with-session-id with --session-id/--continue") process.exit(1) } @@ -141,7 +163,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption if (isResumeRequested && prompt) { console.error("[CLI] Error: cannot use prompt or --prompt-file with --session-id/--continue") - console.error("[CLI] Usage: roo [--session-id | --continue] [options]") + console.error("[CLI] Usage: roo [--session-id | --continue] [options]") process.exit(1) } @@ -342,6 +364,12 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption process.exit(1) } + if (flagOptions.stdinPromptStream && requestedCreateSessionId) { + console.error("[CLI] Error: --create-with-session-id is not supported with --stdin-prompt-stream") + console.error('[CLI] Use per-request "taskId" in stdin start commands instead.') + process.exit(1) + } + const useStdinPromptStream = flagOptions.stdinPromptStream let resolvedResumeSessionId: string | undefined @@ -389,6 +417,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption createElement(App, { ...extensionHostOptions, initialPrompt: prompt, + initialTaskId: requestedCreateSessionId, initialSessionId: resolvedResumeSessionId, continueSession: false, version: VERSION, @@ -612,7 +641,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption if (isResumeRequested) { await host.resumeTask(resolvedResumeSessionId!) } else { - await host.runTask(prompt!) + await host.runTask(prompt!, requestedCreateSessionId) } } diff --git a/apps/cli/src/commands/cli/stdin-stream.ts b/apps/cli/src/commands/cli/stdin-stream.ts index 4b9ac19f9b5..a9e4c474583 100644 --- a/apps/cli/src/commands/cli/stdin-stream.ts +++ b/apps/cli/src/commands/cli/stdin-stream.ts @@ -9,6 +9,7 @@ import { } from "@roo-code/types" import { isRecord } from "@/lib/utils/guards.js" +import { isValidSessionId } from "@/lib/utils/session-id.js" import { isCancellationLikeError, isExpectedControlFlowError, isNoActiveTaskLikeError } from "./cancellation.js" import type { ExtensionHost } from "@/agent/index.js" @@ -79,13 +80,38 @@ export function parseStdinStreamCommand(line: string, lineNumber: number): Stdin images = imagesRaw } - if (command === "start" && isRecord(parsed.configuration)) { + if (command === "start") { + const taskIdRaw = parsed.taskId + let taskId: string | undefined + + if (taskIdRaw !== undefined) { + if (typeof taskIdRaw !== "string" || taskIdRaw.trim().length === 0) { + throw new Error(`stdin command line ${lineNumber}: "start" taskId must be a non-empty string`) + } + taskId = taskIdRaw.trim() + + if (!isValidSessionId(taskId)) { + throw new Error(`stdin command line ${lineNumber}: "start" taskId must be a valid UUID`) + } + } + + if (isRecord(parsed.configuration)) { + return { + command, + requestId, + prompt: promptRaw, + ...(taskId !== undefined ? { taskId } : {}), + ...(images !== undefined ? { images } : {}), + configuration: parsed.configuration as RooCliStartCommand["configuration"], + } + } + return { command, requestId, prompt: promptRaw, + ...(taskId !== undefined ? { taskId } : {}), ...(images !== undefined ? { images } : {}), - configuration: parsed.configuration as RooCliStartCommand["configuration"], } } @@ -616,7 +642,7 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId activeRequestId = stdinCommand.requestId activeTaskCommand = "start" setStreamRequestId(stdinCommand.requestId) - latestTaskId = randomUUID() + latestTaskId = stdinCommand.taskId ?? randomUUID() cancelRequestedForActiveTask = false awaitingPostCancelRecovery = false diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 68bbd29229f..2805e6c9099 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -26,7 +26,8 @@ program program .argument("[prompt]", "Your prompt") .option("--prompt-file ", "Read prompt from a file instead of command line argument") - .option("--session-id ", "Resume a specific task by task ID") + .option("--create-with-session-id ", "Create a new task with a specific session ID (must be a UUID)") + .option("--session-id ", "Resume a specific task by session ID") .option("-c, --continue", "Resume the most recent task in the current workspace", false) .option("-w, --workspace ", "Workspace directory path (defaults to current working directory)") .option("-p, --print", "Print response and exit (non-interactive mode)", false) diff --git a/apps/cli/src/lib/utils/session-id.ts b/apps/cli/src/lib/utils/session-id.ts new file mode 100644 index 00000000000..6bd5b065670 --- /dev/null +++ b/apps/cli/src/lib/utils/session-id.ts @@ -0,0 +1,5 @@ +const SESSION_ID_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +export function isValidSessionId(value: string): boolean { + return SESSION_ID_UUID_PATTERN.test(value) +} diff --git a/apps/cli/src/types/types.ts b/apps/cli/src/types/types.ts index 2759b4f113a..ecd3922aa1c 100644 --- a/apps/cli/src/types/types.ts +++ b/apps/cli/src/types/types.ts @@ -20,6 +20,7 @@ export type ReasoningEffortFlagOptions = ReasoningEffortExtended | "unspecified" export type FlagOptions = { promptFile?: string + createWithSessionId?: string sessionId?: string continue: boolean workspace?: string diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index d38d5a3c18b..ede7c831705 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -60,6 +60,7 @@ const PICKER_HEIGHT = 10 export interface TUIAppProps extends ExtensionHostOptions { initialPrompt?: string + initialTaskId?: string initialSessionId?: string continueSession?: boolean version: string @@ -73,6 +74,7 @@ export interface TUIAppProps extends ExtensionHostOptions { function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps) { const { initialPrompt, + initialTaskId, initialSessionId, continueSession, workspacePath, @@ -174,6 +176,7 @@ function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps) const { sendToExtension, runTask, cleanup } = useExtensionHost({ initialPrompt, + initialTaskId, initialSessionId, continueSession, mode, diff --git a/apps/cli/src/ui/hooks/useExtensionHost.ts b/apps/cli/src/ui/hooks/useExtensionHost.ts index e2feb9e0ea3..235c7c5aa81 100644 --- a/apps/cli/src/ui/hooks/useExtensionHost.ts +++ b/apps/cli/src/ui/hooks/useExtensionHost.ts @@ -39,6 +39,7 @@ function getMostRecentTaskId(taskHistory: HistoryItem[], workspacePath: string): // TODO: Unify with TUIAppProps? export interface UseExtensionHostOptions extends ExtensionHostOptions { initialPrompt?: string + initialTaskId?: string initialSessionId?: string continueSession?: boolean onExtensionMessage: (msg: ExtensionMessage) => void @@ -63,6 +64,7 @@ export interface UseExtensionHostReturn { */ export function useExtensionHost({ initialPrompt, + initialTaskId, initialSessionId, continueSession, mode, @@ -86,6 +88,7 @@ export function useExtensionHost({ const hostRef = useRef(null) const isReadyRef = useRef(false) + const pendingInitialTaskIdRef = useRef(initialTaskId?.trim() || undefined) const cleanup = useCallback(async () => { if (hostRef.current) { @@ -193,7 +196,9 @@ export function useExtensionHost({ setHasStartedTask(true) setLoading(true) addMessage({ id: randomUUID(), role: "user", content: initialPrompt }) - await host.runTask(initialPrompt) + const taskId = pendingInitialTaskIdRef.current + pendingInitialTaskIdRef.current = undefined + await host.runTask(initialPrompt, taskId) } } catch (err) { setError(err instanceof Error ? err.message : String(err)) @@ -221,7 +226,9 @@ export function useExtensionHost({ return Promise.reject(new Error("Extension host not ready")) } - return hostRef.current.runTask(prompt) + const taskId = pendingInitialTaskIdRef.current + pendingInitialTaskIdRef.current = undefined + return hostRef.current.runTask(prompt, taskId) }, []) // Memoized return object to prevent unnecessary re-renders in consumers. diff --git a/packages/types/src/__tests__/cli.test.ts b/packages/types/src/__tests__/cli.test.ts index ff4534c17da..b1b704d5f0f 100644 --- a/packages/types/src/__tests__/cli.test.ts +++ b/packages/types/src/__tests__/cli.test.ts @@ -12,6 +12,7 @@ describe("CLI types", () => { command: "start", requestId: "req-1", prompt: "hello", + taskId: "018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87", images: ["data:image/png;base64,abc"], configuration: {}, }) @@ -38,6 +39,17 @@ describe("CLI types", () => { expect(result.success).toBe(false) }) + + it("rejects a start command with invalid taskId format", () => { + const result = rooCliInputCommandSchema.safeParse({ + command: "start", + requestId: "req-invalid-task-id", + prompt: "hello", + taskId: "task-123", + }) + + expect(result.success).toBe(false) + }) }) describe("rooCliControlEventSchema", () => { diff --git a/packages/types/src/cli.ts b/packages/types/src/cli.ts index 05334dde09f..ad178f745da 100644 --- a/packages/types/src/cli.ts +++ b/packages/types/src/cli.ts @@ -19,9 +19,15 @@ export const rooCliCommandBaseSchema = z.object({ export type RooCliCommandBase = z.infer +const rooCliSessionIdSchema = z + .string() + .trim() + .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) + export const rooCliStartCommandSchema = rooCliCommandBaseSchema.extend({ command: z.literal("start"), prompt: z.string(), + taskId: rooCliSessionIdSchema.optional(), images: z.array(z.string()).optional(), configuration: rooCodeSettingsSchema.optional(), })