feat(agent): add workflow-agent-office runner with generate/edit and tests
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
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 { editDocument, generateDocument } 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(), `office-test-${Date.now()}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("generateDocument", () => {
|
||||
test("calls office-agent create with correct args and returns outputDocx path", async () => {
|
||||
const base = tempDir();
|
||||
const spawnFn = makeSpawn(ok("agent reply") as MockSpawnResult);
|
||||
// Simulate CLI creating the file
|
||||
const outFile = join(base, "thread1", "output.docx");
|
||||
mkdirSync(join(base, "thread1"), { recursive: true });
|
||||
writeFileSync(outFile, "");
|
||||
|
||||
const result = await generateDocument(
|
||||
{ outputDir: base, command: "office-agent", timeout: null },
|
||||
"thread1",
|
||||
"Write a report",
|
||||
spawnFn,
|
||||
);
|
||||
|
||||
expect(result.outputDocx).toBe(outFile);
|
||||
expect(result.sourceDocx).toBeNull();
|
||||
expect(spawnFn.mock.calls[0][0]).toBe("office-agent");
|
||||
expect(spawnFn.mock.calls[0][1]).toEqual(["create", "Write a report", "-o", "output.docx"]);
|
||||
expect(spawnFn.mock.calls[0][2].cwd).toBe(join(base, "thread1"));
|
||||
});
|
||||
|
||||
test("uses PATH office-agent when command is null", async () => {
|
||||
const base = tempDir();
|
||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
||||
mkdirSync(join(base, "t2"), { recursive: true });
|
||||
writeFileSync(join(base, "t2", "output.docx"), "");
|
||||
|
||||
await generateDocument(
|
||||
{ outputDir: base, command: null, timeout: null },
|
||||
"t2",
|
||||
"Generate",
|
||||
spawnFn,
|
||||
);
|
||||
|
||||
expect(spawnFn.mock.calls[0][0]).toBe("office-agent");
|
||||
});
|
||||
|
||||
test("throws on non_zero_exit", async () => {
|
||||
const base = tempDir();
|
||||
const spawnFn = makeSpawn(
|
||||
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "error" }) as MockSpawnResult,
|
||||
);
|
||||
|
||||
await expect(
|
||||
generateDocument({ outputDir: base, command: null, timeout: null }, "t3", "fail", spawnFn),
|
||||
).rejects.toThrow("office-agent failed (exit 1)");
|
||||
});
|
||||
|
||||
test("throws on timeout", async () => {
|
||||
const base = tempDir();
|
||||
const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
|
||||
|
||||
await expect(
|
||||
generateDocument({ outputDir: base, command: null, timeout: null }, "t4", "slow", spawnFn),
|
||||
).rejects.toThrow("office-agent: timed out");
|
||||
});
|
||||
|
||||
test("throws when output file not created", async () => {
|
||||
const base = tempDir();
|
||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
||||
// Do NOT create output.docx
|
||||
|
||||
await expect(
|
||||
generateDocument({ outputDir: base, command: null, timeout: null }, "t5", "no file", spawnFn),
|
||||
).rejects.toThrow("output file not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("editDocument", () => {
|
||||
test("copies input to original.docx and modified.docx, calls edit, returns paths", async () => {
|
||||
const base = tempDir();
|
||||
// Create a fake inputDocx
|
||||
const inputFile = join(base, "source.docx");
|
||||
writeFileSync(inputFile, "original content");
|
||||
|
||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
||||
// Simulate CLI overwriting modified.docx
|
||||
const outDir = join(base, "te1");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
writeFileSync(join(outDir, "modified.docx"), "modified content");
|
||||
|
||||
const result = await editDocument(
|
||||
{ outputDir: base, command: "office-agent", timeout: null },
|
||||
"te1",
|
||||
"Edit the doc",
|
||||
inputFile,
|
||||
spawnFn,
|
||||
);
|
||||
|
||||
expect(result.outputDocx).toBe(join(outDir, "modified.docx"));
|
||||
expect(result.sourceDocx).toBe(join(outDir, "original.docx"));
|
||||
expect(spawnFn.mock.calls[0][1]).toEqual(["edit", "modified.docx", "Edit the doc"]);
|
||||
});
|
||||
|
||||
test("throws on spawn_failed", async () => {
|
||||
const base = tempDir();
|
||||
const inputFile = join(base, "src.docx");
|
||||
writeFileSync(inputFile, "");
|
||||
const spawnFn = makeSpawn(
|
||||
err({ kind: "spawn_failed", message: "not found" }) as MockSpawnResult,
|
||||
);
|
||||
|
||||
await expect(
|
||||
editDocument({ outputDir: base, command: null, timeout: null }, "te2", "edit", inputFile, spawnFn),
|
||||
).rejects.toThrow("spawn failed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { copyFile, mkdir, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
|
||||
import type { OfficeAgentConfig } from "./types.js";
|
||||
|
||||
type SpawnCliFn = typeof spawnCli;
|
||||
|
||||
function throwSpawnError(e: SpawnCliError): never {
|
||||
if (e.kind === "non_zero_exit")
|
||||
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
|
||||
if (e.kind === "timeout")
|
||||
throw new Error("office-agent: timed out");
|
||||
throw new Error(`office-agent: spawn failed: ${e.message}`);
|
||||
}
|
||||
|
||||
async function assertFileExists(path: string): Promise<void> {
|
||||
try {
|
||||
await stat(path);
|
||||
} catch {
|
||||
throw new Error(`office-agent: output file not found: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateDocument(
|
||||
config: OfficeAgentConfig,
|
||||
threadId: string,
|
||||
prompt: string,
|
||||
spawnCliFn: SpawnCliFn = spawnCli,
|
||||
): Promise<{ outputDocx: string; sourceDocx: null }> {
|
||||
const outputDir = join(config.outputDir, threadId);
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
const command = config.command ?? "office-agent";
|
||||
const result = await spawnCliFn(command, ["create", prompt, "-o", "output.docx"], {
|
||||
cwd: outputDir,
|
||||
timeoutMs: config.timeout,
|
||||
});
|
||||
if (!result.ok) throwSpawnError(result.error);
|
||||
const outputDocx = join(outputDir, "output.docx");
|
||||
await assertFileExists(outputDocx);
|
||||
return { outputDocx, sourceDocx: null };
|
||||
}
|
||||
|
||||
export async function editDocument(
|
||||
config: OfficeAgentConfig,
|
||||
threadId: string,
|
||||
prompt: string,
|
||||
inputDocx: string,
|
||||
spawnCliFn: SpawnCliFn = spawnCli,
|
||||
): Promise<{ outputDocx: string; sourceDocx: string }> {
|
||||
const outputDir = join(config.outputDir, threadId);
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
const originalDocx = join(outputDir, "original.docx");
|
||||
const modifiedDocx = join(outputDir, "modified.docx");
|
||||
await copyFile(inputDocx, originalDocx);
|
||||
await copyFile(inputDocx, modifiedDocx);
|
||||
const command = config.command ?? "office-agent";
|
||||
const result = await spawnCliFn(command, ["edit", "modified.docx", prompt], {
|
||||
cwd: outputDir,
|
||||
timeoutMs: config.timeout,
|
||||
});
|
||||
if (!result.ok) throwSpawnError(result.error);
|
||||
await assertFileExists(modifiedDocx);
|
||||
return { outputDocx: modifiedDocx, sourceDocx: originalDocx };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type OfficeAgentConfig = {
|
||||
outputDir: string;
|
||||
command: string | null;
|
||||
timeout: number | null;
|
||||
};
|
||||
Reference in New Issue
Block a user