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

Telling the forge from the composer, the toolbar, the CLI and the MCP makes the same issue #12424

Merged opened this

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

Merged

scout/forge:scout/12404-telling-the-forge-from-the-composer-the → main 5 commits fork

  • Merged. scout/12404-telling-the-forge-from-the-composer-the was merged into main as 9afb56b.

Review

Sign in to review this change.
6 files changed +374 −5
docs/FEED.md +1 −1
@@ -107,7 +107,7 @@ The `choose` card comes from `src/feed/choose.ts`, a run card offers Stop
107107 error, 5xx, 408, 429 or 401 keeps them in order.
108108 - Browser sessions now honour `Idempotency-Key` on `/api/v1/` mutations, scoped
109109 to the person (as tokens' are to the token): a replay returns the first
110 answer. `POST /api/v1/feed/tells { app, text }` is the JSON tell the outbox
110 answer. `POST /api/v1/feed/tells { app, text }` (answers `{ issue, says }`) is the JSON tell the outbox
111111 sends. `from` (optional) is the page the tell was told from: a path on this
112112 forge, at most 512 characters; anything else is a 400 `invalid_from` and
113113 opens nothing. The issue reads "Told from <link>". `variants` (an integer, 1 to 4; anything else is a 400 and opens
src/cli/api.ts +7 −3
@@ -466,8 +466,8 @@ export class ForgeApi {
466466 name: string,
467467 text: string,
468468 more: { variants?: number | undefined; from?: string | undefined } = {},
469 ): Promise<ApiResult<{ issue: number; url: string }>> {
470 const out = await this.call<{ issue?: unknown }>("/api/v1/feed/tells", {
469 ): Promise<ApiResult<{ issue: number; url: string; says: string }>> {
470 const out = await this.call<{ issue?: unknown; says?: unknown }>("/api/v1/feed/tells", {
471471 method: "POST",
472472 body: JSON.stringify({
473473 app: `${owner}/${name}`,
@@ -484,7 +484,11 @@ export class ForgeApi {
484484 message: "the forge did not say which issue the tell became",
485485 status: 0,
486486 });
487 return ok({ issue, url: `${this.webUrl(owner, name)}/issues/${issue}` });
487 return ok({
488 issue,
489 url: `${this.webUrl(owner, name)}/issues/${issue}`,
490 says: typeof out.value.says === "string" ? out.value.says : "",
491 });
488492 }
489493
490494 /** Say something to the forge's own agent (`POST /api/v1/conversation/messages`). */
src/cli/commands/tell.ts +2
@@ -58,6 +58,8 @@ async function tell(out: Out, args: Args): Promise<number> {
5858 out.err(`forge tell: ${inert(told.error.message)}`);
5959 return told.error.status === 401 ? EXIT.notConfigured : EXIT.failed;
6060 }
61 // The receipt is the sentence every door says (`toldWords`), from the server.
62 if (told.value.says !== "") out.line(inert(told.value.says));
6163 out.line(
6264 `told ${ref.owner}/${ref.name}: issue #${told.value.issue} ${told.value.url}`,
6365 );
src/contract/openapi/routes/postApiV1FeedTells.ts +1 −1
@@ -3,7 +3,7 @@ import type { Route } from "../shared.ts";
33 export const route: Route = {
44 method: "post",
55 path: "/api/v1/feed/tells",
6 summary: "Tell an app's agents what you want, from a card or any harness (rw scope); `app` is owner/name, `text` 1 to 2000 characters, `variants` 1 to 4 tries (default 1), `from` the forge path the tell was sent from (a leading single /, at most 512 characters, no spaces); answers 201 with { issue } and, for variants, { issue, issues, assigned, variantGroup }",
6 summary: "Tell an app's agents what you want, from a card or any harness (rw scope); `app` is owner/name, `text` 1 to 2000 characters, `variants` 1 to 4 tries (default 1), `from` the forge path the tell was sent from (a leading single /, at most 512 characters, no spaces); answers 201 with { issue, says } (says: the receipt sentence every door uses) and, for variants, also { issues, assigned, variantGroup }",
77 auth: true,
88 role: "member",
99 body: { app: "string", text: "string", variants: "integer", from: "string" },
src/http/feed.ts +2
@@ -5,6 +5,7 @@
55
66 import {
77 parseVariants,
8 toldWords,
89 FROM_WORDS,
910 tell,
1011 UNVERIFIED_WORDS,
@@ -341,6 +342,7 @@ export function feedRoutes(router: Router<Handler>): void {
341342 return json(
342343 {
343344 issue: told.value.issue.number,
345 says: toldWords(told.value),
344346 ...(told.value.variantGroup === null
345347 ? {}
346348 : {
test/tell-doors-parity.test.ts added +361
@@ -0,0 +1,361 @@
1 /**
2 * One sentence through every door that tells the crew leaves the same issue
3 * row and the same receipt: the feed composer's form post, the toolbar's note,
4 * the feed's JSON tell, `forge tell` (which posts it), the SSH `tell` command
5 * and the MCP `tell` tool. What may differ is only where it came from or how
6 * it is framed, and is named in ALLOWED. The share to an agent is held to the
7 * same kind and routing, wrapped in "Shared by …" on purpose.
8 *
9 * The SSH door is driven in process with a fake session (the sandbox binds no
10 * socket, and `serveTell` is the whole of that door past the transport). It
11 * refuses control characters before anything runs, so a multi-line sentence
12 * cannot go through it; that is asserted, not skipped.
13 *
14 * Every test makes its own rows (`tellAll`), so each passes alone and in any order.
15 */
16
17 import { afterAll, beforeAll, expect, test } from "bun:test";
18 import { mkdtemp, rm } from "node:fs/promises";
19 import { tmpdir } from "node:os";
20 import { join } from "node:path";
21 import { addToCrew, createAgent } from "../src/actions/crew.ts";
22 import { saveCrewKey } from "../src/actions/crew_key.ts";
23 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
24 import { createApp, type App } from "../src/app.ts";
25 import { getIssue, listIssues, type Issue } from "../src/core/issues.ts";
26 import type { Repo } from "../src/core/repos.ts";
27 import { createToken } from "../src/core/tokens.ts";
28 import { createUser, type User } from "../src/core/users.ts";
29 import { share } from "../src/feed/share.ts";
30 import { serve, type Servers } from "../src/http/server.ts";
31 import type { SshSession } from "../src/ssh/channels.ts";
32 import { serveTell } from "../src/tui/ssh-tell.ts";
33 import { FLASH_COOKIE } from "../src/web/flash.ts";
34 import { TELL_TITLE_MAX } from "../src/actions/tell.ts";
35 import { quietLogs } from "./helpers/globals.ts";
36 import { mcpTool } from "./helpers/mcp.ts";
37 import { proveAll } from "./helpers/verified.ts";
38
39 quietLogs();
40
41 /** The only things a door may add: where the words were said. */
42 const ALLOWED = {
43 /** The toolbar appends the page (and pull) it was said on to the body. */
44 toolbar: "a `Said on …` context block after the words",
45 } as const;
46
47 /** The composer's flash adds a pointer to the inline progress after the shared sentence. */
48 const COMPOSER_FLASH_SUFFIX = " Watch it work below.";
49
50 const CLI = new URL("../src/cli/main.ts", import.meta.url).pathname;
51 const SAID = "make the header smaller";
52
53 let dir: string;
54 let app: App;
55 let servers: Servers;
56 let repo: Repo;
57 let token: string;
58 let alice: string;
59 let aliceUser: User;
60
61 beforeAll(async () => {
62 dir = await mkdtemp(join(tmpdir(), "forge-doors-"));
63 app = createApp({ dataDir: dir, port: 0, host: "forge.test", forgeRepo: "alice/shop", url: "https://forge.test" });
64 servers = await serve(app);
65 const u = await createUser(app.db, {
66 origin: "seed",
67 trusted: true,
68 username: "alice",
69 email: "[email protected]",
70 password: "a-good-passphrase",
71 adminIfFirst: true,
72 });
73 if (!u.ok) throw new Error(u.error);
74 alice = u.value.id;
75 aliceUser = u.value;
76 proveAll(app);
77 const agent = await createAgent(app, u.value, { username: "bot", brief: "Be brief." });
78 if (!agent.ok) throw new Error(String(agent.error));
79 const made = await createRepo(app, u.value, { name: "shop", visibility: "public" });
80 if (!made.ok) throw new Error(String(made.error));
81 repo = made.value;
82 const seeded = await commitToBranch(app, u.value, repo, {
83 message: "Start",
84 files: [{ path: "README.md", content: "# shop\n" }],
85 });
86 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
87 // The composer says the sentence only when the crew can run; with no key it says so instead.
88 expect((await saveCrewKey(app, u.value, "sk-ant-api03-doorskey")).ok).toBe(true);
89 expect(addToCrew(app, u.value, repo, "bot").ok).toBe(true);
90 token = createToken(app.db, alice, "doors", "rw").token;
91 }, 120_000);
92
93 afterAll(async () => {
94 await servers.stop();
95 app.close();
96 await rm(dir, { recursive: true, force: true });
97 });
98
99 const headers = () => ({ authorization: `Bearer ${token}` });
100
101 /** The issue a door opened: the newest one, after the door answered. */
102 function newest(before: number): Issue {
103 const fresh = listIssues(app.db, repo.id, {}).filter((i) => i.number > before);
104 expect(fresh.length).toBe(1);
105 return getIssue(app.db, repo.id, fresh[0]!.number)!;
106 }
107 const last = () => Math.max(0, ...listIssues(app.db, repo.id, {}).map((i) => i.number));
108
109 const assigneeOf = (i: Issue): string | null =>
110 app.db
111 .query<{ assignee_id: string | null }, [string]>("SELECT assignee_id FROM issue WHERE id = ?1")
112 .get(i.id)!.assignee_id;
113
114 /** What a door answered: the sentence it says back, as plain text. */
115 type Door = {
116 name: string;
117 /** False where the door refuses a control character (a newline) in the words. */
118 lines: boolean;
119 say: (text: string) => Promise<string>;
120 };
121
122 const unescape = (h: string): string =>
123 h.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
124
125 /** `forge tell` with the words as one argument. */
126 function runCli(text: string): Promise<{ code: number; out: string }> {
127 const cwd = join(dir, "clone");
128 Bun.spawnSync(["git", "init", "-q", cwd]);
129 Bun.spawnSync(["git", "remote", "add", "origin", `${servers.url}/alice/shop.git`], { cwd });
130 const proc = Bun.spawn(["bun", CLI, "tell", text], {
131 cwd,
132 env: {
133 PATH: process.env["PATH"] ?? "",
134 HOME: process.env["HOME"] ?? "",
135 FORGE_URL: servers.url,
136 FORGE_TOKEN: token,
137 FORGE_CONFIG_DIR: dir,
138 FORGE_KEYCHAIN: "0",
139 NO_COLOR: "1",
140 },
141 stdout: "pipe",
142 stderr: "pipe",
143 });
144 return (async () => {
145 const out = await new Response(proc.stdout).text();
146 return { code: await proc.exited, out };
147 })();
148 }
149
150 /** The SSH `tell` command, past the transport: a fake session for alice's key. */
151 async function runSsh(text: string): Promise<{ code: number | undefined; out: string; err: string }> {
152 let out = "";
153 let err = "";
154 let code: number | undefined;
155 const noop = (): void => {};
156 const session: SshSession = {
157 key: { algo: "ssh-ed25519", blob: new Uint8Array(), fingerprint: "SHA256:test", line: "ssh-ed25519 AAAA" },
158 username: "alice",
159 remoteAddress: "127.0.0.1",
160 kind: "exec",
161 command: undefined,
162 term: undefined,
163 env: {},
164 cols: 0,
165 rows: 0,
166 widthPx: 0,
167 heightPx: 0,
168 write: (d) => void (out += typeof d === "string" ? d : Buffer.from(d).toString()),
169 writeStderr: (d) => void (err += typeof d === "string" ? d : Buffer.from(d).toString()),
170 onData: noop,
171 onResize: noop,
172 onEof: noop,
173 onClose: noop,
174 ended: false,
175 exit: (c) => void (code ??= c),
176 pending: 0,
177 drain: async () => {},
178 };
179 await serveTell(app, session, aliceUser, text);
180 return { code, out, err };
181 }
182
183 const doors: Door[] = [
184 {
185 name: "composer",
186 lines: true,
187 say: async (text) => {
188 const res = await fetch(`${servers.url}/tell`, {
189 method: "POST",
190 headers: headers(),
191 body: new URLSearchParams({ app: "alice/shop", text }),
192 redirect: "manual",
193 });
194 expect(res.status).toBeLessThan(400);
195 const set = res.headers.getSetCookie().find((c) => c.startsWith(`${FLASH_COOKIE}=`));
196 expect(set).toBeDefined();
197 const raw = set!.slice(FLASH_COOKIE.length + 1).split(";")[0]!;
198 return (JSON.parse(decodeURIComponent(raw)) as { message: string }).message;
199 },
200 },
201 {
202 name: "toolbar",
203 lines: true,
204 say: async (text) => {
205 const res = await fetch(`${servers.url}/alice/shop/tell`, {
206 method: "POST",
207 headers: headers(),
208 body: new URLSearchParams({ text, page: "http://shop.test/cart" }),
209 redirect: "manual",
210 });
211 expect(res.status).toBe(200);
212 const html = await res.text();
213 return unescape(/<p>([^<]*is on it[^<]*)<\/p>/.exec(html)?.[1] ?? "");
214 },
215 },
216 {
217 name: "feed api",
218 lines: true,
219 say: async (text) => {
220 const res = await fetch(`${servers.url}/api/v1/feed/tells`, {
221 method: "POST",
222 headers: { ...headers(), "content-type": "application/json" },
223 body: JSON.stringify({ app: "alice/shop", text }),
224 });
225 expect(res.status).toBe(201);
226 return ((await res.json()) as { says: string }).says;
227 },
228 },
229 {
230 name: "forge tell",
231 lines: true,
232 say: async (text) => {
233 const r = await runCli(text);
234 expect(r.code).toBe(0);
235 return r.out;
236 },
237 },
238 {
239 name: "ssh tell",
240 lines: false,
241 say: async (text) => {
242 const r = await runSsh(text);
243 expect(r.err).toBe("");
244 expect(r.code).toBe(0);
245 return r.out;
246 },
247 },
248 {
249 name: "mcp",
250 lines: true,
251 say: async (text) => {
252 const res = await mcpTool(servers.url, token, "tell", { repo: "alice/shop", text });
253 expect(res.error).toBeUndefined();
254 return res.value.says as string;
255 },
256 },
257 ];
258
259 type Told = { row: Issue; says: string };
260
261 /** One sentence through every door that takes it; each door's row and receipt. */
262 async function tellAll(text: string): Promise<Record<string, Told>> {
263 const out: Record<string, Told> = {};
264 for (const door of doors) {
265 if (!door.lines && text.includes("\n")) continue;
266 const before = last();
267 const says = await door.say(text);
268 out[door.name] = { row: newest(before), says };
269 }
270 return out;
271 }
272
273 /** The part of a row every door must agree on. */
274 const shape = (i: Issue) => ({
275 title: i.title,
276 kind: i.kind,
277 state: i.state,
278 author: i.authorId,
279 assignee: assigneeOf(i),
280 });
281
282 function expectSameRow(told: Record<string, Told>, title: string): void {
283 const base = told["composer"]!.row;
284 expect(base.title).toBe(title);
285 expect(base.kind).toBe("issue");
286 expect(base.state).toBe("open");
287 for (const [name, { row }] of Object.entries(told)) {
288 expect(shape(row), name).toEqual(shape(base));
289 if (name in ALLOWED) {
290 expect(row.body.startsWith(base.body), name).toBe(true);
291 expect(row.body.length, name).toBeGreaterThan(base.body.length);
292 } else expect(row.body, name).toBe(base.body);
293 }
294 }
295
296 test("one sentence through every door leaves the same issue row", async () => {
297 const told = await tellAll(SAID);
298 expect(Object.keys(told).length).toBe(doors.length);
299 expectSameRow(told, SAID);
300 });
301
302 test("a tell is routed to the crew the same way at every door", async () => {
303 const told = await tellAll("move the cart link");
304 const bot = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'bot'").get()!.id;
305 for (const [name, { row }] of Object.entries(told)) expect(assigneeOf(row), name).toBe(bot);
306 });
307
308 test("a long first line is cut to the same title, whole in the body, at every door", async () => {
309 const long = `${"restyle the checkout page so every button is bigger ".repeat(3)}end`;
310 const told = await tellAll(long);
311 const title = told["composer"]!.row.title;
312 expect(title.length).toBeLessThanOrEqual(TELL_TITLE_MAX);
313 expect(title.length).toBeGreaterThan(TELL_TITLE_MAX - 5);
314 expect(title.endsWith("…")).toBe(true);
315 expectSameRow(told, title);
316 expect(told["composer"]!.row.body).toBe(long);
317 });
318
319 test("a multi-line sentence is titled by its first line, the rest in the body, at every door that takes lines", async () => {
320 const told = await tellAll("fix the footer\nit overlaps the cart on phones\n\nand the logo is blurry");
321 expect(Object.keys(told).sort()).toEqual(doors.filter((d) => d.lines).map((d) => d.name).sort());
322 expectSameRow(told, "fix the footer");
323 expect(told["composer"]!.row.body).toBe("it overlaps the cart on phones\n\nand the logo is blurry");
324 });
325
326 test("the ssh door refuses a newline before anything opens", async () => {
327 const before = last();
328 const r = await runSsh("fix the footer\nplease");
329 expect(r.code).toBe(1);
330 expect(r.out).toBe("");
331 expect(last()).toBe(before);
332 });
333
334 test("the receipt says the same words at every door", async () => {
335 const told = await tellAll("the receipt please");
336 const sentence = told["mcp"]!.says;
337 expect(sentence).toMatch(/^bot is on it — /);
338 for (const [name, { says }] of Object.entries(told)) {
339 if (name === "composer") expect(says, name).toBe(`${sentence}${COMPOSER_FLASH_SUFFIX}`);
340 // The toolbar's context block makes the text longer, so the crew may size it up a step:
341 // same words, its own size.
342 else if (name in ALLOWED) expect(says, name).toMatch(/^bot is on it — \w+ \w+\.$/);
343 else expect(says, name).toContain(sentence);
344 }
345 });
346
347 test("a share to the agent opens the same kind of issue, routed the same way, wrapped in Shared by", async () => {
348 const plain = (await tellAll("a sentence to compare with"))["mcp"]!.row;
349 const ev = app.bus.append({
350 kind: "run.finished",
351 repoId: repo.id,
352 actorId: alice,
353 payload: { kind: "deploy", state: "ok", summary: "run 1", runId: "r1" },
354 });
355 const before = last();
356 const shared = await share(app, aliceUser, `event:${ev.id}`, ["bot"]);
357 expect(shared.ok && shared.value.sent).toEqual([{ to: "bot", outcome: "told" }]);
358 const row = newest(before);
359 expect(row.title.startsWith("Shared by alice with bot: ")).toBe(true);
360 expect({ ...shape(row), title: "" }).toEqual({ ...shape(plain), title: "" });
361 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs