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>
210 lines
5.7 KiB
TypeScript
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");
|
|
}
|
|
});
|
|
});
|