From 84f14abf56a84ad0bcf1d0d51de8ea25959f9f0f Mon Sep 17 00:00:00 2001 From: finallylly Date: Tue, 28 Apr 2026 10:56:13 +0800 Subject: [PATCH 1/2] Add CLI task send command --- src/commands/task.ts | 86 +++++++++++++++++ test/runtime/task-command.test.ts | 150 ++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 test/runtime/task-command.test.ts diff --git a/src/commands/task.ts b/src/commands/task.ts index 323ecac3c..d01674e56 100644 --- a/src/commands/task.ts +++ b/src/commands/task.ts @@ -667,6 +667,72 @@ async function unlinkTasks(input: { cwd: string; dependencyId: string; projectPa }; } +async function readStdinText(): Promise { + if (process.stdin.isTTY) { + return ""; + } + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} + +async function resolveSendTaskText(text: string | undefined): Promise { + if (text !== undefined && text.length > 0) { + return text; + } + const stdinText = await readStdinText(); + if (stdinText.length > 0) { + return stdinText; + } + throw new Error("task send requires --text or stdin input."); +} + +async function sendTaskInput(input: { + cwd: string; + taskId: string; + projectPath?: string; + text?: string; + submit: boolean; +}): Promise { + const workspaceRepoPath = await resolveWorkspaceRepoPath(input.projectPath, input.cwd); + const workspaceId = await ensureRuntimeWorkspace(workspaceRepoPath); + const runtimeClient = createRuntimeTrpcClient(workspaceId); + const sent = await runtimeClient.runtime.sendTaskSessionInput.mutate({ + taskId: input.taskId, + text: await resolveSendTaskText(input.text), + appendNewline: false, + }); + if (!sent.ok) { + throw new Error(sent.error ?? "Could not send task input."); + } + if (!input.submit || sent.summary?.agentId === "cline") { + return { + ok: true, + workspacePath: workspaceRepoPath, + taskId: input.taskId, + summary: sent.summary, + submitted: input.submit, + }; + } + const submitted = await runtimeClient.runtime.sendTaskSessionInput.mutate({ + taskId: input.taskId, + text: "\r", + appendNewline: false, + }); + if (!submitted.ok) { + throw new Error(submitted.error ?? "Could not submit task input."); + } + return { + ok: true, + workspacePath: workspaceRepoPath, + taskId: input.taskId, + summary: submitted.summary, + submitted: true, + }; +} + async function startTask(input: { cwd: string; taskId: string; projectPath?: string }): Promise { const workspaceRepoPath = await resolveWorkspaceRepoPath(input.projectPath, input.cwd); const workspaceId = await ensureRuntimeWorkspace(workspaceRepoPath); @@ -1313,6 +1379,26 @@ export function registerTaskCommand(program: Command): void { ); }); + task + .command("send") + .description("Send follow-up input to an existing task session.") + .requiredOption("--task-id ", "Task ID.") + .option("--text ", "Text to send. Reads stdin when omitted.") + .option("--project-path ", "Workspace path. Defaults to current directory workspace.") + .option("--no-submit", "Type the text without pressing Enter.") + .action(async (options: { taskId: string; text?: string; projectPath?: string; submit: boolean }) => { + await runTaskCommand( + async () => + await sendTaskInput({ + cwd: process.cwd(), + taskId: options.taskId, + text: options.text, + projectPath: options.projectPath, + submit: options.submit, + }), + ); + }); + task .command("start") .description("Start a task session and move task to in_progress.") diff --git a/test/runtime/task-command.test.ts b/test/runtime/task-command.test.ts new file mode 100644 index 000000000..f9e482c61 --- /dev/null +++ b/test/runtime/task-command.test.ts @@ -0,0 +1,150 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { registerTaskCommand } from "../../src/commands/task"; + +const sendTaskSessionInput = vi.fn(); +const projectsAdd = vi.fn(); + +vi.mock("@trpc/client", () => ({ + createTRPCProxyClient: vi.fn(() => ({ + projects: { + add: { + mutate: projectsAdd, + }, + }, + runtime: { + sendTaskSessionInput: { + mutate: sendTaskSessionInput, + }, + }, + })), + httpBatchLink: vi.fn((input: unknown) => input), +})); + +vi.mock("../../src/state/workspace-state", () => ({ + loadWorkspaceContext: vi.fn(async (repoPath: string) => ({ + repoPath, + workspaceId: "workspace-1", + statePath: `${repoPath}/.cline/kanban/workspaces/workspace-1`, + git: { + currentBranch: "main", + defaultBranch: "main", + branches: ["main"], + }, + })), + mutateWorkspaceState: vi.fn(), +})); + +function createProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ + writeErr: () => {}, + writeOut: () => {}, + }); + registerTaskCommand(program); + return program; +} + +describe("task command", () => { + let stdout = ""; + let originalWrite: typeof process.stdout.write; + + beforeEach(() => { + stdout = ""; + originalWrite = process.stdout.write; + process.stdout.write = ((chunk: string | Uint8Array) => { + stdout += chunk.toString(); + return true; + }) as typeof process.stdout.write; + projectsAdd.mockResolvedValue({ + ok: true, + project: { + id: "workspace-1", + }, + }); + sendTaskSessionInput.mockResolvedValue({ + ok: true, + summary: { + agentId: "claude", + state: "running", + }, + }); + }); + + afterEach(() => { + process.stdout.write = originalWrite; + vi.clearAllMocks(); + }); + + it("submits terminal task input with a carriage return", async () => { + await createProgram().parseAsync( + ["task", "send", "--task-id", "task-1", "--text", "Continue", "--project-path", "/repo"], + { from: "user" }, + ); + + expect(sendTaskSessionInput).toHaveBeenCalledTimes(2); + expect(sendTaskSessionInput).toHaveBeenNthCalledWith(1, { + appendNewline: false, + taskId: "task-1", + text: "Continue", + }); + expect(sendTaskSessionInput).toHaveBeenNthCalledWith(2, { + appendNewline: false, + taskId: "task-1", + text: "\r", + }); + expect(JSON.parse(stdout)).toMatchObject({ + ok: true, + submitted: true, + taskId: "task-1", + }); + }); + + it("can type terminal task input without submitting", async () => { + await createProgram().parseAsync( + ["task", "send", "--task-id", "task-1", "--text", "Continue", "--project-path", "/repo", "--no-submit"], + { from: "user" }, + ); + + expect(sendTaskSessionInput).toHaveBeenCalledTimes(1); + expect(sendTaskSessionInput).toHaveBeenCalledWith({ + appendNewline: false, + taskId: "task-1", + text: "Continue", + }); + expect(JSON.parse(stdout)).toMatchObject({ + ok: true, + submitted: false, + taskId: "task-1", + }); + }); + + it("does not send a carriage return after Cline task input", async () => { + sendTaskSessionInput.mockResolvedValue({ + ok: true, + summary: { + agentId: "cline", + state: "running", + }, + }); + + await createProgram().parseAsync( + ["task", "send", "--task-id", "task-1", "--text", "Continue", "--project-path", "/repo"], + { from: "user" }, + ); + + expect(sendTaskSessionInput).toHaveBeenCalledTimes(1); + expect(sendTaskSessionInput).toHaveBeenCalledWith({ + appendNewline: false, + taskId: "task-1", + text: "Continue", + }); + expect(JSON.parse(stdout)).toMatchObject({ + ok: true, + submitted: true, + taskId: "task-1", + }); + }); +}); From 8a9fa3cd864f4dc714953c0369d90d839951140b Mon Sep 17 00:00:00 2001 From: finallylly Date: Tue, 28 Apr 2026 11:21:00 +0800 Subject: [PATCH 2/2] Handle task send stdin edge cases --- src/commands/task.ts | 9 ++++-- test/runtime/task-command.test.ts | 50 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/commands/task.ts b/src/commands/task.ts index d01674e56..1e39e13c7 100644 --- a/src/commands/task.ts +++ b/src/commands/task.ts @@ -675,11 +675,16 @@ async function readStdinText(): Promise { for await (const chunk of process.stdin) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } - return Buffer.concat(chunks).toString("utf8"); + return Buffer.concat(chunks) + .toString("utf8") + .replace(/\r?\n$/, ""); } async function resolveSendTaskText(text: string | undefined): Promise { - if (text !== undefined && text.length > 0) { + if (text !== undefined) { + if (text.length === 0) { + throw new Error("--text value cannot be empty. Omit the flag to read from stdin."); + } return text; } const stdinText = await readStdinText(); diff --git a/test/runtime/task-command.test.ts b/test/runtime/task-command.test.ts index f9e482c61..147e72920 100644 --- a/test/runtime/task-command.test.ts +++ b/test/runtime/task-command.test.ts @@ -147,4 +147,54 @@ describe("task command", () => { taskId: "task-1", }); }); + + it("trims one trailing newline from stdin input", async () => { + const originalIsTty = process.stdin.isTTY; + const originalAsyncIterator = process.stdin[Symbol.asyncIterator]; + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: false, + }); + process.stdin[Symbol.asyncIterator] = async function* () { + yield Buffer.from("Continue\n"); + }; + + try { + await createProgram().parseAsync(["task", "send", "--task-id", "task-1", "--project-path", "/repo"], { + from: "user", + }); + } finally { + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: originalIsTty, + }); + process.stdin[Symbol.asyncIterator] = originalAsyncIterator; + } + + expect(sendTaskSessionInput).toHaveBeenNthCalledWith(1, { + appendNewline: false, + taskId: "task-1", + text: "Continue", + }); + expect(sendTaskSessionInput).toHaveBeenNthCalledWith(2, { + appendNewline: false, + taskId: "task-1", + text: "\r", + }); + }); + + it("rejects an empty text option", async () => { + await createProgram().parseAsync( + ["task", "send", "--task-id", "task-1", "--text", "", "--project-path", "/repo"], + { from: "user" }, + ); + + expect(sendTaskSessionInput).not.toHaveBeenCalled(); + expect(JSON.parse(stdout)).toMatchObject({ + ok: false, + error: expect.stringContaining("--text value cannot be empty"), + }); + expect(process.exitCode).toBe(1); + process.exitCode = undefined; + }); });