feat(cli-uwf): thread read shows Content + new step-details command
- thread read: add ### Content section (last assistant message) before ### Output - Remove --detail flag (replaced by step-details command) - New: uwf thread step-details <step-hash> — full detail dump as yaml Closes #357
This commit is contained in:
@@ -22,9 +22,12 @@
|
|||||||
"yaml": "^2.8.4"
|
"yaml": "^2.8.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<UwfStore> {
|
||||||
|
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<typeof createFsStore>) {
|
||||||
|
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<string, unknown>;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+79
-57
@@ -1,33 +1,34 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { Command } from "commander";
|
|
||||||
import type { ThreadId } from "@uncaged/uwf-protocol";
|
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 {
|
import {
|
||||||
cmdThreadFork,
|
cmdThreadFork,
|
||||||
cmdThreadKill,
|
cmdThreadKill,
|
||||||
cmdThreadList,
|
cmdThreadList,
|
||||||
cmdThreadRead,
|
cmdThreadRead,
|
||||||
THREAD_READ_DEFAULT_QUOTA,
|
|
||||||
cmdThreadShow,
|
cmdThreadShow,
|
||||||
cmdThreadStart,
|
cmdThreadStart,
|
||||||
cmdThreadStep,
|
cmdThreadStep,
|
||||||
|
cmdThreadStepDetails,
|
||||||
cmdThreadSteps,
|
cmdThreadSteps,
|
||||||
|
THREAD_READ_DEFAULT_QUOTA,
|
||||||
} from "./commands/thread.js";
|
} from "./commands/thread.js";
|
||||||
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
|
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
|
||||||
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
import { formatOutput, type OutputFormat } from "./format.js";
|
||||||
import {
|
|
||||||
cmdCasGet,
|
|
||||||
cmdCasHas,
|
|
||||||
cmdCasPut,
|
|
||||||
cmdCasReindex,
|
|
||||||
cmdCasRefs,
|
|
||||||
cmdCasSchemaGet,
|
|
||||||
cmdCasSchemaList,
|
|
||||||
cmdCasWalk,
|
|
||||||
} from "./commands/cas.js";
|
|
||||||
import { resolveStorageRoot } from "./store.js";
|
import { resolveStorageRoot } from "./store.js";
|
||||||
import { type OutputFormat, formatOutput } from "./format.js";
|
|
||||||
|
|
||||||
function writeOutput(data: unknown): void {
|
function writeOutput(data: unknown): void {
|
||||||
const fmt = program.opts().format as OutputFormat;
|
const fmt = program.opts().format as OutputFormat;
|
||||||
@@ -168,20 +169,27 @@ thread
|
|||||||
.option("--quota <chars>", "Max output characters", String(THREAD_READ_DEFAULT_QUOTA))
|
.option("--quota <chars>", "Max output characters", String(THREAD_READ_DEFAULT_QUOTA))
|
||||||
.option("--before <step-hash>", "Load steps before this hash (exclusive)")
|
.option("--before <step-hash>", "Load steps before this hash (exclusive)")
|
||||||
.option("--start", "Include start step in output")
|
.option("--start", "Include start step in output")
|
||||||
.option("--detail", "Expand detail content for each step")
|
.action(
|
||||||
.action((threadId: string, opts: { quota: string; before: string | undefined; start: boolean; detail: boolean }) => {
|
(threadId: string, opts: { quota: string; before: string | undefined; start: boolean }) => {
|
||||||
const storageRoot = resolveStorageRoot();
|
const storageRoot = resolveStorageRoot();
|
||||||
runAction(async () => {
|
runAction(async () => {
|
||||||
const quota = Number.parseInt(opts.quota, 10);
|
const quota = Number.parseInt(opts.quota, 10);
|
||||||
if (!Number.isFinite(quota) || quota < 1) {
|
if (!Number.isFinite(quota) || quota < 1) {
|
||||||
process.stderr.write("invalid --quota: must be a positive integer\n");
|
process.stderr.write("invalid --quota: must be a positive integer\n");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const before = opts.before ?? null;
|
const before = opts.before ?? null;
|
||||||
const markdown = await cmdThreadRead(storageRoot, threadId as ThreadId, quota, before, opts.start ?? false, opts.detail ?? false);
|
const markdown = await cmdThreadRead(
|
||||||
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
|
storageRoot,
|
||||||
});
|
threadId as ThreadId,
|
||||||
});
|
quota,
|
||||||
|
before,
|
||||||
|
opts.start ?? false,
|
||||||
|
);
|
||||||
|
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
thread
|
thread
|
||||||
.command("fork")
|
.command("fork")
|
||||||
@@ -195,6 +203,18 @@ thread
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("step-details")
|
||||||
|
.description("Dump the full detail node of a step as YAML")
|
||||||
|
.argument("<step-hash>", "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
|
program
|
||||||
.command("setup")
|
.command("setup")
|
||||||
.description("Configure provider, model, and agent")
|
.description("Configure provider, model, and agent")
|
||||||
@@ -203,34 +223,36 @@ program
|
|||||||
.option("--api-key <key>", "API key")
|
.option("--api-key <key>", "API key")
|
||||||
.option("--model <name>", "Default model name")
|
.option("--model <name>", "Default model name")
|
||||||
.option("--agent <name>", "Default agent alias")
|
.option("--agent <name>", "Default agent alias")
|
||||||
.action((opts: {
|
.action(
|
||||||
provider?: string;
|
(opts: {
|
||||||
baseUrl?: string;
|
provider?: string;
|
||||||
apiKey?: string;
|
baseUrl?: string;
|
||||||
model?: string;
|
apiKey?: string;
|
||||||
agent?: string;
|
model?: string;
|
||||||
}) => {
|
agent?: string;
|
||||||
const storageRoot = resolveStorageRoot();
|
}) => {
|
||||||
runAction(async () => {
|
const storageRoot = resolveStorageRoot();
|
||||||
if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) {
|
runAction(async () => {
|
||||||
const result = await cmdSetup({
|
if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) {
|
||||||
provider: opts.provider,
|
const result = await cmdSetup({
|
||||||
baseUrl: opts.baseUrl,
|
provider: opts.provider,
|
||||||
apiKey: opts.apiKey,
|
baseUrl: opts.baseUrl,
|
||||||
model: opts.model,
|
apiKey: opts.apiKey,
|
||||||
agent: opts.agent ?? undefined,
|
model: opts.model,
|
||||||
storageRoot,
|
agent: opts.agent ?? undefined,
|
||||||
});
|
storageRoot,
|
||||||
writeOutput(result);
|
});
|
||||||
} else if (!opts.provider && !opts.baseUrl && !opts.apiKey && !opts.model) {
|
writeOutput(result);
|
||||||
await cmdSetupInteractive(storageRoot);
|
} else if (!opts.provider && !opts.baseUrl && !opts.apiKey && !opts.model) {
|
||||||
} else {
|
await cmdSetupInteractive(storageRoot);
|
||||||
throw new Error(
|
} else {
|
||||||
"Non-interactive setup requires all of: --provider, --base-url, --api-key, --model",
|
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");
|
const cas = program.command("cas").description("Content-addressable storage operations");
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
|
import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas";
|
||||||
import { getSchema, validate } 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 { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit";
|
||||||
import { evaluate } from "@uncaged/uwf-moderator";
|
import { evaluate } from "@uncaged/uwf-moderator";
|
||||||
import type {
|
import type {
|
||||||
@@ -26,6 +24,7 @@ import type {
|
|||||||
} from "@uncaged/uwf-protocol";
|
} from "@uncaged/uwf-protocol";
|
||||||
import { generateUlid } from "@uncaged/workflow-util";
|
import { generateUlid } from "@uncaged/workflow-util";
|
||||||
import { config as loadDotenv } from "dotenv";
|
import { config as loadDotenv } from "dotenv";
|
||||||
|
import { stringify } from "yaml";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
appendThreadHistory,
|
appendThreadHistory,
|
||||||
@@ -293,7 +292,12 @@ function expandDeep(store: CasStore, hash: CasRef, visited?: Set<string>): unkno
|
|||||||
return expandValue(store, schema, node.payload, seen);
|
return expandValue(store, schema, node.payload, seen);
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandValue(store: CasStore, schema: JSONSchema, value: unknown, visited: Set<string>): unknown {
|
function expandValue(
|
||||||
|
store: CasStore,
|
||||||
|
schema: JSONSchema,
|
||||||
|
value: unknown,
|
||||||
|
visited: Set<string>,
|
||||||
|
): unknown {
|
||||||
// If this field is a cas_ref, expand it
|
// If this field is a cas_ref, expand it
|
||||||
if (schema.format === "cas_ref") {
|
if (schema.format === "cas_ref") {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
@@ -383,6 +387,37 @@ function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: str
|
|||||||
].join("\n");
|
].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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
if (
|
||||||
|
turn.role === "assistant" &&
|
||||||
|
typeof turn.content === "string" &&
|
||||||
|
turn.content.trim() !== ""
|
||||||
|
) {
|
||||||
|
return turn.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function formatThreadReadMarkdown(options: {
|
function formatThreadReadMarkdown(options: {
|
||||||
threadId: ThreadId;
|
threadId: ThreadId;
|
||||||
workflowName: string;
|
workflowName: string;
|
||||||
@@ -394,9 +429,8 @@ function formatThreadReadMarkdown(options: {
|
|||||||
quota: number;
|
quota: number;
|
||||||
before: CasRef | null;
|
before: CasRef | null;
|
||||||
showStart: boolean;
|
showStart: boolean;
|
||||||
showDetail: boolean;
|
|
||||||
}): string {
|
}): string {
|
||||||
const { ordered, uwf, workflow, quota, before, showStart, showDetail } = options;
|
const { ordered, uwf, workflow, quota, before, showStart } = options;
|
||||||
|
|
||||||
// Determine which steps to consider
|
// Determine which steps to consider
|
||||||
let candidates = ordered;
|
let candidates = ordered;
|
||||||
@@ -456,7 +490,10 @@ function formatThreadReadMarkdown(options: {
|
|||||||
if (item === undefined) continue;
|
if (item === undefined) continue;
|
||||||
const stepNum = startIndex + i + 1;
|
const stepNum = startIndex + i + 1;
|
||||||
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
|
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 = [
|
const stepLines = [
|
||||||
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
|
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
|
||||||
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
|
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
|
||||||
@@ -465,19 +502,13 @@ function formatThreadReadMarkdown(options: {
|
|||||||
if (roleDef) {
|
if (roleDef) {
|
||||||
stepLines.push("", "### Prompt", "", roleDef.systemPrompt);
|
stepLines.push("", "### Prompt", "", roleDef.systemPrompt);
|
||||||
}
|
}
|
||||||
stepLines.push(
|
if (item.payload.detail) {
|
||||||
"",
|
const content = extractLastAssistantContent(uwf, item.payload.detail);
|
||||||
"### Output",
|
if (content !== null) {
|
||||||
"",
|
stepLines.push("", "### Content", "", content);
|
||||||
"```yaml",
|
}
|
||||||
outputYaml,
|
|
||||||
"```",
|
|
||||||
);
|
|
||||||
if (showDetail && item.payload.detail) {
|
|
||||||
const detailExpanded = expandDeep(uwf.store, item.payload.detail);
|
|
||||||
const detailYaml = formatYaml(detailExpanded);
|
|
||||||
stepLines.push("", "### Detail", "", "```yaml", detailYaml, "```");
|
|
||||||
}
|
}
|
||||||
|
stepLines.push("", "### Output", "", "```yaml", outputYaml, "```");
|
||||||
parts.push(stepLines.join("\n"));
|
parts.push(stepLines.join("\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,7 +750,6 @@ export async function cmdThreadRead(
|
|||||||
quota: number = THREAD_READ_DEFAULT_QUOTA,
|
quota: number = THREAD_READ_DEFAULT_QUOTA,
|
||||||
before: CasRef | null = null,
|
before: CasRef | null = null,
|
||||||
showStart: boolean = false,
|
showStart: boolean = false,
|
||||||
showDetail: boolean = false,
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const headHash = await resolveHeadHash(storageRoot, threadId);
|
const headHash = await resolveHeadHash(storageRoot, threadId);
|
||||||
const uwf = await createUwfStore(storageRoot);
|
const uwf = await createUwfStore(storageRoot);
|
||||||
@@ -738,7 +768,6 @@ export async function cmdThreadRead(
|
|||||||
quota,
|
quota,
|
||||||
before,
|
before,
|
||||||
showStart,
|
showStart,
|
||||||
showDetail,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,6 +797,25 @@ export async function cmdThreadFork(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadStepDetails(
|
||||||
|
storageRoot: string,
|
||||||
|
stepHash: CasRef,
|
||||||
|
): Promise<unknown> {
|
||||||
|
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<KillOutput> {
|
export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Promise<KillOutput> {
|
||||||
const index = await loadThreadsIndex(storageRoot);
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
const head = index[threadId];
|
const head = index[threadId];
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["src/__tests__/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user