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>
This commit is contained in:
79
tests/config.test.ts
Normal file
79
tests/config.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { loadConfig } from "../src/config/loader.js";
|
||||
import { DEFAULT_CONFIG } from "../src/config/schema.js";
|
||||
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await mkdtemp(join(tmpdir(), "rails-config-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("loadConfig", () => {
|
||||
it("returns defaults when no path provided", async () => {
|
||||
const cfg = await loadConfig();
|
||||
expect(cfg).toEqual(DEFAULT_CONFIG);
|
||||
});
|
||||
|
||||
it("returns defaults when file does not exist", async () => {
|
||||
const cfg = await loadConfig(join(testDir, "missing.yaml"));
|
||||
expect(cfg).toEqual(DEFAULT_CONFIG);
|
||||
});
|
||||
|
||||
it("parses valid yaml config", async () => {
|
||||
const yamlPath = join(testDir, "rails.config.yaml");
|
||||
await writeFile(
|
||||
yamlPath,
|
||||
`
|
||||
pipeline:
|
||||
stages: [plan, implement, review]
|
||||
agents:
|
||||
plan:
|
||||
role: plan
|
||||
displayName: TestPlanner
|
||||
transport: mock
|
||||
timeoutMs: 15000
|
||||
`,
|
||||
);
|
||||
const cfg = await loadConfig(yamlPath);
|
||||
expect(cfg.pipeline.stages).toEqual(["plan", "implement", "review"]);
|
||||
expect(cfg.agents["plan"]?.displayName).toBe("TestPlanner");
|
||||
expect(cfg.agents["plan"]?.timeoutMs).toBe(15_000);
|
||||
});
|
||||
|
||||
it("interpolates environment variables", async () => {
|
||||
const yamlPath = join(testDir, "rails.config.yaml");
|
||||
await writeFile(
|
||||
yamlPath,
|
||||
`
|
||||
discord:
|
||||
enabled: true
|
||||
railsToken: \${MY_TEST_TOKEN}
|
||||
guildId: fixed-guild
|
||||
`,
|
||||
);
|
||||
const cfg = await loadConfig(yamlPath, { MY_TEST_TOKEN: "secret-abc" });
|
||||
expect(cfg.discord.railsToken).toBe("secret-abc");
|
||||
expect(cfg.discord.guildId).toBe("fixed-guild");
|
||||
});
|
||||
|
||||
it("defaults missing env vars to empty string", async () => {
|
||||
const yamlPath = join(testDir, "rails.config.yaml");
|
||||
await writeFile(
|
||||
yamlPath,
|
||||
`
|
||||
discord:
|
||||
enabled: false
|
||||
railsToken: \${MISSING_VAR}
|
||||
`,
|
||||
);
|
||||
const cfg = await loadConfig(yamlPath, {});
|
||||
expect(cfg.discord.railsToken).toBe("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user