From 3146832d1b23e897e91baabb3164fe14f4bab1e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 25 May 2026 01:36:25 +0000 Subject: [PATCH] fix(cli-workflow): complete step read command implementation Implements the `uwf step read` command to render a single step's turns as human-readable markdown with quota enforcement. Changes: - Implement cmdStepRead() in step.ts with quota enforcement - Renders step metadata (hash, role, agent) - Loads and formats turns from detail node - Enforces quota by selecting most recent turns - Always shows at least one turn even if it exceeds quota - Gracefully handles steps with no detail or no turns - Register `step read` command in cli.ts with --quota flag (default 4000) - Add comprehensive test suite in step-read.test.ts (6 tests covering basic functionality, quota enforcement, edge cases, and special chars) - Update README.md CLI Reference table to include `step read` - Update package-level README.md with command documentation and example Closes #484 Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- packages/cli-workflow/README.md | 2 + .../src/__tests__/step-read.test.ts | 519 ++++++++++++++++++ packages/cli-workflow/src/cli.ts | 20 +- packages/cli-workflow/src/commands/step.ts | 111 +++- 5 files changed, 638 insertions(+), 16 deletions(-) create mode 100644 packages/cli-workflow/src/__tests__/step-read.test.ts diff --git a/README.md b/README.md index 0ccd10e..879363c 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Global options: `-V, --version`, `--format `, `-h, --help`. | Group | Commands | |-------|----------| | **thread** | `start`, `exec`, `show`, `list`, `stop`, `cancel`, `read` | -| **step** | `list`, `show`, `fork` | +| **step** | `list`, `show`, `read`, `fork` | | **workflow** | `add`, `show`, `list` | | **cas** | `get`, `put`, `put-text`, `has`, `refs`, `walk`, `reindex`, `schema list`, `schema get` | | **setup** | Interactive or `--provider`, `--base-url`, `--api-key`, `--model`, `--agent` | diff --git a/packages/cli-workflow/README.md b/packages/cli-workflow/README.md index a76f44e..a310ed7 100644 --- a/packages/cli-workflow/README.md +++ b/packages/cli-workflow/README.md @@ -77,6 +77,7 @@ uwf thread stop 01ARZ3NDEKTSV4RRFFQ69G5FAV |---------|-------------| | `uwf step list ` | List all steps in a thread chronologically | | `uwf step show ` | Show step metadata and frontmatter | +| `uwf step read [--quota ]` | Read a step's turns as human-readable markdown | | `uwf step fork ` | Fork a thread from a specific step | Examples: @@ -84,6 +85,7 @@ Examples: ```bash uwf step list 01ARZ3NDEKTSV4RRFFQ69G5FAV uwf step show 32GCDE899RRQ3 +uwf step read 32GCDE899RRQ3 --quota 2000 uwf step fork 32GCDE899RRQ3 ``` diff --git a/packages/cli-workflow/src/__tests__/step-read.test.ts b/packages/cli-workflow/src/__tests__/step-read.test.ts new file mode 100644 index 0000000..61d17ec --- /dev/null +++ b/packages/cli-workflow/src/__tests__/step-read.test.ts @@ -0,0 +1,519 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { bootstrap, putSchema } from "@uncaged/json-cas"; +import { createFsStore } from "@uncaged/json-cas-fs"; +import type { CasRef } from "@uncaged/workflow-protocol"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { cmdStepRead } from "../commands/step.js"; +import { registerUwfSchemas } from "../schemas.js"; + +// ── schemas used in tests ──────────────────────────────────────────────────── + +const TURN_SCHEMA = { + title: "hermes-turn", + type: "object" as const, + required: ["index", "role", "content"], + properties: { + index: { type: "integer" as const }, + role: { type: "string" as const }, + content: { type: "string" as const }, + toolCalls: { + anyOf: [ + { type: "array" as const, items: { type: "object" as const } }, + { type: "null" as const }, + ], + }, + reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] }, + }, + additionalProperties: false, +}; + +const DETAIL_SCHEMA = { + title: "hermes-detail", + type: "object" as const, + required: ["sessionId", "model", "duration", "turnCount", "turns"], + properties: { + sessionId: { type: "string" as const }, + model: { type: "string" as const }, + duration: { type: "integer" as const }, + turnCount: { type: "integer" as const }, + turns: { + type: "array" as const, + items: { type: "string" as const, format: "cas_ref" }, + }, + }, + additionalProperties: false, +}; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +async function registerDetailSchemas(store: ReturnType) { + await bootstrap(store); + const [turn, detail] = await Promise.all([ + putSchema(store, TURN_SCHEMA), + putSchema(store, DETAIL_SCHEMA), + ]); + return { turn, detail }; +} + +function generateContent(size: number, prefix = "Content"): string { + const base = `${prefix} `; + const repeat = Math.ceil(size / base.length); + return base.repeat(repeat).slice(0, size); +} + +// ── fixture ─────────────────────────────────────────────────────────────────── + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-read-test-")); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +// ── step read tests ─────────────────────────────────────────────────────────── + +describe("step read", () => { + test("test 1: basic single-step read with 3 turns", async () => { + const casDir = join(tmpDir, "cas"); + await mkdir(casDir, { recursive: true }); + const store = createFsStore(casDir); + const schemas = await registerUwfSchemas(store); + const detailSchemas = await registerDetailSchemas(store); + + const workflowHash = await store.put(schemas.workflow, { + name: "test-wf", + description: "desc", + roles: { + worker: { + description: "Worker", + goal: "You are a worker agent.", + capabilities: [], + procedure: "Do the work.", + output: "Summarize the work.", + meta: "placeholder00" as CasRef, + }, + }, + conditions: {}, + graph: {}, + }); + + const startHash = await store.put(schemas.startNode, { + workflow: workflowHash, + prompt: "Test task", + }); + + const outputHash = await store.put(schemas.workflow, { + name: "out", + description: "", + roles: {}, + conditions: {}, + graph: {}, + }); + + // Create 3 turns + const turnHashes: CasRef[] = []; + for (let i = 1; i <= 3; i++) { + const content = `Turn ${i} content with some text to make it readable.`; + const turnHash = await store.put(detailSchemas.turn, { + index: i - 1, + role: "assistant", + content, + toolCalls: null, + reasoning: null, + }); + turnHashes.push(turnHash); + } + + const detailHash = await store.put(detailSchemas.detail, { + sessionId: "session-1", + model: "test-model", + duration: 1000, + turnCount: 3, + turns: turnHashes, + }); + + const stepHash = await store.put(schemas.stepNode, { + start: startHash, + prev: null, + role: "worker", + output: outputHash, + detail: detailHash, + agent: "uwf-test", + }); + + // Read step with large quota + const markdown = await cmdStepRead(tmpDir, stepHash, 10000); + + // Assert structure + expect(markdown).toContain(`# Step ${stepHash}`); + expect(markdown).toContain("**Role:** worker"); + expect(markdown).toContain("**Agent:** uwf-test"); + expect(markdown).toContain("## Turn 1"); + expect(markdown).toContain("## Turn 2"); + expect(markdown).toContain("## Turn 3"); + expect(markdown).toContain("Turn 1 content with some text to make it readable."); + expect(markdown).toContain("Turn 2 content with some text to make it readable."); + expect(markdown).toContain("Turn 3 content with some text to make it readable."); + }); + + test("test 2: quota enforcement - multiple turns", async () => { + const casDir = join(tmpDir, "cas"); + await mkdir(casDir, { recursive: true }); + const store = createFsStore(casDir); + const schemas = await registerUwfSchemas(store); + const detailSchemas = await registerDetailSchemas(store); + + const workflowHash = await store.put(schemas.workflow, { + name: "test-wf", + description: "desc", + roles: { + worker: { + description: "Worker", + goal: "You are a worker agent.", + capabilities: [], + procedure: "Do the work.", + output: "Summarize the work.", + meta: "placeholder00" as CasRef, + }, + }, + conditions: {}, + graph: {}, + }); + + const startHash = await store.put(schemas.startNode, { + workflow: workflowHash, + prompt: "Test task", + }); + + const outputHash = await store.put(schemas.workflow, { + name: "out", + description: "", + roles: {}, + conditions: {}, + graph: {}, + }); + + // Create 4 turns of ~300 chars each + const turnHashes: CasRef[] = []; + for (let i = 1; i <= 4; i++) { + const content = generateContent(300, `Turn${i}`); + const turnHash = await store.put(detailSchemas.turn, { + index: i - 1, + role: "assistant", + content, + toolCalls: null, + reasoning: null, + }); + turnHashes.push(turnHash); + } + + const detailHash = await store.put(detailSchemas.detail, { + sessionId: "session-1", + model: "test-model", + duration: 1000, + turnCount: 4, + turns: turnHashes, + }); + + const stepHash = await store.put(schemas.stepNode, { + start: startHash, + prev: null, + role: "worker", + output: outputHash, + detail: detailHash, + agent: "uwf-test", + }); + + // Read step with limited quota (700 chars) + const markdown = await cmdStepRead(tmpDir, stepHash, 700); + + // Assert only most recent turns fit + expect(markdown).toContain(`# Step ${stepHash}`); + // Should have skip hint + expect(markdown).toContain("Earlier turns omitted"); + // Should include at least Turn 4 (most recent) + expect(markdown).toContain("Turn4"); + // Total length should respect quota (with tolerance for structural overhead) + expect(markdown.length).toBeLessThanOrEqual(900); // 700 quota + 200 buffer tolerance + }); + + test("test 3: minimal quota edge case - always show at least one turn", async () => { + const casDir = join(tmpDir, "cas"); + await mkdir(casDir, { recursive: true }); + const store = createFsStore(casDir); + const schemas = await registerUwfSchemas(store); + const detailSchemas = await registerDetailSchemas(store); + + const workflowHash = await store.put(schemas.workflow, { + name: "test-wf", + description: "desc", + roles: { + worker: { + description: "Worker", + goal: "You are a worker agent.", + capabilities: [], + procedure: "Do the work.", + output: "Summarize the work.", + meta: "placeholder00" as CasRef, + }, + }, + conditions: {}, + graph: {}, + }); + + const startHash = await store.put(schemas.startNode, { + workflow: workflowHash, + prompt: "Test task", + }); + + const outputHash = await store.put(schemas.workflow, { + name: "out", + description: "", + roles: {}, + conditions: {}, + graph: {}, + }); + + // Create 1 turn of 500 chars + const content = generateContent(500, "LongTurn"); + const turnHash = await store.put(detailSchemas.turn, { + index: 0, + role: "assistant", + content, + toolCalls: null, + reasoning: null, + }); + + const detailHash = await store.put(detailSchemas.detail, { + sessionId: "session-1", + model: "test-model", + duration: 1000, + turnCount: 1, + turns: [turnHash], + }); + + const stepHash = await store.put(schemas.stepNode, { + start: startHash, + prev: null, + role: "worker", + output: outputHash, + detail: detailHash, + agent: "uwf-test", + }); + + // Read step with minimal quota (1 char) + const markdown = await cmdStepRead(tmpDir, stepHash, 1); + + // Assert at least one turn is always shown + expect(markdown).toContain("LongTurn"); + expect(markdown.length).toBeGreaterThan(1); + }); + + test("test 4: step with no detail field", async () => { + const casDir = join(tmpDir, "cas"); + await mkdir(casDir, { recursive: true }); + const store = createFsStore(casDir); + const schemas = await registerUwfSchemas(store); + + const workflowHash = await store.put(schemas.workflow, { + name: "test-wf", + description: "desc", + roles: { + worker: { + description: "Worker", + goal: "You are a worker agent.", + capabilities: [], + procedure: "Do the work.", + output: "Summarize the work.", + meta: "placeholder00" as CasRef, + }, + }, + conditions: {}, + graph: {}, + }); + + const startHash = await store.put(schemas.startNode, { + workflow: workflowHash, + prompt: "Test task", + }); + + const outputHash = await store.put(schemas.workflow, { + name: "out", + description: "", + roles: {}, + conditions: {}, + graph: {}, + }); + + const stepHash = await store.put(schemas.stepNode, { + start: startHash, + prev: null, + role: "worker", + output: outputHash, + detail: null, + agent: "uwf-test", + }); + + // Read step - should return metadata only (no error) + const markdown = await cmdStepRead(tmpDir, stepHash, 4000); + + // Assert metadata is present + expect(markdown).toContain(`# Step ${stepHash}`); + expect(markdown).toContain("**Role:** worker"); + expect(markdown).toContain("**Agent:** uwf-test"); + // Should not have turn sections + expect(markdown).not.toContain("## Turn"); + }); + + test("test 5: step with detail but no turns array", async () => { + const casDir = join(tmpDir, "cas"); + await mkdir(casDir, { recursive: true }); + const store = createFsStore(casDir); + const schemas = await registerUwfSchemas(store); + await registerDetailSchemas(store); + + const workflowHash = await store.put(schemas.workflow, { + name: "test-wf", + description: "desc", + roles: { + worker: { + description: "Worker", + goal: "You are a worker agent.", + capabilities: [], + procedure: "Do the work.", + output: "Summarize the work.", + meta: "placeholder00" as CasRef, + }, + }, + conditions: {}, + graph: {}, + }); + + const startHash = await store.put(schemas.startNode, { + workflow: workflowHash, + prompt: "Test task", + }); + + const outputHash = await store.put(schemas.workflow, { + name: "out", + description: "", + roles: {}, + conditions: {}, + graph: {}, + }); + + // Create detail with different schema (no turns) + const SIMPLE_DETAIL_SCHEMA = { + title: "simple-detail", + type: "object" as const, + required: ["sessionId"], + properties: { + sessionId: { type: "string" as const }, + }, + additionalProperties: false, + }; + + await bootstrap(store); + const simpleDetailType = await putSchema(store, SIMPLE_DETAIL_SCHEMA); + const detailHash = await store.put(simpleDetailType, { + sessionId: "session-1", + }); + + const stepHash = await store.put(schemas.stepNode, { + start: startHash, + prev: null, + role: "worker", + output: outputHash, + detail: detailHash, + agent: "uwf-test", + }); + + // Read step - should return metadata only (no error) + const markdown = await cmdStepRead(tmpDir, stepHash, 4000); + + // Assert metadata is present + expect(markdown).toContain(`# Step ${stepHash}`); + expect(markdown).toContain("**Role:** worker"); + // Should not have turn sections + expect(markdown).not.toContain("## Turn"); + }); + + test("test 6: turn content with special characters", async () => { + const casDir = join(tmpDir, "cas"); + await mkdir(casDir, { recursive: true }); + const store = createFsStore(casDir); + const schemas = await registerUwfSchemas(store); + const detailSchemas = await registerDetailSchemas(store); + + const workflowHash = await store.put(schemas.workflow, { + name: "test-wf", + description: "desc", + roles: { + worker: { + description: "Worker", + goal: "You are a worker agent.", + capabilities: [], + procedure: "Do the work.", + output: "Summarize the work.", + meta: "placeholder00" as CasRef, + }, + }, + conditions: {}, + graph: {}, + }); + + const startHash = await store.put(schemas.startNode, { + workflow: workflowHash, + prompt: "Test task", + }); + + const outputHash = await store.put(schemas.workflow, { + name: "out", + description: "", + roles: {}, + conditions: {}, + graph: {}, + }); + + // Create turn with special markdown characters + const content = "This has `backticks`, **bold**, *italic*, and [links](http://example.com)"; + const turnHash = await store.put(detailSchemas.turn, { + index: 0, + role: "assistant", + content, + toolCalls: null, + reasoning: null, + }); + + const detailHash = await store.put(detailSchemas.detail, { + sessionId: "session-1", + model: "test-model", + duration: 1000, + turnCount: 1, + turns: [turnHash], + }); + + const stepHash = await store.put(schemas.stepNode, { + start: startHash, + prev: null, + role: "worker", + output: outputHash, + detail: detailHash, + agent: "uwf-test", + }); + + // Read step + const markdown = await cmdStepRead(tmpDir, stepHash, 4000); + + // Assert content is rendered correctly without corruption + expect(markdown).toContain("`backticks`"); + expect(markdown).toContain("**bold**"); + expect(markdown).toContain("*italic*"); + expect(markdown).toContain("[links](http://example.com)"); + }); +}); diff --git a/packages/cli-workflow/src/cli.ts b/packages/cli-workflow/src/cli.ts index ef1559c..12ed805 100755 --- a/packages/cli-workflow/src/cli.ts +++ b/packages/cli-workflow/src/cli.ts @@ -16,7 +16,7 @@ import { import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js"; import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js"; import { cmdSkillCli } from "./commands/skill.js"; -import { cmdStepFork, cmdStepList, cmdStepShow } from "./commands/step.js"; +import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js"; import { cmdThreadCancel, cmdThreadExec, @@ -346,7 +346,23 @@ step }); }); -// step read is not yet registered (half-baked, see step.ts cmdStepRead) +step + .command("read") + .description("Read a step's turns as human-readable markdown") + .argument("", "CAS hash of the StepNode") + .option("--quota ", "Max output characters", "4000") + .action((stepHash: string, opts: { quota: string }) => { + const storageRoot = resolveStorageRoot(); + runAction(async () => { + const quota = Number.parseInt(opts.quota, 10); + if (!Number.isFinite(quota) || quota < 1) { + process.stderr.write("invalid --quota: must be a positive integer\n"); + process.exit(1); + } + const markdown = await cmdStepRead(storageRoot, stepHash as CasRef, quota); + process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`); + }); + }); step .command("fork") diff --git a/packages/cli-workflow/src/commands/step.ts b/packages/cli-workflow/src/commands/step.ts index 33b5d47..0b8c3ed 100644 --- a/packages/cli-workflow/src/commands/step.ts +++ b/packages/cli-workflow/src/commands/step.ts @@ -111,13 +111,12 @@ export async function cmdStepFork( } /** - * Read a step's agent output as markdown (new command - requires #462) - * TODO: Implement once unified agent detail/turn schema is available + * Read a step's agent turns as human-readable markdown with quota enforcement */ export async function cmdStepRead( storageRoot: string, stepHash: CasRef, - _before: number | null = null, + quota: number, ): Promise { const uwf = await createUwfStore(storageRoot); const node = uwf.store.get(stepHash); @@ -128,18 +127,104 @@ export async function cmdStepRead( fail(`node ${stepHash} is not a StepNode`); } const payload = node.payload as StepNodePayload; - if (!payload.output) { - fail(`step ${stepHash} has no output`); + + // Build header section + const parts: string[] = []; + parts.push(`# Step ${stepHash}`); + parts.push(""); + parts.push(`**Role:** ${payload.role}`); + parts.push(`**Agent:** ${payload.agent}`); + + // If no detail, return metadata only + if (payload.detail === null) { + return parts.join("\n"); } - // TODO: Implement progressive turn reading with --before N - // For now, return a placeholder - const outputNode = uwf.store.get(payload.output); - if (outputNode === null) { - fail(`output node not found: ${payload.output}`); + // Load detail node + const detailNode = uwf.store.get(payload.detail); + if (detailNode === null) { + fail(`detail node not found: ${payload.detail}`); } - // Return the output as JSON for now - // Once #462 is implemented, this will properly format frontmatter + markdown - return JSON.stringify(outputNode.payload, null, 2); + const detail = detailNode.payload as Record; + const turns = detail.turns; + + // If no turns array, return metadata only + if (!Array.isArray(turns) || turns.length === 0) { + return parts.join("\n"); + } + + // Load all turn nodes + type TurnData = { + index: number; + content: string; + }; + const turnData: TurnData[] = []; + for (const turnRef of turns) { + if (typeof turnRef !== "string") { + continue; + } + const turnNode = uwf.store.get(turnRef as CasRef); + if (turnNode === null) { + continue; + } + const turn = turnNode.payload as Record; + if (typeof turn.content === "string") { + turnData.push({ + index: typeof turn.index === "number" ? turn.index : turnData.length, + content: turn.content, + }); + } + } + + if (turnData.length === 0) { + return parts.join("\n"); + } + + // Calculate header length for quota accounting + const headerSection = parts.join("\n"); + const headerLength = headerSection.length; + + // Select turns that fit within quota (working backwards from most recent) + const BUFFER = 200; // Conservative buffer for structural overhead + const availableQuota = quota - headerLength - BUFFER; + + const selectedTurns: TurnData[] = []; + let totalChars = 0; + + for (let i = turnData.length - 1; i >= 0; i--) { + const turn = turnData[i]; + if (turn === undefined) continue; + + // Calculate formatted turn length + const turnHeader = `## Turn ${turn.index + 1}\n\n`; + const turnBlock = turnHeader + turn.content; + const separatorCost = selectedTurns.length > 0 ? 2 : 0; // "\n\n" between turns + const addCost = turnBlock.length + separatorCost; + + // Check quota - but always include at least one turn + if (totalChars + addCost > availableQuota && selectedTurns.length > 0) { + break; + } + + selectedTurns.unshift(turn); + totalChars += addCost; + } + + // Add skip hint if not all turns fit + const skippedCount = turnData.length - selectedTurns.length; + if (skippedCount > 0) { + parts.push(""); + parts.push(`_[Earlier turns omitted due to quota. Use --quota to increase.]_`); + } + + // Add selected turns + for (const turn of selectedTurns) { + parts.push(""); + parts.push(`## Turn ${turn.index + 1}`); + parts.push(""); + parts.push(turn.content); + } + + return parts.join("\n"); }