feat(agent): add workflow-agent-docx-diff with docx-diff AdapterFn
Implements createDocxDiffAgent (AdapterFn), packageDescriptor, and exports in index.ts; 9 tests pass (runner 6 + agent 3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<typeof import("@uncaged/workflow-util-agent").spawnCli>>;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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 <T>(_prompt: string, schema: z.ZodType<T>) =>
|
||||
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
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 };
|
||||
};
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export {};
|
||||
export { createDocxDiffAgent } from "./agent.js";
|
||||
export { packageDescriptor } from "./package-descriptor.js";
|
||||
export type { DocxDiffAgentConfig } from "./types.js";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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<string> {
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type DocxDiffAgentConfig = {
|
||||
command: string | null;
|
||||
};
|
||||
Reference in New Issue
Block a user