diff --git a/workflows/__tests__/differ-adapter.test.ts b/workflows/__tests__/differ-adapter.test.ts
new file mode 100644
index 0000000..3ab1209
--- /dev/null
+++ b/workflows/__tests__/differ-adapter.test.ts
@@ -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>;
+
+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,
+ 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"), "");
+ }
+
+ 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"), "");
+ 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");
+ });
+});
diff --git a/workflows/adapters/differ-adapter.ts b/workflows/adapters/differ-adapter.ts
new file mode 100644
index 0000000..aae8695
--- /dev/null
+++ b/workflows/adapters/differ-adapter.ts
@@ -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 (_systemPrompt: string, schema: z.ZodType) =>
+ async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise> => {
+ 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 };
+ };
+}