diff --git a/packages/cli-workflow/src/__tests__/thread-read-xml-tags.test.ts b/packages/cli-workflow/src/__tests__/thread-read-xml-tags.test.ts new file mode 100644 index 0000000..86023c8 --- /dev/null +++ b/packages/cli-workflow/src/__tests__/thread-read-xml-tags.test.ts @@ -0,0 +1,683 @@ +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, 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 }); +}); + +// ── thread read XML tag isolation ───────────────────────────────────────────── + +describe("thread read XML tag isolation", () => { + test("scenario 1: wraps output in XML tags instead of heading", 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: { + planner: { + description: "Planner", + goal: "You are a planning agent. Your task is to...", + capabilities: [], + procedure: "Plan the work.", + output: "Summarize the plan.", + meta: "placeholder00" as CasRef, + }, + }, + conditions: {}, + graph: {}, + }); + + const startHash = await uwf.store.put(uwf.schemas.startNode, { + workflow: workflowHash, + prompt: "Fix issue #459", + }); + + 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: + "---\nstatus: ready\nplan: CMWGHQKT58RY4\n---\n\n# Analysis Complete\n## Issue Summary\nThe issue requires XML tag isolation.", + 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: "planner", + output: outputHash, + detail: detailHash, + agent: "uwf-claude-code", + }); + + const threadId = "01JTEST0000000000000001" as ThreadId; + await saveThreadsIndex(tmpDir, { [threadId]: stepHash }); + + const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false); + + // Should wrap output in XML tags + expect(markdown).toContain(""); + expect(markdown).toContain(""); + + // Should not have ### Content heading + expect(markdown).not.toContain("### Content"); + + // Should preserve markdown headings inside output tags + expect(markdown).toContain("# Analysis Complete"); + expect(markdown).toContain("## Issue Summary"); + }); + + test("scenario 2: wraps prompt in XML tags", 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: { + planner: { + description: "Planner", + goal: "You are a planning agent. Your task is to analyze and plan.", + capabilities: [], + procedure: "Plan the work.", + output: "Summarize the plan.", + meta: "placeholder00" as CasRef, + }, + }, + conditions: {}, + graph: {}, + }); + + const startHash = await uwf.store.put(uwf.schemas.startNode, { + workflow: workflowHash, + prompt: "Fix issue", + }); + + 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: "---\nstatus: ready\n---\n\nContent here...", + 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: "planner", + output: outputHash, + detail: detailHash, + agent: "uwf-claude-code", + }); + + const threadId = "01JTEST0000000000000002" as ThreadId; + await saveThreadsIndex(tmpDir, { [threadId]: stepHash }); + + const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false); + + // Should wrap prompt in XML tags + expect(markdown).toContain(""); + expect(markdown).toContain(""); + expect(markdown).toContain("You are a planning agent. Your task is to analyze and plan."); + + // Should not have ### Prompt heading + expect(markdown).not.toContain("### Prompt"); + + // Should wrap output in XML tags + expect(markdown).toContain(""); + expect(markdown).toContain(""); + }); + + test("scenario 3: same role repeated does not show prompt twice", async () => { + const uwf = await makeUwfStore(tmpDir); + + const workflowHash = await uwf.store.put(uwf.schemas.workflow, { + name: "test-wf", + description: "desc", + roles: { + writer: { + description: "Writer", + goal: "You are a writer agent.", + capabilities: [], + procedure: "Write content.", + output: "Summarize writing.", + meta: "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 step1 = await uwf.store.put(uwf.schemas.stepNode, { + start: startHash, + prev: null, + role: "writer", + output: outputHash, + detail: null, + agent: "uwf-test", + }); + + const step2 = await uwf.store.put(uwf.schemas.stepNode, { + start: startHash, + prev: step1 as CasRef, + role: "writer", + output: outputHash, + detail: null, + agent: "uwf-test", + }); + + const threadId = "01JTEST0000000000000003" as ThreadId; + await saveThreadsIndex(tmpDir, { [threadId]: step2 }); + + const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false); + + // Should only show prompt tags once + const promptCount = (markdown.match(//g) ?? []).length; + expect(promptCount).toBe(1); + }); + + test("scenario 4: step with no detail shows no output tags", async () => { + const uwf = await makeUwfStore(tmpDir); + + const workflowHash = await uwf.store.put(uwf.schemas.workflow, { + name: "test-wf", + description: "desc", + roles: { + worker: { + description: "Worker", + goal: "You are a worker agent.", + capabilities: [], + procedure: "Do work.", + output: "Summarize work.", + meta: "placeholder00" as CasRef, + }, + }, + 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: {}, + }); + + const stepHash = await uwf.store.put(uwf.schemas.stepNode, { + start: startHash, + prev: null, + role: "worker", + output: outputHash, + detail: null, + agent: "uwf-test", + }); + + const threadId = "01JTEST0000000000000004" as ThreadId; + await saveThreadsIndex(tmpDir, { [threadId]: stepHash }); + + const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false); + + // Should not have output tags + expect(markdown).not.toContain(""); + expect(markdown).not.toContain(""); + + // Step header should still be displayed + expect(markdown).toContain("## Step 1: worker"); + + // Prompt should still be shown + expect(markdown).toContain(""); + }); + + test("scenario 5: empty content shows no output tags", async () => { + const uwf = await makeUwfStore(tmpDir); + + const workflowHash = await uwf.store.put(uwf.schemas.workflow, { + name: "test-wf", + 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 → 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-test", + }); + + const threadId = "01JTEST0000000000000005" as ThreadId; + await saveThreadsIndex(tmpDir, { [threadId]: stepHash }); + + const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false); + + // Should not have output tags + expect(markdown).not.toContain(""); + expect(markdown).not.toContain(""); + }); + + test("scenario 6: thread read with --start flag shows task section", async () => { + const uwf = await makeUwfStore(tmpDir); + + const workflowHash = await uwf.store.put(uwf.schemas.workflow, { + name: "test-wf", + description: "desc", + roles: { + roleA: { + description: "Role A", + goal: "Goal for roleA", + capabilities: [], + procedure: "Do stuff.", + output: "Output.", + meta: "placeholder00" as CasRef, + }, + }, + conditions: {}, + graph: {}, + }); + + const startHash = await uwf.store.put(uwf.schemas.startNode, { + workflow: workflowHash, + prompt: "Initial prompt", + }); + + const outputHash = await uwf.store.put(uwf.schemas.workflow, { + name: "out", + description: "", + roles: {}, + conditions: {}, + graph: {}, + }); + + const stepHash = await uwf.store.put(uwf.schemas.stepNode, { + start: startHash, + prev: null, + role: "roleA", + output: outputHash, + detail: null, + agent: "uwf-test", + }); + + const threadId = "01JTEST0000000000000006" as ThreadId; + await saveThreadsIndex(tmpDir, { [threadId]: stepHash }); + + const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, true); + + // Should include task section + expect(markdown).toContain("# Thread"); + expect(markdown).toContain("## Task"); + expect(markdown).toContain("Initial prompt"); + + // Prompts should use XML tags + expect(markdown).toContain(""); + }); + + test("scenario 7: thread read with --before parameter", async () => { + const uwf = await makeUwfStore(tmpDir); + + const workflowHash = await uwf.store.put(uwf.schemas.workflow, { + name: "test-wf", + description: "desc", + roles: { + roleA: { + description: "Role A", + goal: "Goal for roleA", + capabilities: [], + procedure: "Do stuff.", + output: "Output.", + meta: "placeholder00" as CasRef, + }, + roleB: { + description: "Role B", + goal: "Goal for roleB", + capabilities: [], + procedure: "Do stuff.", + output: "Output.", + meta: "placeholder00" as CasRef, + }, + roleC: { + description: "Role C", + goal: "Goal for roleC", + capabilities: [], + procedure: "Do stuff.", + output: "Output.", + meta: "placeholder00" as CasRef, + }, + }, + conditions: {}, + graph: {}, + }); + + const startHash = await uwf.store.put(uwf.schemas.startNode, { + workflow: workflowHash, + prompt: "Initial prompt", + }); + + const outputHash = await uwf.store.put(uwf.schemas.workflow, { + name: "out", + description: "", + roles: {}, + conditions: {}, + graph: {}, + }); + + const step1 = await uwf.store.put(uwf.schemas.stepNode, { + start: startHash, + prev: null, + role: "roleA", + output: outputHash, + detail: null, + agent: "uwf-test", + }); + + const step2 = await uwf.store.put(uwf.schemas.stepNode, { + start: startHash, + prev: step1 as CasRef, + role: "roleB", + output: outputHash, + detail: null, + agent: "uwf-test", + }); + + const step3 = await uwf.store.put(uwf.schemas.stepNode, { + start: startHash, + prev: step2 as CasRef, + role: "roleC", + output: outputHash, + detail: null, + agent: "uwf-test", + }); + + const threadId = "01JTEST0000000000000007" as ThreadId; + await saveThreadsIndex(tmpDir, { [threadId]: step3 }); + + const markdown = await cmdThreadRead( + tmpDir, + threadId, + THREAD_READ_DEFAULT_QUOTA, + step2 as CasRef, + false, + ); + + // Should only show roleA + expect(markdown).toContain("roleA"); + expect(markdown).not.toContain("roleB"); + expect(markdown).not.toContain("roleC"); + + // Should use XML tags + expect(markdown).toContain(""); + }); + + test("scenario 9: special characters in content are preserved", 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: "Writer", + goal: "You are a writer.", + capabilities: [], + procedure: "Write content.", + output: "Summarize.", + meta: "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: "Content with & characters > like ", + 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-test", + }); + + const threadId = "01JTEST0000000000000008" as ThreadId; + await saveThreadsIndex(tmpDir, { [threadId]: stepHash }); + + const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false); + + // Special characters should be preserved as-is + expect(markdown).toContain("Content with & characters > like "); + }); + + test("scenario 10: quota limit with XML tags", async () => { + const uwf = await makeUwfStore(tmpDir); + + const workflowHash = await uwf.store.put(uwf.schemas.workflow, { + name: "test-wf", + description: "desc", + roles: { + roleA: { + description: "Role A", + goal: "Goal for roleA", + capabilities: [], + procedure: "Do stuff.", + output: "Output.", + meta: "placeholder00" as CasRef, + }, + }, + conditions: {}, + graph: {}, + }); + + const startHash = await uwf.store.put(uwf.schemas.startNode, { + workflow: workflowHash, + prompt: "Initial prompt", + }); + + const outputHash = await uwf.store.put(uwf.schemas.workflow, { + name: "out", + description: "", + roles: {}, + conditions: {}, + graph: {}, + }); + + const steps: CasRef[] = []; + let prev: CasRef | null = null; + for (let i = 0; i < 5; i++) { + const step = (await uwf.store.put(uwf.schemas.stepNode, { + start: startHash, + prev, + role: "roleA", + output: outputHash, + detail: null, + agent: "uwf-test", + })) as CasRef; + steps.push(step); + prev = step; + } + + const threadId = "01JTEST0000000000000009" as ThreadId; + await saveThreadsIndex(tmpDir, { [threadId]: steps[steps.length - 1]! }); + + // Use very small quota + const markdown = await cmdThreadRead(tmpDir, threadId, 1, null, false); + + // Should have skip hint + expect(markdown).toContain("earlier step"); + + // Should have XML tags for displayed steps + if (markdown.includes("")) { + expect(markdown).toContain(""); + } + }); +}); diff --git a/packages/cli-workflow/src/__tests__/thread.test.ts b/packages/cli-workflow/src/__tests__/thread.test.ts index ce66d65..3ca456b 100644 --- a/packages/cli-workflow/src/__tests__/thread.test.ts +++ b/packages/cli-workflow/src/__tests__/thread.test.ts @@ -198,10 +198,10 @@ describe("extractLastAssistantContent", () => { }); }); -// ── cmdThreadRead: ### Content section ─────────────────────────────────────── +// ── cmdThreadRead: section ────────────────────────────────────────── -describe("cmdThreadRead ### Content section", () => { - test("includes ### Content before ### Output when detail has assistant turns", async () => { +describe("cmdThreadRead section", () => { + test("includes tags when detail has assistant turns", async () => { const uwf = await makeUwfStore(tmpDir); const detailSchemas = await registerDetailSchemas(uwf.store); @@ -264,12 +264,13 @@ describe("cmdThreadRead ### Content section", () => { const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false); - expect(markdown).toContain("### Content"); + expect(markdown).toContain(""); + expect(markdown).toContain(""); expect(markdown).toContain("The assistant response text"); - expect(markdown).not.toContain("### Output"); + expect(markdown).not.toContain("### Content"); }); - test("omits ### Content when detail has no matching assistant turns", async () => { + test("omits tags when detail has no matching assistant turns", async () => { const uwf = await makeUwfStore(tmpDir); const workflowHash = await uwf.store.put(uwf.schemas.workflow, { @@ -308,8 +309,9 @@ describe("cmdThreadRead ### Content section", () => { const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false); + expect(markdown).not.toContain(""); + expect(markdown).not.toContain(""); expect(markdown).not.toContain("### Content"); - expect(markdown).not.toContain("### Output"); }); }); @@ -384,9 +386,9 @@ describe("cmdThreadStepDetails", () => { }); }); -// ── cmdThreadRead: ### Prompt deduplication ─────────────────────────────────── +// ── cmdThreadRead: deduplication ──────────────────────────────────── -describe("cmdThreadRead ### Prompt deduplication", () => { +describe("cmdThreadRead deduplication", () => { async function makeThreadWithRoles(uwf: UwfStore, roles: string[]): Promise { const roleMap: Record = {}; for (const r of [...new Set(roles)]) { @@ -434,36 +436,36 @@ describe("cmdThreadRead ### Prompt deduplication", () => { return stepHash; } - test("same consecutive role shows ### Prompt once", async () => { + test("same consecutive role shows once", async () => { const uwf = await makeUwfStore(tmpDir); const headHash = await makeThreadWithRoles(uwf, ["writer", "writer"]); const threadId = "01JTEST0000000000000003" as ThreadId; await saveThreadsIndex(tmpDir, { [threadId]: headHash }); const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false); - const count = (markdown.match(/### Prompt/g) ?? []).length; + const count = (markdown.match(//g) ?? []).length; expect(count).toBe(1); }); - test("different consecutive roles each show ### Prompt", async () => { + test("different consecutive roles each show ", async () => { const uwf = await makeUwfStore(tmpDir); const headHash = await makeThreadWithRoles(uwf, ["planner", "coder"]); const threadId = "01JTEST0000000000000004" as ThreadId; await saveThreadsIndex(tmpDir, { [threadId]: headHash }); const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false); - const count = (markdown.match(/### Prompt/g) ?? []).length; + const count = (markdown.match(//g) ?? []).length; expect(count).toBe(2); }); - test("non-consecutive same role shows ### Prompt twice", async () => { + test("non-consecutive same role shows twice", async () => { const uwf = await makeUwfStore(tmpDir); const headHash = await makeThreadWithRoles(uwf, ["roleA", "roleB", "roleA"]); const threadId = "01JTEST0000000000000005" as ThreadId; await saveThreadsIndex(tmpDir, { [threadId]: headHash }); const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false); - const count = (markdown.match(/### Prompt/g) ?? []).length; + const count = (markdown.match(//g) ?? []).length; expect(count).toBe(2); }); }); diff --git a/packages/cli-workflow/src/commands/thread.ts b/packages/cli-workflow/src/commands/thread.ts index 12d0f0c..f71900a 100644 --- a/packages/cli-workflow/src/commands/thread.ts +++ b/packages/cli-workflow/src/commands/thread.ts @@ -665,14 +665,14 @@ function formatStepPrompt( ): string { if (!roleDef || shownPromptRoles.has(role)) return ""; shownPromptRoles.add(role); - return ["", "", "### Prompt", "", roleDef.goal].join("\n"); + return ["", "", "", roleDef.goal, ""].join("\n"); } function formatStepContent(uwf: UwfStore, item: OrderedStepItem): string { if (!item.payload.detail) return ""; const content = extractLastAssistantContent(uwf, item.payload.detail); if (content === null) return ""; - return ["", "", "### Content", "", content].join("\n"); + return ["", "", "", content, ""].join("\n"); } function formatStartSection(options: {