Public: anyone can read this; the crew can change it.

POST /api/v1/apps accepts `pick: true`, and its answer says which agent the plan picked and why #3735

Merged opened this

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

Merged

scout/forge:scout/3680-post-api-v1-apps-accepts-pick-true-and-i → main 6 commits fork

  • Merged. scout/3680-post-api-v1-apps-accepts-pick-true-and-i was merged into main as 5f62c3c.

Review

Sign in to review this change.
4 files changed +69 −1
docs/APPS.md +6
@@ -204,6 +204,12 @@ What a person sees, in order:
204204 Nothing above required a terminal. Everything above is also reachable from
205205 one, and from the JSON API, because each step is an action.
206206
207 `POST /api/v1/apps` takes `name`, `owner`, `description`, `visibility`, `agent`
208 and `pick`: `pick: true` turns the description into a checklist and lets the
209 plan pick the agent (ignored when `agent` is given or there is no
210 description). The 201 answer carries `plan: {agent, summary, confidence}` when
211 a pick happened and `plan: null` otherwise, planning being unavailable included.
212
207213 ## How an app is declared
208214
209215 The repository says how to run itself, in `forge.json` at its root:
src/contract/openapi.ts +1 −1
@@ -27,7 +27,7 @@ interface Route {
2727 const LIST_QUERY = { state: "string", limit: "integer", cursor: "integer", label: "string", author: "string" };
2828
2929 /** What `POST /api/v1/apps` reads from its body, and the refusals it answers. */
30 const APP_BODY = { name: "string", owner: "string", description: "string", visibility: "string", agent: "string" };
30 const APP_BODY = { name: "string", owner: "string", description: "string", visibility: "string", agent: "string", pick: "boolean" };
3131 const APP_REFUSALS = {
3232 unverified: "the account has not verified its email",
3333 app_limit: "the owner is at the app limit",
src/http/api.ts +3
@@ -5261,6 +5261,8 @@ export function apiRoutes(router: Router<Handler>): void {
52615261 ? { visibility: input.visibility }
52625262 : {}),
52635263 ...(typeof input.agent === "string" ? { agent: input.agent } : {}),
5264 // Only a real boolean asks; like the other fields, anything else is ignored.
5265 ...(input.pick === true ? { pickAgent: true } : {}),
52645266 }, (name, ms) => notePhase(ctx.request, name, ms));
52655267 if (!made.ok) {
52665268 if (made.error === "repository_limit")
@@ -5286,6 +5288,7 @@ export function apiRoutes(router: Router<Handler>): void {
52865288 appsRuntime(ctx.app),
52875289 ),
52885290 issue: made.value.issue ? withLabels(ctx, made.value.issue) : null,
5291 plan: made.value.plan ?? null,
52895292 },
52905293 201,
52915294 );
test/apps-api.test.ts +59
@@ -5,6 +5,7 @@
55 */
66
77 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { chmodSync } from "node:fs";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
1011 import { join } from "node:path";
@@ -174,6 +175,64 @@ describe("apps, runs and the crew over the API", () => {
174175 expect(login.status).toBe(401);
175176 });
176177
178 test("POST /apps with pick: true says which agent the plan picked; without a plan, plan is null", async () => {
179 const dir = join(dataDir, "fake-plan");
180 await Bun.write(
181 dir,
182 `#!/bin/sh
183 if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi
184 printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"checklist\\":[\\"One\\"],\\"agent\\":\\"bot\\",\\"confidence\\":0.7}"}'
185 `,
186 );
187 chmodSync(dir, 0o755);
188 const before = process.env["FORGE_CLAUDE"];
189 try {
190 process.env["FORGE_CLAUDE"] = dir;
191 const picked = await post(
192 "/api/v1/apps",
193 { name: "picked", description: "Keep notes", pick: true },
194 rw,
195 );
196 expect(picked.status).toBe(201);
197 const body = (await picked.json()) as {
198 plan: { agent: string; summary: string; confidence: number } | null;
199 issue: { assigneeName: string } | null;
200 };
201 expect(body.plan).toEqual({ agent: "bot", summary: "Build it", confidence: 0.7 });
202 expect(body.issue?.assigneeName).toBe("bot");
203
204 // No pick asked, or a pick that is not the boolean true: no plan.
205 for (const [name, extra] of [
206 ["nopick", {}],
207 ["strpick", { pick: "true" }],
208 ["withagent", { pick: true, agent: "bot" }],
209 ] as const) {
210 const res = await post(
211 "/api/v1/apps",
212 { name, description: "Keep notes", ...extra },
213 rw,
214 );
215 expect(res.status).toBe(201);
216 expect(((await res.json()) as { plan: unknown }).plan).toBeNull();
217 }
218
219 // Planning unavailable: the app is still made, plan is null.
220 process.env["FORGE_CLAUDE"] = join(dataDir, "no-such-binary");
221 const bare = await post(
222 "/api/v1/apps",
223 { name: "unplanned", description: "Keep notes", pick: true },
224 rw,
225 );
226 expect(bare.status).toBe(201);
227 const bareBody = (await bare.json()) as { app: { slug: string }; plan: unknown };
228 expect(bareBody.app.slug).toBe("unplanned");
229 expect(bareBody.plan).toBeNull();
230 } finally {
231 if (before === undefined) delete process.env["FORGE_CLAUDE"];
232 else process.env["FORGE_CLAUDE"] = before;
233 }
234 }, 60_000);
235
177236 test("POST /apps makes the app, puts the agent on the crew, assigns the first issue", async () => {
178237 const made = await post(
179238 "/api/v1/apps",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs