diff --git a/packages/workflow-agent-docx-diff/__tests__/agent.test.ts b/packages/workflow-agent-docx-diff/__tests__/agent.test.ts new file mode 100644 index 0000000..c201f83 --- /dev/null +++ b/packages/workflow-agent-docx-diff/__tests__/agent.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { packageDescriptor } from "../src/package-descriptor.js"; +import { createDocxDiffAgent } from "../src/agent.js"; + +describe("createDocxDiffAgent", () => { + test("returns an AdapterFn (function)", () => { + const agent = createDocxDiffAgent({ command: null }); + expect(typeof agent).toBe("function"); + }); + + test("AdapterFn returns a RoleFn (function)", () => { + const agent = createDocxDiffAgent({ command: null }); + const roleFn = agent("", expect.anything() as never); + expect(typeof roleFn).toBe("function"); + }); +}); + +describe("packageDescriptor", () => { + test("has correct name", () => { + expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-docx-diff"); + }); +}); diff --git a/packages/workflow-agent-docx-diff/__tests__/runner.test.ts b/packages/workflow-agent-docx-diff/__tests__/runner.test.ts new file mode 100644 index 0000000..3d7d2e0 --- /dev/null +++ b/packages/workflow-agent-docx-diff/__tests__/runner.test.ts @@ -0,0 +1,113 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, mock, test } from "bun:test"; +import { ok, err } from "@uncaged/workflow-util"; +import type { SpawnCliConfig } from "@uncaged/workflow-util-agent"; +import { runDocxDiff } from "../src/runner.js"; + +type MockSpawnResult = Awaited>; + +function makeSpawn(result: MockSpawnResult) { + return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result); +} + +function tempDir(): string { + const dir = join(tmpdir(), `diff-test-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +describe("runDocxDiff", () => { + test("exit 0: success, returns DifferMeta JSON", async () => { + const dir = tempDir(); + const sourceDocx = join(dir, "original.docx"); + const modifiedDocx = join(dir, "modified.docx"); + const diffDocx = join(dir, "diff.docx"); + writeFileSync(sourceDocx, ""); + writeFileSync(modifiedDocx, ""); + + const spawnFn = makeSpawn(ok("") as MockSpawnResult); + // simulate docx-diff creating the diff file + writeFileSync(diffDocx, ""); + + const raw = await runDocxDiff( + { command: "docx-diff" }, + sourceDocx, + modifiedDocx, + diffDocx, + spawnFn, + ); + const meta = JSON.parse(raw); + expect(meta.sourceDocx).toBe(sourceDocx); + expect(meta.modifiedDocx).toBe(modifiedDocx); + expect(meta.diffDocx).toBe(diffDocx); + + expect(spawnFn.mock.calls[0][1]).toEqual([ + sourceDocx, + modifiedDocx, + "--output", + "docx", + "--out-file", + diffDocx, + ]); + }); + + test("exit 1 (changes found): treated as success", async () => { + const dir = tempDir(); + const sourceDocx = join(dir, "s.docx"); + const modifiedDocx = join(dir, "m.docx"); + const diffDocx = join(dir, "diff.docx"); + writeFileSync(sourceDocx, ""); + writeFileSync(modifiedDocx, ""); + writeFileSync(diffDocx, ""); + + const spawnFn = makeSpawn( + err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "" }) as MockSpawnResult, + ); + + await expect( + runDocxDiff({ command: "docx-diff" }, sourceDocx, modifiedDocx, diffDocx, spawnFn), + ).resolves.toBeDefined(); + }); + + test("exit 2: throws error", async () => { + const dir = tempDir(); + const spawnFn = makeSpawn( + err({ kind: "non_zero_exit", exitCode: 2, stdout: "", stderr: "fatal error" }) as MockSpawnResult, + ); + + await expect( + runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn), + ).rejects.toThrow("docx-diff failed"); + }); + + test("timeout: throws error", async () => { + const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult); + + await expect( + runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn), + ).rejects.toThrow("timed out"); + }); + + test("throws when diff file not created", async () => { + const dir = tempDir(); + const spawnFn = makeSpawn(ok("") as MockSpawnResult); + // do NOT create diffDocx + + await expect( + runDocxDiff({ command: null }, "s.docx", "m.docx", join(dir, "missing.docx"), spawnFn), + ).rejects.toThrow("diff file not found"); + }); + + test("uses PATH docx-diff when command is null", async () => { + const dir = tempDir(); + const diffDocx = join(dir, "diff.docx"); + writeFileSync(diffDocx, ""); + const spawnFn = makeSpawn(ok("") as MockSpawnResult); + + await runDocxDiff({ command: null }, "s.docx", "m.docx", diffDocx, spawnFn); + + expect(spawnFn.mock.calls[0][0]).toBe("docx-diff"); + }); +}); diff --git a/packages/workflow-agent-docx-diff/package.json b/packages/workflow-agent-docx-diff/package.json index 311b9b3..1cbbf33 100644 --- a/packages/workflow-agent-docx-diff/package.json +++ b/packages/workflow-agent-docx-diff/package.json @@ -17,7 +17,11 @@ "dependencies": { "@uncaged/workflow-runtime": "workspace:^", "@uncaged/workflow-util-agent": "workspace:^", - "@uncaged/workflow-template-document": "workspace:^" + "@uncaged/workflow-template-document": "workspace:^", + "zod": "^4.0.0" + }, + "devDependencies": { + "@uncaged/workflow-util": "workspace:^" }, "publishConfig": { "access": "public" diff --git a/packages/workflow-agent-docx-diff/src/agent.ts b/packages/workflow-agent-docx-diff/src/agent.ts new file mode 100644 index 0000000..4c9ea87 --- /dev/null +++ b/packages/workflow-agent-docx-diff/src/agent.ts @@ -0,0 +1,29 @@ +import * as z from "zod/v4"; +import { dirname, join } from "node:path"; +import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime"; +import type { WriterMeta } from "@uncaged/workflow-template-document"; +import { runDocxDiff } from "./runner.js"; +import type { DocxDiffAgentConfig } from "./types.js"; + +export function createDocxDiffAgent(config: DocxDiffAgentConfig): AdapterFn { + return (_prompt: string, schema: z.ZodType) => + async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise> => { + const writerStep = ctx.steps.find((s) => s.role === "writer"); + if (writerStep === undefined) throw new Error("differ: no writer step found"); + + const writerMeta = writerStep.meta as WriterMeta; + if (writerMeta.mode !== "edit") + throw new Error("differ: writer did not run in edit mode"); + + const diffDocx = join(dirname(writerMeta.outputDocx), "diff.docx"); + const raw = await runDocxDiff( + config, + writerMeta.sourceDocx, + writerMeta.outputDocx, + diffDocx, + ); + + const meta = schema.parse(JSON.parse(raw)) as T; + return { meta, childThread: null }; + }; +} diff --git a/packages/workflow-agent-docx-diff/src/index.ts b/packages/workflow-agent-docx-diff/src/index.ts index cb0ff5c..1dcc0a3 100644 --- a/packages/workflow-agent-docx-diff/src/index.ts +++ b/packages/workflow-agent-docx-diff/src/index.ts @@ -1 +1,3 @@ -export {}; +export { createDocxDiffAgent } from "./agent.js"; +export { packageDescriptor } from "./package-descriptor.js"; +export type { DocxDiffAgentConfig } from "./types.js"; diff --git a/packages/workflow-agent-docx-diff/src/package-descriptor.ts b/packages/workflow-agent-docx-diff/src/package-descriptor.ts new file mode 100644 index 0000000..60c9925 --- /dev/null +++ b/packages/workflow-agent-docx-diff/src/package-descriptor.ts @@ -0,0 +1,17 @@ +import type { PackageDescriptor } from "@uncaged/workflow-runtime"; + +export const packageDescriptor: PackageDescriptor = { + name: "@uncaged/workflow-agent-docx-diff", + version: "0.1.0", + capabilities: ["docx-diff-cli", "docx-diff-report"], + configSchema: { + type: "object", + properties: { + command: { + anyOf: [{ type: "string" }, { type: "null" }], + description: "Path to docx-diff CLI binary; null uses PATH.", + }, + }, + additionalProperties: false, + }, +}; diff --git a/packages/workflow-agent-docx-diff/src/runner.ts b/packages/workflow-agent-docx-diff/src/runner.ts new file mode 100644 index 0000000..f29f5e4 --- /dev/null +++ b/packages/workflow-agent-docx-diff/src/runner.ts @@ -0,0 +1,47 @@ +import { stat } from "node:fs/promises"; +import { spawnCli } from "@uncaged/workflow-util-agent"; +import type { SpawnCliError } from "@uncaged/workflow-util-agent"; +import type { DocxDiffAgentConfig } from "./types.js"; + +type SpawnCliFn = typeof spawnCli; + +function throwSpawnError(e: SpawnCliError): never { + if (e.kind === "non_zero_exit") + throw new Error(`docx-diff failed (exit ${e.exitCode}): ${e.stderr}`); + if (e.kind === "timeout") + throw new Error("docx-diff: timed out"); + throw new Error(`docx-diff: spawn failed: ${e.message}`); +} + +export async function runDocxDiff( + config: DocxDiffAgentConfig, + sourceDocx: string, + modifiedDocx: string, + diffDocx: string, + spawnCliFn: SpawnCliFn = spawnCli, +): Promise { + const command = config.command ?? "docx-diff"; + const result = await spawnCliFn( + command, + [sourceDocx, modifiedDocx, "--output", "docx", "--out-file", diffDocx], + { cwd: null, timeoutMs: null }, + ); + + if (!result.ok) { + const e = result.error; + // exit 1 = changes found (normal for docx-diff) + if (e.kind === "non_zero_exit" && e.exitCode === 1) { + // fall through to file check + } else { + throwSpawnError(e); + } + } + + try { + await stat(diffDocx); + } catch { + throw new Error(`docx-diff: diff file not found: ${diffDocx}`); + } + + return JSON.stringify({ sourceDocx, modifiedDocx, diffDocx }); +} diff --git a/packages/workflow-agent-docx-diff/src/types.ts b/packages/workflow-agent-docx-diff/src/types.ts new file mode 100644 index 0000000..4bcc174 --- /dev/null +++ b/packages/workflow-agent-docx-diff/src/types.ts @@ -0,0 +1,3 @@ +export type DocxDiffAgentConfig = { + command: string | null; +};