diff --git a/packages/agent-mock/__tests__/fixtures/simple-scenario.yaml b/packages/agent-mock/__tests__/fixtures/simple-scenario.yaml new file mode 100644 index 0000000..58c452a --- /dev/null +++ b/packages/agent-mock/__tests__/fixtures/simple-scenario.yaml @@ -0,0 +1,18 @@ +steps: + - role: planner + output: | + --- + $status: ready + plan: test-plan-hash + repoPath: /tmp/test-repo + --- + Plan: implement the feature. + + - role: developer + output: | + --- + $status: done + branch: fix/1-test + worktree: /tmp/worktree + --- + Implemented the feature. diff --git a/packages/agent-mock/__tests__/mock-agent.test.ts b/packages/agent-mock/__tests__/mock-agent.test.ts new file mode 100644 index 0000000..2cafb2e --- /dev/null +++ b/packages/agent-mock/__tests__/mock-agent.test.ts @@ -0,0 +1,48 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; + +import { parseScenario, selectMockStep } from "../src/mock-agent.js"; + +const FIXTURE = join(__dirname, "fixtures", "simple-scenario.yaml"); + +describe("parseScenario", () => { + test("parses the 2-step fixture in order", async () => { + const scenario = parseScenario(await readFile(FIXTURE, "utf8")); + expect(scenario.steps).toHaveLength(2); + expect(scenario.steps[0].role).toBe("planner"); + expect(scenario.steps[1].role).toBe("developer"); + expect(scenario.steps[0].output).toContain("$status: ready"); + expect(scenario.steps[1].output).toContain("branch: fix/1-test"); + }); + + test("rejects documents without a steps array", () => { + expect(() => parseScenario("foo: bar")).toThrow(/steps/); + }); + + test("rejects steps missing role or output", () => { + expect(() => parseScenario("steps:\n - role: planner")).toThrow(/role.*output/); + }); +}); + +describe("selectMockStep", () => { + const scenario = { + steps: [ + { role: "planner", output: "plan-output" }, + { role: "developer", output: "dev-output" }, + ], + }; + + test("step index counts existing steps to pick the current step", () => { + expect(selectMockStep(scenario, 0, "planner").output).toBe("plan-output"); + expect(selectMockStep(scenario, 1, "developer").output).toBe("dev-output"); + }); + + test("throws when the moderator routes to an unexpected role", () => { + expect(() => selectMockStep(scenario, 0, "developer")).toThrow(/expected role "planner"/); + }); + + test("throws when the step index runs past the scripted steps", () => { + expect(() => selectMockStep(scenario, 2, "planner")).toThrow(/no step at index 2/); + }); +}); diff --git a/packages/agent-mock/package.json b/packages/agent-mock/package.json new file mode 100644 index 0000000..62fe149 --- /dev/null +++ b/packages/agent-mock/package.json @@ -0,0 +1,47 @@ +{ + "name": "@united-workforce/agent-mock", + "version": "0.5.0", + "files": [ + "src", + "dist", + "package.json" + ], + "type": "module", + "bin": { + "uwf-mock": "./src/cli.ts" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "prepublishOnly": "echo 'Use pnpm run release from repo root' && exit 1", + "test": "vitest run __tests__/", + "test:ci": "vitest run __tests__/" + }, + "dependencies": { + "@ocas/core": "^0.3.0", + "@united-workforce/protocol": "workspace:^", + "@united-workforce/util": "workspace:^", + "@united-workforce/util-agent": "workspace:^", + "yaml": "^2.9.0" + }, + "devDependencies": { + "typescript": "^5.8.3" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://git.shazhou.work/shazhou/united-workforce.git", + "directory": "packages/agent-mock" + }, + "homepage": "https://git.shazhou.work/shazhou/united-workforce#readme", + "bugs": { + "url": "https://git.shazhou.work/shazhou/united-workforce/issues" + }, + "license": "MIT" +} diff --git a/packages/agent-mock/src/cli.ts b/packages/agent-mock/src/cli.ts new file mode 100644 index 0000000..2676ac6 --- /dev/null +++ b/packages/agent-mock/src/cli.ts @@ -0,0 +1,18 @@ +#!/usr/bin/env node + +import { createMockAgent } from "./mock-agent.js"; + +const USAGE = "usage: uwf-mock --mock-data --thread --role --prompt "; + +function getMockDataPath(argv: string[]): string { + const idx = argv.indexOf("--mock-data"); + if (idx === -1 || idx + 1 >= argv.length || argv[idx + 1] === "") { + process.stderr.write(`--mock-data is required. ${USAGE}\n`); + process.exit(1); + } + return argv[idx + 1]; +} + +const mockDataPath = getMockDataPath(process.argv); +const main = createMockAgent(mockDataPath); +void main(); diff --git a/packages/agent-mock/src/index.ts b/packages/agent-mock/src/index.ts new file mode 100644 index 0000000..4d36d72 --- /dev/null +++ b/packages/agent-mock/src/index.ts @@ -0,0 +1,2 @@ +export { createMockAgent, parseScenario, selectMockStep } from "./mock-agent.js"; +export type { MockScenario, MockStep } from "./types.js"; diff --git a/packages/agent-mock/src/mock-agent.ts b/packages/agent-mock/src/mock-agent.ts new file mode 100644 index 0000000..1044232 --- /dev/null +++ b/packages/agent-mock/src/mock-agent.ts @@ -0,0 +1,128 @@ +import { readFile } from "node:fs/promises"; + +import { bootstrap, type JSONSchema, putSchema, type Store } from "@ocas/core"; +import { createLogger } from "@united-workforce/util"; +import { type AgentContext, type AgentRunResult, createAgent } from "@united-workforce/util-agent"; +import { parse } from "yaml"; + +import type { MockScenario, MockStep } from "./types.js"; + +const log = createLogger({ sink: { kind: "stderr" } }); + +const MOCK_DETAIL_SCHEMA: JSONSchema = { + title: "mock-detail", + type: "object", + required: ["sessionId", "role", "stepIndex"], + properties: { + sessionId: { type: "string" }, + role: { type: "string" }, + stepIndex: { type: "integer" }, + }, + additionalProperties: false, +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Parse a YAML mock data document into a {@link MockScenario}. Pure — no I/O. */ +export function parseScenario(text: string): MockScenario { + const raw = parse(text) as unknown; + if (!isRecord(raw) || !Array.isArray(raw.steps)) { + throw new Error("mock data must be a mapping with a 'steps' array"); + } + const steps: MockStep[] = raw.steps.map((entry, i) => { + if (!isRecord(entry) || typeof entry.role !== "string" || typeof entry.output !== "string") { + throw new Error(`mock step ${i} must have string 'role' and string 'output'`); + } + return { role: entry.role, output: entry.output }; + }); + return { steps }; +} + +async function loadScenario(path: string): Promise { + const text = await readFile(path, "utf8"); + return parseScenario(text); +} + +/** + * Pick the scripted step for the given index and verify the moderator routed to + * the expected role. Throws on out-of-range index or role mismatch so routing + * bugs surface loudly during E2E runs. + */ +export function selectMockStep(scenario: MockScenario, stepIndex: number, role: string): MockStep { + const step = scenario.steps[stepIndex]; + if (step === undefined) { + throw new Error( + `mock scenario has no step at index ${stepIndex} (total ${scenario.steps.length}); ` + + `moderator routed to role "${role}"`, + ); + } + if (step.role !== role) { + throw new Error( + `mock step ${stepIndex} expected role "${step.role}" but moderator routed to "${role}"`, + ); + } + return step; +} + +/** Persist a minimal detail node so the step node has a valid CAS ref. */ +async function storeMockDetail( + store: Store, + sessionId: string, + role: string, + stepIndex: number, +): Promise { + await bootstrap(store); + const schemaHash = await putSchema(store, MOCK_DETAIL_SCHEMA); + return store.cas.put(schemaHash, { sessionId, role, stepIndex }); +} + +/** + * Agent CLI factory: a deterministic, LLM-free agent that replays pre-scripted + * outputs from a YAML mock data file. The step index is derived by counting the + * existing steps in the thread's CAS chain (exposed via `ctx.steps`). + */ +export function createMockAgent(mockDataPath: string): () => Promise { + let lastResult: AgentRunResult | null = null; + + async function run(ctx: AgentContext): Promise { + const scenario = await loadScenario(mockDataPath); + const stepIndex = ctx.steps.length; + log( + "MK7X2QPV", + `mock step ${stepIndex} for role "${ctx.role}" (${scenario.steps.length} scripted)`, + ); + + const step = selectMockStep(scenario, stepIndex, ctx.role); + const sessionId = `mock-${stepIndex}`; + const detailHash = await storeMockDetail(ctx.store, sessionId, ctx.role, stepIndex); + + const result: AgentRunResult = { + output: step.output, + detailHash, + sessionId, + assembledPrompt: "", + }; + lastResult = result; + return result; + } + + async function continueRun( + sessionId: string, + _message: string, + _store: Store, + ): Promise { + if (lastResult === null) { + throw new Error("mock continue called before run"); + } + log("MK3N8RTW", `mock continue for session ${sessionId}, replaying scripted output`); + return lastResult; + } + + return createAgent({ + name: "mock", + run, + continue: continueRun, + }); +} diff --git a/packages/agent-mock/src/types.ts b/packages/agent-mock/src/types.ts new file mode 100644 index 0000000..63e83cf --- /dev/null +++ b/packages/agent-mock/src/types.ts @@ -0,0 +1,12 @@ +/** One pre-scripted step in a mock scenario. */ +export type MockStep = { + /** Role this step is expected to run as. Validated against the actual `--role` argument. */ + role: string; + /** Frontmatter markdown output the mock agent emits for this step. */ + output: string; +}; + +/** Deterministic, pre-scripted agent script loaded from a YAML mock data file. */ +export type MockScenario = { + steps: MockStep[]; +}; diff --git a/packages/agent-mock/tsconfig.json b/packages/agent-mock/tsconfig.json new file mode 100644 index 0000000..7b7fa2a --- /dev/null +++ b/packages/agent-mock/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "references": [{ "path": "../util-agent" }, { "path": "../util" }, { "path": "../protocol" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bbdfce..1aa12c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,28 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/agent-mock: + dependencies: + '@ocas/core': + specifier: ^0.3.0 + version: 0.3.0 + '@united-workforce/protocol': + specifier: workspace:^ + version: link:../protocol + '@united-workforce/util': + specifier: workspace:^ + version: link:../util + '@united-workforce/util-agent': + specifier: workspace:^ + version: link:../util-agent + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages/cli: dependencies: '@ocas/core': diff --git a/proman.yaml b/proman.yaml index 551f753..a0cc8e5 100644 --- a/proman.yaml +++ b/proman.yaml @@ -23,6 +23,10 @@ packages: path: packages/agent-builtin type: cli + - name: "@united-workforce/agent-mock" + path: packages/agent-mock + type: cli + - name: "@united-workforce/cli" path: packages/cli type: cli diff --git a/tsconfig.json b/tsconfig.json index 1547e16..76e1129 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,7 @@ { "path": "packages/util-agent" }, { "path": "packages/agent-hermes" }, { "path": "packages/agent-builtin" }, + { "path": "packages/agent-mock" }, { "path": "packages/agent-claude-code" }, { "path": "packages/cli" } ]