Public: anyone can read this; the crew can change it.
MCP `steer` refuses in the run page's words, not `refused: <code>` #7610
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7598-mcp-steer-refuses-in-the-run-page-s-word → main 4 commits fork
7 files
changed +104
−22
src/actions/steer_words.ts added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | /** | |
| 2 | * What a refused steer says (#7598): one sentence per code `steerRepoRun` | |
| 3 | * returns, so the run page's steer box and MCP `steer` word it the same. | |
| 4 | */ | |
| 5 | import { SAY_SOMETHING_WORDS } from "./tell_words.ts"; | |
| 6 | import { VERIFY_FIRST } from "./verify_words.ts"; | |
| 7 | ||
| 8 | const REFUSED: Readonly<Record<string, string>> = { | |
| 9 | empty: SAY_SOMETHING_WORDS, | |
| 10 | not_agent: "Only an agent's run can be steered.", | |
| 11 | not_found: "There is no such run here.", | |
| 12 | unauthorized: "Sign in to steer a run.", | |
| 13 | unverified: `${VERIFY_FIRST[0]!.toUpperCase()}${VERIFY_FIRST.slice(1)} to steer a run.`, | |
| 14 | forbidden: "You need write access to steer a run.", | |
| 15 | }; | |
| 16 | ||
| 17 | /** The sentence for a refused steer; an unknown code reads as no access. */ | |
| 18 | export function steerRefusedWords(code: string): string { | |
| 19 | return REFUSED[code] ?? REFUSED["forbidden"]!; | |
| 20 | } |
src/http/mcp/tools/steer.ts +2 −7
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { STEER_SAYS, steerRepoRun } from "../../../actions/runs.ts"; |
| 2 | import { steerRefusedWords } from "../../../actions/steer_words.ts"; | |
| 2 | 3 | import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 3 | 4 | |
| 4 | 5 | export const tool: Tool = { |
| @@ -15,13 +16,7 @@ export const tool: Tool = { | ||
| 15 | 16 | const repo = repoArg(ctx, user, args); |
| 16 | 17 | if (isAnswer(repo)) return repo; |
| 17 | 18 | const steered = steerRepoRun(ctx.app, user, repo, str(args["run"]), str(args["text"])); |
| 18 | if (!steered.ok) | |
| 19 | return bad( | |
| 20 | steered.error, | |
| 21 | steered.error === "empty" || steered.error === "not_agent" | |
| 22 | ? "say something, to an agent's run" | |
| 23 | : `refused: ${steered.error}`, | |
| 24 | ); | |
| 19 | if (!steered.ok) return bad(steered.error, steerRefusedWords(steered.error)); | |
| 25 | 20 | return good({ |
| 26 | 21 | says: STEER_SAYS[steered.value], |
| 27 | 22 | outcome: steered.value, |
src/tui/screens/runlist.ts +3 −4
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { STEER_SAYS, steerRepoRun } from "../../actions/runs.ts"; |
| 14 | import { SAY_SOMETHING_WORDS } from "../../actions/tell_words.ts"; | |
| 14 | import { steerRefusedWords } from "../../actions/steer_words.ts"; | |
| 15 | 15 | import { placementWords } from "../../checks/placement.ts"; |
| 16 | 16 | import { getIssuesByIds, type Issue } from "../../core/issues.ts"; |
| 17 | 17 | import { canWrite, type Repo } from "../../core/repos.ts"; |
| @@ -25,7 +25,6 @@ import type { Session } from "../session.ts"; | ||
| 25 | 25 | import { |
| 26 | 26 | ListState, |
| 27 | 27 | drawList, |
| 28 | errorText, | |
| 29 | 28 | hitList, |
| 30 | 29 | metaOf, |
| 31 | 30 | promptScreen, |
| @@ -219,8 +218,8 @@ export function steerPrompt( | ||
| 219 | 218 | onSubmit: (text) => { |
| 220 | 219 | const steered = steerRepoRun(f.app, f.user, repo, run.id, text); |
| 221 | 220 | if (!steered.ok) { |
| 222 | if (steered.error === "empty") return SAY_SOMETHING_WORDS; | |
| 223 | ctx.toast(`Could not steer: ${errorText(steered.error)}.`, "danger"); | |
| 221 | if (steered.error === "empty") return steerRefusedWords("empty"); | |
| 222 | ctx.toast(steerRefusedWords(steered.error), "danger"); | |
| 224 | 223 | return undefined; |
| 225 | 224 | } |
| 226 | 225 | ctx.toast(`${steered.value === "sent" ? "Sent" : "Commented"}. ${STEER_SAYS[steered.value]}`); |
src/web/pages/runs.ts +2 −7
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | * id, so the redirect itself says nothing about what exists. |
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | import { SAY_SOMETHING_WORDS } from "../../actions/tell_words.ts"; | |
| 14 | import { steerRefusedWords } from "../../actions/steer_words.ts"; | |
| 15 | 15 | import { readLooks } from "../../checks/looks.ts"; |
| 16 | 16 | import type { Database } from "bun:sqlite"; |
| 17 | 17 | import { |
| @@ -347,12 +347,7 @@ export async function runSteer(ctx: Ctx): Promise<Response> { | ||
| 347 | 347 | if (steered.error === "not_found") return notFoundPage(ctx); |
| 348 | 348 | return flashTo(ctx, dest, { |
| 349 | 349 | tone: "danger", |
| 350 | message: | |
| 351 | steered.error === "empty" | |
| 352 | ? SAY_SOMETHING_WORDS | |
| 353 | : steered.error === "not_agent" | |
| 354 | ? "Only an agent's run can be steered." | |
| 355 | : "You need write access to steer a run.", | |
| 350 | message: steerRefusedWords(steered.error), | |
| 356 | 351 | }); |
| 357 | 352 | } |
| 358 | 353 | return flashTo(ctx, dest, { |
test/mcp-steer.test.ts +32
| @@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 7 | 7 | import { mkdtemp, rm } from "node:fs/promises"; |
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | import { steerRefusedWords } from "../src/actions/steer_words.ts"; | |
| 10 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 11 | 12 | import { newApp } from "../src/actions/apps.ts"; |
| 12 | 13 | import { openIssue } from "../src/actions/issues.ts"; |
| @@ -106,4 +107,35 @@ describe("MCP steer", () => { | ||
| 106 | 107 | expect(said()).not.toContain("no"); |
| 107 | 108 | expect(said()).not.toContain("reader words"); |
| 108 | 109 | }); |
| 110 | ||
| 111 | test("each refusal is worded as the run page's steer box words it", async () => { | |
| 112 | const said = (r: { error?: string }, code: string) => { | |
| 113 | expect(r.error).toContain(code); | |
| 114 | expect(r.error).toContain(steerRefusedWords(code)); | |
| 115 | }; | |
| 116 | said(await tool(bobRw, { repo: "alice/hello", run: agentRun.id, text: "x" }), "forbidden"); | |
| 117 | said(await tool(aliceRw, { repo: "alice/hello", run: agentRun.id, text: " " }), "empty"); | |
| 118 | said(await tool(aliceRw, { repo: "alice/hello", run: deployRun.id, text: "hi" }), "not_agent"); | |
| 119 | said(await tool(aliceRw, { repo: "alice/hello", run: "nope", text: "hi" }), "not_found"); | |
| 120 | // The run page: the flash it redirects with carries the same sentence. | |
| 121 | for (const [token, code] of [[bobRw, "forbidden"], [aliceRw, "empty"]] as const) { | |
| 122 | const page = await fetch(`${servers.url}/alice/hello/runs/${agentRun.id}/steer`, { | |
| 123 | method: "POST", | |
| 124 | redirect: "manual", | |
| 125 | headers: { | |
| 126 | authorization: `Bearer ${token}`, | |
| 127 | "content-type": "application/x-www-form-urlencoded", | |
| 128 | origin: servers.url, | |
| 129 | }, | |
| 130 | body: new URLSearchParams({ text: code === "empty" ? " " : "x" }), | |
| 131 | }); | |
| 132 | expect(page.status).toBe(303); | |
| 133 | const flash = decodeURIComponent( | |
| 134 | `${page.headers.get("location")} ${page.headers.get("set-cookie")}`.replace(/\+/g, " "), | |
| 135 | ); | |
| 136 | expect(flash).toContain(steerRefusedWords(code)); | |
| 137 | } | |
| 138 | for (const code of ["empty", "not_agent", "not_found", "unauthorized", "unverified", "forbidden"]) | |
| 139 | expect(steerRefusedWords(code)).not.toContain(code); | |
| 140 | }); | |
| 109 | 141 | }); |
test/tell-words.test.ts +18 −2
| @@ -21,10 +21,10 @@ const SENTENCES = { | ||
| 21 | 21 | }; |
| 22 | 22 | |
| 23 | 23 | const DOORS = [ |
| 24 | "src/actions/steer_words.ts", | |
| 24 | 25 | "src/web/studio/actions.ts", |
| 25 | 26 | "src/tui/screens/tell.ts", |
| 26 | 27 | "src/tui/screens/report.ts", |
| 27 | "src/web/pages/runs.ts", | |
| 28 | 28 | "src/web/studio/toolbar.ts", |
| 29 | 29 | "src/web/client.ts", |
| 30 | 30 | ]; |
| @@ -40,7 +40,7 @@ describe("tell words", () => { | ||
| 40 | 40 | test(`${door} types none of them out`, () => { |
| 41 | 41 | const text = read(door); |
| 42 | 42 | for (const sentence of Object.values(SENTENCES)) expect(text).not.toContain(sentence); |
| 43 | expect(text).toContain("actions/tell_words.ts"); | |
| 43 | expect(text).toMatch(/tell_words\.ts/); | |
| 44 | 44 | }); |
| 45 | 45 | } |
| 46 | 46 | |
| @@ -48,4 +48,20 @@ describe("tell words", () => { | ||
| 48 | 48 | const { shellJs } = await import("../src/web/client.ts"); |
| 49 | 49 | expect(shellJs).toContain(`note.textContent = '${NOT_TOLD_WORDS}';`); |
| 50 | 50 | }); |
| 51 | ||
| 52 | const STEER_DOORS = ["src/web/pages/runs.ts", "src/tui/screens/runlist.ts", "src/http/mcp/tools/steer.ts"]; | |
| 53 | for (const door of STEER_DOORS) { | |
| 54 | test(`${door} reads the steer refusals from steer_words.ts, typing none out`, () => { | |
| 55 | const text = read(door); | |
| 56 | for (const sentence of [ | |
| 57 | "Only an agent's run can be steered.", | |
| 58 | "You need write access to steer a run.", | |
| 59 | "Sign in to steer a run.", | |
| 60 | "There is no such run here.", | |
| 61 | ...Object.values(SENTENCES), | |
| 62 | ]) | |
| 63 | expect(text).not.toContain(sentence); | |
| 64 | expect(text).toContain("actions/steer_words.ts"); | |
| 65 | }); | |
| 66 | } | |
| 51 | 67 | }); |
test/tui-steer.test.ts +27 −2
| @@ -3,16 +3,17 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; | ||
| 3 | 3 | import { mkdtemp, rm } from "node:fs/promises"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | import { steerRefusedWords } from "../src/actions/steer_words.ts"; | |
| 6 | 7 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 7 | 8 | import { openIssue } from "../src/actions/issues.ts"; |
| 8 | 9 | import { openPull } from "../src/actions/pulls.ts"; |
| 9 | 10 | import { commitToBranch, createRepo as makeRepo } from "../src/actions/repos.ts"; |
| 10 | 11 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 11 | 12 | import { listComments, type Issue } from "../src/core/issues.ts"; |
| 12 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 13 | import { addTeamMember, createOrg, createTeam, removeTeamMember } from "../src/core/orgs.ts"; | |
| 13 | 14 | import { canWrite, createRepo, type Repo } from "../src/core/repos.ts"; |
| 14 | 15 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| 15 | import type { User } from "../src/core/users.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | 17 | import { listenForSteer } from "../src/crew/runner.ts"; |
| 17 | 18 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 18 | 19 | import { issueScreen } from "../src/tui/screens/issue.ts"; |
| @@ -222,3 +223,27 @@ test("an agent session that may write is not offered steering", async () => { | ||
| 222 | 223 | stop(); |
| 223 | 224 | h.app.stop(); |
| 224 | 225 | }); |
| 226 | ||
| 227 | test("a refusal reads as the run page and MCP read it", async () => { | |
| 228 | const { repo, issue } = await seed(); | |
| 229 | const c = await createUser(forge.db, { | |
| 230 | origin: "seed", | |
| 231 | trusted: true, | |
| 232 | username: "carol", | |
| 233 | email: "[email protected]", | |
| 234 | password: "a-long-passphrase", | |
| 235 | }); | |
| 236 | if (!c.ok) throw new Error("carol failed"); | |
| 237 | const writers = createTeam(forge.db, orgId, "Carol's", "write"); | |
| 238 | if (!writers.ok) throw new Error("team failed"); | |
| 239 | addTeamMember(forge.db, writers.value, c.value.id); | |
| 240 | const h = await open(c.value, (s, ctx) => runsScreen(s, ctx, repo)); | |
| 241 | await h.press("S"); | |
| 242 | await h.type("hello"); | |
| 243 | // Her write access goes after the prompt opened: the action refuses. | |
| 244 | removeTeamMember(forge.db, writers.value, c.value.id); | |
| 245 | const text = await h.press("enter"); | |
| 246 | expect(text).toContain(steerRefusedWords("forbidden")); | |
| 247 | expect(listComments(forge.db, issue.id)).toHaveLength(0); | |
| 248 | h.app.stop(); | |
| 249 | }); |
new above
offline