feat: add differ adapter — wraps docx-diff CLI, handles exit code 0/1/2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jiayiyan
2026-05-18 16:06:32 +08:00
parent deacad57fe
commit 9a07418d89
2 changed files with 213 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { START } from "@uncaged/workflow-runtime";
import { describe, expect, mock, test } from "bun:test";
import { err, ok } from "@uncaged/workflow-util";
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
import { differMetaSchema } from "@local/template-document-editor";
import { createDifferAdapter } from "../adapters/differ-adapter.js";
import type { ThreadContext } from "@uncaged/workflow-runtime";
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(), `differ-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
function makeCtx(officeMode: "generate" | "edit" | null): ThreadContext {
const steps =
officeMode === null
? []
: [
{
role: "office",
meta:
officeMode === "edit"
? { mode: "edit", outputDocx: "/tmp/modified.docx", sourceDocx: "/tmp/original.docx" }
: { mode: "generate", outputDocx: "/tmp/output.docx", sourceDocx: null },
contentHash: "",
refs: [],
timestamp: 0,
},
];
return {
threadId: "t1",
depth: 0,
bundleHash: "",
start: { role: START, content: "", meta: {}, timestamp: 0, parentState: null },
steps,
} as unknown as ThreadContext;
}
async function runAdapter(
spawnFn: ReturnType<typeof makeSpawn>,
ctx: ThreadContext,
reportExists = true,
) {
const dir = tempDir();
const outputDocx = join(dir, "modified.docx");
const sourceDocx = join(dir, "original.docx");
writeFileSync(outputDocx, "");
writeFileSync(sourceDocx, "");
const editCtx: ThreadContext = {
...ctx,
steps: [
{
role: "office",
meta: { mode: "edit", outputDocx, sourceDocx },
contentHash: "",
refs: [],
timestamp: 0,
},
],
} as unknown as ThreadContext;
if (reportExists) {
writeFileSync(join(dir, "diff_report.html"), "<html/>");
}
const adapter = createDifferAdapter({ command: "docx-diff", timeout: null }, spawnFn);
const roleFn = adapter("", differMetaSchema);
return roleFn(editCtx, {} as never);
}
describe("createDifferAdapter", () => {
test("returns DifferMeta on exit 0 (no changes)", async () => {
const spawn = makeSpawn(ok("") as MockSpawnResult);
const result = await runAdapter(spawn, makeCtx("edit"));
expect(result.meta.diffReport).toMatch(/diff_report\.html$/);
expect(result.childThread).toBeNull();
});
test("returns DifferMeta on exit 1 (has changes)", async () => {
const spawn = makeSpawn(
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "" }) as MockSpawnResult,
);
const result = await runAdapter(spawn, makeCtx("edit"));
expect(result.meta.diffReport).toMatch(/diff_report\.html$/);
});
test("throws on exit 2 (docx-diff error)", async () => {
const spawn = makeSpawn(
err({ kind: "non_zero_exit", exitCode: 2, stdout: "", stderr: "parse error" }) as MockSpawnResult,
);
await expect(runAdapter(spawn, makeCtx("edit"))).rejects.toThrow("docx-diff failed (exit 2)");
});
test("throws on timeout", async () => {
const spawn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
await expect(runAdapter(spawn, makeCtx("edit"))).rejects.toThrow("timed out");
});
test("throws on spawn_failed", async () => {
const spawn = makeSpawn(
err({ kind: "spawn_failed", message: "binary not found" }) as MockSpawnResult,
);
await expect(runAdapter(spawn, makeCtx("edit"))).rejects.toThrow("spawn failed");
});
test("throws when office step is missing from ctx.steps", async () => {
const spawn = makeSpawn(ok("") as MockSpawnResult);
const adapter = createDifferAdapter({ command: "docx-diff", timeout: null }, spawn);
const roleFn = adapter("", differMetaSchema);
const emptyCtx = makeCtx(null);
await expect(roleFn(emptyCtx, {} as never)).rejects.toThrow("office step not found");
});
test("throws when report file is not created by docx-diff", async () => {
const spawn = makeSpawn(ok("") as MockSpawnResult);
await expect(runAdapter(spawn, makeCtx("edit"), false)).rejects.toThrow("report file not found");
});
test("uses 'docx-diff' when command is null", async () => {
const spawn = makeSpawn(ok("") as MockSpawnResult);
const dir = tmpdir();
const outputDocx = join(dir, `mod-${Date.now()}.docx`);
const sourceDocx = join(dir, `src-${Date.now()}.docx`);
writeFileSync(outputDocx, "");
writeFileSync(sourceDocx, "");
writeFileSync(join(dir, "diff_report.html"), "<html/>");
const ctx: ThreadContext = {
threadId: "t2",
depth: 0,
bundleHash: "",
start: { role: START, content: "", meta: {}, timestamp: 0, parentState: null },
steps: [{ role: "office", meta: { mode: "edit", outputDocx, sourceDocx }, contentHash: "", refs: [], timestamp: 0 }],
} as unknown as ThreadContext;
const adapter = createDifferAdapter({ command: null, timeout: null }, spawn);
const roleFn = adapter("", differMetaSchema);
await roleFn(ctx, {} as never);
expect(spawn.mock.calls[0][0]).toBe("docx-diff");
});
});
+63
View File
@@ -0,0 +1,63 @@
import { stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { spawnCli } from "@uncaged/workflow-util-agent";
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
export type DifferAdapterConfig = {
command: string | null;
timeout: number | null;
};
type SpawnCliFn = typeof spawnCli;
export function createDifferAdapter(
config: DifferAdapterConfig,
spawnCliFn: SpawnCliFn = spawnCli,
): AdapterFn {
const command = config.command ?? "docx-diff";
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const officeStep = ctx.steps.find((s) => s.role === "office");
if (officeStep === undefined) {
throw new Error("differ: office step not found in ctx.steps (invariant violation)");
}
const officeMeta = officeStep.meta as { mode: string; outputDocx: string; sourceDocx: string | null };
if (officeMeta.mode !== "edit" || officeMeta.sourceDocx === null) {
throw new Error("differ: office step is not in edit mode");
}
const { outputDocx, sourceDocx } = officeMeta;
const reportPath = join(dirname(outputDocx), "diff_report.html");
const result = await spawnCliFn(
command,
[sourceDocx, outputDocx, "-o", "html", "--out-file", reportPath],
{ cwd: null, timeoutMs: config.timeout },
);
if (!result.ok) {
const e = result.error;
if (e.kind === "non_zero_exit" && e.exitCode === 1) {
// exit 1 means "documents differ" — the normal success case for docx-diff
} else if (e.kind === "non_zero_exit") {
throw new Error(`differ: docx-diff failed (exit ${e.exitCode}): ${e.stderr}`);
} else if (e.kind === "timeout") {
throw new Error("differ: timed out");
} else {
throw new Error(`differ: spawn failed: ${(e as { message: string }).message}`);
}
}
try {
await stat(reportPath);
} catch {
throw new Error(`differ: report file not found: ${reportPath}`);
}
const raw = JSON.stringify({ outputDocx, sourceDocx, diffReport: reportPath });
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}