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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1438-fast-test-plan-route-test-ts-the-planner → main 3 commits fork

  • Merged. scout/1438-fast-test-plan-route-test-ts-the-planner was merged into main as b9a655b.

Review

Sign in to review this change.
2 files changed +20 −78
scripts/gate.ts +1 −6
@@ -125,6 +125,7 @@ export const GATE: readonly string[] = [
125125 "test/asks-expiry.test.ts",
126126 "test/asks-rank.test.ts",
127127 "test/plan-pillars.test.ts",
128 "test/plan-route.test.ts",
128129 "test/planner-declined.test.ts",
129130 "test/feed.test.ts",
130131 "test/feed-page.test.ts",
@@ -256,12 +257,6 @@ export const EXCLUDED: readonly Exclusion[] = [
256257 reason: "not yet admitted",
257258 issue: 261,
258259 },
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 },
265260 { file: "test/parity.test.ts", reason: "not yet admitted", issue: 261 },
266261 { file: "test/policy.test.ts", reason: "not yet admitted", issue: 261 },
267262 { file: "test/probe.test.ts", reason: "not yet admitted", issue: 261 },
test/plan-route.test.ts +19 −72
@@ -1,32 +1,26 @@
11 /**
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.
65 */
76 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
97 import { mkdtemp, rm } from "node:fs/promises";
108 import { tmpdir } from "node:os";
119 import { join } from "node:path";
1210 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";
1512 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
16 import { setCrewRole } from "../src/core/agents.ts";
1713 import { listIssues } from "../src/core/issues.ts";
1814 import { pillarsOf } from "../src/core/pillars.ts";
1915 import type { Repo } from "../src/core/repos.ts";
2016 import { createToken } from "../src/core/tokens.ts";
2117 import { createUser, type User } from "../src/core/users.ts";
22 import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts";
2318 import { serve, type Servers } from "../src/http/server.ts";
2419
2520 describe("POST …/plan", () => {
2621 let dataDir = "";
2722 let app: App;
2823 let servers: Servers;
29 let crew: CrewHandle;
3024 let alice: User;
3125 let repo: Repo;
3226 let rw = "";
@@ -43,33 +37,6 @@ describe("POST …/plan", () => {
4337 dataDir = await mkdtemp(join(tmpdir(), "forge-planroute-"));
4438 app = createApp({ dataDir, port: 0 });
4539 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 });
7340 const a = await createUser(app.db, {
7441 trusted: true,
7542 username: "alice",
@@ -80,8 +47,6 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Planned.",
8047 if (!a.ok) throw new Error(a.error);
8148 alice = a.value;
8249 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));
8550 const created = await createRepo(app, alice, { name: "garden", visibility: "private" });
8651 if (!created.ok) throw new Error(String(created.error));
8752 repo = created.value;
@@ -90,57 +55,39 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Planned.",
9055 files: [{ path: "README.md", content: "# garden\n" }],
9156 });
9257 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");
9558 const umbrella = openIssue(app, alice, repo, { title: "P0 Trust: be trusted", body: "" });
9659 if (!umbrella.ok) throw new Error(String(umbrella.error));
9760 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));
11461 const gate = openIssue(app, alice, repo, { title: "Later gate: not yet", body: "" });
11562 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 });
11964
12065 afterAll(async () => {
121 await crew.stop();
12266 servers.stop();
12367 app.close();
12468 await rm(dataDir, { recursive: true, force: true });
12569 });
12670
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"]);
13482 expect(titles().filter((t) => t === "Water the beds").length).toBe(1);
13583 expect(second.later.length).toBe(1);
13684 const trust = pillarsOf(app.db, repo).pillars[0]!;
137 const served = second.opened[0].number;
13885 expect(
13986 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),
14289 ).not.toBeNull();
143 }, 40_000);
90 });
14491
14592 test("a person's token plans the same way, all or nothing, and a read token is refused", async () => {
14693 const before = titles().length;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs