From 762ecec872188288e8fb7a06b5feaf37864683a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Tue, 19 May 2026 06:44:18 +0000 Subject: [PATCH] feat(cli-uwf): thread read shows Content + new step-details command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - thread read: add ### Content section (last assistant message) before ### Output - Remove --detail flag (replaced by step-details command) - New: uwf thread step-details — full detail dump as yaml Closes #357 --- packages/cli-uwf/package.json | 5 +- packages/cli-uwf/src/__tests__/thread.test.ts | 391 ++++++++++++++++++ packages/cli-uwf/src/cli.ts | 136 +++--- packages/cli-uwf/src/commands/thread.ts | 90 +++- packages/cli-uwf/vitest.config.ts | 7 + 5 files changed, 550 insertions(+), 79 deletions(-) create mode 100644 packages/cli-uwf/src/__tests__/thread.test.ts create mode 100644 packages/cli-uwf/vitest.config.ts diff --git a/packages/cli-uwf/package.json b/packages/cli-uwf/package.json index e736d02..02fb7dc 100644 --- a/packages/cli-uwf/package.json +++ b/packages/cli-uwf/package.json @@ -22,9 +22,12 @@ "yaml": "^2.8.4" }, "scripts": { - "test": "bun test" + "test": "vitest run" }, "publishConfig": { "access": "public" + }, + "devDependencies": { + "vitest": "^4.1.6" } } diff --git a/packages/cli-uwf/src/__tests__/thread.test.ts b/packages/cli-uwf/src/__tests__/thread.test.ts new file mode 100644 index 0000000..5964cc5 --- /dev/null +++ b/packages/cli-uwf/src/__tests__/thread.test.ts @@ -0,0 +1,391 @@ +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/uwf-protocol"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + cmdThreadRead, + cmdThreadStepDetails, + extractLastAssistantContent, + THREAD_READ_DEFAULT_QUOTA, +} from "../commands/thread.js"; +import { registerUwfSchemas } from "../schemas.js"; +import type { UwfStore } from "../store.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 makeUwfStore(storageRoot: string): Promise { + const casDir = join(storageRoot, "cas"); + await mkdir(casDir, { recursive: true }); + const store = createFsStore(casDir); + const schemas = await registerUwfSchemas(store); + return { storageRoot, store, schemas }; +} + +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 }; +} + +// ── fixture ─────────────────────────────────────────────────────────────────── + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-test-")); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +// ── extractLastAssistantContent ─────────────────────────────────────────────── + +describe("extractLastAssistantContent", () => { + test("returns last non-empty assistant content from turns", async () => { + const uwf = await makeUwfStore(tmpDir); + const schemas = await registerDetailSchemas(uwf.store); + + const turn1 = await uwf.store.put(schemas.turn, { + index: 0, + role: "assistant", + content: "intermediate", + toolCalls: null, + reasoning: null, + }); + const turn2 = await uwf.store.put(schemas.turn, { + index: 1, + role: "tool", + content: "ok", + toolCalls: null, + reasoning: null, + }); + const turn3 = await uwf.store.put(schemas.turn, { + index: 2, + role: "assistant", + content: "final answer", + toolCalls: null, + reasoning: null, + }); + + const detailHash = await uwf.store.put(schemas.detail, { + sessionId: "s1", + model: "m1", + duration: 1000, + turnCount: 3, + turns: [turn1, turn2, turn3], + }); + + expect(extractLastAssistantContent(uwf, detailHash)).toBe("final answer"); + }); + + test("returns null when detail node does not exist in store", async () => { + const uwf = await makeUwfStore(tmpDir); + expect(extractLastAssistantContent(uwf, "nonexistent00" as CasRef)).toBeNull(); + }); + + test("returns null when turns array is empty", async () => { + const uwf = await makeUwfStore(tmpDir); + const schemas = await registerDetailSchemas(uwf.store); + + const detailHash = await uwf.store.put(schemas.detail, { + sessionId: "s2", + model: "m2", + duration: 0, + turnCount: 0, + turns: [], + }); + + expect(extractLastAssistantContent(uwf, detailHash)).toBeNull(); + }); + + test("returns null when all assistant turns have empty content", async () => { + const uwf = await makeUwfStore(tmpDir); + const schemas = await registerDetailSchemas(uwf.store); + + const turn1 = await uwf.store.put(schemas.turn, { + index: 0, + role: "assistant", + content: "", + toolCalls: null, + reasoning: null, + }); + + const detailHash = await uwf.store.put(schemas.detail, { + sessionId: "s3", + model: "m3", + duration: 0, + turnCount: 1, + turns: [turn1], + }); + + expect(extractLastAssistantContent(uwf, detailHash)).toBeNull(); + }); + + test("skips whitespace-only assistant content and returns earlier match", async () => { + const uwf = await makeUwfStore(tmpDir); + const schemas = await registerDetailSchemas(uwf.store); + + const turn1 = await uwf.store.put(schemas.turn, { + index: 0, + role: "assistant", + content: "real content", + toolCalls: null, + reasoning: null, + }); + const turn2 = await uwf.store.put(schemas.turn, { + index: 1, + role: "assistant", + content: " ", + toolCalls: null, + reasoning: null, + }); + + const detailHash = await uwf.store.put(schemas.detail, { + sessionId: "s4", + model: "m4", + duration: 0, + turnCount: 2, + turns: [turn1, turn2], + }); + + expect(extractLastAssistantContent(uwf, detailHash)).toBe("real content"); + }); +}); + +// ── cmdThreadRead: ### Content section ─────────────────────────────────────── + +describe("cmdThreadRead ### Content section", () => { + test("includes ### Content before ### Output when detail has assistant turns", async () => { + const uwf = await makeUwfStore(tmpDir); + const detailSchemas = await registerDetailSchemas(uwf.store); + + const workflowHash = await uwf.store.put(uwf.schemas.workflow, { + name: "test-wf", + description: "desc", + roles: { + writer: { + description: "Write", + systemPrompt: "You are a writer.", + outputSchema: "placeholder00" as CasRef, + }, + }, + conditions: {}, + graph: {}, + }); + + const startHash = await uwf.store.put(uwf.schemas.startNode, { + workflow: workflowHash, + prompt: "Write something", + }); + + const outputHash = await uwf.store.put(uwf.schemas.workflow, { + name: "out", + description: "", + roles: {}, + conditions: {}, + graph: {}, + }); + + const turnHash = await uwf.store.put(detailSchemas.turn, { + index: 0, + role: "assistant", + content: "The assistant response text", + toolCalls: null, + reasoning: null, + }); + const detailHash = await uwf.store.put(detailSchemas.detail, { + sessionId: "sx", + model: "mx", + duration: 500, + turnCount: 1, + turns: [turnHash], + }); + + const stepHash = await uwf.store.put(uwf.schemas.stepNode, { + start: startHash, + prev: null, + role: "writer", + output: outputHash, + detail: detailHash, + agent: "uwf-hermes", + }); + + const threadId = "01JTEST0000000000000000001" as ThreadId; + await saveThreadsIndex(tmpDir, { [threadId]: stepHash }); + + const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false); + + expect(markdown).toContain("### Content"); + expect(markdown).toContain("The assistant response text"); + + const contentIdx = markdown.indexOf("### Content"); + const outputIdx = markdown.indexOf("### Output"); + expect(contentIdx).toBeGreaterThanOrEqual(0); + expect(outputIdx).toBeGreaterThanOrEqual(0); + expect(contentIdx).toBeLessThan(outputIdx); + }); + + test("omits ### Content when detail has no matching assistant turns", async () => { + const uwf = await makeUwfStore(tmpDir); + + const workflowHash = await uwf.store.put(uwf.schemas.workflow, { + name: "test-wf2", + description: "desc", + roles: {}, + conditions: {}, + graph: {}, + }); + const startHash = await uwf.store.put(uwf.schemas.startNode, { + workflow: workflowHash, + prompt: "Do stuff", + }); + const outputHash = await uwf.store.put(uwf.schemas.workflow, { + name: "out", + description: "", + roles: {}, + conditions: {}, + graph: {}, + }); + + // A detail ref that doesn't exist in the store → extractLastAssistantContent returns null + const missingDetailRef = "missingdetail0" as CasRef; + + const stepHash = await uwf.store.put(uwf.schemas.stepNode, { + start: startHash, + prev: null, + role: "worker", + output: outputHash, + detail: missingDetailRef, + agent: "uwf-hermes", + }); + + const threadId = "01JTEST0000000000000000002" as ThreadId; + await saveThreadsIndex(tmpDir, { [threadId]: stepHash }); + + const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false); + + expect(markdown).not.toContain("### Content"); + expect(markdown).toContain("### Output"); + }); +}); + +// ── cmdThreadStepDetails ────────────────────────────────────────────────────── + +describe("cmdThreadStepDetails", () => { + test("returns expanded detail node with turns inlined", async () => { + const uwf = await makeUwfStore(tmpDir); + const detailSchemas = await registerDetailSchemas(uwf.store); + + const workflowHash = await uwf.store.put(uwf.schemas.workflow, { + name: "wf", + description: "", + roles: {}, + conditions: {}, + graph: {}, + }); + const startHash = await uwf.store.put(uwf.schemas.startNode, { + workflow: workflowHash, + prompt: "p", + }); + const outputHash = await uwf.store.put(uwf.schemas.workflow, { + name: "out", + description: "", + roles: {}, + conditions: {}, + graph: {}, + }); + + const turnHash = await uwf.store.put(detailSchemas.turn, { + index: 0, + role: "assistant", + content: "done", + toolCalls: null, + reasoning: null, + }); + const detailHash = await uwf.store.put(detailSchemas.detail, { + sessionId: "sess42", + model: "gpt-4o", + duration: 3000, + turnCount: 1, + turns: [turnHash], + }); + + const stepHash = await uwf.store.put(uwf.schemas.stepNode, { + start: startHash, + prev: null, + role: "coder", + output: outputHash, + detail: detailHash, + agent: "uwf-hermes", + }); + + const result = await cmdThreadStepDetails(tmpDir, stepHash); + + expect(result).toMatchObject({ + sessionId: "sess42", + model: "gpt-4o", + duration: 3000, + turnCount: 1, + }); + + const expanded = result as Record; + expect(Array.isArray(expanded.turns)).toBe(true); + const turns = expanded.turns as unknown[]; + expect(turns).toHaveLength(1); + expect(turns[0]).toMatchObject({ + index: 0, + role: "assistant", + content: "done", + }); + }); + + test("throws when step hash does not exist", async () => { + await expect(cmdThreadStepDetails(tmpDir, "nonexistenth0" as CasRef)).rejects.toThrow(); + }); +}); diff --git a/packages/cli-uwf/src/cli.ts b/packages/cli-uwf/src/cli.ts index 5a93593..ad18c9b 100755 --- a/packages/cli-uwf/src/cli.ts +++ b/packages/cli-uwf/src/cli.ts @@ -1,33 +1,34 @@ #!/usr/bin/env bun -import { Command } from "commander"; import type { ThreadId } from "@uncaged/uwf-protocol"; - +import { Command } from "commander"; +import { stringify as yamlStringify } from "yaml"; +import { + cmdCasGet, + cmdCasHas, + cmdCasPut, + cmdCasRefs, + cmdCasReindex, + cmdCasSchemaGet, + cmdCasSchemaList, + cmdCasWalk, +} from "./commands/cas.js"; +import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js"; import { cmdThreadFork, cmdThreadKill, cmdThreadList, cmdThreadRead, - THREAD_READ_DEFAULT_QUOTA, cmdThreadShow, cmdThreadStart, cmdThreadStep, + cmdThreadStepDetails, cmdThreadSteps, + THREAD_READ_DEFAULT_QUOTA, } from "./commands/thread.js"; import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js"; -import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js"; -import { - cmdCasGet, - cmdCasHas, - cmdCasPut, - cmdCasReindex, - cmdCasRefs, - cmdCasSchemaGet, - cmdCasSchemaList, - cmdCasWalk, -} from "./commands/cas.js"; +import { formatOutput, type OutputFormat } from "./format.js"; import { resolveStorageRoot } from "./store.js"; -import { type OutputFormat, formatOutput } from "./format.js"; function writeOutput(data: unknown): void { const fmt = program.opts().format as OutputFormat; @@ -168,20 +169,27 @@ thread .option("--quota ", "Max output characters", String(THREAD_READ_DEFAULT_QUOTA)) .option("--before ", "Load steps before this hash (exclusive)") .option("--start", "Include start step in output") - .option("--detail", "Expand detail content for each step") - .action((threadId: string, opts: { quota: string; before: string | undefined; start: boolean; detail: boolean }) => { - 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 before = opts.before ?? null; - const markdown = await cmdThreadRead(storageRoot, threadId as ThreadId, quota, before, opts.start ?? false, opts.detail ?? false); - process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`); - }); - }); + .action( + (threadId: string, opts: { quota: string; before: string | undefined; start: boolean }) => { + 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 before = opts.before ?? null; + const markdown = await cmdThreadRead( + storageRoot, + threadId as ThreadId, + quota, + before, + opts.start ?? false, + ); + process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`); + }); + }, + ); thread .command("fork") @@ -195,6 +203,18 @@ thread }); }); +thread + .command("step-details") + .description("Dump the full detail node of a step as YAML") + .argument("", "CAS hash of the StepNode") + .action((stepHash: string) => { + const storageRoot = resolveStorageRoot(); + runAction(async () => { + const detail = await cmdThreadStepDetails(storageRoot, stepHash); + process.stdout.write(yamlStringify(detail)); + }); + }); + program .command("setup") .description("Configure provider, model, and agent") @@ -203,34 +223,36 @@ program .option("--api-key ", "API key") .option("--model ", "Default model name") .option("--agent ", "Default agent alias") - .action((opts: { - provider?: string; - baseUrl?: string; - apiKey?: string; - model?: string; - agent?: string; - }) => { - const storageRoot = resolveStorageRoot(); - runAction(async () => { - if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) { - const result = await cmdSetup({ - provider: opts.provider, - baseUrl: opts.baseUrl, - apiKey: opts.apiKey, - model: opts.model, - agent: opts.agent ?? undefined, - storageRoot, - }); - writeOutput(result); - } else if (!opts.provider && !opts.baseUrl && !opts.apiKey && !opts.model) { - await cmdSetupInteractive(storageRoot); - } else { - throw new Error( - "Non-interactive setup requires all of: --provider, --base-url, --api-key, --model", - ); - } - }); - }); + .action( + (opts: { + provider?: string; + baseUrl?: string; + apiKey?: string; + model?: string; + agent?: string; + }) => { + const storageRoot = resolveStorageRoot(); + runAction(async () => { + if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) { + const result = await cmdSetup({ + provider: opts.provider, + baseUrl: opts.baseUrl, + apiKey: opts.apiKey, + model: opts.model, + agent: opts.agent ?? undefined, + storageRoot, + }); + writeOutput(result); + } else if (!opts.provider && !opts.baseUrl && !opts.apiKey && !opts.model) { + await cmdSetupInteractive(storageRoot); + } else { + throw new Error( + "Non-interactive setup requires all of: --provider, --base-url, --api-key, --model", + ); + } + }); + }, + ); const cas = program.command("cas").description("Content-addressable storage operations"); diff --git a/packages/cli-uwf/src/commands/thread.ts b/packages/cli-uwf/src/commands/thread.ts index d5c9e3a..927eab0 100644 --- a/packages/cli-uwf/src/commands/thread.ts +++ b/packages/cli-uwf/src/commands/thread.ts @@ -1,8 +1,6 @@ import { execFileSync } from "node:child_process"; - +import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas"; import { getSchema, validate } from "@uncaged/json-cas"; -import type { JSONSchema, Store as CasStore } from "@uncaged/json-cas"; -import { stringify } from "yaml"; import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit"; import { evaluate } from "@uncaged/uwf-moderator"; import type { @@ -26,6 +24,7 @@ import type { } from "@uncaged/uwf-protocol"; import { generateUlid } from "@uncaged/workflow-util"; import { config as loadDotenv } from "dotenv"; +import { stringify } from "yaml"; import { appendThreadHistory, @@ -293,7 +292,12 @@ function expandDeep(store: CasStore, hash: CasRef, visited?: Set): unkno return expandValue(store, schema, node.payload, seen); } -function expandValue(store: CasStore, schema: JSONSchema, value: unknown, visited: Set): unknown { +function expandValue( + store: CasStore, + schema: JSONSchema, + value: unknown, + visited: Set, +): unknown { // If this field is a cas_ref, expand it if (schema.format === "cas_ref") { if (typeof value === "string") { @@ -383,6 +387,37 @@ function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: str ].join("\n"); } +export function extractLastAssistantContent(uwf: UwfStore, detailRef: CasRef): string | null { + const detailNode = uwf.store.get(detailRef); + if (detailNode === null) { + return null; + } + const detail = detailNode.payload as Record; + const turns = detail.turns; + if (!Array.isArray(turns) || turns.length === 0) { + return null; + } + for (let i = turns.length - 1; i >= 0; i--) { + const turnRef = turns[i]; + if (typeof turnRef !== "string") { + continue; + } + const turnNode = uwf.store.get(turnRef as CasRef); + if (turnNode === null) { + continue; + } + const turn = turnNode.payload as Record; + if ( + turn.role === "assistant" && + typeof turn.content === "string" && + turn.content.trim() !== "" + ) { + return turn.content; + } + } + return null; +} + function formatThreadReadMarkdown(options: { threadId: ThreadId; workflowName: string; @@ -394,9 +429,8 @@ function formatThreadReadMarkdown(options: { quota: number; before: CasRef | null; showStart: boolean; - showDetail: boolean; }): string { - const { ordered, uwf, workflow, quota, before, showStart, showDetail } = options; + const { ordered, uwf, workflow, quota, before, showStart } = options; // Determine which steps to consider let candidates = ordered; @@ -456,7 +490,10 @@ function formatThreadReadMarkdown(options: { if (item === undefined) continue; const stepNum = startIndex + i + 1; const outputYaml = formatYaml(expandOutput(uwf, item.payload.output)); - const ts = new Date(item.timestamp).toISOString().replace("T", " ").replace(/\.\d+Z$/, ""); + const ts = new Date(item.timestamp) + .toISOString() + .replace("T", " ") + .replace(/\.\d+Z$/, ""); const stepLines = [ `## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``, `**Agent:** ${item.payload.agent} | **Time:** ${ts}`, @@ -465,19 +502,13 @@ function formatThreadReadMarkdown(options: { if (roleDef) { stepLines.push("", "### Prompt", "", roleDef.systemPrompt); } - stepLines.push( - "", - "### Output", - "", - "```yaml", - outputYaml, - "```", - ); - if (showDetail && item.payload.detail) { - const detailExpanded = expandDeep(uwf.store, item.payload.detail); - const detailYaml = formatYaml(detailExpanded); - stepLines.push("", "### Detail", "", "```yaml", detailYaml, "```"); + if (item.payload.detail) { + const content = extractLastAssistantContent(uwf, item.payload.detail); + if (content !== null) { + stepLines.push("", "### Content", "", content); + } } + stepLines.push("", "### Output", "", "```yaml", outputYaml, "```"); parts.push(stepLines.join("\n")); } @@ -719,7 +750,6 @@ export async function cmdThreadRead( quota: number = THREAD_READ_DEFAULT_QUOTA, before: CasRef | null = null, showStart: boolean = false, - showDetail: boolean = false, ): Promise { const headHash = await resolveHeadHash(storageRoot, threadId); const uwf = await createUwfStore(storageRoot); @@ -738,7 +768,6 @@ export async function cmdThreadRead( quota, before, showStart, - showDetail, }); } @@ -768,6 +797,25 @@ export async function cmdThreadFork( }; } +export async function cmdThreadStepDetails( + storageRoot: string, + stepHash: CasRef, +): Promise { + const uwf = await createUwfStore(storageRoot); + const node = uwf.store.get(stepHash); + if (node === null) { + fail(`CAS node not found: ${stepHash}`); + } + if (node.type !== uwf.schemas.stepNode) { + fail(`node ${stepHash} is not a StepNode`); + } + const payload = node.payload as StepNodePayload; + if (!payload.detail) { + fail(`step ${stepHash} has no detail`); + } + return expandDeep(uwf.store, payload.detail); +} + export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Promise { const index = await loadThreadsIndex(storageRoot); const head = index[threadId]; diff --git a/packages/cli-uwf/vitest.config.ts b/packages/cli-uwf/vitest.config.ts new file mode 100644 index 0000000..8696084 --- /dev/null +++ b/packages/cli-uwf/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/__tests__/**/*.test.ts"], + }, +});