Public: anyone can read this; the crew can change it.
The terminal's run screen says a word to a running crew run with `s`, as the run page's steer box and MCP `steer` do #7309
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7288-the-terminal-s-run-screen-says-a-word-to → main 5 commits fork 3 threads · 3 unresolved
9 files
changed +345
−14
docs/design/sections/6g-terminal-apps-runs-crew.md +5
| @@ -21,6 +21,11 @@ remains is where the work is reviewed: | ||
| 21 | 21 | the summary. `enter` on one hands over its web page, |
| 22 | 22 | `/:owner/:name/runs/:id`, where the live log, cancel and steer are — as a |
| 23 | 23 | link when the forge knows its public URL, as the path otherwise. |
| 24 | - **Steer** a running agent run from the terminal: `S` on the runs list, `s` | |
| 25 | on an issue's Crew card and on a pull's Crew tab. Offered to a writer who | |
| 26 | is a person, never an agent, on a running agent run only; it sends through | |
| 27 | the same action as the run page's steer box and MCP `steer`, and the toast | |
| 28 | says whether the agent heard it or it became a comment. | |
| 24 | 29 | - **Settings** — the App tab (enable with slug, access and preview data; edit; |
| 25 | 30 | `D` to stop running as an app behind a confirm), `C` for require checks on |
| 26 | 31 | General, the Crew tab (`a` adds an agent from the forge's, `d` removes). |
src/actions/runs.ts +7
| @@ -320,6 +320,13 @@ export type SteerOutcome = | ||
| 320 | 320 | /** No session was listening; it went on the conversation for the next run. */ |
| 321 | 321 | | "commented"; |
| 322 | 322 | |
| 323 | /** What each outcome means, said the same on every surface that steers. */ | |
| 324 | export const STEER_SAYS: Record<SteerOutcome, string> = { | |
| 325 | sent: "The agent hears it at its next step.", | |
| 326 | commented: | |
| 327 | "No session was listening; it went on the conversation for the next run.", | |
| 328 | }; | |
| 329 | ||
| 323 | 330 | /** |
| 324 | 331 | * Say something to an agent mid-run. A running agent hears it as its next |
| 325 | 332 | * user turn. A run that is queued, paused, or over has nobody listening, |
src/crew/runner.ts +14 −1
| @@ -203,6 +203,17 @@ export { crewRoot, workdirFor } from "./workdir.ts"; | ||
| 203 | 203 | /** Runs with an agent listening: what a person says reaches it next. */ |
| 204 | 204 | const steering = new Map<string, (text: string) => boolean>(); |
| 205 | 205 | |
| 206 | /** A session starts listening to a run; the answer stops it. */ | |
| 207 | export function listenForSteer( | |
| 208 | runId: string, | |
| 209 | send: (text: string) => boolean, | |
| 210 | ): () => void { | |
| 211 | steering.set(runId, send); | |
| 212 | return () => { | |
| 213 | if (steering.get(runId) === send) steering.delete(runId); | |
| 214 | }; | |
| 215 | } | |
| 216 | ||
| 206 | 217 | /** |
| 207 | 218 | * Say something to an agent while its run goes. Written to the run's log |
| 208 | 219 | * as the person's line first, so the record holds it whether or not the |
| @@ -1347,7 +1358,9 @@ async function work( | ||
| 1347 | 1358 | timeoutMs: Math.max(deadline - Date.now(), 0), |
| 1348 | 1359 | log: write, |
| 1349 | 1360 | signal, |
| 1350 | onSteer: (send) => steering.set(run.id, send), | |
| 1361 | onSteer: (send) => { | |
| 1362 | listenForSteer(run.id, send); | |
| 1363 | }, | |
| 1351 | 1364 | onUsage: (so) => |
| 1352 | 1365 | meterLive( |
| 1353 | 1366 | app, |
src/http/mcp/tools/steer.ts +2 −5
| @@ -1,4 +1,4 @@ | ||
| 1 | import { steerRepoRun } from "../../../actions/runs.ts"; | |
| 1 | import { STEER_SAYS, steerRepoRun } from "../../../actions/runs.ts"; | |
| 2 | 2 | import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| @@ -23,10 +23,7 @@ export const tool: Tool = { | ||
| 23 | 23 | : `refused: ${steered.error}`, |
| 24 | 24 | ); |
| 25 | 25 | return good({ |
| 26 | says: | |
| 27 | steered.value === "sent" | |
| 28 | ? "The agent hears it at its next step." | |
| 29 | : "No session was listening; it went on the conversation for the next run.", | |
| 26 | says: STEER_SAYS[steered.value], | |
| 30 | 27 | outcome: steered.value, |
| 31 | 28 | }); |
| 32 | 29 | }, |
src/tui/screens/issue.ts +10 −1
| @@ -65,7 +65,7 @@ import { | ||
| 65 | 65 | } from "./common.ts"; |
| 66 | 66 | import { assignPicker, assignedText, queuedText } from "./assign.ts"; |
| 67 | 67 | import { liveBindings, liveKey } from "./mirror.ts"; |
| 68 | import { RunList, openRunLog } from "./runlist.ts"; | |
| 68 | import { RunList, maySteer, openRunLog, steerPrompt } from "./runlist.ts"; | |
| 69 | 69 | import { Composer, threadLines, type ThreadEntry } from "./thread.ts"; |
| 70 | 70 | import { local, localRepo, whereRows, whereStrip } from "./where.ts"; |
| 71 | 71 | |
| @@ -422,6 +422,9 @@ export function issueScreen( | ||
| 422 | 422 | focus === "crew" |
| 423 | 423 | ? binding(["enter"], "open the run") |
| 424 | 424 | : binding(["j", "k"], "scroll"), |
| 425 | ...(focus === "crew" && repo !== undefined && maySteer(f, repo, crew.selected) | |
| 426 | ? [binding(["s"], "steer")] | |
| 427 | : []), | |
| 425 | 428 | ...(mayWrite() |
| 426 | 429 | ? [ |
| 427 | 430 | binding(["c"], "comment"), |
| @@ -606,6 +609,12 @@ export function issueScreen( | ||
| 606 | 609 | openRun(); |
| 607 | 610 | return true; |
| 608 | 611 | } |
| 612 | if (e.key === "s") { | |
| 613 | const run = crew.selected; | |
| 614 | if (run !== undefined && repo !== undefined && maySteer(f, repo, run)) | |
| 615 | steerPrompt(f, ctx, screen, repo, run, () => runAsync(ctx, reload)); | |
| 616 | return true; | |
| 617 | } | |
| 609 | 618 | } |
| 610 | 619 | if (e.key === "a" && mayWrite()) { |
| 611 | 620 | assign(); |
src/tui/screens/pull.ts +15 −3
| @@ -120,7 +120,7 @@ import { FINISH_REFUSALS, finishConflicts } from "../../actions/finish-conflicts | ||
| 120 | 120 | import { PROPOSE_WHY, proposalOf, proposeResolution } from "../../crew/proposal.ts"; |
| 121 | 121 | import { conflictScreen } from "./conflicts.ts"; |
| 122 | 122 | import { reviewScreen } from "./review.ts"; |
| 123 | import { RunList, openRunLog } from "./runlist.ts"; | |
| 123 | import { RunList, maySteer, openRunLog, steerPrompt } from "./runlist.ts"; | |
| 124 | 124 | import { runTone } from "./runwords.ts"; |
| 125 | 125 | import { Composer, threadLines, type ThreadEntry } from "./thread.ts"; |
| 126 | 126 | import { local, localRepo, whereRows, whereStrip } from "./where.ts"; |
| @@ -826,7 +826,13 @@ export function pullScreen( | ||
| 826 | 826 | ...(tab === 1 |
| 827 | 827 | ? view.keys |
| 828 | 828 | : tab === 2 |
| 829 | ? [binding(["j", "k"], "move"), binding(["enter"], "open the run")] | |
| 829 | ? [ | |
| 830 | binding(["j", "k"], "move"), | |
| 831 | binding(["enter"], "open the run"), | |
| 832 | ...(repo !== undefined && maySteer(f, repo, crew.selected) | |
| 833 | ? [binding(["s"], "steer")] | |
| 834 | : []), | |
| 835 | ] | |
| 830 | 836 | : [binding(["j", "k"], "scroll")]), |
| 831 | 837 | ...(isApp && preview !== undefined |
| 832 | 838 | ? [binding(["O"], "open the preview")] |
| @@ -1135,7 +1141,7 @@ export function pullScreen( | ||
| 1135 | 1141 | seeConflicts(); |
| 1136 | 1142 | return true; |
| 1137 | 1143 | } |
| 1138 | // The crew tab is a list: the cursor moves, enter opens the run. | |
| 1144 | // The crew tab is a list: the cursor moves, enter opens the run, `s` steers it. | |
| 1139 | 1145 | if (tab === 2) { |
| 1140 | 1146 | if (crew.state.key(e, crew.runs.length)) return true; |
| 1141 | 1147 | if (e.key === "enter") { |
| @@ -1144,6 +1150,12 @@ export function pullScreen( | ||
| 1144 | 1150 | openRunLog(f, ctx, repo, run); |
| 1145 | 1151 | return true; |
| 1146 | 1152 | } |
| 1153 | if (e.key === "s") { | |
| 1154 | const run = crew.selected; | |
| 1155 | if (run !== undefined && repo !== undefined && maySteer(f, repo, run)) | |
| 1156 | steerPrompt(f, ctx, screen, repo, run, () => runAsync(ctx, reload)); | |
| 1157 | return true; | |
| 1158 | } | |
| 1147 | 1159 | } |
| 1148 | 1160 | // A thread the cursor is standing in owns `x` and `enter` before the |
| 1149 | 1161 | // screen does: settling a conversation is the act on offer there, and |
src/tui/screens/runlist.ts +57 −2
| @@ -10,12 +10,14 @@ | ||
| 10 | 10 | * the summary that says what failed. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { STEER_SAYS, steerRepoRun } from "../../actions/runs.ts"; | |
| 14 | import { SAY_SOMETHING_WORDS } from "../../actions/tell_words.ts"; | |
| 13 | 15 | import { placementWords } from "../../checks/placement.ts"; |
| 14 | 16 | import { getIssuesByIds, type Issue } from "../../core/issues.ts"; |
| 15 | import type { Repo } from "../../core/repos.ts"; | |
| 17 | import { canWrite, type Repo } from "../../core/repos.ts"; | |
| 16 | 18 | import type { Run, RunState } from "../../core/runs.ts"; |
| 17 | 19 | import { getUsersByIds } from "../../core/users.ts"; |
| 18 | import type { Ctx } from "../app/app.ts"; | |
| 20 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 19 | 21 | import type { Rect, Screen as Surface } from "../term/screen.ts"; |
| 20 | 22 | import type { Tokens } from "../ui/theme.ts"; |
| 21 | 23 | import { icon, type IconSet } from "../ui/icons.ts"; |
| @@ -23,8 +25,10 @@ import type { Session } from "../session.ts"; | ||
| 23 | 25 | import { |
| 24 | 26 | ListState, |
| 25 | 27 | drawList, |
| 28 | errorText, | |
| 26 | 29 | hitList, |
| 27 | 30 | metaOf, |
| 31 | promptScreen, | |
| 28 | 32 | relTime, |
| 29 | 33 | type ListRow, |
| 30 | 34 | } from "./common.ts"; |
| @@ -175,3 +179,54 @@ export function openRunLog( | ||
| 175 | 179 | ): void { |
| 176 | 180 | ctx.push(runLogScreen(f, ctx, repo, run.id)); |
| 177 | 181 | } |
| 182 | ||
| 183 | /** | |
| 184 | * Whether the viewer may say a word to this run: an agent's run that is | |
| 185 | * running, and a person who may write here. An agent session is never | |
| 186 | * offered it — a run costs money, and steering one is a person's call, as | |
| 187 | * `steerRepoRun` refuses it too. | |
| 188 | */ | |
| 189 | export function maySteer(f: Session, repo: Repo, run: Run | undefined): boolean { | |
| 190 | return ( | |
| 191 | run !== undefined && | |
| 192 | run.kind === "agent" && | |
| 193 | run.state === "running" && | |
| 194 | f.user !== undefined && | |
| 195 | f.user.kind !== "agent" && | |
| 196 | canWrite(f.db, repo, f.user) | |
| 197 | ); | |
| 198 | } | |
| 199 | ||
| 200 | /** | |
| 201 | * Steer: a one-line composer whose enter sends the words through | |
| 202 | * `steerRepoRun`, as the run page's steer box and MCP `steer` do. Empty text | |
| 203 | * keeps the composer open with the refusal; the outcome is a toast in the | |
| 204 | * words MCP answers with. | |
| 205 | */ | |
| 206 | export function steerPrompt( | |
| 207 | f: Session, | |
| 208 | ctx: Ctx, | |
| 209 | parent: Screen, | |
| 210 | repo: Repo, | |
| 211 | run: Run, | |
| 212 | after: () => void = () => {}, | |
| 213 | ): void { | |
| 214 | ctx.push( | |
| 215 | promptScreen(ctx, parent, { | |
| 216 | title: "Steer the run", | |
| 217 | label: `Say a word to ${runTitle(run, runActors(f, [run]))}`, | |
| 218 | placeholder: "One sentence the agent hears next", | |
| 219 | onSubmit: (text) => { | |
| 220 | const steered = steerRepoRun(f.app, f.user, repo, run.id, text); | |
| 221 | if (!steered.ok) { | |
| 222 | if (steered.error === "empty") return SAY_SOMETHING_WORDS; | |
| 223 | ctx.toast(`Could not steer: ${errorText(steered.error)}.`, "danger"); | |
| 224 | return undefined; | |
| 225 | } | |
| 226 | ctx.toast(`${steered.value === "sent" ? "Sent" : "Commented"}. ${STEER_SAYS[steered.value]}`); | |
| 227 | after(); | |
| 228 | return undefined; | |
| 229 | }, | |
| 230 | }), | |
| 231 | ); | |
| 232 | } |
src/tui/screens/runs.ts +11 −2
| @@ -3,7 +3,9 @@ | ||
| 3 | 3 | * (All, Failed, Running, Succeeded) — the web runs page's `?state=` row. |
| 4 | 4 | * Read through `listRepoRuns`, so it is gated like every other run read. |
| 5 | 5 | * `x` cancels a run that has not finished; `a` queues a failed or cancelled |
| 6 | * agent run's task again (`requeueRepoRun`), each after a confirm. | |
| 6 | * agent run's task again (`requeueRepoRun`), each after a confirm; `S` says a | |
| 7 | * word to a run while an agent runs it (`s` is the state row here; the crew | |
| 8 | * cards steer with `s`). | |
| 7 | 9 | */ |
| 8 | 10 | |
| 9 | 11 | import { cancelRepoRun, listRepoRuns, requeueRepoRun } from "../../actions/runs.ts"; |
| @@ -20,7 +22,7 @@ import { truncate } from "../term/width.ts"; | ||
| 20 | 22 | import { fill, len, split } from "../ui/layout.ts"; |
| 21 | 23 | import { emptyState } from "../ui/widgets.ts"; |
| 22 | 24 | import { confirmScreen } from "./common.ts"; |
| 23 | import { RunList, openRunLog, runTitle } from "./runlist.ts"; | |
| 25 | import { RunList, maySteer, openRunLog, runTitle, steerPrompt } from "./runlist.ts"; | |
| 24 | 26 | |
| 25 | 27 | const LIMIT = 50; |
| 26 | 28 | |
| @@ -130,6 +132,7 @@ export function runsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 130 | 132 | binding(["j", "k"], "move"), |
| 131 | 133 | binding(["enter"], "open the log"), |
| 132 | 134 | ...(cancellable() === undefined ? [] : [binding(["x"], "cancel")]), |
| 135 | ...(maySteer(f, repo, list.selected) ? [binding(["S"], "steer")] : []), | |
| 133 | 136 | ...(requeueable() === undefined ? [] : [binding(["a"], "run again")]), |
| 134 | 137 | binding(["s"], "state: All, Failed, Running, Succeeded (cycle)"), |
| 135 | 138 | binding(["r"], "refresh"), |
| @@ -170,6 +173,12 @@ export function runsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 170 | 173 | ask(); |
| 171 | 174 | return true; |
| 172 | 175 | } |
| 176 | if (e.key === "S") { | |
| 177 | const run = list.selected; | |
| 178 | if (run !== undefined && maySteer(f, repo, run)) | |
| 179 | steerPrompt(f, ctx, self, repo, run, read); | |
| 180 | return true; | |
| 181 | } | |
| 173 | 182 | if (e.key === "a") { |
| 174 | 183 | askRequeue(); |
| 175 | 184 | return true; |
test/tui-steer.test.ts added +224
| @@ -0,0 +1,224 @@ | ||
| 1 | /** The terminal says a word to a running crew run, as the run page and MCP `steer` do (#7288). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 7 | import { openIssue } from "../src/actions/issues.ts"; | |
| 8 | import { openPull } from "../src/actions/pulls.ts"; | |
| 9 | import { commitToBranch, createRepo as makeRepo } from "../src/actions/repos.ts"; | |
| 10 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 11 | import { listComments, type Issue } from "../src/core/issues.ts"; | |
| 12 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 13 | import { canWrite, createRepo, type Repo } from "../src/core/repos.ts"; | |
| 14 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 15 | import type { User } from "../src/core/users.ts"; | |
| 16 | import { listenForSteer } from "../src/crew/runner.ts"; | |
| 17 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 18 | import { issueScreen } from "../src/tui/screens/issue.ts"; | |
| 19 | import { pullScreen } from "../src/tui/screens/pull.ts"; | |
| 20 | import { runsScreen } from "../src/tui/screens/runs.ts"; | |
| 21 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 22 | import { local } from "../src/tui/screens/where.ts"; | |
| 23 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 24 | import { quietLogs } from "./helpers/globals.ts"; | |
| 25 | import { settleApp } from "./helpers/tui.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | ||
| 29 | let dir: string; | |
| 30 | let forge: Forge; | |
| 31 | let alice: User; | |
| 32 | let bot: User; | |
| 33 | let orgId: string; | |
| 34 | let n = 0; | |
| 35 | ||
| 36 | beforeAll(async () => { | |
| 37 | dir = await mkdtemp(join(tmpdir(), "forge-tui-steer-")); | |
| 38 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 39 | const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 40 | if (!a.ok) throw new Error("signup failed"); | |
| 41 | alice = a.value; | |
| 42 | const agent = await createAgent(forge, alice, { username: "robo" }); | |
| 43 | if (!agent.ok) throw new Error("agent failed"); | |
| 44 | bot = agent.value.user; | |
| 45 | // An org's repositories, so a team can make the agent a writer. | |
| 46 | const org = createOrg(forge.db, "acme", alice.id); | |
| 47 | if (!org.ok) throw new Error("org failed"); | |
| 48 | orgId = org.value.id; | |
| 49 | const writers = createTeam(forge.db, orgId, "Writers", "write"); | |
| 50 | if (!writers.ok) throw new Error("team failed"); | |
| 51 | addTeamMember(forge.db, writers.value, bot.id); | |
| 52 | }); | |
| 53 | afterAll(async () => { | |
| 54 | forge.close?.(); | |
| 55 | await rm(dir, { recursive: true, force: true }); | |
| 56 | }); | |
| 57 | ||
| 58 | /** A repository with one issue and one agent run on it, claimed unless `finished`. */ | |
| 59 | async function seed(finished = false): Promise<{ repo: Repo; issue: Issue; runId: string }> { | |
| 60 | const r = await createRepo(forge.db, forge.reposRoot, { ownerId: orgId, name: `lamp${n++}`, visibility: "public" }); | |
| 61 | if (!r.ok) throw new Error("repo failed"); | |
| 62 | if (!addToCrew(forge, alice, r.value, "robo").ok) throw new Error("crew failed"); | |
| 63 | const issue = openIssue(forge, alice, r.value, { title: "Say hello" }); | |
| 64 | if (!issue.ok) throw new Error("issue failed"); | |
| 65 | const run = createRun(forge.db, { | |
| 66 | repoId: r.value.id, | |
| 67 | kind: "agent", | |
| 68 | issueId: issue.value.id, | |
| 69 | actorId: bot.id, | |
| 70 | }); | |
| 71 | claimRun(forge.db, run.id); | |
| 72 | if (finished) finishRun(forge.db, run.id, { state: "ok", summary: "done" }); | |
| 73 | return { repo: r.value, issue: issue.value, runId: run.id }; | |
| 74 | } | |
| 75 | ||
| 76 | async function open(user: User, push: (session: any, ctx: any) => any) { | |
| 77 | const { app, session } = createForgeApp( | |
| 78 | forge, | |
| 79 | { sshUsername: user.username, remoteAddress: "127.0.0.1", user }, | |
| 80 | { terminal: headlessTerminal(120, 30), exitHooks: false }, | |
| 81 | ); | |
| 82 | app.start(); | |
| 83 | await settleApp(app); | |
| 84 | app.push(push(session, app.ctx)); | |
| 85 | await settleApp(app); | |
| 86 | const press = async (key: string): Promise<string> => { | |
| 87 | app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 88 | await settleApp(app); | |
| 89 | return app.screen.toText(); | |
| 90 | }; | |
| 91 | const type = async (text: string): Promise<void> => { | |
| 92 | for (const ch of text) await press(ch); | |
| 93 | }; | |
| 94 | return { app, press, type, text: () => app.screen.toText() }; | |
| 95 | } | |
| 96 | ||
| 97 | test("S on the runs list sends the word to the running agent and says so", async () => { | |
| 98 | const { repo, runId } = await seed(); | |
| 99 | const heard: string[] = []; | |
| 100 | const stop = listenForSteer(runId, (t) => { | |
| 101 | heard.push(t); | |
| 102 | return true; | |
| 103 | }); | |
| 104 | const h = await open(alice, (s, ctx) => runsScreen(s, ctx, repo)); | |
| 105 | expect(h.text()).toMatch(/S +steer/); | |
| 106 | expect(await h.press("S")).toContain("Steer the run"); | |
| 107 | await h.type("also add a test"); | |
| 108 | const text = await h.press("enter"); | |
| 109 | expect(heard).toEqual(["also add a test"]); | |
| 110 | expect(text).toContain("Sent. The agent hears it at its next step."); | |
| 111 | stop(); | |
| 112 | h.app.stop(); | |
| 113 | }); | |
| 114 | ||
| 115 | test("with no session listening the word goes on the conversation", async () => { | |
| 116 | const { repo, issue } = await seed(); | |
| 117 | const h = await open(alice, (s, ctx) => runsScreen(s, ctx, repo)); | |
| 118 | await h.press("S"); | |
| 119 | await h.type("try the other door"); | |
| 120 | const text = await h.press("enter"); | |
| 121 | expect(text).toContain("Commented."); | |
| 122 | expect(listComments(forge.db, issue.id).map((c) => c.body)).toContain("try the other door"); | |
| 123 | h.app.stop(); | |
| 124 | }); | |
| 125 | ||
| 126 | test("empty text is refused in place and nothing is said", async () => { | |
| 127 | const { repo, runId } = await seed(); | |
| 128 | const heard: string[] = []; | |
| 129 | const stop = listenForSteer(runId, (t) => { | |
| 130 | heard.push(t); | |
| 131 | return true; | |
| 132 | }); | |
| 133 | const h = await open(alice, (s, ctx) => runsScreen(s, ctx, repo)); | |
| 134 | await h.press("S"); | |
| 135 | await h.type(" "); | |
| 136 | const text = await h.press("enter"); | |
| 137 | expect(text).toContain("Steer the run"); | |
| 138 | expect(text).toContain("Say something first."); | |
| 139 | expect(heard).toEqual([]); | |
| 140 | stop(); | |
| 141 | h.app.stop(); | |
| 142 | }); | |
| 143 | ||
| 144 | test("a finished run is not offered steering", async () => { | |
| 145 | const { repo, issue } = await seed(true); | |
| 146 | const h = await open(alice, (s, ctx) => runsScreen(s, ctx, repo)); | |
| 147 | expect(h.text()).not.toMatch(/S +steer/); | |
| 148 | expect(await h.press("S")).not.toContain("Steer the run"); | |
| 149 | expect(listComments(forge.db, issue.id)).toHaveLength(0); | |
| 150 | h.app.stop(); | |
| 151 | }); | |
| 152 | ||
| 153 | test("the issue's crew card steers with s", async () => { | |
| 154 | const { repo, issue, runId } = await seed(); | |
| 155 | const heard: string[] = []; | |
| 156 | const stop = listenForSteer(runId, (t) => { | |
| 157 | heard.push(t); | |
| 158 | return true; | |
| 159 | }); | |
| 160 | const h = await open(alice, (s, ctx) => issueScreen(s, ctx, local(s, repo), issue.number)); | |
| 161 | await h.press("tab"); | |
| 162 | expect(h.text()).toMatch(/s +steer/); | |
| 163 | expect(await h.press("s")).toContain("Steer the run"); | |
| 164 | await h.type("smaller steps"); | |
| 165 | expect(await h.press("enter")).toContain("Sent."); | |
| 166 | expect(heard).toEqual(["smaller steps"]); | |
| 167 | stop(); | |
| 168 | h.app.stop(); | |
| 169 | }); | |
| 170 | ||
| 171 | test("the pull's Crew tab steers with s", async () => { | |
| 172 | const repo = await makeRepo(forge, alice, { name: `pull${n++}`, visibility: "public" }); | |
| 173 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 174 | for (const branch of ["main", "topic"]) { | |
| 175 | const done = await commitToBranch(forge, alice, repo.value, { | |
| 176 | branch, | |
| 177 | message: "edit", | |
| 178 | files: [{ path: `${branch}.txt`, content: "x\n" }], | |
| 179 | }); | |
| 180 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 181 | } | |
| 182 | if (!addToCrew(forge, alice, repo.value, "robo").ok) throw new Error("crew failed"); | |
| 183 | const pull = await openPull(forge, alice, repo.value, { title: "Topic", baseBranch: "main", headBranch: "topic" }); | |
| 184 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 185 | const run = createRun(forge.db, { repoId: repo.value.id, kind: "agent", issueId: pull.value.id, actorId: bot.id }); | |
| 186 | claimRun(forge.db, run.id); | |
| 187 | const heard: string[] = []; | |
| 188 | const stop = listenForSteer(run.id, (t) => { | |
| 189 | heard.push(t); | |
| 190 | return true; | |
| 191 | }); | |
| 192 | const h = await open(alice, (s, ctx) => pullScreen(s, ctx, local(s, repo.value), pull.value.number)); | |
| 193 | const end = Date.now() + 3_000; | |
| 194 | while (!h.text().includes("Crew")) { | |
| 195 | if (Date.now() > end) throw new Error(h.text()); | |
| 196 | await settleApp(h.app); | |
| 197 | await Bun.sleep(20); | |
Non-blocking: a 20 s ceiling is generous for a unit test. Consider a smaller one so a hang fails fast and stays within #1242's per-test budget. |
||
| 198 | } | |
| 199 | await h.press("tab"); | |
| 200 | await h.press("tab"); | |
| 201 | expect(h.text()).toMatch(/s +steer/); | |
| 202 | expect(await h.press("s")).toContain("Steer the run"); | |
| 203 | await h.type("one file at a time"); | |
| 204 | expect(await h.press("enter")).toContain("Sent."); | |
| 205 | expect(heard).toEqual(["one file at a time"]); | |
| 206 | stop(); | |
| 207 | h.app.stop(); | |
| 208 | }); | |
| 209 | ||
| 210 | test("an agent session that may write is not offered steering", async () => { | |
| 211 | const { repo, runId } = await seed(); | |
| 212 | expect(canWrite(forge.db, repo, bot)).toBe(true); | |
| 213 | const heard: string[] = []; | |
| 214 | const stop = listenForSteer(runId, (t) => { | |
| 215 | heard.push(t); | |
| 216 | return true; | |
| 217 | }); | |
| 218 | const h = await open(bot, (s, ctx) => runsScreen(s, ctx, repo)); | |
| 219 | expect(h.text()).not.toMatch(/S +steer/); | |
| 220 | expect(await h.press("S")).not.toContain("Steer the run"); | |
| 221 | expect(heard).toEqual([]); | |
| 222 | stop(); | |
| 223 | h.app.stop(); | |
| 224 | }); |
new above
offline
This file covers the runs list and the issue card, but not the pull's Crew tab, which this change also adds. One more test is needed here.