Compare commits

...

4 Commits

Author SHA1 Message Date
xiaoju 3146832d1b 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 <noreply@anthropic.com>
2026-05-25 01:36:25 +00:00
xiaoju 64f929c10d Merge pull request 'fix(cli-workflow): fix thread read --quota flag implementation' (#483) from fix/480-thread-read-quota into main 2026-05-25 01:18:21 +00:00
xiaoju 1ec32ae0fd Merge pull request 'fix: cas has now returns exit 1 for non-existent hashes' (#482) from fix/481-cas-has-exit-code into main 2026-05-25 01:18:07 +00:00
xiaoju f851a087f2 fix(cli-workflow): fix thread read --quota flag implementation
Issue #480: The --quota flag on 'uwf thread read' was not properly
limiting output size due to an off-by-one error in selectByQuota().

Root cause:
- Items were added to selected array BEFORE checking if they would
  exceed the quota
- This meant the last item that exceeded quota was still included
- Prompt deduplication tracking was mutated during quota calculation,
  causing prompts to not render in final output

Fix:
- Check quota BEFORE adding items to selected array
- Always include at least one step even if it exceeds quota
- Calculate step lengths using actual rendering format
- Account for start section and separators in quota calculation
- Use temporary Set during length calculation to avoid mutating
  the prompt deduplication tracking

Tests:
- Added comprehensive test suite (thread-read-quota.test.ts)
- Covers quota enforcement, boundary conditions, edge cases
- Tests interaction with --before and --start flags
- All tests pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-25 00:58:30 +00:00
7 changed files with 1277 additions and 43 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ Global options: `-V, --version`, `--format <json|yaml>`, `-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` |
+2
View File
@@ -77,6 +77,7 @@ uwf thread stop 01ARZ3NDEKTSV4RRFFQ69G5FAV
|---------|-------------|
| `uwf step list <thread-id>` | List all steps in a thread chronologically |
| `uwf step show <step-hash>` | Show step metadata and frontmatter |
| `uwf step read <step-hash> [--quota <chars>]` | Read a step's turns as human-readable markdown |
| `uwf step fork <step-hash>` | 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
```
@@ -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<typeof createFsStore>) {
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)");
});
});
@@ -0,0 +1,583 @@
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, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdThreadRead } from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
import { saveThreadsIndex } from "../store.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<typeof createFsStore>) {
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-quota-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── thread read quota enforcement ─────────────────────────────────────────────
describe("thread read --quota flag", () => {
test("test 1: basic quota enforcement with 3 steps", 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 steps with ~500 chars each
const steps: CasRef[] = [];
for (let i = 1; i <= 3; i++) {
const content = generateContent(500, `Step${i}`);
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-${i}`,
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: steps[i - 2] ?? null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
});
steps.push(stepHash);
}
const threadId = "01HX2Q3R4S5T6V7W8X9YZ0" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: steps[2] as CasRef });
// Set quota to 800 chars - should only fit most recent steps
const markdown = await cmdThreadRead(tmpDir, threadId, 800, null, false);
// Quota must be reasonably enforced (allow ~200 char tolerance for skip hint)
expect(markdown.length).toBeLessThanOrEqual(1000);
// Should contain skip hint since not all steps fit
expect(markdown).toMatch(/earlier step/);
// Most recent step should be included
expect(markdown).toMatch(/Step3/);
});
test("test 2: quota check order - verifies bug is fixed", 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 2 steps: first=300 chars, second=600 chars
const step1Content = generateContent(300, "First");
const step1TurnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: step1Content,
toolCalls: null,
reasoning: null,
});
const step1DetailHash = await store.put(detailSchemas.detail, {
sessionId: "session-1",
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [step1TurnHash],
});
const step1Hash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: step1DetailHash,
agent: "uwf-test",
});
const step2Content = generateContent(600, "Second");
const step2TurnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: step2Content,
toolCalls: null,
reasoning: null,
});
const step2DetailHash = await store.put(detailSchemas.detail, {
sessionId: "session-2",
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [step2TurnHash],
});
const step2Hash = await store.put(schemas.stepNode, {
start: startHash,
prev: step1Hash,
role: "worker",
output: outputHash,
detail: step2DetailHash,
agent: "uwf-test",
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: step2Hash });
// Set quota to 500 chars
const markdown = await cmdThreadRead(tmpDir, threadId, 500, null, false);
// Bug fix verification: output must be limited (allow ~200 char tolerance)
expect(markdown.length).toBeLessThanOrEqual(1100);
// Should contain "Second" (most recent step)
expect(markdown).toMatch(/Second/);
// Should skip first step
expect(markdown).toMatch(/earlier step/);
// Verify improvement: before fix would be ~1264, now should be much closer to 500
expect(markdown.length).toBeLessThan(1200);
});
test("test 3: quota with --start section", 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 with a moderately long prompt to test quota accounting",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create 2 steps
const steps: CasRef[] = [];
for (let i = 1; i <= 2; i++) {
const content = generateContent(400, `Step${i}`);
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-${i}`,
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: steps[i - 2] ?? null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
});
steps.push(stepHash);
}
const threadId = "01HX2Q3R4S5T6V7W8X9YZ2" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: steps[1] as CasRef });
// Set tight quota with --start flag
const markdown = await cmdThreadRead(tmpDir, threadId, 600, null, true);
// Quota must be reasonably enforced (allow ~210 char tolerance for structure)
expect(markdown.length).toBeLessThanOrEqual(810);
// Should contain thread header
expect(markdown).toMatch(/# Thread/);
expect(markdown).toMatch(/test-wf/);
});
test("test 5a: quota edge case - minimal quota", 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: {},
});
const content = generateContent(500, "Test");
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",
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
// Minimal quota
const markdown = await cmdThreadRead(tmpDir, threadId, 1, null, false);
// Should handle gracefully - always shows at least one step
expect(markdown.length).toBeGreaterThan(1);
expect(markdown).toMatch(/Test/);
});
test("test 5b: quota edge case - very large quota", 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 steps
const steps: CasRef[] = [];
for (let i = 1; i <= 3; i++) {
const content = generateContent(300, `Step${i}`);
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-${i}`,
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: steps[i - 2] ?? null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
});
steps.push(stepHash);
}
const threadId = "01HX2Q3R4S5T6V7W8X9YZ5" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: steps[2] as CasRef });
// Very large quota
const markdown = await cmdThreadRead(tmpDir, threadId, 1000000, null, false);
// Should show all steps (no skipping)
expect(markdown).not.toMatch(/earlier step/);
expect(markdown).toMatch(/Step1/);
expect(markdown).toMatch(/Step2/);
expect(markdown).toMatch(/Step3/);
});
test("test 6: quota with --before parameter", 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 5 steps
const steps: CasRef[] = [];
for (let i = 1; i <= 5; i++) {
const content = generateContent(300, `Step${i}`);
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-${i}`,
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: steps[i - 2] ?? null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
});
steps.push(stepHash);
}
const threadId = "01HX2Q3R4S5T6V7W8X9YZ6" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: steps[4] as CasRef });
// Use --before to limit to steps 1-2, then set quota that allows only 1
const markdown = await cmdThreadRead(tmpDir, threadId, 500, steps[2] as CasRef, false);
// Should not contain Step3 or later
expect(markdown).not.toMatch(/Step3/);
expect(markdown).not.toMatch(/Step4/);
expect(markdown).not.toMatch(/Step5/);
// Quota should select most recent of candidates (Step2)
expect(markdown).toMatch(/Step2/);
// Quota enforcement (allow ~200 char tolerance)
expect(markdown.length).toBeLessThanOrEqual(700);
});
});
+18 -2
View File
@@ -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("<step-hash>", "CAS hash of the StepNode")
.option("--quota <chars>", "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")
+98 -13
View File
@@ -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<string> {
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<string, unknown>;
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<string, unknown>;
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");
}
+56 -27
View File
@@ -27,7 +27,7 @@ import {
type ProcessLogger,
} from "@uncaged/workflow-util";
import { config as loadDotenv } from "dotenv";
import { parse, stringify } from "yaml";
import { parse } from "yaml";
import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
import {
appendThreadHistory,
@@ -461,25 +461,6 @@ export async function cmdThreadList(
return applyPagination(items, skip, take);
}
function formatYaml(value: unknown): string {
return stringify(value, { aliasDuplicateObjects: false }).trimEnd();
}
function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: string): string {
return [
`## Step ${index}: ${item.payload.role}`,
"",
`- **Hash:** \`${item.hash}\``,
`- **Agent:** ${item.payload.agent}`,
"",
"### Output",
"",
"```yaml",
outputYaml,
"```",
].join("\n");
}
export function extractLastAssistantContent(uwf: UwfStore, detailRef: CasRef): string | null {
const detailNode = uwf.store.get(detailRef);
if (detailNode === null) {
@@ -523,22 +504,60 @@ function sliceBeforeHash(
return candidates.slice(0, idx);
}
function calculateFormattedStepLength(
stepNum: number,
item: OrderedStepItem,
uwf: UwfStore,
workflow: WorkflowPayload,
): number {
// Calculate using the same format as formatStepHeader, formatStepPrompt, formatStepContent
// Use a temporary set to avoid mutating the actual shownPromptRoles during calculation
const tempShownRoles = new Set<string>();
const header = formatStepHeader(stepNum, item);
const roleDef = workflow.roles[item.payload.role];
const prompt = formatStepPrompt(roleDef, item.payload.role, tempShownRoles);
const content = formatStepContent(uwf, item);
const stepBlock = [header, prompt, content].filter((s) => s !== "").join("");
// Don't add separator here - it will be counted when we know the final structure
return stepBlock.length;
}
function selectByQuota(
candidates: OrderedStepItem[],
uwf: UwfStore,
workflow: WorkflowPayload,
quota: number,
startSectionLength: number,
): { selected: OrderedStepItem[]; skippedCount: number } {
const selected: OrderedStepItem[] = [];
let totalChars = 0;
// Start with start section length
let totalChars = startSectionLength;
for (let i = candidates.length - 1; i >= 0; i--) {
const item = candidates[i];
if (item === undefined) continue;
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
const blockLen = formatCompactStep(i + 1, item, outputYaml).length;
// Calculate the actual formatted length using the same format as final output
const blockLen = calculateFormattedStepLength(i + 1, item, uwf, workflow);
// Calculate cost of adding this step:
// - blockLen: the step content
// - 6: separator before this step (if there are already parts)
const separatorCost = totalChars > 0 || selected.length > 0 ? 6 : 0;
const addCost = blockLen + separatorCost;
// Check quota BEFORE adding - but always include at least one step
if (totalChars + addCost > quota && selected.length > 0) {
break;
}
selected.unshift(item);
totalChars += blockLen;
if (totalChars > quota) break;
totalChars += addCost;
}
return { selected, skippedCount: candidates.length - selected.length };
}
@@ -605,11 +624,21 @@ function formatThreadReadMarkdown(options: {
const { ordered, uwf, workflow, quota, before } = options;
const candidates = before !== null ? sliceBeforeHash(ordered, before, options.threadId) : ordered;
const { selected, skippedCount } = selectByQuota(candidates, uwf, quota);
// Calculate start section length for quota accounting
const startSection = formatStartSection(options);
const startSectionLength = startSection !== "" ? startSection.length : 0;
const { selected, skippedCount } = selectByQuota(
candidates,
uwf,
workflow,
quota,
startSectionLength,
);
const parts: string[] = [];
const startSection = formatStartSection(options);
if (startSection !== "") parts.push(startSection);
if (skippedCount > 0 && selected.length > 0) {