Skip to content
Open
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
91 changes: 91 additions & 0 deletions src/commands/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,77 @@ async function unlinkTasks(input: { cwd: string; dependencyId: string; projectPa
};
}

async function readStdinText(): Promise<string> {
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")
.replace(/\r?\n$/, "");
}

async function resolveSendTaskText(text: string | undefined): Promise<string> {
if (text !== undefined) {
if (text.length === 0) {
throw new Error("--text value cannot be empty. Omit the flag to read from stdin.");
}
return text;
}
Comment thread
finallylly marked this conversation as resolved.
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<JsonRecord> {
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<JsonRecord> {
const workspaceRepoPath = await resolveWorkspaceRepoPath(input.projectPath, input.cwd);
const workspaceId = await ensureRuntimeWorkspace(workspaceRepoPath);
Expand Down Expand Up @@ -1329,6 +1400,26 @@ export function registerTaskCommand(program: Command): void {
);
});

task
.command("send")
.description("Send follow-up input to an existing task session.")
.requiredOption("--task-id <id>", "Task ID.")
.option("--text <text>", "Text to send. Reads stdin when omitted.")
.option("--project-path <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.")
Expand Down
200 changes: 200 additions & 0 deletions test/runtime/task-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
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",
});
});

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