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": {
|
"dependencies": {
|
||||||
"@uncaged/workflow-runtime": "workspace:^",
|
"@uncaged/workflow-runtime": "workspace:^",
|
||||||
"@uncaged/workflow-util-agent": "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": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"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