From 9a07418d8987186816007f7254a39c93de1c87e3 Mon Sep 17 00:00:00 2001 From: jiayiyan <43424880@qq.com> Date: Mon, 18 May 2026 16:06:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20differ=20adapter=20=E2=80=94=20wr?= =?UTF-8?q?aps=20docx-diff=20CLI,=20handles=20exit=20code=200/1/2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- workflows/__tests__/differ-adapter.test.ts | 150 +++++++++++++++++++++ workflows/adapters/differ-adapter.ts | 63 +++++++++ 2 files changed, 213 insertions(+) create mode 100644 workflows/__tests__/differ-adapter.test.ts create mode 100644 workflows/adapters/differ-adapter.ts 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 }; + }; +}