Files
united-workforce/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts
T
xiaoju bebf4aad45 feat(protocol): add AdapterFn/RoleFn/AdapterBinding, refactor createWorkflow to use AdapterBinding (#222)
- Add RoleFn<T>, AdapterFn, AdapterBinding types to workflow-protocol
- Mark AgentFn, AgentFnResult, AgentBinding as @deprecated
- Refactor createWorkflow to accept AdapterBinding instead of AgentBinding
- Adapter returns typed meta directly — no more extract call in workflow loop
- Add buildThreadInput (ThreadContext-based), keep buildAgentPrompt as deprecated wrapper
- Update template bundle-entries to wrap AgentFn as AdapterFn
- Update solve-issue tests to use AdapterFn directly
2026-05-13 02:27:36 +00:00

304 lines
8.5 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore } from "@uncaged/workflow-cas";
import { createExtract } from "@uncaged/workflow-execute";
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
import { validateWorkflowDescriptor } from "@uncaged/workflow-register";
import {
type AdapterFn,
createWorkflow,
END,
type ModeratorContext,
type RoleStep,
START,
type ThreadContext,
type WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
import type { DeveloperMeta } from "../src/developer.js";
import { solveIssueTable, solveIssueWorkflowDefinition } from "../src/index.js";
import type { PreparerMeta, SubmitterMeta } from "../src/roles/index.js";
import type { SolveIssueMeta } from "../src/roles.js";
const solveIssueModerator = tableToModerator(solveIssueTable);
function makeStart(): ModeratorContext<SolveIssueMeta>["start"] {
return {
role: START,
content: "Fix the flaky login test",
meta: {},
timestamp: 0,
parentState: null,
};
}
function makeCtx(
steps: ModeratorContext<SolveIssueMeta>["steps"],
): ModeratorContext<SolveIssueMeta> {
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
bundleHash: "TESTHASH00001",
start: makeStart(),
steps,
};
}
function preparerStep(): RoleStep<SolveIssueMeta> {
return {
role: "preparer",
contentHash: "STUBHASHPREPARER01",
meta: {
repoPath: "/home/user/repos/test",
defaultBranch: "main",
conventions: null,
toolchain: {
packageManager: "bun",
testCommand: "bun test",
lintCommand: null,
buildCommand: "bun run build",
},
},
refs: [],
timestamp: 0,
};
}
function developerStep(): RoleStep<SolveIssueMeta> {
return {
role: "developer",
contentHash: "STUBHASHDEVELOPER1",
meta: {
branch: "feat/issue-1",
commitSha: "abc1234",
filesChanged: ["src/login.ts"],
summary: "Fixed flaky login test by stabilising async setup.",
},
refs: [],
timestamp: 1,
};
}
function submitterStep(meta: SubmitterMeta): RoleStep<SolveIssueMeta> {
return {
role: "submitter",
contentHash: "STUBHASHSUBMITTER1",
meta,
refs: [],
timestamp: 2,
};
}
function makeThread(prompt: string) {
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
bundleHash: "TESTHASH00001",
start: {
role: START,
content: prompt,
meta: {},
timestamp: Date.now(),
parentState: null,
},
steps: [],
};
}
/** Creates an AdapterFn that returns a fixed sequence of meta values. */
function createSequenceAdapter(sequence: ReadonlyArray<Record<string, unknown>>): AdapterFn {
let i = 0;
return <T>(_prompt: string, _schema: z.ZodType<T>) => {
return async (_ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<T> => {
const meta = sequence[i] ?? sequence[sequence.length - 1];
if (meta === undefined) {
throw new Error("createSequenceAdapter: empty sequence");
}
i += 1;
return meta as T;
};
};
}
/** Creates an AdapterFn that tracks calls and returns fixed meta. */
function createTrackingAdapter(
name: string,
calls: string[],
meta: Record<string, unknown>,
): AdapterFn {
return <T>(_prompt: string, _schema: z.ZodType<T>) => {
return async (_ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<T> => {
calls.push(name);
return meta as T;
};
};
}
describe("solveIssueModerator", () => {
test("routes initial → preparer → developer → submitter → END", () => {
expect(solveIssueModerator(makeCtx([]))).toBe("preparer");
expect(solveIssueModerator(makeCtx([preparerStep()]))).toBe("developer");
expect(solveIssueModerator(makeCtx([preparerStep(), developerStep()]))).toBe("submitter");
expect(
solveIssueModerator(
makeCtx([
preparerStep(),
developerStep(),
submitterStep({
status: "submitted",
prUrl: "https://github.com/example/repo/pull/1",
}),
]),
),
).toBe(END);
});
test("submitter failed → END", () => {
expect(
solveIssueModerator(
makeCtx([
preparerStep(),
developerStep(),
submitterStep({ status: "failed", error: "gh not authenticated" }),
]),
),
).toBe(END);
});
test("returns END for any unexpected last step (defensive)", () => {
expect(
solveIssueModerator(
makeCtx([
preparerStep(),
developerStep(),
submitterStep({ status: "submitted", prUrl: "https://example.com/pr/1" }),
]),
),
).toBe(END);
});
});
describe("solveIssueWorkflowDefinition + createWorkflow", () => {
let casDir: string | undefined;
afterEach(async () => {
if (casDir !== undefined) {
await rm(casDir, { recursive: true, force: true }).catch(() => {});
casDir = undefined;
}
});
test("adapter yields preparer meta directly", async () => {
const EXPECT_PREPARER_META: PreparerMeta = {
repoPath: "/home/user/repos/test",
defaultBranch: "main",
conventions: null,
toolchain: {
packageManager: "bun",
testCommand: "bun test",
lintCommand: null,
buildCommand: "bun run build",
},
};
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
const cas = createCasStore(casDir);
const adapter = createSequenceAdapter([EXPECT_PREPARER_META]);
const run = createWorkflow(solveIssueWorkflowDefinition, {
adapter,
overrides: null,
});
const gen = run(makeThread("task"), {
cas,
extract: createExtract(
{ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" },
{ cas },
),
});
const first = await gen.next();
expect(first.done).toBe(false);
if (first.done) {
throw new Error("expected yield");
}
expect(first.value.role).toBe("preparer");
expect(first.value.meta).toEqual(EXPECT_PREPARER_META);
});
test("per-role adapter overrides default", async () => {
const PREPARER_META: PreparerMeta = {
repoPath: "/tmp/r",
defaultBranch: "main",
conventions: null,
toolchain: { packageManager: null, testCommand: null, lintCommand: null, buildCommand: null },
};
const DEVELOPER_META: DeveloperMeta = {
branch: "feat/x",
commitSha: "abc1234",
filesChanged: ["a.ts"],
summary: "did the work",
};
const SUBMITTER_META: SubmitterMeta = {
status: "submitted",
prUrl: "https://github.com/example/repo/pull/2",
};
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
const cas = createCasStore(casDir);
const calls: string[] = [];
const run = createWorkflow(solveIssueWorkflowDefinition, {
adapter: createTrackingAdapter("default", calls, PREPARER_META),
overrides: {
preparer: createTrackingAdapter("preparer", calls, PREPARER_META),
developer: createTrackingAdapter("developer", calls, DEVELOPER_META),
submitter: createTrackingAdapter("submitter", calls, SUBMITTER_META),
},
});
const gen = run(makeThread("task"), {
cas,
extract: createExtract(
{ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" },
{ cas },
),
});
await gen.next();
expect(calls).toEqual(["preparer"]);
calls.length = 0;
await gen.next();
expect(calls).toEqual(["developer"]);
calls.length = 0;
await gen.next();
expect(calls).toEqual(["submitter"]);
});
});
describe("buildSolveIssueDescriptor", () => {
test("lists preparer, developer, submitter with schemas that validate", () => {
const descriptor = buildSolveIssueDescriptor();
const validated = validateWorkflowDescriptor(descriptor);
expect(validated.ok).toBe(true);
if (!validated.ok) {
throw new Error(validated.error);
}
expect(Object.keys(validated.value.roles).sort()).toEqual([
"developer",
"preparer",
"submitter",
]);
expect(validated.value.graph.edges.length).toBe(4);
for (const key of ["preparer", "developer", "submitter"] as const) {
const role = validated.value.roles[key];
expect(role).toBeDefined();
expect(typeof role.schema).toBe("object");
expect(role.schema).not.toBeNull();
expect(Array.isArray(role.schema)).toBe(false);
}
});
});