Files

338 lines
21 KiB
TypeScript
Raw Permalink Normal View History

import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
2026-06-29 12:47:44 +00:00
import { describe, test } from "node:test";
import { expect } from "expect";
import { initProject } from "../src/projects.js";
import { executeRunLifecycle, prepareRun } from "../src/runner.js";
function seedTemplateRoot(root: string): void {
for (const entry of ["AGENTS.md", ".codex/agents", ".codex/workflows", ".agents/skills"]) {
cpSync(path.join(process.cwd(), entry), path.join(root, entry), { recursive: true });
}
}
function initTemplateProject(options: Parameters<typeof initProject>[0], cwd: string): ReturnType<typeof initProject> {
seedTemplateRoot(cwd);
return initProject(options, cwd);
}
describe("runner", () => {
test("inspection modes do not write run cache files", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-"));
const { projectRoot } = initTemplateProject({ name: "Inspect Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const runsDir = path.join(projectRoot, ".codex", "runs");
const before = readdirSync(runsDir);
const dryRun = prepareRun("gameplay-programmer", { project: projectRoot, task: "Inspect movement", dryRun: true }, cwd);
const printPrompt = prepareRun("gameplay-programmer", { project: projectRoot, task: "Inspect movement", printPrompt: true }, cwd);
expect(readdirSync(runsDir)).toEqual(before);
expect(existsSync(dryRun.promptPath)).toBe(false);
expect(existsSync(dryRun.metadataPath)).toBe(false);
expect(existsSync(printPrompt.promptPath)).toBe(false);
expect(existsSync(printPrompt.metadataPath)).toBe(false);
});
2026-06-17 18:47:15 +10:00
test("strict studio blocks unapproved mutating role runs before run metadata writes", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-strict-"));
const { projectRoot } = initTemplateProject({ name: "Strict Game", engine: "godot", mode: "prototype", studioMode: "strict-studio", nonInteractive: true }, cwd);
2026-06-17 18:47:15 +10:00
const runsDir = path.join(projectRoot, ".codex", "runs");
const before = readdirSync(runsDir);
expect(() => prepareRun("gameplay-programmer", { project: projectRoot, task: "Implement movement", codexBin: path.join(cwd, "missing-codex") }, cwd)).toThrow(
/matching approval/i
);
expect(readdirSync(runsDir)).toEqual(before);
});
test("guided override and fast prototype record provenance metadata for allowed runs", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-policy-"));
const guided = initTemplateProject({ name: "Guided Game", engine: "godot", mode: "prototype", studioMode: "guided-studio", nonInteractive: true }, cwd);
2026-06-17 18:47:15 +10:00
const guidedDryRun = prepareRun("gameplay-programmer", { project: guided.projectRoot, task: "Implement movement", dryRun: true, approvedByUser: true }, cwd);
expect(guidedDryRun.output).toContain("Write policy: override-write");
expect(guidedDryRun.output).toContain("Sandbox: danger-full-access");
expect(guidedDryRun.output).toContain("Provenance: override");
expect(guidedDryRun.output).toContain("Approved by user: true");
const guidedRun = prepareRun("gameplay-programmer", { project: guided.projectRoot, task: "Implement movement", approvedByUser: true, codexBin: path.join(cwd, "missing-codex") }, cwd);
const guidedMetadata = JSON.parse(readFileSync(guidedRun.metadataPath, "utf8")) as { eligibility: { writePolicy: string; codexSandbox: string; metadata: { provenance: string; approvedByUser: boolean } } };
expect(guidedMetadata.eligibility).toMatchObject({
writePolicy: "override-write",
codexSandbox: "danger-full-access",
metadata: { provenance: "override", approvedByUser: true }
});
const fast = initTemplateProject({ name: "Fast Game", engine: "godot", mode: "prototype", studioMode: "fast-prototype", nonInteractive: true }, mkdtempSync(path.join(tmpdir(), "ogs-runner-policy-")));
2026-06-17 18:47:15 +10:00
const fastRun = prepareRun("gameplay-programmer", { project: fast.projectRoot, task: "Implement movement", codexBin: path.join(cwd, "missing-codex") }, cwd);
const fastMetadata = JSON.parse(readFileSync(fastRun.metadataPath, "utf8")) as { eligibility: { writePolicy: string; metadata: { provenance: string } } };
expect(fastRun.output).toContain("Write policy: advisory-write");
expect(fastMetadata.eligibility).toMatchObject({ writePolicy: "advisory-write", metadata: { provenance: "advisory" } });
});
test("mutating runs default to full access and require explicit constrained sandbox for workspace-write", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-sandbox-"));
const { projectRoot } = initTemplateProject({ name: "Sandbox Game", engine: "godot", mode: "prototype", studioMode: "fast-prototype", nonInteractive: true }, cwd);
2026-06-17 18:47:15 +10:00
const defaultRun = prepareRun("gameplay-programmer", { project: projectRoot, task: "Implement movement", dryRun: true }, cwd);
expect(defaultRun.codexCommand.args).toEqual(expect.arrayContaining(["--sandbox", "danger-full-access"]));
expect(defaultRun.prompt).toContain("Sandbox: danger-full-access");
expect(defaultRun.prompt).toContain("Write Policy: advisory-write");
const constrainedRun = prepareRun("gameplay-programmer", { project: projectRoot, task: "Implement movement", dryRun: true, constrainedSandbox: true }, cwd);
expect(constrainedRun.codexCommand.args).toEqual(expect.arrayContaining(["--sandbox", "workspace-write"]));
});
test("inlines runtime role context without generating prompt files", () => {
2026-05-30 14:33:23 +00:00
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-"));
const { projectRoot } = initTemplateProject({ name: "Prompt Game", engine: "godot", mode: "design", nonInteractive: true }, cwd);
2026-05-30 14:33:23 +00:00
const run = prepareRun("market-analyst", { project: projectRoot, task: "Assess competitors", printPrompt: true }, cwd);
expect(run.prompt).toContain("# Runtime Role Context: market-analyst");
expect(run.prompt).toContain("Project: Prompt Game");
expect(run.contextFiles).toContain(".codex/agents/market-analyst.toml");
expect(existsSync(path.join(projectRoot, ".codex", "prompts"))).toBe(false);
2026-05-30 14:33:23 +00:00
});
test("infers the agent surface tier from its model and preserves override precedence", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "codex-game-studio-runner-model-tier-"));
const { projectRoot } = initTemplateProject({ name: "Model Tier", engine: "godot", mode: "prototype", studioMode: "fast-prototype", nonInteractive: true }, cwd);
const agentPath = path.join(projectRoot, ".codex", "agents", "gameplay-programmer.toml");
const agent = readFileSync(agentPath, "utf8")
.replace(/^model = ".*"$/m, 'model = "gpt-5.6-luna"')
.replace(/^model_reasoning_effort = ".*"$/m, 'model_reasoning_effort = "low"');
writeFileSync(agentPath, agent);
const surfaceRun = prepareRun("gameplay-programmer", { project: projectRoot, task: "Rename one label", dryRun: true }, cwd);
expect(surfaceRun.selectedModelTier).toBe("luna");
expect(surfaceRun.selectedModel).toBe("gpt-5.6-luna");
expect(surfaceRun.modelReasoningEffort).toBe("low");
expect(surfaceRun.fallbackModel).toBe("gpt-5.4-mini");
expect(surfaceRun.fallbackReasoningEffort).toBe("low");
expect(surfaceRun.prompt).toContain("Selected tier: luna");
const lunaFixRun = prepareRun("gameplay-programmer", { project: projectRoot, task: "Format the inventory", dryRun: true, fix: true }, cwd);
expect(lunaFixRun.fixCodexCommand?.args).toContain("gpt-5.6-terra");
expect(lunaFixRun.fixCodexCommand?.args).toEqual(expect.arrayContaining(["-c", 'model_reasoning_effort="medium"']));
expect(lunaFixRun.fixFallbackCodexCommand?.args).toContain("gpt-5.4");
const overrideRun = prepareRun("gameplay-programmer", { project: projectRoot, task: "Review architecture", dryRun: true, modelTier: "sol" }, cwd);
expect(overrideRun.selectedModelTier).toBe("sol");
expect(overrideRun.selectedModel).toBe("gpt-5.6-sol");
expect(overrideRun.modelReasoningEffort).toBe("low");
expect(overrideRun.modelSelectionSource).toBe("explicit-tier");
});
test("missing tracked custom agent is reported as context blocker", () => {
2026-05-30 14:33:23 +00:00
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-"));
const { projectRoot } = initTemplateProject({ name: "Missing Agent Game", engine: "godot", mode: "design", nonInteractive: true }, cwd);
rmSync(path.join(projectRoot, ".codex", "agents", "market-analyst.toml"));
2026-05-30 14:33:23 +00:00
const run = prepareRun("market-analyst", { project: projectRoot, task: "Assess competitors", printPrompt: true }, cwd);
expect(run.prompt).toContain(".codex/agents/market-analyst.toml: missing");
expect(run.contextFiles).not.toContain(".codex/agents/market-analyst.toml");
2026-05-30 14:33:23 +00:00
});
2026-06-17 18:47:15 +10:00
test("wrong-engine specialist run fails clearly before producing a prompt", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-specialist-"));
const { projectRoot } = initTemplateProject({ name: "Godot Specialist Run", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
2026-06-17 18:47:15 +10:00
expect(() => prepareRun("unity-specialist", { project: projectRoot, task: "Review engine setup", printPrompt: true }, cwd)).toThrow(
/unity-specialist is not available for godot projects/i
);
2026-07-07 14:09:47 +10:00
expect(() => prepareRun("unity-dots-specialist", { project: projectRoot, task: "Review ECS architecture", printPrompt: true }, cwd)).toThrow(
/unity-dots-specialist is not available for godot projects/i
);
2026-06-17 18:47:15 +10:00
const run = prepareRun("godot-specialist", { project: projectRoot, task: "Review engine setup", printPrompt: true }, cwd);
expect(run.prompt).toContain("# Runtime Role Context: godot-specialist");
2026-06-17 18:47:15 +10:00
expect(run.prompt).toContain("docs/engine-reference/godot/specialist.md");
2026-07-07 14:09:47 +10:00
const sub = prepareRun("godot-gdscript-specialist", { project: projectRoot, task: "Review typed GDScript signals", printPrompt: true }, cwd);
expect(sub.prompt).toContain("# Runtime Role Context: godot-gdscript-specialist");
expect(sub.prompt).toContain("docs/engine-reference/godot/specialist.md");
expect(sub.prompt).toContain("docs/engine-reference/godot/gameplay.md");
});
test("unreal GAS sub-specialist selects focused plugin context", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-gas-"));
const { projectRoot } = initTemplateProject({ name: "Unreal GAS Run", engine: "unreal", mode: "development", nonInteractive: true }, cwd);
const run = prepareRun("ue-gas-specialist", { project: projectRoot, task: "Review Gameplay Ability System attributes", printPrompt: true }, cwd);
expect(run.prompt).toContain("# Runtime Role Context: ue-gas-specialist");
expect(run.prompt).toContain("docs/engine-reference/unreal/plugins/gas.md");
expect(() => prepareRun("godot-gdscript-specialist", { project: projectRoot, task: "Review script", printPrompt: true }, cwd)).toThrow(
/godot-gdscript-specialist is not available for unreal projects/i
);
2026-06-17 18:47:15 +10:00
});
test("fix prompt includes runtime role context", () => {
2026-05-30 14:33:23 +00:00
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-"));
const { projectRoot } = initTemplateProject({ name: "Fix Prompt Game", engine: "godot", mode: "design", nonInteractive: true }, cwd);
2026-05-30 14:33:23 +00:00
const run = prepareRun("market-analyst", { project: projectRoot, task: "Assess competitors", dryRun: true, fix: true }, cwd);
expect(run.fixPrompt).toContain("# Runtime Role Context: market-analyst");
2026-05-30 14:33:23 +00:00
});
test("role runs inline selected templates", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-run-template-"));
const { projectRoot } = initTemplateProject({ name: "Template Run Game", engine: "godot", mode: "design", nonInteractive: true }, cwd);
2026-05-30 14:33:23 +00:00
const market = prepareRun("market-analyst", { project: projectRoot, task: "Assess positioning", printPrompt: true }, cwd);
expect(market.prompt).toContain("## Selected Templates");
expect(market.prompt).toContain("Template: market_analysis");
expect(market.prompt).toContain("Source: package:templates/market_analysis_template.md");
expect(market.prompt).not.toContain("Template: analytics_setup");
const analytics = prepareRun("data-scientist", { project: projectRoot, task: "Plan evidence loop", printPrompt: true }, cwd);
expect(analytics.prompt).toContain("Template: analytics_setup");
expect(analytics.prompt).not.toContain("Template: market_analysis");
const gameplay = prepareRun("gameplay-programmer", { project: projectRoot, task: "Tune movement", printPrompt: true }, cwd);
expect(gameplay.prompt).not.toContain("Template: market_analysis");
expect(gameplay.prompt).not.toContain("Template: analytics_setup");
const ui = prepareRun("ui-ux-designer", { project: projectRoot, task: "Review menus", printPrompt: true }, cwd);
expect(ui.prompt).toContain("Template: ui_ux_review");
expect(ui.prompt).not.toContain("Template: ship_check");
const release = prepareRun("release-manager", { project: projectRoot, task: "Assess readiness", dryRun: true, fix: true }, cwd);
expect(release.prompt).toContain("Template: ship_check");
expect(release.fixPrompt).toContain("Template: ship_check");
expect(release.prompt).not.toContain("Template: ui_ux_review");
});
test("broad context discovers bounded existing project files", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-"));
const { projectRoot } = initTemplateProject({ name: "Broad Context Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
2026-05-30 14:33:23 +00:00
const narrow = prepareRun("producer", { project: projectRoot, task: "Plan next milestone", dryRun: true }, cwd);
expect(narrow.contextFiles).not.toContain("design/gdd.md");
expect(narrow.contextFiles).not.toContain("production/timeline.md");
2026-05-30 14:33:23 +00:00
const broad = prepareRun("producer", { project: projectRoot, task: "Plan next milestone", dryRun: true, allowBroadContext: true }, cwd);
expect(broad.contextFiles).toContain("design/gdd.md");
expect(broad.contextFiles).toContain("production/timeline.md");
expect(broad.contextFiles).toContain("docs/market-overview.md");
2026-05-30 14:33:23 +00:00
expect(broad.contextFiles).not.toContain("Broad context explicitly allowed by CLI flag.");
expect(broad.contextFiles.filter((file) => file.startsWith(".codex/agents/"))).toHaveLength(1);
2026-05-30 14:33:23 +00:00
});
test("broad context ignores directories and realpath escapes", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-"));
const { projectRoot } = initTemplateProject({ name: "Bounded Context Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const gdd = path.join(projectRoot, "design", "gdd.md");
2026-05-30 14:33:23 +00:00
rmSync(gdd);
mkdirSync(gdd);
const overview = path.join(projectRoot, "docs", "market-overview.md");
const outsideDir = mkdtempSync(path.join(tmpdir(), "ogs-outside-"));
const outside = path.join(outsideDir, "outside.md");
2026-05-30 14:33:23 +00:00
writeFileSync(outside, "outside");
rmSync(overview);
symlinkSync(outside, overview);
const broad = prepareRun("producer", { project: projectRoot, task: "Plan next milestone", dryRun: true, allowBroadContext: true }, cwd);
expect(broad.contextFiles).not.toContain("design/gdd.md");
expect(broad.contextFiles).not.toContain("docs/market-overview.md");
2026-05-30 14:33:23 +00:00
});
test("include artifact renders canonical project-relative paths", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-"));
const { projectRoot } = initTemplateProject({ name: "Artifact Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
2026-05-30 14:33:23 +00:00
const run = prepareRun("producer", { project: projectRoot, task: "Plan next milestone", printPrompt: true, includeArtifact: ["design/../design/gdd.md"] }, cwd);
2026-05-30 14:33:23 +00:00
expect(run.contextFiles).toContain("design/gdd.md");
expect(run.prompt).toContain("# Included Artifact: design/gdd.md");
expect(() => prepareRun("producer", { project: projectRoot, task: "Plan next milestone", printPrompt: true, includeArtifact: ["design/gdd.md\n# injected"] }, cwd)).toThrow(
2026-05-30 14:33:23 +00:00
"--include-artifact cannot contain control characters"
);
});
2026-06-17 18:47:15 +10:00
test("rejected included artifacts are not embedded in prompts", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-artifact-rejected-"));
const { projectRoot } = initTemplateProject({ name: "Rejected Artifact Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
2026-06-17 18:47:15 +10:00
mkdirSync(path.join(projectRoot, "dist"), { recursive: true });
writeFileSync(path.join(projectRoot, "dist", "bundle.js"), "REJECTED_BUNDLE_BODY");
const run = prepareRun("producer", { project: projectRoot, task: "Plan next milestone", printPrompt: true, includeArtifact: ["dist/bundle.js"] }, cwd);
expect(run.contextFiles).not.toContain("dist/bundle.js");
expect(run.prompt).toContain("- dist/bundle.js: rejected");
expect(run.prompt).not.toContain("# Included Artifact: dist/bundle.js");
expect(run.prompt).not.toContain("REJECTED_BUNDLE_BODY");
});
test("oversized included artifacts are not embedded in prompts", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-artifact-oversized-"));
const { projectRoot } = initTemplateProject({ name: "Oversized Artifact Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const artifactPath = path.join(projectRoot, "design", "large-artifact.md");
2026-06-17 18:47:15 +10:00
writeFileSync(artifactPath, `OVERSIZED_ARTIFACT_BODY\n${"x".repeat(20_000)}`);
const run = prepareRun("producer", { project: projectRoot, task: "Plan next milestone", printPrompt: true, includeArtifact: ["design/large-artifact.md"] }, cwd);
2026-06-17 18:47:15 +10:00
expect(run.contextFiles).not.toContain("design/large-artifact.md");
expect(run.prompt).toContain("- design/large-artifact.md: omitted");
expect(run.prompt).not.toContain("# Included Artifact: design/large-artifact.md");
2026-06-17 18:47:15 +10:00
expect(run.prompt).not.toContain("OVERSIZED_ARTIFACT_BODY");
});
test("review passes execute Codex with a read-only sandbox", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-review-"));
const { projectRoot } = initTemplateProject({ name: "Review Game", engine: "godot", mode: "prototype", studioMode: "fast-prototype", nonInteractive: true }, cwd);
const log = path.join(cwd, "codex-invocations.jsonl");
const stub = path.join(cwd, "codex-stub.mjs");
writeFileSync(
stub,
`#!/usr/bin/env node
import { appendFileSync, readFileSync } from "node:fs";
const input = readFileSync(0, "utf8");
appendFileSync(${JSON.stringify(log)}, JSON.stringify({ args: process.argv.slice(2), input }) + "\\n");
console.log(JSON.stringify({ blockers: [], warnings: [], summary: "ok", needsFix: false }));
`
);
chmodSync(stub, 0o755);
const run = prepareRun("gameplay-programmer", { project: projectRoot, task: "Implement movement", review: true, codexBin: stub }, cwd);
await executeRunLifecycle(run);
const invocations = readFileSync(log, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { args: string[]; input: string });
expect(invocations).toHaveLength(2);
2026-06-17 18:47:15 +10:00
expect(invocations[0].args).toEqual(expect.arrayContaining(["--sandbox", "danger-full-access"]));
expect(invocations[1].args).toEqual(expect.arrayContaining(["--sandbox", "read-only"]));
expect(invocations[1].input).toContain("# Runtime Role Context: qa-playtester");
2026-05-30 14:33:23 +00:00
expect(invocations[1].input).toContain("Template: playtest_report");
});
2026-06-17 18:47:15 +10:00
test("implementation, review, and fix prompts share a bounded Context Contract", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-contract-"));
const { projectRoot } = initTemplateProject({ name: "Contract Game", engine: "godot", mode: "prototype", studioMode: "fast-prototype", nonInteractive: true }, cwd);
2026-06-17 18:47:15 +10:00
const run = prepareRun("gameplay-programmer", { project: projectRoot, task: "Implement movement", dryRun: true, review: true, fix: true }, cwd);
for (const prompt of [run.prompt, run.reviewPrompt, run.fixPrompt]) {
expect(prompt).toContain("# Context Contract");
expect(prompt).toContain("Project Stage: prototype");
expect(prompt).toContain("Studio Mode: fast-prototype");
expect(prompt).toMatch(/Phase: (implement|review|fix)/);
expect(prompt).toContain("Write Policy:");
expect(prompt).toContain("Sandbox:");
expect(prompt).toContain("File Edits:");
expect(prompt).toContain("Selected Context:");
expect(prompt).toContain("- AGENTS.md (required; project instructions)");
expect(prompt).toContain("Omissions and Blockers:");
}
expect(run.reviewPrompt).toContain("Read-only review: inspect diff and verification output; do not edit files.");
expect(run.reviewPrompt).toContain(".codex/agents/qa-playtester.toml");
2026-06-17 18:47:15 +10:00
expect(run.fixPrompt).toContain("Bounded Blockers:");
expect(run.fixPrompt).toContain("- Review and verification blockers will be supplied at execution time.");
});
});