Files
hanarang-rails/tests/handoff.test.ts
이랑이 fcd2e56129 feat(sprint-004): 4-agent handoff engine — Transport 추상화 + runner + Discord marker
Sprint 004 핵심 구현 — F3 (QA 자동 라우팅 누락) + F4 (핸드오프 불안정) 해결:

Config (범용):
- src/config/schema.ts — Zod RailsConfig (pipeline/agents/discord)
- src/config/loader.ts — YAML + 환경변수 interpolation (${VAR})
- rails.config.example.yaml — 샘플 설정

Handoff:
- src/handoff/message.ts — HandoffMessage discriminated union (plan/implement/review/deploy)
- src/handoff/transport.ts — SisterTransport 인터페이스
- src/handoff/mock-transport.ts — 시나리오 override 가능한 mock
- src/handoff/discord-transport.ts — encodeInvokeMarker / decodeResultMarker
  (HTML 주석 + json 블록 — 자매는 LLM 우회 파서로 처리)
  DiscordPoster 인터페이스 주입으로 discord.js 와 독립 테스트 가능

Orchestrator:
- src/orchestrator/runner.ts — runPipeline E2E
  state → stage 매핑 → transport.invoke → HandoffMessage → FSM 이벤트
  타임아웃/에러는 ERROR 이벤트로 변환해 FSM 에 위임

CLI:
- rails run <project> [-r requirements] [-c config.yaml] [--mock]

Tests (16 신규, 57 total pass):
- HandoffMessage discriminated union 검증
- MockTransport 기본/오버라이드 시나리오
- Discord marker encode/decode round-trip
- DiscordTransport with fake poster
- Config loader YAML + 환경변수 interpolation

검증: tsc --noEmit ✓ | vitest 57/57 ✓ | build ✓ | rails run --help ✓

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:34:28 +09:00

210 lines
5.7 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { HandoffMessage, InvokeRequest } from "../src/handoff/message.js";
import { MockTransport } from "../src/handoff/mock-transport.js";
import {
encodeInvokeMarker,
decodeResultMarker,
DiscordTransport,
type DiscordPoster,
} from "../src/handoff/discord-transport.js";
describe("HandoffMessage schema", () => {
it("parses a valid plan result", () => {
const parsed = HandoffMessage.parse({
stage: "plan",
verdict: "PLAN_READY",
payload: { planDir: "/tmp", sprintId: "S1", contractId: "c1" },
abortReason: "",
});
expect(parsed.stage).toBe("plan");
if (parsed.stage === "plan") {
expect(parsed.verdict).toBe("PLAN_READY");
}
});
it("parses a review REQUEST_CHANGES with issues", () => {
const parsed = HandoffMessage.parse({
stage: "review",
verdict: "REQUEST_CHANGES",
payload: {
artifactPath: "/tmp/r.json",
checklistResults: [],
issues: [
{ severity: "major", message: "fix this", file: "src/a.ts", line: 10 },
],
},
abortReason: "",
});
expect(parsed.stage).toBe("review");
if (parsed.stage === "review" && parsed.payload) {
expect(parsed.payload.issues[0]!.severity).toBe("major");
}
});
it("rejects invalid stage", () => {
expect(() =>
HandoffMessage.parse({ stage: "bogus", verdict: "PLAN_READY" }),
).toThrow();
});
it("rejects invalid verdict for stage", () => {
expect(() =>
HandoffMessage.parse({ stage: "plan", verdict: "DEPLOY_DONE" }),
).toThrow();
});
});
describe("MockTransport", () => {
it("returns PLAN_READY for plan stage by default", async () => {
const t = new MockTransport();
const req = InvokeRequest.parse({
pipelineId: "01MOCK",
stage: "plan",
role: "plan",
sprintId: "S1",
task: { title: "test" },
});
const result = await t.invoke(req);
expect(result.stage).toBe("plan");
if (result.stage === "plan") {
expect(result.verdict).toBe("PLAN_READY");
}
});
it("applies per-pipeline scenario overrides", async () => {
const t = new MockTransport({
"01TEST:review": {
stage: "review",
verdict: "REQUEST_CHANGES",
payload: { artifactPath: "", checklistResults: [], issues: [] },
abortReason: "",
},
});
const req = InvokeRequest.parse({
pipelineId: "01TEST",
stage: "review",
role: "review",
task: { title: "test" },
});
const result = await t.invoke(req);
if (result.stage === "review") {
expect(result.verdict).toBe("REQUEST_CHANGES");
}
});
it("applies stage-level overrides", async () => {
const t = new MockTransport();
t.setScenario("implement", {
stage: "implement",
verdict: "ERROR",
errorReason: "mock fail",
});
const req = InvokeRequest.parse({
pipelineId: "01ANY",
stage: "implement",
role: "implement",
task: { title: "test" },
});
const result = await t.invoke(req);
if (result.stage === "implement") {
expect(result.verdict).toBe("ERROR");
expect(result.errorReason).toBe("mock fail");
}
});
});
describe("Discord marker encoding", () => {
it("encode → decode round-trip (result marker)", () => {
const original: HandoffMessage = {
stage: "implement",
verdict: "IMPL_DONE",
payload: {
branch: "feature/x",
commits: ["abc1234"],
workdir: "/tmp",
selfTestReport: { tests: "pass" },
},
errorReason: "",
};
const body =
"some natural language before\n\n" +
"<!-- rails:result v1 -->\n" +
"```json\n" +
JSON.stringify(original) +
"\n```\n" +
"<!-- /rails:result -->\n\n" +
"constructor note";
const parsed = decodeResultMarker(body);
expect(parsed.stage).toBe("implement");
if (parsed.stage === "implement" && parsed.payload) {
expect(parsed.payload.branch).toBe("feature/x");
expect(parsed.payload.commits).toEqual(["abc1234"]);
}
});
it("encodeInvokeMarker produces a parseable block", () => {
const req = InvokeRequest.parse({
pipelineId: "01INV",
stage: "plan",
role: "plan",
task: { title: "go" },
});
const marker = encodeInvokeMarker(req);
expect(marker).toContain("rails:invoke");
expect(marker).toContain("01INV");
expect(marker).toContain('"stage":"plan"');
});
it("decodeResultMarker throws when no marker present", () => {
expect(() => decodeResultMarker("no marker here")).toThrow(/No rails:result/);
});
});
describe("DiscordTransport (fake poster)", () => {
it("posts invoke and parses result", async () => {
const fakePoster: DiscordPoster = {
async postMessage(_channelId, _content) {
return "msg-123";
},
async waitForResult() {
const result: HandoffMessage = {
stage: "plan",
verdict: "PLAN_READY",
payload: {
planDir: "/tmp/plans",
sprintId: "S1",
contractId: "c1",
},
abortReason: "",
};
return (
"<!-- rails:result v1 -->\n" +
"```json\n" +
JSON.stringify(result) +
"\n```\n" +
"<!-- /rails:result -->"
);
},
async close() {},
};
const t = new DiscordTransport({
token: "fake",
guildId: "g1",
channelId: "c1",
poster: fakePoster,
});
const req = InvokeRequest.parse({
pipelineId: "01DC",
stage: "plan",
role: "plan",
task: { title: "test" },
});
const result = await t.invoke(req);
if (result.stage === "plan") {
expect(result.verdict).toBe("PLAN_READY");
}
});
});