diff --git a/biome.json b/biome.json index 5f01a5a2..5e69e3fe 100644 --- a/biome.json +++ b/biome.json @@ -12,6 +12,17 @@ } }, "files": { - "includes": ["**", "!node_modules", "!bun.lock", "!.*", "!coverage", "!dist", "!work"] + "includes": [ + "**", + "!node_modules", + "!bun.lock", + "!.bun-cache", + "!.devos", + "!.ponytrail", + "!coverage", + "!dist", + "!work", + "!packages" + ] } } diff --git a/scripts/demo-animation.ts b/scripts/demo-animation.ts new file mode 100644 index 00000000..6359d4d5 --- /dev/null +++ b/scripts/demo-animation.ts @@ -0,0 +1,124 @@ +#!/usr/bin/env bun +/** + * Demo script — runs the court animation end-to-end with fake bots. + * Usage: bun scripts/demo-animation.ts + */ +import { createCourtAnimator, printHorseRaceHeader, printRaceTrack } from "../src/court-animation"; +import type { Manifest } from "../src/runtimes/ponytrail/manifest"; +import type { RequirementCourtRound } from "../src/runtimes/ponytrail/requirement-court"; + +const manifest = { + manifestVersion: "0.1", + kind: "ai-work-runtime.ponytrail", + metadata: { name: "demo", description: "", owner: "human" }, + runtime: { mode: "requirement_first", defaultLanguage: "en", workerAgents: [] }, + models: [{ id: "m1", provider: "demo", name: "demo-model" }], + bots: [ + { + id: "product_manager_bot", + displayName: "PM Bot", + role: "Product Manager", + panel: "requirement_court", + model: "m1", + instruction: "", + votes: true, + }, + { + id: "engineer_bot", + displayName: "Engineer", + role: "Engineer", + panel: "requirement_court", + model: "m1", + instruction: "", + votes: true, + }, + { + id: "testing_bot", + displayName: "Testing", + role: "QA", + panel: "requirement_court", + model: "m1", + instruction: "", + votes: true, + }, + { + id: "senior_engineer_bot", + displayName: "Sr Engineer", + role: "Senior Engineer", + panel: "requirement_court", + model: "m1", + instruction: "", + votes: true, + }, + ], + deliberation: { + maxRounds: 1, + decisionRule: { + voterIds: ["product_manager_bot", "engineer_bot", "testing_bot", "senior_engineer_bot"], + voters: 4, + requiredApprovals: 3, + }, + }, +} as unknown as Manifest; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const votes: Array<{ botId: string; displayName: string; vote: string }> = [ + { botId: "product_manager_bot", displayName: "PM Bot", vote: "approve" }, + { botId: "engineer_bot", displayName: "Engineer", vote: "amend" }, + { botId: "testing_bot", displayName: "Testing", vote: "approve" }, + { botId: "senior_engineer_bot", displayName: "Sr Engineer", vote: "approve" }, +]; + +const animator = createCourtAnimator(manifest, { + minPonyMs: 1800, // show each pony for at least 1.8s + frameMs: 160, // gallop frame speed +}); + +await animator.onRoundStart(1, manifest.deliberation.decisionRule.voterIds); + +const discussion: RequirementCourtRound["discussion"] = []; + +for (const { botId, displayName, vote } of votes) { + await animator.onPonyStart(botId, displayName, 1); + // Simulate the bot "thinking" — just wait, the interval does the animation. + await sleep(2200); + const entry = { + botId, + displayName, + role: botId, + round: 1, + message: "deliberated", + visibleThinking: { focus: "", concern: "", recommendation: "" }, + line: `${botId}: ${vote}`, + vote, + confidence: 0.9, + requiredChanges: vote === "amend" ? ["clarify scope"] : [], + } as RequirementCourtRound["discussion"][number]; + discussion.push(entry); + await animator.onPonyComplete(entry); +} + +const round: RequirementCourtRound = { + round: 1, + discussion, + votes: [], + verdict: { + approved: true, + approvals: 3, + amendments: 1, + rejections: 0, + missingVoters: [], + requiredChanges: [], + }, +}; + +await animator.onRoundComplete(round); +animator.stop(); + +// Print full header first so you can see the starting lineup. +console.log("\n--- Replay: starting lineup ---\n"); +printHorseRaceHeader(manifest); + +console.log("\n--- Replay: race track ---\n"); +printRaceTrack([round], manifest); diff --git a/src/cli.ts b/src/cli.ts index 3962da9c..b1f0933f 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,7 @@ import { basename, isAbsolute, join, resolve } from "node:path"; import { confirm, intro, isCancel, outro, select, text } from "@clack/prompts"; import { Command } from "commander"; import pc from "picocolors"; +import { type CourtAnimator, createCourtAnimator, printHorseRaceHeader } from "./court-animation"; import { installAgentSkill, parseSkillInstallAgents, type SkillInstallResult } from "./plugins"; import { applySnapshotRevert, @@ -643,6 +644,10 @@ async function runGoalFlow(requestParts: string[], input: RunGoalFlowInput): Pro const request = requestParts.join(" "); const preparedDiscussion = prepareGoalDiscussion(request, { manifest }); + // Only animate in interactive TTY sessions, not when JSON output is requested. + const animator = + !input.jsonOutput && process.stdout.isTTY ? createCourtAnimator(manifest) : undefined; + if (preparedDiscussion.status === "needs_clarification") { if (input.jsonOutput) { console.log(JSON.stringify(preparedDiscussion, null, 2)); @@ -675,12 +680,14 @@ async function runGoalFlow(requestParts: string[], input: RunGoalFlowInput): Pro return; } - const result = await runRequirementCourt( + if (animator) printHorseRaceHeader(manifest); + const clarifiedResult = await runRequirementCourt( clarifiedDiscussion.contract, - createRunRequirementCourtInput(manifest, input.ponyRunner), + createRunRequirementCourtInput(manifest, input.ponyRunner, animator), ); + animator?.stop(); - printRequirementCourtResult(result, { + printRequirementCourtResult(clarifiedResult, { discussionHeading: input.discussionHeading, printVisibleThinking: input.printVisibleThinking, }); @@ -692,10 +699,12 @@ async function runGoalFlow(requestParts: string[], input: RunGoalFlowInput): Pro return; } + if (animator) printHorseRaceHeader(manifest); const result = await runRequirementCourt( preparedDiscussion.contract, - createRunRequirementCourtInput(manifest, input.ponyRunner), + createRunRequirementCourtInput(manifest, input.ponyRunner, animator), ); + animator?.stop(); if (input.jsonOutput === "court") { console.log(JSON.stringify(result, null, 2)); @@ -711,6 +720,7 @@ async function runGoalFlow(requestParts: string[], input: RunGoalFlowInput): Pro function createRunRequirementCourtInput( manifest: RunRequirementCourtInput["manifest"], ponyRunner: RequirementPonyRunner | undefined, + animator?: CourtAnimator, ): RunRequirementCourtInput { const input: RunRequirementCourtInput = { manifest }; @@ -718,6 +728,14 @@ function createRunRequirementCourtInput( input.ponyRunner = ponyRunner; } + if (animator) { + input.onRoundStart = (round, botIds) => animator.onRoundStart(round, botIds); + input.onRoundComplete = (round) => animator.onRoundComplete(round); + input.onPonyStart = (botId, displayName, round) => + animator.onPonyStart(botId, displayName, round); + input.onPonyComplete = (entry) => animator.onPonyComplete(entry); + } + return input; } diff --git a/src/court-animation.ts b/src/court-animation.ts new file mode 100644 index 00000000..9ec0cf89 --- /dev/null +++ b/src/court-animation.ts @@ -0,0 +1,286 @@ +import pc from "picocolors"; +import type { Manifest } from "./runtimes/ponytrail/manifest"; +import type { + RequirementCourtRound, + RequirementDiscussionEntry, +} from "./runtimes/ponytrail/requirement-court"; + +// ─── Per-role colors ─────────────────────────────────────────────────────── + +const BOT_COLORS: Record string> = { + product_manager_bot: pc.magenta, + project_manager_bot: pc.blue, + engineer_bot: pc.cyan, + senior_engineer_bot: pc.green, + testing_bot: pc.yellow, +}; + +function botColor(botId: string): (s: string) => string { + return BOT_COLORS[botId] ?? pc.white; +} + +// ─── Front-facing pony (3 gallop frames) ────────────────────────────────── +// +// Design from user's script: /) (\ ears, •_• dot eyes, /| |\ shoulders. +// drawFrame and the lineup renderer each prepend 2 spaces, so body strings +// carry 6/5/4/3 leading spaces matching the original's 8/7/6/5. + +const COL_W = 15; // column width per pony in the lineup + +// Shared head/body lines — same for every gallop frame. +const PONY_BODY = [ + " /) (\\", // ears + " ( •_• )", // dot eyes + " /| |\\", // shoulders +]; + +// Three leg positions for the gallop cycle (one line each). +// frame 0 — original flat stance +// frame 1 — hooves lifted (trot) +// frame 2 — wider planted stance (gallop) +const PONY_LEGS = [ + [" /_|___|_\\"], // hooves flat + [" / |_| \\ "], // hooves raised + [" /___|___|\\"], // wide stance +]; + +// Total lines per animation frame (body + 1 leg line + label). +const FRAME_H = PONY_BODY.length + 1 + 1; // 3 + 1 + 1 = 5 + +// ─── Header: starting lineup ─────────────────────────────────────────────── + +// Print all voter bots as a side-by-side row of colored ponies. +export function printHorseRaceHeader(manifest: Manifest): void { + const voterIds = manifest.deliberation.decisionRule.voterIds; + const bots = voterIds + .map((id) => manifest.bots.find((b) => b.id === id)) + .filter((b): b is NonNullable => b !== undefined); + + console.log(""); + console.log(pc.bold(pc.cyan(" 🏁 PONY COURT IS IN SESSION 🏁"))); + console.log(pc.dim(" ══════════════════════════════════")); + console.log(""); + + // Render body lines side-by-side. + for (const bodyLine of PONY_BODY) { + const row = bots.map((b) => botColor(b.id)(bodyLine.padEnd(COL_W))).join(" "); + process.stdout.write(` ${row}\n`); + } + + // Render a static "legs spread" frame for the lineup. + const staticLegs = PONY_LEGS[0] ?? []; + for (const legLine of staticLegs) { + const row = bots.map((b) => botColor(b.id)(legLine.padEnd(COL_W))).join(" "); + process.stdout.write(` ${row}\n`); + } + + // Names row below each pony. + const names = bots + .map((b) => { + const name = (b.displayName ?? b.id).slice(0, COL_W - 1).padEnd(COL_W); + return botColor(b.id)(name); + }) + .join(" "); + process.stdout.write(` ${names}\n`); + + console.log(""); + console.log(pc.dim(" All ponies must vote before implementation begins.")); + console.log(""); +} + +// ─── Funny per-role thoughts ─────────────────────────────────────────────── + +const THOUGHTS: Record = { + product_manager_bot: [ + "is rewriting the roadmap on a hay bale...", + "is aligning pony OKRs with the requirement...", + "is questioning the user story over oats...", + ], + project_manager_bot: [ + "is updating the sprint board with a hoof...", + "is calculating story points in horseshoes...", + "is moving the ticket to 'In Deliberation'...", + ], + engineer_bot: [ + "is refactoring the horseshoe...", + "is Googling 'how to implement in one sprint'...", + "is checking if it breaks the stable CI...", + ], + senior_engineer_bot: [ + "is drawing system diagrams in the dirt...", + "is raising concerns about technical debt...", + "is insisting on a proper architecture review...", + ], + testing_bot: [ + "is writing edge cases for galloping...", + "is asking 'but what if the carrot is null?'...", + "is demanding a smoke test before approval...", + ], +}; + +const DEFAULT_THOUGHTS = [ + "is deliberating very seriously...", + "is consulting the hay oracle...", + "is pondering the requirement...", +]; + +function thoughtsFor(botId: string): string[] { + return THOUGHTS[botId] ?? DEFAULT_THOUGHTS; +} + +// ─── ANSI helpers ────────────────────────────────────────────────────────── + +const UP = (n: number) => `\x1b[${n}A`; +const CLEAR = "\x1b[2K"; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +function drawFrame(frameIdx: number, colorFn: (s: string) => string, label: string): void { + const legs = PONY_LEGS[frameIdx % PONY_LEGS.length] ?? PONY_LEGS[0] ?? []; + const lines = [...PONY_BODY, ...legs]; + for (const line of lines) { + process.stdout.write(`${CLEAR} ${colorFn(line)}\n`); + } + process.stdout.write(`${CLEAR}${label}\n`); + process.stdout.write(UP(FRAME_H)); +} + +function clearAnimArea(resultLine: string): void { + // Cursor is at TOP of animation area after last drawFrame. + process.stdout.write(`${CLEAR} ${resultLine}\n`); + for (let i = 1; i < FRAME_H; i++) { + process.stdout.write(`${CLEAR}\n`); + } + // Sit right after the result line. + process.stdout.write(UP(FRAME_H - 1)); +} + +// ─── Race track ──────────────────────────────────────────────────────────── + +const VOTE_BARS: Record = { + approve: pc.green("████████"), + amend: pc.yellow("████░░░░"), + reject: pc.red("██░░░░░░"), +}; + +const VOTE_LABELS: Record = { + approve: pc.green("✓ approve"), + amend: pc.yellow("~ amend"), + reject: pc.red("✗ reject"), +}; + +export function printRaceTrack(rounds: RequirementCourtRound[], manifest: Manifest): void { + if (rounds.length === 0) return; + const latest = rounds.at(-1); + if (!latest) return; + + console.log(pc.bold(` ── Race Track Round ${latest.round} ──`)); + for (const botId of manifest.deliberation.decisionRule.voterIds) { + const entry = latest.discussion.find((d) => d.botId === botId); + if (!entry) continue; + const color = botColor(botId); + const bar = VOTE_BARS[entry.vote] ?? pc.dim("░░░░░░░░"); + const label = VOTE_LABELS[entry.vote] ?? entry.vote; + const name = color((entry.displayName ?? botId).padEnd(24)); + console.log(` ${name} ${bar} ${label}`); + } + const verdict = latest.verdict.approved + ? pc.green(" Verdict: approved ✓") + : pc.yellow(` Verdict: not yet — ${latest.verdict.approvals} approval(s) so far`); + console.log(verdict); + console.log(""); +} + +// ─── Animator ────────────────────────────────────────────────────────────── + +export interface CourtAnimatorOptions { + /** Minimum ms to show each pony's animation before revealing the result. Default 1800. */ + minPonyMs?: number; + /** Frame interval in ms for the gallop animation. Default 170. */ + frameMs?: number; +} + +export interface CourtAnimator { + onRoundStart(round: number, botIds: string[]): Promise; + onRoundComplete(round: RequirementCourtRound): Promise; + onPonyStart(botId: string, displayName: string, round: number): Promise; + onPonyComplete(entry: RequirementDiscussionEntry): Promise; + stop(): void; +} + +export function createCourtAnimator( + manifest: Manifest, + options?: CourtAnimatorOptions, +): CourtAnimator { + const minPonyMs = options?.minPonyMs ?? 1800; + const frameMs = options?.frameMs ?? 170; + const completedRounds: RequirementCourtRound[] = []; + let gallopInterval: ReturnType | undefined; + let ponyStartTime = 0; + let frameIndex = 0; + let currentBotId = ""; + let currentDisplayName = ""; + let currentColorFn: (s: string) => string = pc.white; + + function clearGallop(): void { + if (gallopInterval !== undefined) { + clearInterval(gallopInterval); + gallopInterval = undefined; + } + } + + return { + async onRoundStart(round: number, botIds: string[]): Promise { + console.log( + pc.bold(pc.cyan(`\n 🏁 Round ${round} — ${botIds.length} ponies enter the court\n`)), + ); + }, + + async onPonyStart(botId: string, displayName: string): Promise { + currentBotId = botId; + currentDisplayName = displayName; + currentColorFn = botColor(botId); + ponyStartTime = Date.now(); + frameIndex = 0; + + process.stdout.write("\n".repeat(FRAME_H)); + process.stdout.write(UP(FRAME_H)); + + const thoughts = thoughtsFor(botId); + const label = ` ${currentColorFn("🐴")} ${displayName} ${thoughts[0] ?? ""}`; + drawFrame(0, currentColorFn, label); + + gallopInterval = setInterval(() => { + frameIndex++; + const thought = thoughts[frameIndex % thoughts.length] ?? thoughts[0] ?? ""; + drawFrame( + frameIndex, + currentColorFn, + ` ${currentColorFn("🐴")} ${currentDisplayName} ${thought}`, + ); + }, frameMs); + }, + + async onPonyComplete(entry: RequirementDiscussionEntry): Promise { + const elapsed = Date.now() - ponyStartTime; + if (elapsed < minPonyMs) await sleep(minPonyMs - elapsed); + + clearGallop(); + + const color = botColor(currentBotId); + const bar = VOTE_BARS[entry.vote] ?? pc.dim("░░░░░░░░"); + const voteLabel = VOTE_LABELS[entry.vote] ?? entry.vote; + const name = pc.bold(color((entry.displayName ?? currentBotId).padEnd(24))); + clearAnimArea(`${name} ${bar} ${voteLabel}`); + }, + + async onRoundComplete(round: RequirementCourtRound): Promise { + completedRounds.push(round); + console.log(""); + printRaceTrack(completedRounds, manifest); + }, + + stop(): void { + clearGallop(); + }, + }; +} diff --git a/src/runtimes/ponytrail/requirement-court.ts b/src/runtimes/ponytrail/requirement-court.ts index c8624e98..6fb8a3bc 100644 --- a/src/runtimes/ponytrail/requirement-court.ts +++ b/src/runtimes/ponytrail/requirement-court.ts @@ -55,6 +55,10 @@ export interface RequirementCourtResult { export interface RunRequirementCourtInput { manifest: Manifest; ponyRunner?: RequirementPonyRunner; + onRoundStart?: (round: number, botIds: string[]) => void | Promise; + onRoundComplete?: (round: RequirementCourtRound) => void | Promise; + onPonyStart?: (botId: string, displayName: string, round: number) => void | Promise; + onPonyComplete?: (entry: RequirementDiscussionEntry) => void | Promise; } export interface RequirementPonyRunInput { @@ -119,23 +123,21 @@ export async function runRequirementCourt( const rounds: RequirementCourtRound[] = []; for (let round = 1; round <= input.manifest.deliberation.maxRounds; round += 1) { + const voterIds = input.manifest.deliberation.decisionRule.voterIds; + await input.onRoundStart?.(round, voterIds); const priorDiscussion = [...discussion]; - const roundDiscussion = await Promise.all( - input.manifest.deliberation.decisionRule.voterIds.map(async (botId) => { - const bot = findRequirementCourtBot(botId, input.manifest); - const model = findRequirementCourtModel(bot, input.manifest); - const response = await ponyRunner({ - manifest: input.manifest, - bot, - model, - contract, - round, - priorDiscussion, - }); - return createDiscussionEntry(bot, response, round); - }), + // Run bots sequentially when per-pony animation callbacks are present, + // otherwise run in parallel for faster execution. + const roundDiscussion = await runPonies( + voterIds, + round, + input, + contract, + priorDiscussion, + ponyRunner, ); + const votes = roundDiscussion.map(toVote); const verdict = tallyVotes(votes, input.manifest.deliberation.decisionRule); @@ -147,6 +149,8 @@ export async function runRequirementCourt( verdict, }); + await input.onRoundComplete?.(rounds.at(-1) as RequirementCourtRound); + if (verdict.approved) { break; } @@ -185,6 +189,53 @@ export async function runRequirementCourt( }; } +// Run all voter ponies for a round — sequentially when per-pony callbacks exist, in parallel otherwise. +async function runPonies( + voterIds: string[], + round: number, + input: RunRequirementCourtInput, + contract: GoalContract, + priorDiscussion: readonly RequirementDiscussionEntry[], + ponyRunner: RequirementPonyRunner, +): Promise { + if (!input.onPonyStart && !input.onPonyComplete) { + return Promise.all( + voterIds.map(async (botId) => { + const bot = findRequirementCourtBot(botId, input.manifest); + const model = findRequirementCourtModel(bot, input.manifest); + const response = await ponyRunner({ + manifest: input.manifest, + bot, + model, + contract, + round, + priorDiscussion, + }); + return createDiscussionEntry(bot, response, round); + }), + ); + } + + const entries: RequirementDiscussionEntry[] = []; + for (const botId of voterIds) { + const bot = findRequirementCourtBot(botId, input.manifest); + const model = findRequirementCourtModel(bot, input.manifest); + await input.onPonyStart?.(botId, bot.displayName, round); + const response = await ponyRunner({ + manifest: input.manifest, + bot, + model, + contract, + round, + priorDiscussion, + }); + const entry = createDiscussionEntry(bot, response, round); + await input.onPonyComplete?.(entry); + entries.push(entry); + } + return entries; +} + function createDiscussionEntry( bot: Manifest["bots"][number], response: RequirementPonyResponse, diff --git a/tests/court-animation.test.ts b/tests/court-animation.test.ts new file mode 100644 index 00000000..43b7d4b8 --- /dev/null +++ b/tests/court-animation.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, test } from "bun:test"; +import { createCourtAnimator, printHorseRaceHeader, printRaceTrack } from "../src/court-animation"; +import type { Manifest } from "../src/runtimes/ponytrail/manifest"; +import type { RequirementCourtRound } from "../src/runtimes/ponytrail/requirement-court"; + +// Minimal manifest fixture with two voter bots. +function makeManifest(): Manifest { + return { + manifestVersion: "0.1", + kind: "ai-work-runtime.ponytrail", + metadata: { name: "test", description: "", owner: "human_owner" }, + runtime: { mode: "requirement_first", defaultLanguage: "en", workerAgents: [] }, + models: [{ id: "m1", provider: "test", name: "test-model" }], + bots: [ + { + id: "product_manager_bot", + displayName: "PM Bot", + role: "Product Manager", + panel: "requirement_court", + model: "m1", + instruction: "focus on user value", + votes: true, + }, + { + id: "engineer_bot", + displayName: "Engineer Bot", + role: "Engineer", + panel: "requirement_court", + model: "m1", + instruction: "focus on feasibility", + votes: true, + }, + ], + deliberation: { + maxRounds: 3, + decisionRule: { + voterIds: ["product_manager_bot", "engineer_bot"], + voters: 2, + requiredApprovals: 2, + }, + }, + } as unknown as Manifest; +} + +function makeRound(round: number, approved: boolean): RequirementCourtRound { + return { + round, + discussion: [ + { + botId: "product_manager_bot", + displayName: "PM Bot", + role: "product", + round, + message: "looks good", + visibleThinking: { focus: "", concern: "", recommendation: "" }, + line: "product_manager_bot: looks good", + vote: "approve", + confidence: 0.9, + requiredChanges: [], + }, + { + botId: "engineer_bot", + displayName: "Engineer Bot", + role: "engineering", + round, + message: "feasible", + visibleThinking: { focus: "", concern: "", recommendation: "" }, + line: "engineer_bot: feasible", + vote: approved ? "approve" : "amend", + confidence: 0.8, + requiredChanges: approved ? [] : ["clarify scope"], + }, + ], + votes: [], + verdict: { + approved, + approvals: approved ? 2 : 1, + amendments: approved ? 0 : 1, + rejections: 0, + missingVoters: [], + requiredChanges: [], + }, + }; +} + +describe("court animation", () => { + test("printHorseRaceHeader writes to stdout without throwing", () => { + const written: string[] = []; + const origWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = (chunk: unknown) => { + written.push(String(chunk)); + return true; + }; + + try { + printHorseRaceHeader(makeManifest()); + expect(written.length).toBeGreaterThan(0); + } finally { + process.stdout.write = origWrite; + } + }); + + test("printRaceTrack logs nothing when rounds array is empty", () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + printRaceTrack([], makeManifest()); + expect(logs.length).toBe(0); + } finally { + console.log = origLog; + } + }); + + test("printRaceTrack renders approve and needs_changes rows", () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + const round = makeRound(1, false); + printRaceTrack([round], makeManifest()); + const combined = logs.join("\n"); + expect(combined).toContain("PM Bot"); + expect(combined).toContain("Engineer Bot"); + expect(combined).toContain("not yet"); + } finally { + console.log = origLog; + } + }); + + test("printRaceTrack shows approved verdict", () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + printRaceTrack([makeRound(1, true)], makeManifest()); + expect(logs.join("\n")).toContain("approved"); + } finally { + console.log = origLog; + } + }); + + test("createCourtAnimator onRoundStart logs round header", async () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + const animator = createCourtAnimator(makeManifest()); + await animator.onRoundStart(1, ["product_manager_bot", "engineer_bot"]); + animator.stop(); + expect(logs.join("\n")).toContain("Round 1"); + } finally { + console.log = origLog; + } + }); + + test("createCourtAnimator onRoundComplete appends round to race track", async () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + const animator = createCourtAnimator(makeManifest()); + await animator.onRoundComplete(makeRound(1, true)); + animator.stop(); + expect(logs.join("\n")).toContain("Race Track"); + } finally { + console.log = origLog; + } + }); + + test("createCourtAnimator stop clears gallop interval without error", () => { + const animator = createCourtAnimator(makeManifest()); + expect(() => animator.stop()).not.toThrow(); + // Calling stop twice should also be safe. + expect(() => animator.stop()).not.toThrow(); + }); + + test("createCourtAnimator onPonyStart writes animation frames to stdout", async () => { + const written: string[] = []; + const origWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = (chunk: unknown) => { + written.push(String(chunk)); + return true; + }; + try { + const animator = createCourtAnimator(makeManifest(), { minPonyMs: 0, frameMs: 100000 }); + // start the animation but stop immediately so the test doesn't wait + const startPromise = animator.onPonyStart("engineer_bot", "Engineer Bot", 1); + animator.stop(); + await startPromise; + expect(written.length).toBeGreaterThan(0); + } finally { + process.stdout.write = origWrite; + } + }); + + test("createCourtAnimator onPonyComplete clears animation and writes result", async () => { + const written: string[] = []; + const origWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = (chunk: unknown) => { + written.push(String(chunk)); + return true; + }; + try { + // minPonyMs:0 so onPonyComplete returns immediately without waiting + const animator = createCourtAnimator(makeManifest(), { minPonyMs: 0, frameMs: 100000 }); + await animator.onPonyStart("product_manager_bot", "PM Bot", 1); + const round = makeRound(1, true); + const entry = round.discussion[0] ?? round.discussion[1]; + if (!entry) throw new Error("test fixture has no discussion entries"); + + await animator.onPonyComplete(entry); + animator.stop(); + const combined = written.join(""); + // clearAnimArea writes CLEAR sequences and the result line + expect(combined).toContain("\x1b[2K"); + } finally { + process.stdout.write = origWrite; + } + }); + + test("createCourtAnimator uses default thoughts for unknown bot ids", async () => { + const written: string[] = []; + const origWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = (chunk: unknown) => { + written.push(String(chunk)); + return true; + }; + try { + const animator = createCourtAnimator(makeManifest(), { minPonyMs: 0, frameMs: 100000 }); + await animator.onPonyStart("unknown_bot", "Unknown Bot", 1); + animator.stop(); + expect(written.join("")).toContain("Unknown Bot"); + } finally { + process.stdout.write = origWrite; + } + }); +});