feat(cli): support --create-with-session-id and UUID session validation (#11859)

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
This commit is contained in:
Chris Estreich 2026-03-04 10:32:16 -08:00 committed by GitHub
parent 0db51d8bfa
commit 0b0b33e6a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 548 additions and 31 deletions

View file

@ -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 <path>` | Read prompt from a file instead of command line argument | None |
| `-w, --workspace <path>` | 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>` | 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 <key>` | API key for the LLM provider | From env var |
| `--provider <provider>` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) |
| `-m, --model <model>` | Model to use | `anthropic/claude-opus-4.6` |
| `--mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
| `--terminal-shell <path>` | Absolute shell path for inline terminal command execution | Auto-detected shell |
| `-r, --reasoning-effort <effort>` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` |
| `--consecutive-mistake-limit <n>` | 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 <format>` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` |
| Option | Description | Default |
| --------------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------- |
| `[prompt]` | Your prompt (positional argument, optional) | None |
| `--prompt-file <path>` | Read prompt from a file instead of command line argument | None |
| `--create-with-session-id <session-id>` | Create a new task using the provided session ID (UUID) | None |
| `-w, --workspace <path>` | 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>` | 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 <key>` | API key for the LLM provider | From env var |
| `--provider <provider>` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) |
| `-m, --model <model>` | Model to use | `anthropic/claude-opus-4.6` |
| `--mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
| `--terminal-shell <path>` | Absolute shell path for inline terminal command execution | Auto-detected shell |
| `-r, --reasoning-effort <effort>` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` |
| `--consecutive-mistake-limit <n>` | 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 <format>` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` |
## Auth Commands

View file

@ -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<TaskSessionEntry[]> {
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<void> {
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<void> {
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)
})

View file

@ -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),

View file

@ -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 <task-id> | --continue] [options]")
console.error("[CLI] Usage: roo [--session-id <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)
}
}

View file

@ -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

View file

@ -26,7 +26,8 @@ program
program
.argument("[prompt]", "Your prompt")
.option("--prompt-file <path>", "Read prompt from a file instead of command line argument")
.option("--session-id <task-id>", "Resume a specific task by task ID")
.option("--create-with-session-id <session-id>", "Create a new task with a specific session ID (must be a UUID)")
.option("--session-id <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 <path>", "Workspace directory path (defaults to current working directory)")
.option("-p, --print", "Print response and exit (non-interactive mode)", false)

View file

@ -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)
}

View file

@ -20,6 +20,7 @@ export type ReasoningEffortFlagOptions = ReasoningEffortExtended | "unspecified"
export type FlagOptions = {
promptFile?: string
createWithSessionId?: string
sessionId?: string
continue: boolean
workspace?: string

View file

@ -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,

View file

@ -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<ExtensionHostInterface | null>(null)
const isReadyRef = useRef(false)
const pendingInitialTaskIdRef = useRef<string | undefined>(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.

View file

@ -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", () => {

View file

@ -19,9 +19,15 @@ export const rooCliCommandBaseSchema = z.object({
export type RooCliCommandBase = z.infer<typeof rooCliCommandBaseSchema>
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(),
})