Public: anyone can read this; the crew can change it.
Fast test/plan-route.test.ts: the planner's route proved without a whole crew run, every test under 1 s #1505
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1438-fast-test-plan-route-test-ts-the-planner → main 3 commits fork
2 files
changed +20
−78
scripts/gate.ts +1 −6
| @@ -125,6 +125,7 @@ export const GATE: readonly string[] = [ | ||
| 125 | 125 | "test/asks-expiry.test.ts", |
| 126 | 126 | "test/asks-rank.test.ts", |
| 127 | 127 | "test/plan-pillars.test.ts", |
| 128 | "test/plan-route.test.ts", | |
| 128 | 129 | "test/planner-declined.test.ts", |
| 129 | 130 | "test/feed.test.ts", |
| 130 | 131 | "test/feed-page.test.ts", |
| @@ -256,12 +257,6 @@ export const EXCLUDED: readonly Exclusion[] = [ | ||
| 256 | 257 | reason: "not yet admitted", |
| 257 | 258 | issue: 261, |
| 258 | 259 | }, |
| 259 | { | |
| 260 | file: "test/plan-route.test.ts", | |
| 261 | reason: | |
| 262 | "waits up to 30 s on a fake planner run under the crew sandbox, over the 1 s budget; red where the sandbox cannot nest", | |
| 263 | issue: 261, | |
| 264 | }, | |
| 265 | 260 | { file: "test/parity.test.ts", reason: "not yet admitted", issue: 261 }, |
| 266 | 261 | { file: "test/policy.test.ts", reason: "not yet admitted", issue: 261 }, |
| 267 | 262 | { file: "test/probe.test.ts", reason: "not yet admitted", issue: 261 }, |
test/plan-route.test.ts +19 −72
| @@ -1,32 +1,26 @@ | ||
| 1 | 1 | /** |
| 2 | * The planner plans through one route (#1063): `POST …/plan` is the MCP | |
| 3 | * `plan` tool's action. A planning run through the stand-in agent that | |
| 4 | * names no pillar gets `no_pillar` and nothing is opened; the same run with | |
| 5 | * the pillar named opens the issues under the umbrella. | |
| 2 | * Invariant: `POST …/plan` (the MCP `plan` tool's action, #1063) refuses an | |
| 3 | * issue with no pillar and opens nothing, and opens the issues under the | |
| 4 | * umbrella once the pillar is named. No crew run: the route is the unit. | |
| 6 | 5 | */ |
| 7 | 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs"; | |
| 9 | 7 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 8 | import { tmpdir } from "node:os"; |
| 11 | 9 | import { join } from "node:path"; |
| 12 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 13 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 14 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; | |
| 11 | import { openIssue } from "../src/actions/issues.ts"; | |
| 15 | 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 16 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 17 | 13 | import { listIssues } from "../src/core/issues.ts"; |
| 18 | 14 | import { pillarsOf } from "../src/core/pillars.ts"; |
| 19 | 15 | import type { Repo } from "../src/core/repos.ts"; |
| 20 | 16 | import { createToken } from "../src/core/tokens.ts"; |
| 21 | 17 | import { createUser, type User } from "../src/core/users.ts"; |
| 22 | import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts"; | |
| 23 | 18 | import { serve, type Servers } from "../src/http/server.ts"; |
| 24 | 19 | |
| 25 | 20 | describe("POST …/plan", () => { |
| 26 | 21 | let dataDir = ""; |
| 27 | 22 | let app: App; |
| 28 | 23 | let servers: Servers; |
| 29 | let crew: CrewHandle; | |
| 30 | 24 | let alice: User; |
| 31 | 25 | let repo: Repo; |
| 32 | 26 | let rw = ""; |
| @@ -43,33 +37,6 @@ describe("POST …/plan", () => { | ||
| 43 | 37 | dataDir = await mkdtemp(join(tmpdir(), "forge-planroute-")); |
| 44 | 38 | app = createApp({ dataDir, port: 0 }); |
| 45 | 39 | servers = await serve(app); |
| 46 | // The stand-in planner: takes the route from its brief, posts the plan | |
| 47 | // in plan1.json and then the one in plan2.json, and keeps the answers. | |
| 48 | const fake = join(dataDir, "fake-claude"); | |
| 49 | writeFileSync( | |
| 50 | fake, | |
| 51 | `#!/bin/sh | |
| 52 | if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi | |
| 53 | IFS= read -r first; printf '%s\\n' "$first" > "${dataDir}/prompt" | |
| 54 | echo '{"type":"system","subtype":"init","session_id":"p1","model":"fake"}' | |
| 55 | if grep -q "this run is a planning run" "${dataDir}/prompt"; then | |
| 56 | path=$(grep -o 'api/v1/repos/[^"\\\\ ]*/plan' "${dataDir}/prompt" | head -1) | |
| 57 | for n in 1 2; do | |
| 58 | curl -sS -X POST -H "authorization: Bearer $FORGE_TOKEN" -H "content-type: application/json" "\${FORGE_URL%/}/$path" -d @"${dataDir}/plan$n.json" > "${dataDir}/out$n" | |
| 59 | done | |
| 60 | fi | |
| 61 | echo '{"type":"result","subtype":"success","is_error":false,"result":"Planned.","total_cost_usd":0.01,"num_turns":1}' | |
| 62 | `, | |
| 63 | ); | |
| 64 | chmodSync(fake, 0o755); | |
| 65 | crew = await startCrew(app, { | |
| 66 | forgeUrl: servers.url, | |
| 67 | localUrl: servers.url, | |
| 68 | binary: fake, | |
| 69 | concurrency: 1, | |
| 70 | budgetUsd: 1, | |
| 71 | runTimeoutMs: 60_000, | |
| 72 | }); | |
| 73 | 40 | const a = await createUser(app.db, { |
| 74 | 41 | trusted: true, |
| 75 | 42 | username: "alice", |
| @@ -80,8 +47,6 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Planned.", | ||
| 80 | 47 | if (!a.ok) throw new Error(a.error); |
| 81 | 48 | alice = a.value; |
| 82 | 49 | rw = createToken(app.db, alice.id, "rw", "rw").token; |
| 83 | const made = await createAgent(app, alice, { username: "atlas", brief: "Plan." }); | |
| 84 | if (!made.ok) throw new Error(String(made.error)); | |
| 85 | 50 | const created = await createRepo(app, alice, { name: "garden", visibility: "private" }); |
| 86 | 51 | if (!created.ok) throw new Error(String(created.error)); |
| 87 | 52 | repo = created.value; |
| @@ -90,57 +55,39 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Planned.", | ||
| 90 | 55 | files: [{ path: "README.md", content: "# garden\n" }], |
| 91 | 56 | }); |
| 92 | 57 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); |
| 93 | expect(addToCrew(app, alice, repo, "atlas").ok).toBe(true); | |
| 94 | setCrewRole(app.db, repo.id, made.value.user.id, "planner"); | |
| 95 | 58 | const umbrella = openIssue(app, alice, repo, { title: "P0 Trust: be trusted", body: "" }); |
| 96 | 59 | if (!umbrella.ok) throw new Error(String(umbrella.error)); |
| 97 | 60 | expect(pillarsOf(app.db, repo).pillars.length).toBe(1); |
| 98 | ||
| 99 | writeFileSync( | |
| 100 | join(dataDir, "plan1.json"), | |
| 101 | JSON.stringify({ issues: [{ key: "a", title: "Water the beds" }] }), | |
| 102 | ); | |
| 103 | writeFileSync( | |
| 104 | join(dataDir, "plan2.json"), | |
| 105 | JSON.stringify({ | |
| 106 | issues: [ | |
| 107 | { key: "a", title: "Water the beds", pillar: "P0" }, | |
| 108 | { key: "b", title: "Buy a hose", later: true, why: "Nothing needs it yet." }, | |
| 109 | ], | |
| 110 | }), | |
| 111 | ); | |
| 112 | const direction = openIssue(app, alice, repo, { title: "Direction: tend the garden", body: "" }); | |
| 113 | if (!direction.ok) throw new Error(String(direction.error)); | |
| 114 | 61 | const gate = openIssue(app, alice, repo, { title: "Later gate: not yet", body: "" }); |
| 115 | 62 | if (!gate.ok) throw new Error(String(gate.error)); |
| 116 | // Planning run: the stand-in posts the refused plan, then the good one. | |
| 117 | expect(assignIssue(app, alice, repo, direction.value, made.value.user).ok).toBe(true); | |
| 118 | }, 30_000); | |
| 63 | }); | |
| 119 | 64 | |
| 120 | 65 | afterAll(async () => { |
| 121 | await crew.stop(); | |
| 122 | 66 | servers.stop(); |
| 123 | 67 | app.close(); |
| 124 | 68 | await rm(dataDir, { recursive: true, force: true }); |
| 125 | 69 | }); |
| 126 | 70 | |
| 127 | test("the planner's run is refused with no_pillar, then opens the issues under the umbrella", async () => { | |
| 128 | const deadline = Date.now() + 30_000; | |
| 129 | while (!existsSync(join(dataDir, "out2")) && Date.now() < deadline) await Bun.sleep(100); | |
| 130 | const first = JSON.parse(readFileSync(join(dataDir, "out1"), "utf8")); | |
| 131 | expect(first.error).toBe("no_pillar"); | |
| 132 | const second = JSON.parse(readFileSync(join(dataDir, "out2"), "utf8")); | |
| 133 | expect(second.opened.map((o: { key: string }) => o.key)).toEqual(["a", "b"]); | |
| 71 | test("a plan with a pillarless issue is refused with no_pillar and opens nothing; with the pillar named it opens the issues under the umbrella", async () => { | |
| 72 | const before = titles().length; | |
| 73 | const first = await post(rw, [{ key: "a", title: "Water the beds" }]); | |
| 74 | expect(((await first.json()) as { error: string }).error).toBe("no_pillar"); | |
| 75 | expect(titles().length).toBe(before); | |
| 76 | const res = await post(rw, [ | |
| 77 | { key: "a", title: "Water the beds", pillar: "P0" }, | |
| 78 | { key: "b", title: "Buy a hose", later: true, why: "Nothing needs it yet." }, | |
| 79 | ]); | |
| 80 | const second = (await res.json()) as { opened: { key: string; number: number }[]; later: unknown[] }; | |
| 81 | expect(second.opened.map((o) => o.key)).toEqual(["a", "b"]); | |
| 134 | 82 | expect(titles().filter((t) => t === "Water the beds").length).toBe(1); |
| 135 | 83 | expect(second.later.length).toBe(1); |
| 136 | 84 | const trust = pillarsOf(app.db, repo).pillars[0]!; |
| 137 | const served = second.opened[0].number; | |
| 138 | 85 | expect( |
| 139 | 86 | app.db |
| 140 | .query("SELECT 1 FROM issue_dependency WHERE issue_id = ?1 AND blocker_id = (SELECT id FROM issue WHERE repo_id = ?2 AND number = ?3)") | |
| 141 | .get(trust.umbrella.id, repo.id, served), | |
| 87 | .query("SELECT 1 FROM issue_dependency WHERE issue_id = ?1 AND blocked_by_id = (SELECT id FROM issue WHERE repo_id = ?2 AND number = ?3)") | |
| 88 | .get(trust.umbrella.id, repo.id, second.opened[0]!.number), | |
| 142 | 89 | ).not.toBeNull(); |
| 143 | }, 40_000); | |
| 90 | }); | |
| 144 | 91 | |
| 145 | 92 | test("a person's token plans the same way, all or nothing, and a read token is refused", async () => { |
| 146 | 93 | const before = titles().length; |
new above
offline