From 45dacf540b00293c97e8eb7eb96bc2b1b3af3023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Fri, 22 May 2026 08:06:26 +0000 Subject: [PATCH] feat: thread step --count/-c to run multiple steps Add --count/-c flag to 'uwf thread step' for running N steps in one invocation, stopping early if $END is reached. - cmdThreadStep now loops up to count times, delegates to cmdThreadStepOnce - CLI parses -c/--count, defaults to 1 (backward compatible single output) - Validation rejects 0, negative, and non-integer counts - 7 new tests covering CLI parsing and count validation Fixes #373 Co-authored-by: uwf-hermes (solve-issue workflow) --- .../src/__tests__/thread-step-count.test.ts | 71 +++++++++++++++++++ packages/cli-workflow/src/cli.ts | 14 ++-- packages/cli-workflow/src/commands/thread.ts | 21 ++++++ 3 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 packages/cli-workflow/src/__tests__/thread-step-count.test.ts diff --git a/packages/cli-workflow/src/__tests__/thread-step-count.test.ts b/packages/cli-workflow/src/__tests__/thread-step-count.test.ts new file mode 100644 index 0000000..2340f0c --- /dev/null +++ b/packages/cli-workflow/src/__tests__/thread-step-count.test.ts @@ -0,0 +1,71 @@ +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; + +const CLI_PATH = join(import.meta.dirname, "..", "cli.js"); + +function runCli(args: string[]): { stdout: string; stderr: string; exitCode: number } { + try { + const stdout = execFileSync("bun", ["run", CLI_PATH, ...args], { + encoding: "utf8", + env: { ...process.env, WORKFLOW_STORAGE_ROOT: "/tmp/uwf-test-nonexistent" }, + stdio: ["ignore", "pipe", "pipe"], + }); + return { stdout, stderr: "", exitCode: 0 }; + } catch (e: unknown) { + const err = e as NodeJS.ErrnoException & { stdout?: string; stderr?: string; status?: number }; + return { + stdout: err.stdout ?? "", + stderr: err.stderr ?? "", + exitCode: err.status ?? 1, + }; + } +} + +describe("thread step --count CLI parsing", () => { + test("--help shows -c/--count option", () => { + const result = runCli(["thread", "step", "--help"]); + expect(result.stdout).toContain("--count"); + expect(result.stdout).toContain("-c"); + }); + + test("description says 'one or more steps'", () => { + const result = runCli(["thread", "step", "--help"]); + expect(result.stdout).toContain("one or more steps"); + }); +}); + +describe("cmdThreadStep count logic", () => { + test("count=0 fails with validation error", () => { + const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "0"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("positive integer"); + }); + + test("negative count fails with validation error", () => { + const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "-1"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("positive integer"); + }); + + test("non-integer count fails with validation error", () => { + const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "1.5"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("positive integer"); + }); + + test("count=1 is the default (no -c flag)", () => { + // Without -c, it should attempt to run 1 step (failing on missing thread, not on count validation) + const result = runCli(["thread", "step", "FAKE_THREAD_ID"]); + expect(result.exitCode).not.toBe(0); + // Should NOT contain "positive integer" error — should fail on thread lookup instead + expect(result.stderr).not.toContain("positive integer"); + }); + + test("count=3 passes validation (fails on thread lookup)", () => { + const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "3"]); + expect(result.exitCode).not.toBe(0); + // Should NOT contain "positive integer" error — should fail on thread/storage lookup + expect(result.stderr).not.toContain("positive integer"); + }); +}); diff --git a/packages/cli-workflow/src/cli.ts b/packages/cli-workflow/src/cli.ts index 378c093..dd8cc72 100755 --- a/packages/cli-workflow/src/cli.ts +++ b/packages/cli-workflow/src/cli.ts @@ -108,15 +108,21 @@ thread thread .command("step") - .description("Execute one step") + .description("Execute one or more steps") .argument("", "Thread ULID") .option("--agent ", "Override agent command") - .action((threadId: string, opts: { agent: string | undefined }) => { + .option("-c, --count ", "Number of steps to run (default: 1)") + .action((threadId: string, opts: { agent: string | undefined; count: string | undefined }) => { const storageRoot = resolveStorageRoot(); runAction(async () => { const agentOverride = opts.agent ?? null; - const result = await cmdThreadStep(storageRoot, threadId, agentOverride); - writeOutput(result); + const count = opts.count !== undefined ? Number(opts.count) : 1; + const results = await cmdThreadStep(storageRoot, threadId, agentOverride, count); + if (results.length === 1) { + writeOutput(results[0]); + } else { + writeOutput(results); + } }); }); diff --git a/packages/cli-workflow/src/commands/thread.ts b/packages/cli-workflow/src/commands/thread.ts index e7146be..e7614bb 100644 --- a/packages/cli-workflow/src/commands/thread.ts +++ b/packages/cli-workflow/src/commands/thread.ts @@ -673,6 +673,27 @@ export async function cmdThreadStep( storageRoot: string, threadId: ThreadId, agentOverride: string | null, + count: number, +): Promise { + if (count < 1 || !Number.isInteger(count)) { + fail(`--count must be a positive integer, got: ${count}`); + } + + const results: StepOutput[] = []; + for (let i = 0; i < count; i++) { + const result = await cmdThreadStepOnce(storageRoot, threadId, agentOverride); + results.push(result); + if (result.done) { + break; + } + } + return results; +} + +async function cmdThreadStepOnce( + storageRoot: string, + threadId: ThreadId, + agentOverride: string | null, ): Promise { const index = await loadThreadsIndex(storageRoot); const headHash = index[threadId];