From ae757e4d4429355593e553c4365630764abe577b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sun, 7 Jun 2026 13:46:25 +0000 Subject: [PATCH] feat(cli): thread list defaults to active threads only Closes #147. Changes default behavior of `uwf thread list` to show only active threads (idle + running). Adds `--all` flag to opt into the previous full-list behavior. Explicit `--status` still wins over `--all`. - cmdThreadList gains a `showAll: boolean` parameter (default false) - CLI registers `--all` option and passes it through - Test suite includes new `default behavior (issue #147)` describe block covering 9 scenarios; existing tests updated where they implicitly relied on the old "show everything" behavior - README, cli-reference, and usage-reference updated to document the new default and the `--all` flag Co-Authored-By: Claude Opus 4.6 --- .changeset/thread-list-active-default.md | 14 + packages/cli/README.md | 4 +- .../cli/src/__tests__/current-role.test.ts | 2 +- .../src/__tests__/thread-list-filters.test.ts | 284 +++++++++++++++++- .../thread-suspended-display.test.ts | 4 +- packages/cli/src/cli.ts | 6 +- packages/cli/src/commands/thread.ts | 17 +- packages/util/src/cli-reference.ts | 5 +- packages/util/src/usage-reference.ts | 5 +- 9 files changed, 326 insertions(+), 15 deletions(-) create mode 100644 .changeset/thread-list-active-default.md diff --git a/.changeset/thread-list-active-default.md b/.changeset/thread-list-active-default.md new file mode 100644 index 0000000..4fc3290 --- /dev/null +++ b/.changeset/thread-list-active-default.md @@ -0,0 +1,14 @@ +--- +"@united-workforce/cli": minor +"@united-workforce/util": patch +--- + +feat(cli): `uwf thread list` now defaults to active threads only + +Changes the default behavior of `uwf thread list` to show only active threads +(idle + running). Adds a new `--all` flag to opt into the previous behavior of +listing every thread (including completed, cancelled, and suspended). + +When invoked with no flags, the command now hides completed/cancelled/suspended +threads. Use `--all` to see them, or `--status ` to filter explicitly. +The `--status` filter wins when both are present. Resolves issue #147. diff --git a/packages/cli/README.md b/packages/cli/README.md index d97ecb1..beb508d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -49,7 +49,7 @@ bun link packages/cli | `uwf thread start -p ` | Create a thread without executing | | `uwf thread exec [--agent ] [-c ] [--background]` | Execute one or more moderator→agent→extract cycles | | `uwf thread show ` | Show thread head pointer | -| `uwf thread list [--status ] [--after ] [--before ] [--skip ] [--take ]` | List threads filtered by status (idle, running, completed, active, or comma-separated), time range (ISO or relative like '7d'), with pagination | +| `uwf thread list [--status ] [--all] [--after ] [--before ] [--skip ] [--take ]` | List threads (defaults to active: idle + running). Use `--all` to include completed/cancelled/suspended, or `--status` to filter explicitly (idle, running, suspended, completed, cancelled, active, or comma-separated). Supports time range and pagination. | | `uwf thread read [--quota N] [--before ] [--start]` | Render thread as readable markdown | `thread read`, `step list`, and `step show` work on both active and completed threads. @@ -63,6 +63,8 @@ uwf thread start solve-issue -p "Fix the login redirect bug" uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV -c 3 --agent uwf-builtin uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV --background +uwf thread list +uwf thread list --all uwf thread list --status running uwf thread list --status active uwf thread list --status idle,completed diff --git a/packages/cli/src/__tests__/current-role.test.ts b/packages/cli/src/__tests__/current-role.test.ts index 4ce4a12..dfc2bad 100644 --- a/packages/cli/src/__tests__/current-role.test.ts +++ b/packages/cli/src/__tests__/current-role.test.ts @@ -384,7 +384,7 @@ describe("currentRole field", () => { const _compHead = loadActiveThreads(uwfForIndex.varStore)[compId]!.head; completeThread(uwfForIndex.varStore, compId, "completed"); - const list = await cmdThreadList(storageRoot, null, null, null, 0, 100); + const list = await cmdThreadList(storageRoot, null, null, null, 0, 100, true); const idleItem = list.find((i) => i.thread === idleId); expect(idleItem).toBeDefined(); diff --git a/packages/cli/src/__tests__/thread-list-filters.test.ts b/packages/cli/src/__tests__/thread-list-filters.test.ts index 4e6318a..60cfd5d 100644 --- a/packages/cli/src/__tests__/thread-list-filters.test.ts +++ b/packages/cli/src/__tests__/thread-list-filters.test.ts @@ -167,7 +167,7 @@ describe("cmdThreadList status filter", () => { expect(result[0]?.status).toBe("completed"); }); - test("should return all threads when no status filter provided", async () => { + test("should return only active threads when no filter and no --all", async () => { const uwf = await makeUwfStore(tmpDir); const workflowHash = await createTestWorkflow(uwf); @@ -185,8 +185,290 @@ describe("cmdThreadList status filter", () => { const result = await cmdThreadList(tmpDir, null, null, null, null, null); + // Default behavior (issue #147): only active threads (idle + running) + expect(result).toHaveLength(2); + expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2].sort()); + + // Clean up marker + await deleteMarker(tmpDir, thread2); + }); + + test("should return all threads when --all (showAll=true)", async () => { + const uwf = await makeUwfStore(tmpDir); + const workflowHash = await createTestWorkflow(uwf); + + const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000); + const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000); + const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000); + + await markThreadRunning(tmpDir, thread2, workflowHash); + + const uwfIdx = await createUwfStore(tmpDir); + const index = loadAllThreads(uwfIdx.varStore); + const thread3Head = index[thread3]!.head; + if (thread3Head === undefined) throw new Error("thread3 head not found"); + await completeThread(tmpDir, thread3, workflowHash, thread3Head); + + const result = await cmdThreadList(tmpDir, null, null, null, null, null, true); + expect(result).toHaveLength(3); expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2, thread3].sort()); + + // Clean up marker + await deleteMarker(tmpDir, thread2); + }); +}); + +// ── default behavior tests (issue #147) ─────────────────────────────────────── + +describe("cmdThreadList default behavior (issue #147)", () => { + test("default returns only idle + running threads", async () => { + const uwf = await makeUwfStore(tmpDir); + const workflowHash = await createTestWorkflow(uwf); + + const threadA = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 4000); + const threadB = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000); + const threadC = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000); + const threadD = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000); + + await markThreadRunning(tmpDir, threadB, workflowHash); + + const uwfIdx = await createUwfStore(tmpDir); + const index = loadAllThreads(uwfIdx.varStore); + const threadCHead = index[threadC]!.head; + if (threadCHead === undefined) throw new Error("threadC head not found"); + await completeThread(tmpDir, threadC, workflowHash, threadCHead); + + // Cancel threadD + const threadDHead = index[threadD]!.head; + if (threadDHead === undefined) throw new Error("threadD head not found"); + const uwfCancel = await createUwfStore(tmpDir); + completeThreadInStore(uwfCancel.varStore, threadD, "cancelled"); + + const result = await cmdThreadList(tmpDir, null, null, null, null, null); + + expect(result).toHaveLength(2); + expect(result.map((r) => r.thread).sort()).toEqual([threadA, threadB].sort()); + + await deleteMarker(tmpDir, threadB); + }); + + test("default excludes completed threads", async () => { + const uwf = await makeUwfStore(tmpDir); + const workflowHash = await createTestWorkflow(uwf); + + const idleThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 6000); + const completedThreads: ThreadId[] = []; + for (let i = 0; i < 5; i++) { + const t = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - (5 - i) * 1000); + completedThreads.push(t); + const uwfIdx = await createUwfStore(tmpDir); + const index = loadAllThreads(uwfIdx.varStore); + const head = index[t]!.head; + if (head === undefined) throw new Error("head not found"); + await completeThread(tmpDir, t, workflowHash, head); + } + + const result = await cmdThreadList(tmpDir, null, null, null, null, null); + + expect(result).toHaveLength(1); + expect(result[0]?.thread).toBe(idleThread); + }); + + test("default excludes cancelled threads", async () => { + const uwf = await makeUwfStore(tmpDir); + const workflowHash = await createTestWorkflow(uwf); + + const runningThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 4000); + await markThreadRunning(tmpDir, runningThread, workflowHash); + + const cancelled: ThreadId[] = []; + for (let i = 0; i < 3; i++) { + const t = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - (3 - i) * 1000); + cancelled.push(t); + const uwfIdx = await createUwfStore(tmpDir); + completeThreadInStore(uwfIdx.varStore, t, "cancelled"); + } + + const result = await cmdThreadList(tmpDir, null, null, null, null, null); + + expect(result).toHaveLength(1); + expect(result[0]?.thread).toBe(runningThread); + + await deleteMarker(tmpDir, runningThread); + }); + + test("--all (showAll=true) returns every status", async () => { + const uwf = await makeUwfStore(tmpDir); + const workflowHash = await createTestWorkflow(uwf); + + const idleThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 4000); + const runningThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000); + await markThreadRunning(tmpDir, runningThread, workflowHash); + + const completedThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000); + const uwfIdx = await createUwfStore(tmpDir); + const idx = loadAllThreads(uwfIdx.varStore); + const ch = idx[completedThread]!.head; + if (ch === undefined) throw new Error("completedThread head not found"); + await completeThread(tmpDir, completedThread, workflowHash, ch); + + const cancelledThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000); + completeThreadInStore(uwfIdx.varStore, cancelledThread, "cancelled"); + + const result = await cmdThreadList(tmpDir, null, null, null, null, null, true); + + expect(result).toHaveLength(4); + expect(result.map((r) => r.thread).sort()).toEqual( + [idleThread, runningThread, completedThread, cancelledThread].sort(), + ); + + await deleteMarker(tmpDir, runningThread); + }); + + test("explicit --status overrides default (still returns just the filtered statuses)", async () => { + const uwf = await makeUwfStore(tmpDir); + const workflowHash = await createTestWorkflow(uwf); + + const _idleThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000); + const runningThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000); + await markThreadRunning(tmpDir, runningThread, workflowHash); + + const completedThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000); + const uwfIdx = await createUwfStore(tmpDir); + const idx = loadAllThreads(uwfIdx.varStore); + const ch = idx[completedThread]!.head; + if (ch === undefined) throw new Error("completedThread head not found"); + await completeThread(tmpDir, completedThread, workflowHash, ch); + + const result = await cmdThreadList(tmpDir, ["completed"], null, null, null, null); + + expect(result).toHaveLength(1); + expect(result[0]?.thread).toBe(completedThread); + expect(result[0]?.status).toBe("completed"); + + await deleteMarker(tmpDir, runningThread); + }); + + test("--status active keeps working", async () => { + const uwf = await makeUwfStore(tmpDir); + const workflowHash = await createTestWorkflow(uwf); + + const idleThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000); + const runningThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000); + await markThreadRunning(tmpDir, runningThread, workflowHash); + + const completedThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000); + const uwfIdx = await createUwfStore(tmpDir); + const idx = loadAllThreads(uwfIdx.varStore); + const ch = idx[completedThread]!.head; + if (ch === undefined) throw new Error("completedThread head not found"); + await completeThread(tmpDir, completedThread, workflowHash, ch); + + const result = await cmdThreadList(tmpDir, ["idle", "running"], null, null, null, null); + + expect(result).toHaveLength(2); + expect(result.map((r) => r.thread).sort()).toEqual([idleThread, runningThread].sort()); + + await deleteMarker(tmpDir, runningThread); + }); + + test("--status + --all — explicit status wins", async () => { + const uwf = await makeUwfStore(tmpDir); + const workflowHash = await createTestWorkflow(uwf); + + const _idleThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000); + const runningThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000); + await markThreadRunning(tmpDir, runningThread, workflowHash); + + const completedThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000); + const uwfIdx = await createUwfStore(tmpDir); + const idx = loadAllThreads(uwfIdx.varStore); + const ch = idx[completedThread]!.head; + if (ch === undefined) throw new Error("completedThread head not found"); + await completeThread(tmpDir, completedThread, workflowHash, ch); + + const result = await cmdThreadList(tmpDir, ["completed"], null, null, null, null, true); + + expect(result).toHaveLength(1); + expect(result[0]?.thread).toBe(completedThread); + + await deleteMarker(tmpDir, runningThread); + }); + + test("default returns empty when no threads", async () => { + await makeUwfStore(tmpDir); + + const result = await cmdThreadList(tmpDir, null, null, null, null, null); + + expect(result).toHaveLength(0); + }); + + test("default + time range filter composes correctly", async () => { + const uwf = await makeUwfStore(tmpDir); + const workflowHash = await createTestWorkflow(uwf); + + const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0); + const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0); + const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0); + const ts4 = Date.UTC(2026, 4, 23, 0, 0, 0); + const ts5 = Date.UTC(2026, 4, 24, 0, 0, 0); + + const _t1 = await createTestThread(uwf, tmpDir, workflowHash, ts1); + const t2 = await createTestThread(uwf, tmpDir, workflowHash, ts2); + const t3 = await createTestThread(uwf, tmpDir, workflowHash, ts3); + const t4 = await createTestThread(uwf, tmpDir, workflowHash, ts4); + const _t5 = await createTestThread(uwf, tmpDir, workflowHash, ts5); + + // Mark t3 running + await markThreadRunning(tmpDir, t3, workflowHash); + + // Complete t4 (should be excluded by default) + const uwfIdx = await createUwfStore(tmpDir); + const idx = loadAllThreads(uwfIdx.varStore); + const t4head = idx[t4]!.head; + if (t4head === undefined) throw new Error("t4 head not found"); + await completeThread(tmpDir, t4, workflowHash, t4head); + + // afterMs in middle of range to exclude _t1 + const afterMs = Date.UTC(2026, 4, 20, 12, 0, 0); + const result = await cmdThreadList(tmpDir, null, afterMs, null, null, null); + + // Expected: t2 (idle), t3 (running), _t5 (idle); excludes t4 (completed) and _t1 (filtered by time) + expect(result).toHaveLength(3); + const ids = result.map((r) => r.thread).sort(); + expect(ids).toEqual([t2, t3, _t5].sort()); + + await deleteMarker(tmpDir, t3); + }); + + test("default + pagination composes correctly", async () => { + const uwf = await makeUwfStore(tmpDir); + const workflowHash = await createTestWorkflow(uwf); + + // Create 10 idle threads + 5 completed threads + const idleThreads: ThreadId[] = []; + for (let i = 0; i < 10; i++) { + idleThreads.push( + await createTestThread(uwf, tmpDir, workflowHash, Date.now() - (15 - i) * 1000), + ); + } + for (let i = 0; i < 5; i++) { + const t = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - (5 - i) * 1000); + const uwfIdx = await createUwfStore(tmpDir); + const idx = loadAllThreads(uwfIdx.varStore); + const head = idx[t]!.head; + if (head === undefined) throw new Error("head not found"); + await completeThread(tmpDir, t, workflowHash, head); + } + + const result = await cmdThreadList(tmpDir, null, null, null, 2, 3); + + expect(result).toHaveLength(3); + // All results should be idle (default excludes completed) + for (const r of result) { + expect(r.status).toBe("idle"); + } }); }); diff --git a/packages/cli/src/__tests__/thread-suspended-display.test.ts b/packages/cli/src/__tests__/thread-suspended-display.test.ts index 1e18cdf..6f398b5 100644 --- a/packages/cli/src/__tests__/thread-suspended-display.test.ts +++ b/packages/cli/src/__tests__/thread-suspended-display.test.ts @@ -118,8 +118,8 @@ describe("suspended thread display", () => { [idleThreadId]: idleEntry, }); - // Test thread list - const listResult = await cmdThreadList(tmpDir, null, null, null, null, null); + // Test thread list — pass showAll=true to include suspended threads + const listResult = await cmdThreadList(tmpDir, null, null, null, null, null, true); // Find the suspended and idle threads in results const suspendedItem = listResult.find((item) => item.thread === suspendedThreadId); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 02b899d..43f6709 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -233,11 +233,12 @@ function parsePaginationOptions( thread .command("list") - .description("List threads") + .description("List threads (defaults to active: idle + running)") .option( "--status ", "Filter by status: idle, running, completed, cancelled, active (idle+running), or comma-separated values", ) + .option("--all", "Show all threads regardless of status (overrides default active-only filter)") .option("--after ", "Filter threads created after this date (ISO or relative like '7d')") .option("--before ", "Filter threads created before this date (ISO or relative like '7d')") .option("--skip ", "Skip first n threads") @@ -245,6 +246,7 @@ thread .action( (opts: { status: string | undefined; + all: boolean | undefined; after: string | undefined; before: string | undefined; skip: string | undefined; @@ -256,6 +258,7 @@ thread const nowMs = Date.now(); const { afterMs, beforeMs } = parseTimeFilters(opts.after, opts.before, nowMs); const { skip, take } = parsePaginationOptions(opts.skip, opts.take); + const showAll = opts.all === true; const result = await cmdThreadList( storageRoot, @@ -264,6 +267,7 @@ thread beforeMs, skip, take, + showAll, ); writeOutput(result); }); diff --git a/packages/cli/src/commands/thread.ts b/packages/cli/src/commands/thread.ts index 802a1b7..4676cd8 100644 --- a/packages/cli/src/commands/thread.ts +++ b/packages/cli/src/commands/thread.ts @@ -650,18 +650,25 @@ export async function cmdThreadList( beforeMs: number | null, skip: number | null, take: number | null, + showAll: boolean = false, ): Promise { const uwf = await createUwfStore(storageRoot); const index = loadActiveThreads(uwf.varStore); + // Resolve the effective filter: + // - explicit --status wins (showAll has no effect) + // - otherwise: --all → no filter; default → ["idle", "running"] + const effectiveFilter: ThreadStatus[] | null = + statusFilter !== null ? statusFilter : showAll ? null : ["idle", "running"]; + // Collect active threads let items = await collectActiveThreads(storageRoot, uwf, index); // Collect completed threads (if relevant for status filter) const includeCompleted = - statusFilter === null || - statusFilter.includes("completed") || - statusFilter.includes("cancelled"); + effectiveFilter === null || + effectiveFilter.includes("completed") || + effectiveFilter.includes("cancelled"); if (includeCompleted) { const activeIds = new Set(items.map((i) => i.thread)); const completedItems = collectCompletedThreads(uwf, activeIds); @@ -669,8 +676,8 @@ export async function cmdThreadList( } // Apply status filter - if (statusFilter !== null) { - items = items.filter((item) => statusFilter.includes(item.status)); + if (effectiveFilter !== null) { + items = items.filter((item) => effectiveFilter.includes(item.status)); } // Apply time range filters diff --git a/packages/util/src/cli-reference.ts b/packages/util/src/cli-reference.ts index 6b14337..54cb1c1 100644 --- a/packages/util/src/cli-reference.ts +++ b/packages/util/src/cli-reference.ts @@ -29,8 +29,9 @@ uwf thread exec # execute one moderator→agen [-c, --count ] # run multiple steps (default: 1) [--background] # run in background uwf thread show # show thread head pointer -uwf thread list # list threads - [--status ] # filter: idle, running, or completed +uwf thread list # list active threads (idle + running) + [--all] # include completed/cancelled/suspended + [--status ] # filter: idle, running, suspended, completed, cancelled, active uwf thread read # render thread context as markdown [--quota ] # max output characters (default 32000) [--before ] # load steps before this hash (exclusive) diff --git a/packages/util/src/usage-reference.ts b/packages/util/src/usage-reference.ts index f2e7d76..a0dfc9f 100644 --- a/packages/util/src/usage-reference.ts +++ b/packages/util/src/usage-reference.ts @@ -67,8 +67,9 @@ uwf thread exec # execute one step [-c, --count ] # run n steps [--background] # run in background uwf thread show # show head pointer -uwf thread list # list all threads - [--status ] # idle, running, completed, cancelled, active (comma-separated) +uwf thread list # list active threads (idle + running) + [--all] # include completed/cancelled/suspended + [--status ] # idle, running, suspended, completed, cancelled, active (comma-separated) [--after ] # pagination: after this thread [--before ] # pagination: before this thread [--skip ] # skip first n results