From 3be85aee477aa452fdb66a8ee96e9ba45231ec09 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 28 Sep 2026 22:24:39 -0400 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20Present=20Agent=20conversations?= =?UTF-8?q?,=20turns=20and=20permission=20requests=20in=20the=20REPL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Sessions surface said `(none retained)` whatever the process was doing. It now presents one chronology of Agent work: every turn this process observed and every turn the history holds, with the conversations to filter them by and the permission request a turn is waiting on. A turn is not a concatenation of two lists. Prompts running beside each other publish in whatever order their providers answer, so an earlier Prompt can still be live while a later one is already durable — appending the live turns to the retained ones would put it second. Each observed Prompt keeps a slot, taken when it was scheduled, and a slot survives publication: the same mounted node, in the same place, sourcing its facts from the record once there is one. The slot also keeps the last live facts it had, so no frame between the append and its projection shows a turn as gone. Turns this process never observed are in the prefix it replayed, and come first in the order their records state. A pending request appears inline on the turn that is waiting. Arriving opens nothing, moves nothing and claims no focus. Activating it records the request's opaque key in process-local state and opens the Sessions-only `+permission` drawer, which draws every choice the provider offered — `always` scoped to this Agent session, never to a machine — and says that closing denies while the session keeps running. Only a successful authority call closes it: the reducer decides, the program's own `perform()` calls `choose` or `dismiss` once, and focus returns to the turn that was waiting. A request that stops existing without a UI action withdraws its drawer without claiming a choice or a denial. Retained audits are read and never answered. Narrow gives its whole screen to one routed surface, so the other one is not described at all — not clipped, not placed in a region the frame does not have. A node nothing can show is a focus stop that draws nothing and a target behind nothing. `runReplProgram()` accepts the permission mode it passes to the session. Without one every REPL is `deny-all`, so no request could ever reach a person and the end-to-end row could not exist; which mode a *command* selects is still Slice E's. --- packages/cli/src/repl/agent.ts | 72 + packages/cli/src/repl/application.ts | 718 +++++- packages/cli/src/repl/components/actions.ts | 22 +- packages/cli/src/repl/components/rows.ts | 24 + packages/cli/src/repl/program.ts | 60 +- packages/cli/src/repl/session.ts | 1 + .../cli/tests/repl-agent-interface.test.ts | 2033 +++++++++++++++++ packages/cli/tests/repl-forms.test.ts | 8 +- packages/cli/tests/repl-journey.test.ts | 4 +- 9 files changed, 2886 insertions(+), 56 deletions(-) create mode 100644 packages/cli/tests/repl-agent-interface.test.ts diff --git a/packages/cli/src/repl/agent.ts b/packages/cli/src/repl/agent.ts index debea38a..e66e78ac 100644 --- a/packages/cli/src/repl/agent.ts +++ b/packages/cli/src/repl/agent.ts @@ -126,11 +126,50 @@ export interface ReplLivePermission { readonly choices: readonly ReplLiveChoice[]; } +/** + * Where one observed Prompt sits, and what it became. + * + * A slot outlives the live turn it began as. Publication changes where a turn's + * facts come from — the record, rather than this process's observation — and it + * is the same turn a person was already looking at, so the slot keeps one + * position and one identity across that change. Without it a reader has only + * two disjoint lists and has to guess which durable row replaced which live one. + * + * `order` is observation order, which is the order Prompts were scheduled in; + * `durable` is the name the record was journaled under, once the append that + * replaced this turn has been accounted for. Everything here is process-local: + * no slot, key or order reaches a location, the model or the Journal. + */ +export interface ReplAgentSlot { + /** The live key this Prompt was observed under, and stays mounted as. */ + readonly key: string; + /** Where this Prompt sits among the ones this process observed. */ + readonly order: number; + /** The durable name its record was journaled under, or none while it is live. */ + readonly durable: string | undefined; + /** + * The facts this turn had when it published, or none while it is still live. + * + * Kept so the row can never blank: the append is accounted for here and the + * record is projected by whoever owns the transition, and a reader that had + * only the two lists would show nothing for this turn in between. + */ + readonly last: ReplLiveTurn | undefined; +} + /** Everything this process knows about live Agent work right now. */ export interface ReplAgentReading { /** Live turns in the order their Prompts were observed. */ readonly turns: readonly ReplLiveTurn[]; readonly requests: readonly ReplLivePermission[]; + /** + * Every Prompt this process observed, live or since published. + * + * In observation order. A reader presents these rather than concatenating the + * live turns with the retained ones, because a turn that has published is + * still in the same place it was. + */ + readonly slots: readonly ReplAgentSlot[]; } /** @@ -211,6 +250,14 @@ interface LiveTurn { failure: string | undefined; } +/** Mutable bookkeeping for one observed Prompt's place in the reading. */ +interface Slot { + readonly key: string; + readonly order: number; + durable: string | undefined; + last: ReplLiveTurn | undefined; +} + interface LiveRequest { readonly key: string; readonly turn: string; @@ -238,6 +285,15 @@ function frozenTurn(turn: LiveTurn): ReplLiveTurn { }); } +function frozenSlot(slot: Slot): ReplAgentSlot { + return Object.freeze({ + key: slot.key, + order: slot.order, + durable: slot.durable, + last: slot.last, + }); +} + function frozenRequest(request: LiveRequest): ReplLivePermission { return Object.freeze({ key: request.key, @@ -302,6 +358,8 @@ export function useReplAgent(mode: PermissionMode): Operation { const changes = createSignal(); const turns: LiveTurn[] = []; const requests: LiveRequest[] = []; + /** One per Prompt this process observed, kept after publication. */ + const slots: Slot[] = []; /** The live turn core began in a scope, until that scope's prompt claims it. */ const begun = new Map(); /** @@ -325,6 +383,7 @@ export function useReplAgent(mode: PermissionMode): Operation { let reading: ReplAgentReading = Object.freeze({ turns: Object.freeze([]), requests: Object.freeze([]), + slots: Object.freeze([]), }); let keys = 0; let failure: Error | undefined; @@ -339,6 +398,7 @@ export function useReplAgent(mode: PermissionMode): Operation { reading = Object.freeze({ turns: Object.freeze(turns.map(frozenTurn)), requests: Object.freeze(requests.map(frozenRequest)), + slots: Object.freeze(slots.map(frozenSlot)), }); } @@ -388,6 +448,9 @@ export function useReplAgent(mode: PermissionMode): Operation { failure: undefined, }; turns.push(turn); + // Its place, taken when the Prompt was scheduled rather than when it + // finished: a turn that publishes first did not thereby happen first. + slots.push({ key: turn.key, order: slots.length + 1, durable: undefined, last: undefined }); announce(); return turn; } @@ -652,6 +715,14 @@ export function useReplAgent(mode: PermissionMode): Operation { ), ); } + // The live facts are gone, and the record holds them now — but this is + // the same turn, in the same place. The slot says which record that is, + // by the durable name the journal wrote it under. + const slot = slots.find((candidate) => candidate.key === turn.key); + if (slot !== undefined) { + slot.durable = event.description.name; + slot.last = frozenTurn(turn); + } retire(turn); project(); }, @@ -685,6 +756,7 @@ export function useReplAgent(mode: PermissionMode): Operation { requests.length = 0; failures.length = 0; turns.length = 0; + slots.length = 0; begun.clear(); publishing.clear(); // Projected, not announced. A reader still holding this owner sees a diff --git a/packages/cli/src/repl/application.ts b/packages/cli/src/repl/application.ts index 61abbbf2..ed1a753c 100644 --- a/packages/cli/src/repl/application.ts +++ b/packages/cli/src/repl/application.ts @@ -30,7 +30,14 @@ import type { Json } from "@executablemd/durable-streams"; import { describe as describeNode } from "./description.ts"; import type { ReplDescription } from "./description.ts"; -import { drawerHeight, drawerWidth, HISTORY_ROWS, NARROW, surfaceWidth } from "./layout.ts"; +import { + drawerHeight, + drawerWidth, + HISTORY_ROWS, + NARROW, + profileFor, + surfaceWidth, +} from "./layout.ts"; import type { ReplSurface as ReplPlacedSurface, ReplSurfaceCell } from "./layout.ts"; import type { ReplTerminalSize } from "./terminal.ts"; import { decodeLocation, encodeLocation, NO_LIVE, resolveLocation } from "./route.ts"; @@ -41,8 +48,16 @@ import type { ReplSelection, ReplSurface, } from "./route.ts"; -import type { ReplModel, ReplRow, ReplScope } from "./model.ts"; +import type { ReplAgentPermission, ReplAgentTurn, ReplModel, ReplRow, ReplScope } from "./model.ts"; import type { ReplFormField, ReplQuestion, ReplQuestionForm } from "./elicitation.ts"; +import type { + ReplAgentReading, + ReplAgentSlot, + ReplLiveChoice, + ReplLivePermission, + ReplLiveTurn, + ReplLiveTurnState, +} from "./agent.ts"; import type { ExpansionState } from "./expansion.ts"; import type { ReplTree } from "./reconcile.ts"; import { DRAWER, FIELD, LINE, REFUSAL, SELECT_ROW } from "./components/rows.ts"; @@ -59,8 +74,166 @@ export interface ReplLive { readonly expansion: ExpansionState; /** Whether this process holds the continuations, and so may pause at all. */ readonly pausable: boolean; + /** + * What this process knows about Agent work no record holds yet. + * + * Empty for a replay, a document with no Agent work and a frozen prefix — a + * live reading describes work this process is doing, and none of those is. + */ + readonly agent: ReplAgentReading; +} + +/** + * One turn as the Sessions surface shows it, live or retained. + * + * One type for both, because a person is looking at one turn either way: what + * publication changes is where a turn's facts come from, not which turn it is. + * `key` is its mounted identity and survives that change, so the row a person + * had focus on is the same row afterwards. + */ +export interface ReplSessionTurn { + /** The mounted identity: this process's slot, or a record's own marker. */ + readonly key: string; + readonly prompt: string; + /** `retained` once a record holds it, and the live states until then. */ + readonly state: ReplLiveTurnState | "retained"; + readonly text: string; + readonly agent: string | undefined; + /** + * The conversation this turn joined, or none. + * + * None means the provider has not said yet — a queued turn — or never did. + * Either way there is no conversation to filter it by, and the authored + * Session name is not one. + */ + readonly sessionKey: string | undefined; + readonly status: "completed" | "failed" | "cancelled" | undefined; + readonly stopReason: string | undefined; + readonly failure: string | undefined; + /** The history position this turn is recorded at, once it has one. */ + readonly marker: string | undefined; + /** The request waiting on this turn right now, when one is. */ + readonly request: ReplLivePermission | undefined; + /** What this turn was granted, once its record holds the audit. */ + readonly audits: readonly ReplAgentPermission[]; +} + +/** + * Every turn this screen can show, in the order their Prompts were scheduled. + * + * Not a concatenation of the retained turns and the live ones. Prompts running + * beside each other publish in whatever order their providers answer, so an + * earlier Prompt can still be live while a later one is already durable — and + * appending the live list to the retained one would put it second. + * + * The turns this process observed carry their own place, taken when each Prompt + * was scheduled. Turns it did not observe are in the prefix it replayed, which + * is entirely earlier than anything it went on to run, so they come first in the + * order their records state. + */ +function chronology(model: ReplModel, live: ReplLive): readonly ReplSessionTurn[] { + const observed = new Set(); + for (const slot of live.agent.slots) { + if (slot.durable !== undefined) { + observed.add(slot.durable); + } + } + const shown: ReplSessionTurn[] = []; + for (const turn of model.turns) { + if (!observed.has(turn.name)) { + shown.push(retainedTurn(turn.marker, turn)); + } + } + for (const slot of [...live.agent.slots].sort((left, right) => left.order - right.order)) { + const turn = resolved(slot, model, live); + if (turn !== undefined) { + shown.push(turn); + } + } + return Object.freeze(shown); +} + +/** What one observed slot shows now: its record, or the turn as it still stands. */ +function resolved( + slot: ReplAgentSlot, + model: ReplModel, + live: ReplLive, +): ReplSessionTurn | undefined { + if (slot.durable === undefined) { + const turn = live.agent.turns.find((candidate) => candidate.key === slot.key); + return turn === undefined ? undefined : liveTurn(slot.key, turn, live); + } + const record = model.turns.find((candidate) => candidate.name === slot.durable); + if (record !== undefined) { + // Keyed by the slot, not by the marker: this is the row it already was. + return retainedTurn(slot.key, record); + } + // Accounted for here before the history it belongs to was projected. The facts + // it had are still the facts, and a row that vanished for this one frame is + // the turn a person was reading disappearing under them. + return slot.last === undefined ? undefined : liveTurn(slot.key, slot.last, live); +} + +function liveTurn(key: string, turn: ReplLiveTurn, live: ReplLive): ReplSessionTurn { + return Object.freeze({ + key, + prompt: turn.prompt, + state: turn.state, + text: turn.text, + agent: turn.agent, + sessionKey: turn.sessionKey, + status: turn.status, + stopReason: turn.stopReason, + failure: turn.failure, + marker: undefined, + request: live.agent.requests.find((request) => request.turn === turn.key), + audits: Object.freeze([]), + }); } +function retainedTurn(key: string, turn: ReplAgentTurn): ReplSessionTurn { + return Object.freeze({ + key, + prompt: turn.input, + state: "retained", + text: turn.text, + agent: turn.agent, + sessionKey: turn.sessionKey.length === 0 ? undefined : turn.sessionKey, + status: turn.status, + stopReason: turn.stopReason, + failure: turn.failure, + marker: turn.marker, + // A record cannot be waiting on anybody: what it holds is what it was + // granted, and it is read rather than answered. + request: undefined, + audits: turn.permissions, + }); +} + +/** + * The conversations this screen offers to filter by, earliest turn first. + * + * Earliest, never latest activity: a list that reordered itself when a provider + * streamed would move the control somebody was reaching for. + */ +function conversations(turns: readonly ReplSessionTurn[]): readonly string[] { + const keys: string[] = []; + for (const turn of turns) { + const key = turn.sessionKey; + if (key !== undefined && key.length > 0 && !keys.includes(key)) { + keys.push(key); + } + } + return Object.freeze(keys); +} + +/** A process running no Agent work, which is what a frozen view also shows. */ +export const NO_AGENT: ReplAgentReading = Object.freeze({ + turns: Object.freeze([]), + requests: Object.freeze([]), + slots: Object.freeze([]), +}); + /** One thing the last submission said was wrong, as a reader sees it. */ export interface ReplFormMessage { /** The field it belongs to, or none for the object as a whole. */ @@ -120,7 +293,15 @@ export type ReplFocusRestore = readonly answer: Json; } /** The invocation still asking, now that its drawer is not up. */ - | { readonly kind: "asked" }; + | { readonly kind: "asked" } + /** + * The turn a permission drawer was answering for. + * + * The turn rather than the request: the request is gone — that is what + * answering it means — and the turn it was waiting on is the thing still on + * screen to come back to. + */ + | { readonly kind: "turn"; readonly turn: string }; /** Everything typed and not yet committed anywhere. */ export interface ReplState { @@ -131,6 +312,14 @@ export interface ReplState { readonly form: ReplFormState; /** Why the last action changed nothing, or none. */ readonly refusal: string | undefined; + /** + * The pending permission request this screen has selected, or none. + * + * This process's own opaque key, never a location: which request is being + * answered is a fact about the process holding it, and a key in a URL would + * publish a live identity nothing else can use. + */ + readonly permission: string | undefined; /** * Where focus starts again, for the one commit after a drawer went. * @@ -147,7 +336,20 @@ export type ReplIntent = | { readonly kind: "submit"; readonly source: string } | { readonly kind: "pause" } | { readonly kind: "continue" } - | { readonly kind: "answer"; readonly values: Readonly> }; + | { readonly kind: "answer"; readonly values: Readonly> } + /** + * Answer one pending permission request with one option it offered. + * + * Scalars only, and the turn it belongs to comes along because the root needs + * it after the request is gone: answering removes the request, and focus has + * to land on the turn that was waiting. + */ + | { + readonly kind: "settle-permission"; + readonly request: string; + readonly option: string | undefined; + readonly turn: string; + }; /** One reduction: the state that stands now, and what the root owes. */ export interface ReplTransition { @@ -237,6 +439,48 @@ export function answered(state: ReplState, model: ReplModel, answer: Json): Repl }); } +/** + * The state after a permission request was really settled. + * + * Only a successful authority call reaches this: the drawer goes because the + * request it was opened over is gone, and focus returns to the turn that was + * waiting rather than to wherever the drawer was opened from. + */ +export function permissionSettled(state: ReplState, turn: string): ReplState { + return Object.freeze({ + ...state, + permission: undefined, + route: Object.freeze({ + ...state.route, + drawers: Object.freeze( + state.route.drawers.filter((drawer) => drawer.kind !== "live-permission"), + ), + }), + restore: Object.freeze({ kind: "turn", turn }), + refusal: undefined, + }); +} + +/** + * The state after the request a drawer was opened over stopped existing. + * + * Teardown and publication can both remove a request nobody answered. The drawer + * it left behind can resolve to nothing, so it is withdrawn — and nothing here + * claims a choice or a denial, because none was made. + */ +export function permissionWithdrawn(state: ReplState): ReplState { + return Object.freeze({ + ...state, + permission: undefined, + route: Object.freeze({ + ...state.route, + drawers: Object.freeze( + state.route.drawers.filter((drawer) => drawer.kind !== "live-permission"), + ), + }), + }); +} + /** The empty route one fresh execution starts at. */ export function initialRoute(execution: string): ReplRoute { return Object.freeze({ @@ -258,6 +502,7 @@ export function initialState(execution: string): ReplState { draft: "", form: EMPTY_FORM, refusal: undefined, + permission: undefined, restore: undefined, }); } @@ -274,6 +519,7 @@ export function stateFor(location: string): Result { draft: decoded.value.draft ?? "", form: EMPTY_FORM, refusal: undefined, + permission: undefined, restore: undefined, }), ); @@ -296,7 +542,7 @@ export function viewFor( // This process is the only thing that can say a question is waiting, so it // says so here rather than leaving resolution to infer it from a history that // does not record it. - const resolved = resolveLocation(model, state.route, availabilityOf(live)); + const resolved = resolveLocation(model, state.route, availabilityOf(state, live)); if (!resolved.ok) { return resolved; } @@ -310,6 +556,7 @@ export function viewFor( question: undefined, expansion: live.expansion, pausable: false, + agent: NO_AGENT, }; return Ok( Object.freeze({ @@ -350,6 +597,7 @@ export function refusedView( question: undefined, expansion: "playing", pausable: false, + agent: NO_AGENT, }), location: encodeLocation(state.route), refusal: reason, @@ -365,11 +613,33 @@ export function refusedView( * in the REPL yet, so there is no pending permission request and no conversation * that has started without settling anything. */ -function availabilityOf(live: ReplLive): ReplLiveAvailability { +/** + * What this process can say about state no history holds. + * + * `permission` is not "a request is waiting": it is "the request this screen has + * selected is still waiting". A drawer opens over one exact request, so a route + * that named one which has since settled resolves to nothing rather than to + * whatever is pending now. + * + * `sessions` is the conversations live turns have actually started under. A + * queued turn has none yet, and the authored Session name, the Prompt name and + * the agent are not conversations — inferring one from them would offer a filter + * for a key the provider never issued. + */ +function availabilityOf(state: ReplState, live: ReplLive): ReplLiveAvailability { + const selected = state.permission; + const keys: string[] = []; + for (const turn of live.agent.turns) { + const key = turn.sessionKey; + if (key !== undefined && key.length > 0 && !keys.includes(key)) { + keys.push(key); + } + } return { elicit: live.question !== undefined, - permission: false, - sessions: NO_LIVE.sessions, + permission: + selected !== undefined && live.agent.requests.some((request) => request.key === selected), + sessions: Object.freeze(keys), }; } @@ -484,6 +754,28 @@ export function reduceRepl( return refuse(state, "no drawer is open."); } const closing = state.route.drawers[state.route.drawers.length - 1]; + if (closing?.kind === "live-permission") { + // Dismissing a permission request denies it, and a denial is something + // only the authority can do. So this closes nothing yet: the drawer goes + // when the request it was opened over is really gone, and until then a + // screen that had already closed would be claiming an answer nobody gave. + const pending = + state.permission === undefined ? undefined : offered(state, live, state.permission); + if (pending === undefined) { + // Nothing left to deny — teardown or publication took it. The drawer is + // withdrawn rather than answered. + return settled(permissionWithdrawn(state)); + } + return { + state: Object.freeze({ ...state, refusal: undefined }), + intent: { + kind: "settle-permission", + request: pending.key, + option: undefined, + turn: pending.turn, + }, + }; + } const remaining = Object.freeze(state.route.drawers.slice(0, -1)); const closed = navigate(state, model, { ...state.route, drawers: remaining }, live); // Dismissing the question's drawer discards what was typed into it. It is @@ -501,6 +793,70 @@ export function reduceRepl( } : closed; } + case "select-session": { + // Only the filter moves: the surface, the scope path, the history marker, + // the draft and the drawer stack are all left exactly as they stand. A key + // that names no conversation is refused by the codec's own resolution, so + // a stale one cannot become an empty Sessions view. + return navigate(state, model, { ...state.route, session: action.session }, live); + } + case "all-sessions": { + return navigate(state, model, { ...state.route, session: undefined }, live); + } + case "select-permission": { + // The key is taken first, because a `+permission` candidate resolves only + // while the request it names is pending — so the state that navigates has + // to be the one already holding it. A refusal keeps neither. + const holding = Object.freeze({ ...state, permission: action.request }); + // Declared, so the drawer this adds is the one the grammar defines rather + // than a string this case happens to spell the same way. + const opening: ReplDrawerRef = { kind: "live-permission" }; + const opened = navigate( + holding, + model, + { + ...state.route, + drawers: Object.freeze([...state.route.drawers, opening]), + }, + live, + ); + return opened.state.refusal === undefined ? opened : refuse(state, opened.state.refusal); + } + case "choose-permission": { + const pending = offered(state, live, action.request); + if (pending === undefined) { + return refuse(state, "that permission request is not the one being answered."); + } + if (!pending.choices.some((choice) => choice.optionId === action.option)) { + // Re-checked against the reading as it stands: a choice drawn one frame + // ago is not a choice the provider is still offering. + return refuse(state, "that choice is not one this request offered."); + } + return { + state: Object.freeze({ ...state, refusal: undefined }), + intent: { + kind: "settle-permission", + request: action.request, + option: action.option, + turn: pending.turn, + }, + }; + } + case "dismiss-permission": { + const pending = offered(state, live, action.request); + if (pending === undefined) { + return refuse(state, "that permission request is not the one being answered."); + } + return { + state: Object.freeze({ ...state, refusal: undefined }), + intent: { + kind: "settle-permission", + request: action.request, + option: undefined, + turn: pending.turn, + }, + }; + } case "select-marker": { // Adopted rather than resolved here: a position is a *different reading* of // the file, and this model is the one the view being left was built from. @@ -669,6 +1025,24 @@ function editing( }); } +/** + * The request this action may act on, or none. + * + * One request: the one this screen selected *and* the one still pending. An + * unknown key, a stale key and a key for a request that has since settled all + * answer none, so nothing is settled on their behalf. + */ +function offered( + state: ReplState, + live: ReplLive, + request: string, +): ReplLivePermission | undefined { + if (state.permission !== request) { + return undefined; + } + return live.agent.requests.find((candidate) => candidate.key === request); +} + function settled(state: ReplState): ReplTransition { return { state: Object.freeze(state), intent: { kind: "none" } }; } @@ -702,7 +1076,11 @@ function navigate( live: ReplLive, ): ReplTransition { const route = Object.freeze({ ...candidate }); - const resolved = resolveLocation(model, route, availabilityOf(live)); + // Against `state`, because what a candidate route may name depends on what + // this screen has selected: a `+permission` drawer resolves only while the + // selected request is still pending, so whoever selects one navigates from the + // state that already holds its key. + const resolved = resolveLocation(model, route, availabilityOf(state, live)); if (!resolved.ok) { return refuse(state, resolved.error.message); } @@ -792,6 +1170,12 @@ export function focusClaim(view: ReplView): string | undefined { if (state.restore.kind === "asked") { return live.question === undefined || state.route.at !== undefined ? undefined : "footer:asked"; } + if (state.restore.kind === "turn") { + const turn = state.restore.turn; + return chronology(view.model, live).some((candidate) => candidate.key === turn) + ? `sessions:turn:${turn}` + : undefined; + } // The record this answer caused: one the history did not hold when the answer // was taken, holding exactly what was sent. Not the newest record — by the time // a frame can draw this row, another invocation may have settled after it. @@ -940,56 +1324,77 @@ function described(view: ReplView): readonly Described[] { const items: Described[] = []; const { model, selection, live, state } = view; const claim = focusClaim(view); - - items.push( - row( - "sessions:heading", - "Sessions", - { select: "surface", surface: "sessions" }, - { here: view.focused }, - ), - ); - // Present and empty. This REPL keeps one execution per invocation, and a - // Sessions surface that vanished when it held nothing would read as a feature - // that does not exist. - items.push(line("sessions:empty", " (none retained)")); - items.push( - row( - "entries:heading", - "Entries", - { select: "surface", surface: "repl" }, - { here: view.focused }, - ), - ); - - const entry = model.entry; - if (entry === undefined) { - items.push(line("entry:none", " 1. (not submitted)")); + // Narrow gives the whole screen to one routed surface, so the other one is not + // described at all — not drawn small, not clipped, not placed in a region the + // frame does not have. A node nothing can show is a focus stop that draws + // nothing and a pointer target behind nothing. + const narrow = profileFor(view.size) === "narrow"; + const routed = state.route.surface; + const showSessions = !narrow || routed === "sessions"; + const showEntry = !narrow || routed === "repl"; + + const turns = chronology(model, live); + if (showSessions) { + items.push( + row( + "sessions:heading", + "Sessions", + { select: "surface", surface: "sessions" }, + { here: view.focused }, + ), + ); + } + if (!showSessions) { + // Nothing: this frame is showing the other surface. + } else if (turns.length === 0) { + // Present and empty. This REPL keeps one execution per invocation, and a + // Sessions surface that vanished when it held nothing would read as a + // feature that does not exist. + items.push(line("sessions:empty", " (none retained)")); } else { + items.push(...sessionRows(view, turns, claim)); + } + // Read whether or not this frame draws the entry list: the footer's draft says + // whether an entry exists at every size and on either surface. + const entry = model.entry; + if (showEntry) { items.push( row( - "entry:1", - ` 1. ${entry.name}`, - { select: "scope", scopes: [entry.key] }, + "entries:heading", + "Entries", + { select: "surface", surface: "repl" }, { here: view.focused }, ), ); - for (const scope of nested(entry, [entry.key])) { + + if (entry === undefined) { + items.push(line("entry:none", " 1. (not submitted)")); + } else { items.push( row( - `scope:${scope.path.join("/")}`, - ` ${scope.label}`, - { - select: "scope", - scopes: scope.path, - }, + "entry:1", + ` 1. ${entry.name}`, + { select: "scope", scopes: [entry.key] }, { here: view.focused }, ), ); + for (const scope of nested(entry, [entry.key])) { + items.push( + row( + `scope:${scope.path.join("/")}`, + ` ${scope.label}`, + { + select: "scope", + scopes: scope.path, + }, + { here: view.focused }, + ), + ); + } } } - for (const [index, transcript] of model.transcript.entries()) { + for (const [index, transcript] of showEntry ? model.transcript.entries() : []) { // One cell is one row, so a recorded row that holds several lines of output // becomes several cells. A cell given more than one line would show only the // first, which is the whole of what a reader would then believe was there. @@ -1000,13 +1405,13 @@ function described(view: ReplView): readonly Described[] { // The live overlay, explicitly below the recorded rows and explicitly labelled. // Once the durable close exists its recorded output is in the transcript and // this is empty, so the two never both claim to be the output. - if (live.output.length > 0) { + if (showEntry && live.output.length > 0) { for (const [offset, text] of live.output.split("\n").entries()) { items.push(line(`line:live:${offset}`, `… ${text}`)); } } - const scope = selection.scope; + const scope = showEntry ? selection.scope : undefined; if (scope !== undefined) { for (const binding of scope.bindings) { items.push( @@ -1139,6 +1544,137 @@ function described(view: ReplView): readonly Described[] { return items; } +/** + * The Sessions reading, as rows. + * + * One control per conversation and one per turn, with each turn's own facts + * beneath it as lines. A turn's control is keyed by its slot, so a turn that + * publishes keeps the node — and the focus — it already had. + */ +function sessionRows( + view: ReplView, + turns: readonly ReplSessionTurn[], + claim: string | undefined, +): readonly Described[] { + const { state } = view; + const items: Described[] = []; + const filter = state.route.session; + const offered = conversations(turns); + if (offered.length > 0) { + // All is a control rather than the absence of one: clearing a filter is + // something a person does, and a list that could only be narrowed would + // leave them holding a view they cannot get out of. + items.push( + row( + "sessions:all", + filter === undefined ? " All conversations" : " All conversations (filtered)", + { select: "all-sessions" }, + { here: view.focused }, + ), + ); + for (const key of offered) { + items.push( + row( + `sessions:conversation:${key}`, + ` ${filter === key ? "> " : ""}${headline(key)}`, + { select: "session", session: key }, + { here: view.focused }, + ), + ); + } + } + for (const turn of turns) { + if (filter !== undefined && turn.sessionKey !== filter) { + continue; + } + items.push( + row( + `sessions:turn:${turn.key}`, + ` ${headline(turn.prompt)} · ${stateOf(turn)}`, + // A turn is read at the position its record holds; a live one has none + // to go to yet, so it selects the surface it is already on. + turn.marker === undefined + ? { select: "surface", surface: "sessions" } + : { select: "marker", marker: turn.marker }, + // A settled permission sends focus back to the turn that was waiting, so + // this is the row that may be claimed. + { here: view.focused, claim }, + ), + ); + if (turn.agent !== undefined || turn.sessionKey !== undefined) { + const said = [turn.agent, turn.sessionKey].filter((fact) => fact !== undefined); + items.push(line(`sessions:turn:${turn.key}:whose`, ` ${said.join(" · ")}`)); + } + if (turn.text.length > 0) { + items.push(line(`sessions:turn:${turn.key}:text`, ` ${headline(turn.text)}`)); + } + if (turn.stopReason !== undefined) { + items.push(line(`sessions:turn:${turn.key}:stop`, ` stopped: ${turn.stopReason}`)); + } + if (turn.failure !== undefined) { + items.push(line(`sessions:turn:${turn.key}:failed`, ` ${headline(turn.failure)}`)); + } + const request = turn.request; + if (request !== undefined) { + // Inline, on the turn that is waiting. Focusable where it can be answered + // and a plain fact where it cannot: the grammar answers a request on the + // Sessions surface, and a control that refused when activated would be a + // target that does nothing. Either way, arriving here opens nothing — + // somebody activates it. + const label = ` asks: ${headline(request.title ?? request.toolCallId)}`; + items.push( + state.route.surface === "sessions" + ? row( + `sessions:request:${request.key}`, + label, + { select: "permission", request: request.key }, + { here: view.focused }, + ) + : line(`sessions:request:${request.key}`, label), + ); + } + for (const [at, audit] of turn.audits.entries()) { + // Read, never answered: a record is what a turn was granted, and offering + // a control here would invite somebody to answer a question nobody asked. + items.push( + line( + `sessions:audit:${turn.key}:${at}`, + ` granted: ${headline(audit.title ?? audit.toolCallId)} — ${outcomeOf(audit)}`, + ), + ); + } + } + return items; +} + +/** + * How far one turn has got, in words a reader can act on. + * + * How it ended and whether the history holds it are separate facts, and a turn + * that has finished is not the same as one that has been recorded: a person + * looking at the second may go to its position, and a person looking at the + * first is watching this process. + */ +function stateOf(turn: ReplSessionTurn): string { + if (turn.state === "queued") { + return "queued"; + } + if (turn.state === "active") { + return "streaming"; + } + const ended = turn.status ?? "finished"; + return turn.state === "terminal" ? `${ended}, not recorded yet` : `${ended}, recorded`; +} + +/** What a retained audit says happened, without repeating the whole record. */ +function outcomeOf(audit: ReplAgentPermission): string { + if (audit.outcome === "cancelled") { + return "cancelled"; + } + const chosen = audit.options.find((option) => option.optionId === audit.selected); + return chosen === undefined ? "answered" : chosen.name; +} + /** * The innermost open drawer, as a modal branch holding its own controls. * @@ -1157,6 +1693,14 @@ function drawerFor(view: ReplView, history: Described): Described | undefined { // text stopped, because a renderer writes what changed and nothing else. const width = drawerWidth(view.size); let title: string; + /** + * The request this drawer's close control denies, when it is one. + * + * A permission drawer closes by *answering* — dismissal is the direct denial + * path the authority owns — so its close control carries the request rather + * than the generic close action that means "this changed nothing". + */ + let dismissing: string | undefined; if (open.kind === "binding") { title = open.name; @@ -1176,6 +1720,58 @@ function drawerFor(view: ReplView, history: Described): Described | undefined { for (const [offset, text] of detail(open.elicitation.answer).entries()) { children.push(drawerLine(`drawer:answer:${offset}`, text, width).description); } + } else if (open.kind === "live-permission") { + // The request this screen selected, read again here: a drawer draws what is + // pending now, and the one it was opened over may have been answered or torn + // down since. + const request = + view.state.permission === undefined + ? undefined + : view.live.agent.requests.find((candidate) => candidate.key === view.state.permission); + if (request === undefined) { + return undefined; + } + title = request.title ?? "Permission"; + // What is being asked, in the provider's own words. Never `rawInput` and + // never the request object: a screen shows what a person decides about. + if (request.kind !== undefined) { + children.push(drawerLine("drawer:permission:kind", ` ${request.kind}`, width).description); + } + children.push( + drawerLine("drawer:permission:call", ` call ${request.toolCallId}`, width).description, + ); + // Whose turn is waiting, so a decision is not made about an anonymous one. + const waiting = chronology(view.model, view.live).find( + (candidate) => candidate.key === request.turn, + ); + if (waiting !== undefined) { + const whose = + waiting.sessionKey === undefined + ? headline(waiting.prompt) + : `${headline(waiting.prompt)} · ${waiting.sessionKey}`; + children.push(drawerLine("drawer:permission:turn", ` ${whose}`, width).description); + } + // Every choice the provider offered, in its order, each one its own control. + for (const choice of request.choices) { + children.push( + row( + `drawer:permission:choice:${choice.optionId}`, + pad(`[${choice.name}]${lasting(choice.kind)}`, width), + { select: "permission-choice", request: request.key, option: choice.optionId }, + { here: view.focused }, + ).description, + ); + } + // Said rather than implied: dismissing is a denial of this request, and the + // session goes on running either way. + children.push( + drawerLine( + "drawer:permission:dismissal", + " Escape or close denies this request; the session keeps running.", + width, + ).description, + ); + dismissing = request.key; } else if (open.kind === "history") { title = "History"; for (const checkpoint of view.model.checkpoints) { @@ -1334,7 +1930,9 @@ function drawerFor(view: ReplView, history: Described): Described | undefined { row( "drawer:close", width < 1 ? "[close]" : "[close]".padEnd(width, " "), - { select: "close" }, + dismissing === undefined + ? { select: "close" } + : { select: "permission-dismiss", request: dismissing }, { here: view.focused, }, @@ -1352,6 +1950,17 @@ function drawerFor(view: ReplView, history: Described): Described | undefined { }; } +/** + * What a lasting choice lasts for. + * + * This Agent session, and said so: a person reading "always" in a terminal has + * every reason to think it means their machine, and nothing here can make a rule + * that outlives the conversation asking. + */ +function lasting(kind: ReplLiveChoice["kind"]): string { + return kind === "allow_always" || kind === "reject_always" ? " for this Agent session" : ""; +} + /** * The rows the drawer keeps whatever the viewport shows. * @@ -1541,11 +2150,15 @@ export function replSurface(tree: ReplTree, view: ReplView): ReplPla /** Whether a pointer may activate the row this key names. */ function targetable(key: string): boolean { - // A line is text. Everything a pointer may activate is a control, and the two - // footer lines that are not — the location somebody copies and the reason the - // last action changed nothing — are lines. + // A line is text. Everything a pointer may activate is a control, and the lines + // that are not — the location somebody copies, the reason the last action + // changed nothing, a turn's own facts and a retained permission it was granted + // — are lines. A cell that offered itself as a target and then did nothing + // would be a control that is not one. return ( !key.startsWith("line:") && + !key.startsWith("sessions:audit:") && + !TURN_FACTS.some((suffix) => key.endsWith(suffix)) && key !== "sessions:empty" && key !== "entry:none" && key !== "footer:location" && @@ -1553,5 +2166,8 @@ function targetable(key: string): boolean { ); } +/** The suffixes a turn's own read-only facts are keyed with. */ +const TURN_FACTS = [":whose", ":text", ":stop", ":failed"]; + export { HISTORY_ROWS }; export type { ReplDrawerRef, ReplSurface }; diff --git a/packages/cli/src/repl/components/actions.ts b/packages/cli/src/repl/components/actions.ts index 7244de5f..df713b67 100644 --- a/packages/cli/src/repl/components/actions.ts +++ b/packages/cli/src/repl/components/actions.ts @@ -53,4 +53,24 @@ export type ReplAction = * which way it points and nothing else: how far the region can go is the * frame's to decide, and clamping belongs where the height is known. */ - | { readonly kind: "scroll"; readonly delta: number }; + | { readonly kind: "scroll"; readonly delta: number } + /** + * Show only the conversation this provider session key names. + * + * The key and nothing else: which turns that is, and whether the key still + * names a conversation at all, is the root's to resolve against the reading it + * holds. + */ + | { readonly kind: "select-session"; readonly session: string } + /** Show every conversation again. Distinct from selecting one, so it cannot be a key nobody has. */ + | { readonly kind: "all-sessions" } + /** Open the drawer over the one pending permission request this key names. */ + | { readonly kind: "select-permission"; readonly request: string } + /** Answer the selected request with one option the provider offered. */ + | { + readonly kind: "choose-permission"; + readonly request: string; + readonly option: string; + } + /** Dismiss the selected request, which denies it while the session runs on. */ + | { readonly kind: "dismiss-permission"; readonly request: string }; diff --git a/packages/cli/src/repl/components/rows.ts b/packages/cli/src/repl/components/rows.ts index 91a0afff..4f49d298 100644 --- a/packages/cli/src/repl/components/rows.ts +++ b/packages/cli/src/repl/components/rows.ts @@ -160,6 +160,30 @@ function activation(input: ReplViewData): ReplAction | undefined { const delta = named["delta"]; return typeof delta === "number" ? { kind: "scroll", delta } : undefined; } + if (select === "session") { + const session = named["session"]; + return typeof session === "string" && session.length > 0 + ? { kind: "select-session", session } + : undefined; + } + if (select === "all-sessions") { + return { kind: "all-sessions" }; + } + if (select === "permission") { + const request = named["request"]; + return typeof request === "string" ? { kind: "select-permission", request } : undefined; + } + if (select === "permission-choice") { + const request = named["request"]; + const option = named["option"]; + return typeof request === "string" && typeof option === "string" + ? { kind: "choose-permission", request, option } + : undefined; + } + if (select === "permission-dismiss") { + const request = named["request"]; + return typeof request === "string" ? { kind: "dismiss-permission", request } : undefined; + } return undefined; } diff --git a/packages/cli/src/repl/program.ts b/packages/cli/src/repl/program.ts index 80f9fd49..d643b741 100644 --- a/packages/cli/src/repl/program.ts +++ b/packages/cli/src/repl/program.ts @@ -45,6 +45,8 @@ import { admitted, answered, focusSettled, + permissionSettled, + permissionWithdrawn, describeApplication, initialState, reduceRepl, @@ -53,7 +55,7 @@ import { stateFor, viewFor, } from "./application.ts"; -import type { Json, NormalizedIssue } from "@executablemd/core"; +import type { Json, NormalizedIssue, PermissionMode } from "@executablemd/core"; import type { ReplAction, ReplFormMessage, @@ -92,6 +94,14 @@ export interface ReplProgramOptions { readonly installations?: readonly ExecutionInstallation[]; /** The repository root. Defaults to this host's per-user data directory. */ readonly root?: string; + /** + * How this REPL answers Agent permission requests. + * + * Passed through to the session, which installs the policy. Absent means + * `deny-all`, which is what an execution with no configured mode already does — + * so a REPL that nobody configured asks nobody anything. + */ + readonly permissionMode?: PermissionMode; } /** What the command reports when it ends. */ @@ -177,6 +187,7 @@ export function* runReplProgram(options: ReplProgramOptions = {}): Operation request.key === state.permission) + ) { + state = permissionWithdrawn(state); + } + // A question is the interaction, not a place to go looking for one: when // this process starts asking, its drawer is offered once. Dismissing it // with Escape is final for that question, because the offer is remembered @@ -604,6 +637,7 @@ function liveOf(session: ReplSession): ReplLive { question: session.overlay.question, expansion: session.expansion.state, pausable: session.controller !== undefined, + agent: session.agent, }; } @@ -710,6 +744,14 @@ interface Performed { * state so the form a person is looking at can say what is wrong with it. */ readonly messages?: readonly ReplFormMessage[]; + /** + * The turn a permission request was really settled for, when one was. + * + * Only a successful authority call reports one: the drawer closes because the + * request it was opened over is gone, and focus returns to the turn that was + * waiting. + */ + readonly settled?: string; /** Why it could not be done, for the screen to say. */ readonly refusal?: string; } @@ -757,12 +799,28 @@ function* perform( ? { answered: outcome.answer } : { messages: reported(outcome) }; } + case "settle-permission": { + // The authority's answer, once. It is asked here and nowhere else: a + // surface that could settle a request would hold the capability that + // denies one, and this is the only thing that has it. + // + // An unknown, stale or already-settled key settles nothing and says so by + // answering false, and the drawer stays exactly as it is — a screen that + // closed on a call that did nothing would be claiming a decision. + const settled = + intent.option === undefined + ? session.permissions.dismiss(intent.request) + : session.permissions.choose(intent.request, intent.option); + wakes.send({ kind: "session" }); + return settled ? { settled: intent.turn } : {}; + } case "submit": { const submitted = yield* submitReplEntry({ execution, source: intent.source, ...(options.includes === undefined ? {} : { includes: options.includes }), ...(options.installations === undefined ? {} : { installations: options.installations }), + ...(options.permissionMode === undefined ? {} : { permissionMode: options.permissionMode }), }); if (!submitted.ok) { // A preflight refusal leaves the draft exactly as it was and the history diff --git a/packages/cli/src/repl/session.ts b/packages/cli/src/repl/session.ts index cc25c7d6..fb5d1cc1 100644 --- a/packages/cli/src/repl/session.ts +++ b/packages/cli/src/repl/session.ts @@ -543,6 +543,7 @@ function* start( const EMPTY_AGENT_READING: ReplAgentReading = Object.freeze({ turns: Object.freeze([]), requests: Object.freeze([]), + slots: Object.freeze([]), }); /** No live request exists, so no key settles one. */ diff --git a/packages/cli/tests/repl-agent-interface.test.ts b/packages/cli/tests/repl-agent-interface.test.ts new file mode 100644 index 00000000..eba26d65 --- /dev/null +++ b/packages/cli/tests/repl-agent-interface.test.ts @@ -0,0 +1,2033 @@ +/** + * Presenting Agent work in the REPL (#854 U1, U2, U3). + * + * Every row drives one real `ReplSession` over a real Journal, with a real + * Freedom tree mounted from the real descriptions. What is under test is what a + * person can see and reach: the order turns appear in while some are live and + * some are recorded, what a conversation filter changes, who owns a pending + * permission request, and which nodes a frame mounts at each accepted size. + * + * The document is the Story's own shape — one `` with `` children + * each holding an ordinary `` — because concurrent + * turns are the whole difficulty. Nothing here substitutes a shaped object for a + * session, a screenshot for a frame, or a bare `` for a conversation. + */ + +import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { + race, + scoped, + sleep, + spawn, + until as untilResolved, + useScope, + withResolvers, +} from "effection"; +import type { Operation, Result, Stream } from "effection"; +import { DurableContext, InMemoryStream } from "@executablemd/durable-streams"; +import { + Agent, + agentIdentityComponents, + installAgentComponents, + useTempFileCompiler, +} from "@executablemd/core"; +import type { + AgentPromptEvent, + AgentProviderFactory, + PermissionMode, + PermissionOption, + PermissionOutcome, + PermissionRequest, + PromptOptions, + Session, +} from "@executablemd/core"; +import type { ExecutionInstallation } from "@executablemd/core/host"; + +import { ordinaryEvaluationProfile } from "../src/evaluation-profile.ts"; +import { agentReferenceEvents } from "./fixtures/repl/reference.ts"; +import { openReplSession, submitReplEntry } from "../src/repl/session.ts"; +import type { ReplSession } from "../src/repl/session.ts"; +import type { ReplExecution } from "../src/repl/journal.ts"; +import { projectRepl } from "../src/repl/model.ts"; +import type { ReplModel } from "../src/repl/model.ts"; +import { + describeApplication, + focusClaim, + focusSettled, + initialState, + permissionSettled, + reduceRepl, + replSurface, + viewFor, +} from "../src/repl/application.ts"; +import type { + ReplAction, + ReplIntent, + ReplLive, + ReplState, + ReplView, +} from "../src/repl/application.ts"; +import { layout, NARROW, surfaceWidth } from "../src/repl/layout.ts"; +import { decodeLocation, encodeLocation } from "../src/repl/route.ts"; +import { installReplHost } from "../src/repl-assembly.ts"; +import { installReplTerminal } from "../src/repl/terminal-host.ts"; +import type { ReplTerminalCapabilities } from "../src/repl/terminal-host.ts"; +import type { ReplTerminalSize } from "../src/repl/terminal.ts"; +import { ReplClock } from "../src/repl/frame.ts"; +import { runReplProgram } from "../src/repl/program.ts"; +import type { ReplOutcome } from "../src/repl/program.ts"; +import { appendFile, mkdtemp, open } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomBytes } from "node:crypto"; +import { fields, readDescription } from "../src/repl/description.ts"; +import type { ReplDescription } from "../src/repl/description.ts"; +import { useReplTree } from "../src/repl/reconcile.ts"; +import type { ReplTree } from "../src/repl/reconcile.ts"; + +/** The widest accepted frame, stated here because layout keeps it private. */ +const WIDE = { columns: 160, rows: 36 }; + +/** The Story's shape: three spawned children, each its own conversation. */ +const THREE_SPAWNS = [ + "", + '', + '', + '', + "", +].join("\n"); + +/** The child coroutine each `` runs on, in source order. */ +const REVIEWER = "root.0"; +const BUILDER = "root.1"; +const CHECKER = "root.2"; + +/** Every option kind a provider can offer, for the permission rows. */ +const ALL_KINDS: readonly PermissionOption[] = [ + { optionId: "once", name: "Allow once", kind: "allow_once" }, + { optionId: "always", name: "Allow for this session", kind: "allow_always" }, + { optionId: "no", name: "Deny once", kind: "reject_once" }, + { optionId: "never", name: "Deny for this session", kind: "reject_always" }, +]; + +/** + * How long a step a correct engine completes immediately may go uncompleted + * before the wait is called a deadlock. + * + * Never reached by a passing run: every step below is completed by the turn it + * names. It bounds only the failure mode, so a defect that stops one turn says + * which step it stopped at instead of hanging the suite. + */ +const DEADLOCK_MS = 10_000; + +interface Signal { + publish(): void; + readonly published: Operation; +} + +function signal(): Signal { + const resolvers = withResolvers(); + let settled = false; + return { + publish() { + if (!settled) { + settled = true; + resolvers.resolve(true); + } + }, + published: resolvers.operation, + }; +} + +function awaiting(what: string, reached: Operation): Operation { + return (function* () { + const arrived = yield* race([ + reached, + (function* (): Operation { + yield* sleep(DEADLOCK_MS); + return false; + })(), + ]); + if (!arrived) { + throw new Error(`${what} never happened`); + } + })(); +} + +/** What one turn does, and where it waits while doing it. */ +interface Script { + /** Hold before producing anything, which is a turn that stays queued. */ + readonly queued?: true; + /** Hold after the first delta, which is a turn that stays streaming. */ + readonly streaming?: true; + /** + * Hold after the terminal event and before returning the stream's value. + * + * The one hold that separates finishing from recording: everything this turn + * will ever produce has gone past, so it is terminal as far as anything + * watching can see, while a sibling can still settle and append first. + */ + readonly unrecorded?: true; + /** Ask for one permission before producing anything. */ + readonly permission?: { + readonly toolCallId: string; + readonly title?: string; + readonly kind?: string; + readonly options?: readonly PermissionOption[]; + }; + readonly status?: "completed" | "failed" | "cancelled"; +} + +/** The provider these rows drive, and the gates that hold its turns. */ +interface Stub { + readonly factory: AgentProviderFactory; + /** The outcome each permission request settled with, by tool call id. */ + readonly outcomes: Map; + /** How many times each request was answered, by tool call id. */ + readonly answers: Map; + /** Wait for the turn on this child coroutine to reach the provider. */ + reached(coroutine: string): Operation; + /** Wait for the turn on this child coroutine to emit its terminal event. */ + finished(coroutine: string): Operation; + /** Let the turn on this child coroutine start. */ + start(coroutine: string): void; + /** Let the turn on this child coroutine produce the rest of its deltas. */ + stream(coroutine: string): void; + /** Let the turn on this child coroutine return, which records it. */ + record(coroutine: string): void; +} + +function createStub(script: Record = {}): Stub { + const signals = new Map(); + const slot = (name: string): Signal => { + const existing = signals.get(name); + if (existing !== undefined) { + return existing; + } + const created = signal(); + signals.set(name, created); + return created; + }; + + const stub: Stub = { + outcomes: new Map(), + answers: new Map(), + reached(coroutine) { + return awaiting( + `the turn on ${coroutine} reaching the provider`, + slot(`@${coroutine}`).published, + ); + }, + finished(coroutine) { + return awaiting( + `the turn on ${coroutine} reaching its terminal event`, + slot(`~${coroutine}`).published, + ); + }, + start(coroutine) { + slot(`>${coroutine}`).publish(); + }, + stream(coroutine) { + slot(`#${coroutine}`).publish(); + }, + record(coroutine) { + slot(`!${coroutine}`).publish(); + }, + factory: function* (options) { + yield* Agent.around( + { + // deno-lint-ignore require-yield + *agent([name]) { + return name ?? options.defaultAgent ?? "stub-agent"; + }, + // deno-lint-ignore require-yield + *session([routed]) { + const name = typeof routed === "string" ? routed : routed?.name; + return { sessionKey: `stub:${name ?? "default"}`, cwd: "/stub" }; + }, + // deno-lint-ignore require-yield + *prompt([content, promptOptions]) { + return one( + stub, + script[content] ?? {}, + slot, + content, + promptOptions, + options.defaultAgent, + ); + }, + }, + { at: "min" }, + ); + }, + }; + return stub; +} + +/** One turn's cold stream, held at whichever gates its script asks for. */ +function one( + stub: Stub, + script: Script, + slot: (name: string) => Signal, + content: string, + options: PromptOptions | undefined, + defaultAgent: string | undefined, +): Stream { + return { + *[Symbol.iterator]() { + const routed = options?.session; + const session: Session = + typeof routed === "object" && routed !== null && "sessionKey" in routed + ? routed + : { sessionKey: "stub:default", cwd: "/stub" }; + const agent = + typeof options?.agent === "string" ? options.agent : (defaultAgent ?? "stub-agent"); + let stage = 0; + let announced = false; + let asked = false; + // Which turn this is. Two spawns may be written identically and may reach + // the provider in either order, so the child coroutine its `` was + // given in source order is the only stable way to name one. + let where = ""; + let held = false; + return { + *next() { + if (!announced) { + announced = true; + where = (yield* useScope()).get(DurableContext)?.coroutineId ?? ""; + slot(`@${where}`).publish(); + if (script.queued === true) { + yield* awaiting(`the turn on ${where} being started`, slot(`>${where}`).published); + } + } + if (stage === 0) { + stage = 1; + return { done: false, value: { type: "started", agent, session } }; + } + if (!asked) { + asked = true; + // Asked after `started`, which is when a provider knows which + // conversation it is in — so the request belongs to a turn that can + // say whose it is. + const wanted = script.permission; + if (wanted !== undefined) { + const request: PermissionRequest = { + session, + toolCall: { + toolCallId: wanted.toolCallId, + ...(wanted.title === undefined ? {} : { title: wanted.title }), + ...(wanted.kind === undefined ? {} : { kind: wanted.kind }), + }, + options: wanted.options ?? ALL_KINDS, + }; + const outcome = yield* Agent.operations.requestPermission(request); + stub.outcomes.set(wanted.toolCallId, outcome); + stub.answers.set(wanted.toolCallId, (stub.answers.get(wanted.toolCallId) ?? 0) + 1); + } + } + if (stage === 1) { + stage = 2; + return { done: false, value: { type: "text_delta", text: `${content} ` } }; + } + if (stage === 2) { + stage = 3; + if (script.streaming === true) { + yield* awaiting( + `the turn on ${where} being allowed to finish streaming`, + slot(`#${where}`).published, + ); + } + return { done: false, value: { type: "text_delta", text: "done" } }; + } + if (stage === 3) { + stage = 4; + slot(`~${where}`).publish(); + return { + done: false, + value: { type: "terminal", status: script.status ?? "completed" }, + }; + } + if (script.unrecorded === true && !held) { + held = true; + yield* awaiting( + `the turn on ${where} being allowed to record`, + slot(`!${where}`).published, + ); + } + return { done: true, value: `${content} done` }; + }, + }; + }, + }; +} + +/** Install the provider on this scope, the way a host installs one. */ +function* useStub(stub: Stub): Operation { + yield* installAgentComponents({ + defaultAgent: "stub-agent", + rootProvider: { + factory: stub.factory, + options: { defaultAgent: "stub-agent", permissionMode: "deny-all" }, + }, + }); +} + +function installations(): readonly ExecutionInstallation[] { + return [{ evaluation: ordinaryEvaluationProfile() }, { components: agentIdentityComponents() }]; +} + +function execution(): ReplExecution { + return { id: "agent-interface", stream: new InMemoryStream([]) }; +} + +/** The session, or the refusal that means there is nothing to drive. */ +function granted(result: Result): ReplSession { + if (!result.ok) { + throw result.error; + } + return result.value; +} + +/** This process's overlay, exactly as the program reads it into a view. */ +function liveReading(session: ReplSession): ReplLive { + return { + output: session.overlay.output, + question: session.overlay.question, + expansion: session.expansion.state, + pausable: session.controller !== undefined, + agent: session.agent, + }; +} + +/** The view this state reads as, or the failure that stopped it. */ +function reading( + state: ReplState, + session: ReplSession, + size = WIDE, + focused?: string, + model: ReplModel = session.model, +): ReplView { + const resolved = viewFor(state, model, liveReading(session), size, focused); + if (!resolved.ok) { + throw resolved.error; + } + return resolved.value; +} + +/** This journal, projected at the position a frozen route names. */ +function* projectedAt(holder: ReplExecution, marker: string): Operation { + const projected = projectRepl(yield* holder.stream.readAll(), marker); + if (!projected.ok) { + throw projected.error; + } + return projected.value; +} + +/** Every described row, flattened, with its key and label. */ +function rowsOf(descriptions: readonly ReplDescription[]): Array<{ + key: string; + label: string; +}> { + const found: Array<{ key: string; label: string }> = []; + const walk = (description: ReplDescription): void => { + const read = readDescription(description); + const named = fields(read.input); + const label = named?.["label"] ?? named?.["text"] ?? ""; + found.push({ key: read.key, label: typeof label === "string" ? label : "" }); + for (const child of read.children ?? []) { + walk(child); + } + }; + for (const description of descriptions) { + walk(description); + } + return found; +} + +/** The keys this state describes, in order. */ +function keysOf(view: ReplView): string[] { + return rowsOf(describeApplication(view)).map((one) => one.key); +} + +/** Commit one view into the real tree, refusing to assert past a rejected set. */ +function* applied(tree: ReplTree, view: ReplView): Operation { + const result = yield* tree.apply(describeApplication(view)); + if (!result.ok) { + throw result.error; + } +} + +/** The key of whatever holds focus now, as the root reads it. */ +function keyed(tree: ReplTree): string | undefined { + const node = tree.focused(); + return node === undefined ? undefined : tree.keyOf(node); +} + +/** The mounted node this key names, or none. */ +function nodeOf(tree: ReplTree, key: string): string | undefined { + return tree.mounted().find((id) => tree.keyOf(id) === key); +} + +/** Tab until the control this key names holds focus, the way a person reaches it. */ +function* focusTo(tree: ReplTree, key: string): Operation { + for (let press = 0; press < 400; press++) { + if (keyed(tree) === key) { + return; + } + yield* tree.dispatch({ kind: "key", key: "Tab" }); + } + throw new Error(`focus never reached ${key}`); +} + +/** Activate the focused control, and answer what it asked for. */ +function* activate(tree: ReplTree): Operation { + const dispatched = yield* tree.dispatch({ kind: "key", key: "Enter" }); + if (!dispatched.ok || dispatched.value.outcome !== "action") { + throw new Error("the focused control produced no action"); + } + return dispatched.value.action; +} + +/** Activate the control this key names with a pointer, against the drawn frame. */ +function* clicked(tree: ReplTree, key: string): Operation { + const node = nodeOf(tree, key); + if (node === undefined) { + throw new Error(`no mounted node is keyed ${key}`); + } + const dispatched = yield* tree.dispatch({ + kind: "pointer", + target: node, + frame: tree.frame().id, + }); + if (!dispatched.ok || dispatched.value.outcome !== "action") { + throw new Error(`the pointer on ${key} produced no action`); + } + return dispatched.value.action; +} + +/** Every turn control this view draws, in the order it draws them. */ +/** + * A turn's own key may hold colons — a recorded one is keyed by its marker — so + * its detail lines are told apart by what they end with rather than by counting + * separators. + */ +const DETAILS = [":whose", ":text", ":stop", ":failed"]; + +function turnRows(view: ReplView): Array<{ key: string; label: string }> { + return rowsOf(describeApplication(view)).filter( + (one) => + one.key.startsWith("sessions:turn:") && !DETAILS.some((suffix) => one.key.endsWith(suffix)), + ); +} + +/** Every conversation control this view offers, sorted so order is its own row. */ +function conversationRows(view: ReplView): string[] { + return rowsOf(describeApplication(view)) + .filter((one) => one.key.startsWith("sessions:conversation:")) + .map((one) => one.key) + .sort(); +} + +/** The label of the turn control showing this prompt. */ +function labelOf(view: ReplView, prompt: string): string { + const row = turnRows(view).find((one) => one.label.includes(prompt)); + if (row === undefined) { + throw new Error(`no turn row shows the prompt "${prompt}"`); + } + return row.label; +} + +/** One of a turn's own detail lines, by suffix, or none when it has none. */ +function detailOf(view: ReplView, prompt: string, suffix: string): string | undefined { + const key = turnKeyed(view, prompt); + return rowsOf(describeApplication(view)).find((one) => one.key === `${key}:${suffix}`)?.label; +} + +/** One turn row's key, by the prompt text its label starts with. */ +function turnKeyed(view: ReplView, prompt: string): string { + const row = turnRows(view).find((one) => one.label.includes(prompt)); + if (row === undefined) { + throw new Error(`no turn row shows the prompt "${prompt}"`); + } + return row.key; +} + +/** + * Start the three-spawn document with this script, and hand back its session. + * + * `approve-reads` by default, because that is the mode that leaves a decision to + * a person: `approve-all` and `deny-all` answer everything themselves, so a + * request would never be published and there would be nothing to present. + */ +function* asking( + script: Record, + permissionMode: PermissionMode = "approve-reads", +): Operation<{ + readonly session: ReplSession; + readonly stub: Stub; + readonly holder: ReplExecution; +}> { + const stub = createStub(script); + yield* useStub(stub); + const holder = execution(); + const session = granted( + yield* submitReplEntry({ + execution: holder, + installations: installations(), + permissionMode, + source: THREE_SPAWNS, + }), + ); + return { session, stub, holder }; +} + +describe("U1 — one chronology, filtered, undisturbed by the background", () => { + beforeAll(() => useTempFileCompiler()); + + it("U1: retained, streaming and queued turns show together, in scheduling order", function* () { + const { session, stub } = yield* asking({ + review: {}, + build: { streaming: true }, + check: { queued: true }, + }); + // Every turn reaches the provider before any of them is let go, so the order + // they were scheduled in is this test's to decide rather than the + // scheduler's. + yield* stub.reached(REVIEWER); + yield* stub.reached(BUILDER); + yield* stub.reached(CHECKER); + // The reviewer runs to its record; the builder stops mid-stream; the checker + // never starts. + yield* until(session, "all three Prompts being observed", () => observed(session) === 3); + yield* until(session, "the reviewer's turn being recorded", () => recorded(session) === 1); + + const view = reading(initialState("agents"), session); + // Three slots, one screen: a recorded turn, a turn still streaming and a turn + // that has not started, all present at once. + expect(turnRows(view)).toHaveLength(3); + expect(labelOf(view, "review")).toContain("completed, recorded"); + expect(labelOf(view, "build")).toContain("streaming"); + expect(labelOf(view, "check")).toContain("queued"); + // Each started turn says whose it is, and the queued one cannot: the provider + // has not said which conversation it joined, and the authored + // `` is not an answer to that. + expect(detailOf(view, "review", "whose")).toContain("stub:reviewer"); + expect(detailOf(view, "build", "whose")).toContain("stub:builder"); + expect(detailOf(view, "check", "whose")).toBe(undefined); + // So the conversations offered are exactly the two the provider started. + expect(conversationRows(view)).toEqual([ + "sessions:conversation:stub:builder", + "sessions:conversation:stub:reviewer", + ]); + }); + + it("U1: an earlier live turn stays before a later recorded one, and survives its own record", function* () { + // Every turn runs to its terminal event and then waits, so which one is + // *recorded* first is this row's to decide rather than the scheduler's. + const { session, stub } = yield* asking({ + review: { unrecorded: true }, + build: { unrecorded: true }, + check: { unrecorded: true }, + }); + yield* stub.finished(REVIEWER); + yield* stub.finished(BUILDER); + yield* stub.finished(CHECKER); + + // Which turn was scheduled first is the scheduler's business, so it is read + // rather than assumed — and then the *later* one is recorded first, which is + // the order a reader must not be shown. + const order = [...session.agent.slots].sort((left, right) => left.order - right.order); + const first = promptOf(session, order[0]?.key); + const last = promptOf(session, order[order.length - 1]?.key); + expect(first).not.toBe(last); + stub.record(coroutineFor(last)); + yield* until(session, `${last} being recorded`, () => recorded(session) === 1); + + const before = reading(initialState("agents"), session); + expect(turnRows(before)).toHaveLength(3); + expect(labelOf(before, first)).toContain("not recorded yet"); + expect(labelOf(before, last)).toContain("completed, recorded"); + // The earlier turn is still live and the later one is already in the + // history, and the earlier one is still first. Appending the live list to + // the retained one would put it last. + const shown = turnRows(before).map((one) => one.label); + expect(shown.findIndex((label) => label.includes(first))).toBeLessThan( + shown.findIndex((label) => label.includes(last)), + ); + + // Focus the live turn, then let it record underneath the person looking at it. + const tree = yield* useReplTree(); + yield* applied(tree, before); + const mounted = turnKeyed(before, first); + yield* focusTo(tree, mounted); + expect(keyed(tree)).toBe(mounted); + + stub.record(coroutineFor(first)); + yield* until(session, `${first} being recorded`, () => recorded(session) === 2); + const after = reading(initialState("agents"), session, WIDE, keyed(tree)); + // The same node: publication changed where its facts come from, not which + // turn a person is looking at. + expect(turnKeyed(after, first)).toBe(mounted); + expect(turnRows(after)).toHaveLength(3); + expect(labelOf(after, first)).toContain("completed, recorded"); + // And it is still in the same place, before the one that recorded first. + const later = turnRows(after).map((one) => one.label); + expect(later.findIndex((label) => label.includes(first))).toBeLessThan( + later.findIndex((label) => label.includes(last)), + ); + yield* applied(tree, after); + expect(keyed(tree)).toBe(mounted); + }); + + it("U1: conversations are ordered by their earliest turn, and selecting one changes only the filter", function* () { + const { session, stub } = yield* asking({ + review: { streaming: true }, + build: { streaming: true }, + check: { queued: true }, + }); + yield* until( + session, + "both started turns reporting their conversation", + () => started(session) === 2, + ); + // In the order the provider was reached, among the turns it has answered: + // which spawn gets there first is the scheduler's business, and a queued turn + // has no conversation to be ordered by at all. + const [earlier, later] = startedInOrder(session); + + // Activity on the *later* conversation, so the two orders disagree: by + // earliest turn it is still second, and by anything to do with recency it + // would be first. + stub.stream(coroutineFor(later)); + yield* stub.finished(coroutineFor(later)); + yield* until(session, `${later} finishing`, () => recorded(session) >= 1); + + const standing = initialState("agents"); + const view = reading(standing, session); + expect(conversationOrder(view)).toEqual([ + `sessions:conversation:stub:${whose(earlier)}`, + `sessions:conversation:stub:${whose(later)}`, + ]); + + // Selecting one is one semantic action carrying one key, and it moves the + // filter and nothing else. + const transition = reduceRepl( + standing, + { kind: "select-session", session: `stub:${whose(later)}` }, + session.model, + liveReading(session), + WIDE, + ); + expect(transition.intent.kind).toBe("none"); + expect(transition.state.route).toEqual({ + ...standing.route, + session: `stub:${whose(later)}`, + }); + // Only the Sessions rows narrow. The entry, its scopes and the transcript are + // the same reading they were. + const filtered = reading(transition.state, session); + expect(turnRows(filtered).map((one) => one.label.includes(later))).toEqual([true]); + expect( + keysOf(filtered).filter((key) => key.startsWith("entry:") || key.startsWith("line:")), + ).toEqual(keysOf(view).filter((key) => key.startsWith("entry:") || key.startsWith("line:"))); + + // And All puts every turn back, removing only the filter. + const cleared = reduceRepl( + transition.state, + { kind: "all-sessions" }, + session.model, + liveReading(session), + WIDE, + ); + expect(cleared.state.route).toEqual(standing.route); + expect(turnRows(reading(cleared.state, session))).toHaveLength(3); + }); + + it("U1: background Agent work changes no route, no filter and not where focus is", function* () { + const { session, stub } = yield* asking({ + review: { streaming: true }, + build: { streaming: true }, + check: { + queued: true, + // Not a read: `approve-reads` answers those itself, and what this row + // needs is the request that reaches a person. + permission: { toolCallId: "call-1", title: "Write a file", kind: "edit" }, + }, + }); + yield* until(session, "both started turns", () => started(session) === 2); + const [earlier] = startedInOrder(session); + + // Filtered to one conversation, with focus held on one exact control. + const filtered = reduceRepl( + initialState("agents"), + { kind: "select-session", session: `stub:${whose(earlier)}` }, + session.model, + liveReading(session), + WIDE, + ).state; + const before = reading(filtered, session); + const tree = yield* useReplTree(); + yield* applied(tree, before); + const held = turnKeyed(before, earlier); + yield* focusTo(tree, held); + + // Now everything happens in the *other* turns: one starts, one asks for + // permission, one streams and finishes. + stub.start(CHECKER); + yield* until( + session, + "the third turn asking for permission", + () => session.agent.requests.length === 1, + ); + const after = reading(filtered, session, WIDE, keyed(tree)); + yield* applied(tree, after); + // The route is untouched, the filter still holds, and focus has not moved. + expect(after.state.route).toEqual(before.state.route); + expect(keyed(tree)).toBe(held); + // The request arrived and opened nothing. + expect(after.state.route.drawers).toEqual([]); + expect(after.state.permission).toBe(undefined); + }); + + it("U1: a historical prefix shows no live turn and no live request", function* () { + const { session, stub, holder } = yield* asking({ + review: {}, + build: { streaming: true }, + check: { queued: true, permission: { toolCallId: "call-1", kind: "edit" } }, + }); + yield* until(session, "all three Prompts being observed", () => observed(session) === 3); + yield* until(session, "one recorded turn", () => recorded(session) === 1); + stub.start(CHECKER); + yield* until( + session, + "the queued turn asking for permission", + () => session.agent.requests.length === 1, + ); + + const head = reading(initialState("agents"), session); + expect(turnRows(head)).toHaveLength(3); + const recordedTurn = session.model.turns[0]; + if (recordedTurn === undefined) { + throw new Error("the reviewer's turn was not projected"); + } + + // Frozen at the position that turn was recorded at. A prefix is a different + // reading of the file, and this process's live work is not in it. + const frozen = reduceRepl( + initialState("agents"), + { kind: "select-marker", marker: recordedTurn.marker }, + session.model, + liveReading(session), + WIDE, + ).state; + const at = yield* projectedAt(holder, recordedTurn.marker); + const past = reading(frozen, session, WIDE, undefined, at); + expect(turnRows(past)).toHaveLength(1); + expect(labelOf(past, recordedTurn.input)).toContain("recorded"); + // No live request is reachable there, whatever the head holds. + expect(keysOf(past).some((key) => key.startsWith("sessions:request:"))).toBe(false); + // And a conversation only this process knows about cannot be selected there. + const refused = reduceRepl( + frozen, + { kind: "select-session", session: "stub:checker" }, + at, + liveReading(session), + WIDE, + ); + expect(refused.state.route).toEqual(frozen.route); + expect(refused.state.refusal).toBeDefined(); + }); +}); + +/** + * The prompts of the turns that have started, in the order they were observed. + * + * Slot order, which is the order the Prompts were scheduled in — and only the + * ones the provider has answered, because a queued turn has no conversation to + * be ordered or filtered by. + */ +function startedInOrder(session: ReplSession): string[] { + return [...session.agent.slots] + .sort((left, right) => left.order - right.order) + .map((slot) => session.agent.turns.find((turn) => turn.key === slot.key)) + .filter((turn) => turn?.sessionKey !== undefined) + .map((turn) => turn?.prompt ?? ""); +} + +/** How many observed turns have been told which conversation they joined. */ +function started(session: ReplSession): number { + return session.agent.turns.filter((turn) => turn.sessionKey !== undefined).length; +} + +/** The authored `` the spawn asking this prompt routed. */ +function whose(prompt: string): string { + const named: Record = { + review: "reviewer", + build: "builder", + check: "checker", + }; + const name = named[prompt]; + if (name === undefined) { + throw new Error(`this document has no spawn asking "${prompt}"`); + } + return name; +} + +/** Every conversation control, in the order this view draws them. */ +function conversationOrder(view: ReplView): string[] { + return rowsOf(describeApplication(view)) + .filter((one) => one.key.startsWith("sessions:conversation:")) + .map((one) => one.key); +} + +/** The prompt text the live turn or published slot with this key was asked. */ +function promptOf(session: ReplSession, key: string | undefined): string { + const live = session.agent.turns.find((turn) => turn.key === key); + if (live !== undefined) { + return live.prompt; + } + const slot = session.agent.slots.find((candidate) => candidate.key === key); + if (slot?.last !== undefined) { + return slot.last.prompt; + } + throw new Error(`no observed turn is keyed ${key}`); +} + +/** The child coroutine the `` holding this prompt runs on. */ +function coroutineFor(prompt: string): string { + const spawned: Record = { + review: REVIEWER, + build: BUILDER, + check: CHECKER, + }; + const coroutine = spawned[prompt]; + if (coroutine === undefined) { + throw new Error(`this document has no spawn asking "${prompt}"`); + } + return coroutine; +} + +/** + * Wait until this session says `holds`, or say it never did. + * + * The session is read rather than a change stream: a signal delivers to whoever + * is pulling at that moment, and these rows wait for states a turn passes + * through. What a row waits on is monotone — a record appended, a turn observed + * — so reading is exact. + */ +function until(session: ReplSession, what: string, holds: () => boolean): Operation { + return (function* () { + // Bounded by time rather than by a number of turns: how many turns of the + // loop a state takes depends on what else the machine is doing, and a count + // that is generous on an idle machine is a flake on a busy one. + const deadline = Date.now() + DEADLOCK_MS; + while (!holds()) { + if (Date.now() > deadline) { + throw new Error(`this session never reached ${what}`); + } + yield* sleep(0); + } + })(); +} + +/** + * How many Prompts this process has observed at all. + * + * Waited for wherever a row counts turn rows: the three spawns reach the provider + * whenever the scheduler runs them, so a row that counted before the third one + * was observed would be counting how fast the machine is. + */ +function observed(session: ReplSession): number { + return session.agent.slots.length; +} + +/** How many of this process's observed Prompts have been recorded. */ +function recorded(session: ReplSession): number { + return session.agent.slots.filter((slot) => slot.durable !== undefined).length; +} + +describe("U2 — one action path, and one owner for a pending request", () => { + beforeAll(() => useTempFileCompiler()); + + it("U2: Enter and a pointer on the same control ask for the same thing", function* () { + const { session, stub } = yield* asking({ + review: { streaming: true, permission: { toolCallId: "call-1", kind: "edit" } }, + build: { streaming: true }, + check: { queued: true }, + }); + yield* until(session, "a request waiting", () => session.agent.requests.length === 1); + const request = session.agent.requests[0]; + if (request === undefined) { + throw new Error("no request was published"); + } + const standing = onSessions(session); + const view = reading(standing, session); + const tree = yield* useReplTree(); + yield* applied(tree, view); + + // Every control a person can reach here, asked for twice: once with Enter on + // the focused control, once with a pointer resolved against the frame that + // drew it. A router that decided at the target rather than through mounted + // dispatch would answer differently to one of them. + const conversation = conversationOrder(view)[0]; + if (conversation === undefined) { + throw new Error("no conversation control was drawn"); + } + for (const key of ["sessions:all", conversation, `sessions:request:${request.key}`]) { + yield* focusTo(tree, key); + const pressed = yield* activate(tree); + const clickedOn = yield* clicked(tree, key); + expect(clickedOn).toEqual(pressed); + } + // And the same for the choices, which only exist once the drawer is open. + const opened = reduceRepl( + standing, + { kind: "select-permission", request: request.key }, + session.model, + liveReading(session), + WIDE, + ).state; + yield* applied(tree, reading(opened, session)); + for (const choice of request.choices) { + const key = `drawer:permission:choice:${choice.optionId}`; + yield* focusTo(tree, key); + const pressed = yield* activate(tree); + const clickedOn = yield* clicked(tree, key); + expect(clickedOn).toEqual(pressed); + expect(pressed).toEqual({ + kind: "choose-permission", + request: request.key, + option: choice.optionId, + }); + } + // Nothing was settled by asking: an action is a question for the root. + expect(stub.outcomes.size).toBe(0); + }); + + it("U2: a request arriving opens nothing, moves nothing and claims no focus", function* () { + const { session, stub } = yield* asking({ + review: { streaming: true }, + build: { streaming: true, permission: { toolCallId: "call-1", kind: "edit" } }, + check: { queued: true }, + }); + yield* until(session, "a started turn", () => started(session) >= 1); + const standing = onSessions(session); + const before = reading(standing, session); + const tree = yield* useReplTree(); + yield* applied(tree, before); + const held = "sessions:all"; + yield* focusTo(tree, held); + + // The request arrives with somebody looking at something else. + yield* until(session, "a request waiting", () => session.agent.requests.length === 1); + const request = session.agent.requests[0]; + if (request === undefined) { + throw new Error("no request was published"); + } + const after = reading(standing, session, WIDE, keyed(tree)); + yield* applied(tree, after); + // It is there to be seen, on the turn that is waiting. + expect(keysOf(after)).toContain(`sessions:request:${request.key}`); + expect(turnKeyed(after, "build")).toBe(`sessions:turn:${request.turn}`); + // And it opened nothing, selected nothing and took nothing. + expect(after.state.route).toEqual(before.state.route); + expect(after.state.permission).toBe(undefined); + expect(keyed(tree)).toBe(held); + expect(stub.outcomes.size).toBe(0); + }); + + it("U2: activating it opens the drawer, and one choice settles that request once", function* () { + const { session, stub } = yield* asking({ + review: { + streaming: true, + permission: { toolCallId: "call-1", title: "Write", kind: "edit" }, + }, + build: { streaming: true }, + check: { queued: true }, + }); + yield* until(session, "a request waiting", () => session.agent.requests.length === 1); + const request = session.agent.requests[0]; + if (request === undefined) { + throw new Error("no request was published"); + } + const standing = onSessions(session); + const tree = yield* useReplTree(); + yield* applied(tree, reading(standing, session)); + yield* focusTo(tree, `sessions:request:${request.key}`); + const asked = yield* activate(tree); + expect(asked).toEqual({ kind: "select-permission", request: request.key }); + + const opening = reduceRepl(standing, asked, session.model, liveReading(session), WIDE); + expect(opening.state.permission).toBe(request.key); + // The route says a permission drawer is open and nothing about which request: + // a live key in a location would publish an identity nothing else can use. + expect(opening.state.route.drawers).toEqual([{ kind: "live-permission" }]); + const location = reading(opening.state, session).location; + expect(location).toContain("+permission"); + expect(location).not.toContain(request.key); + + // Every choice the provider offered is drawn, scoped to this session where it + // is a lasting one. + const drawer = reading(opening.state, session); + const drawn = rowsOf(describeApplication(drawer)).filter((one) => + one.key.startsWith("drawer:permission:choice:"), + ); + expect(drawn).toHaveLength(request.choices.length); + expect(drawn.find((one) => one.key.endsWith("always"))?.label).toContain( + "for this Agent session", + ); + expect(JSON.stringify(drawn)).not.toContain("machine"); + + // Choosing one: the reducer decides, the root calls the authority once. + yield* applied(tree, drawer); + yield* focusTo(tree, "drawer:permission:choice:once"); + const chose = yield* activate(tree); + const settling = reduceRepl(opening.state, chose, session.model, liveReading(session), WIDE); + expect(settling.intent).toEqual({ + kind: "settle-permission", + request: request.key, + option: "once", + turn: request.turn, + }); + expect(answer(session, settling.intent)).toBe(true); + yield* until(session, "the request being answered", () => stub.outcomes.size === 1); + expect(stub.outcomes.get("call-1")).toEqual({ outcome: "selected", optionId: "once" }); + expect(stub.answers.get("call-1")).toBe(1); + + // The drawer goes because the request is gone, and focus returns to the turn + // that was waiting. + const done = permissionSettled(settling.state, request.turn); + expect(done.permission).toBe(undefined); + expect(done.route.drawers).toEqual([]); + const restored = reading(done, session, WIDE, keyed(tree)); + expect(focusClaim(restored)).toBe(`sessions:turn:${request.turn}`); + yield* applied(tree, restored); + expect(keyed(tree)).toBe(`sessions:turn:${request.turn}`); + // Spent once, so traversal from there is the person's. + expect(focusSettled(restored, keyed(tree)).restore).toBe(undefined); + // And the other conversation is still running. + expect(session.agent.turns.some((turn) => turn.prompt === "build")).toBe(true); + }); + + it("U2: closing the drawer denies exactly once, through the authority", function* () { + const { session, stub } = yield* asking({ + review: { streaming: true, permission: { toolCallId: "call-1", kind: "edit" } }, + build: { streaming: true }, + check: { queued: true }, + }); + yield* until(session, "a request waiting", () => session.agent.requests.length === 1); + const request = session.agent.requests[0]; + if (request === undefined) { + throw new Error("no request was published"); + } + const opened = reduceRepl( + onSessions(session), + { kind: "select-permission", request: request.key }, + session.model, + liveReading(session), + WIDE, + ).state; + const tree = yield* useReplTree(); + yield* applied(tree, reading(opened, session)); + + // Escape on the drawer, which is the ordinary dismissal — and it does not + // close anything by itself, because denying is the authority's to do. + const dismissed = yield* tree.dispatch({ kind: "key", key: "Escape" }); + if (!dismissed.ok || dismissed.value.outcome !== "action") { + throw new Error("Escape reached no drawer"); + } + const transition = reduceRepl( + opened, + dismissed.value.action, + session.model, + liveReading(session), + WIDE, + ); + expect(transition.intent).toEqual({ + kind: "settle-permission", + request: request.key, + option: undefined, + turn: request.turn, + }); + expect(transition.state.route.drawers).toEqual([{ kind: "live-permission" }]); + expect(answer(session, transition.intent)).toBe(true); + yield* until(session, "the request being denied", () => stub.outcomes.size === 1); + // Denied once, by the provider's own denial rather than a rule spelled here. + expect(stub.answers.get("call-1")).toBe(1); + expect(stub.outcomes.get("call-1")).toBeDefined(); + // A second dismissal of the same key settles nothing more. + expect(session.permissions.dismiss(request.key)).toBe(false); + expect(stub.answers.get("call-1")).toBe(1); + // The session is still live: dismissing one request is not cancelling it. + expect(session.live).toBe(true); + }); + + it("U2: an unknown request or an unoffered option changes nothing and calls nobody", function* () { + const { session, stub } = yield* asking({ + review: { streaming: true, permission: { toolCallId: "call-1", kind: "edit" } }, + build: { streaming: true }, + check: { queued: true }, + }); + yield* until(session, "a request waiting", () => session.agent.requests.length === 1); + const request = session.agent.requests[0]; + if (request === undefined) { + throw new Error("no request was published"); + } + const opened = reduceRepl( + onSessions(session), + { kind: "select-permission", request: request.key }, + session.model, + liveReading(session), + WIDE, + ).state; + + // A key this screen never selected, an option the provider never offered, and + // a dismissal of somebody else's request: each changes no route and settles + // nothing. + const stale: readonly ReplAction[] = [ + { kind: "choose-permission", request: "nobody", option: "once" }, + { kind: "choose-permission", request: request.key, option: "not-offered" }, + { kind: "dismiss-permission", request: "nobody" }, + ]; + for (const action of stale) { + const refused = reduceRepl(opened, action, session.model, liveReading(session), WIDE); + expect(refused.intent.kind).toBe("none"); + expect(refused.state.route).toEqual(opened.route); + expect(refused.state.refusal).toBeDefined(); + } + // The authority refuses them too, when asked directly. + expect(session.permissions.choose("nobody", "once")).toBe(false); + expect(session.permissions.choose(request.key, "not-offered")).toBe(false); + expect(stub.outcomes.size).toBe(0); + }); + + it("U2: a recorded permission audit is read, never answered", function* () { + // Reconstructed from a journal a real run recorded, because what this row owns + // is how a *retained* audit is presented: read, with no target, no action and + // no authority call. How a live turn comes to retain one is Slice B's, and is + // proved there. + const holder: ReplExecution = { + id: "agent-audits", + stream: new InMemoryStream([...(yield* agentReferenceEvents())]), + }; + const session = granted( + yield* openReplSession({ execution: holder, installations: installations() }), + ); + const recordedAudits = session.model.turns.flatMap((turn) => turn.permissions); + expect(recordedAudits.length).toBeGreaterThan(0); + const view = reading(onSessions(session), session); + const audits = rowsOf(describeApplication(view)).filter((one) => + one.key.startsWith("sessions:audit:"), + ); + expect(audits).toHaveLength(recordedAudits.length); + // What it was granted and how that was answered, in the record's own words. + expect(audits[0]?.label).toContain("granted:"); + // Readable, and nothing to activate: a record is what a turn was granted. + const tree = yield* useReplTree(); + yield* applied(tree, view); + const node = nodeOf(tree, audits[0]?.key ?? ""); + expect(node).toBeDefined(); + const aimed = yield* tree.dispatch({ + kind: "pointer", + target: node ?? "", + frame: tree.frame().id, + }); + expect(aimed.ok && aimed.value.outcome === "action").toBe(false); + // And no request is waiting on anybody: a replay asks nobody anything. + expect(session.agent.requests).toEqual([]); + expect(keysOf(view).some((key) => key.startsWith("sessions:request:"))).toBe(false); + }); +}); + +/** The same state, on the surface a permission is answered from. */ +function onSessions(session: ReplSession, state = initialState("agents")): ReplState { + const moved = reduceRepl( + state, + { kind: "select-surface", surface: "sessions" }, + session.model, + liveReading(session), + WIDE, + ); + if (moved.state.refusal !== undefined) { + throw new Error(`the Sessions surface refused: ${moved.state.refusal}`); + } + return moved.state; +} + +/** + * Perform one settlement the way the root performs it. + * + * The authority and nothing else: the reducer decided, and this is the one thing + * that holds the capability to answer. + */ +function answer(session: ReplSession, intent: ReplIntent): boolean { + if (intent.kind !== "settle-permission") { + throw new Error("this intent settles no permission"); + } + return intent.option === undefined + ? session.permissions.dismiss(intent.request) + : session.permissions.choose(intent.request, intent.option); +} + +describe("U3 — what each accepted frame mounts, and nothing else", () => { + beforeAll(() => useTempFileCompiler()); + + it("U3: wide keeps Sessions in the sidebar and leaves the other readings alone", function* () { + const { session, stub } = yield* asking({ + review: { streaming: true, permission: { toolCallId: "call-1", kind: "edit" } }, + build: { streaming: true }, + check: { queued: true }, + }); + yield* until(session, "a request waiting", () => session.agent.requests.length === 1); + // Two conversations, so filtering to one is observably narrower than All. + yield* until(session, "two started turns", () => started(session) >= 2); + const request = session.agent.requests[0]; + if (request === undefined) { + throw new Error("no request was published"); + } + const tree = yield* useReplTree(); + + const all = onSessions(session); + // A conversation this reading actually offers: which turns have started is + // the scheduler's business, so the filter is chosen from what is drawn. + const offered = conversationOrder(reading(all, session, WIDE))[0]; + if (offered === undefined) { + throw new Error("no conversation control was drawn"); + } + const filtered = reduceRepl( + all, + { kind: "select-session", session: offered.slice("sessions:conversation:".length) }, + session.model, + liveReading(session), + WIDE, + ).state; + expect(filtered.refusal).toBe(undefined); + const drawered = reduceRepl( + all, + { kind: "select-permission", request: request.key }, + session.model, + liveReading(session), + WIDE, + ).state; + + const placed: Array<{ sessions: string[]; transcript: string[]; inspection: string[] }> = []; + for (const state of [all, filtered, drawered]) { + const view = reading(state, session, WIDE); + yield* applied(tree, view); + const surface = replSurface(tree, view); + placed.push({ + sessions: surface.sessions.map((cell) => tree.keyOf(cell.node) ?? ""), + transcript: surface.transcript.map((cell) => tree.keyOf(cell.node) ?? ""), + inspection: surface.inspection.map((cell) => tree.keyOf(cell.node) ?? ""), + }); + } + // Sessions is in the sidebar at this size, and it holds the turns. + expect(placed[0]?.sessions.some((key) => key.startsWith("sessions:turn:"))).toBe(true); + // Filtering and opening the drawer change what Sessions shows and leave the + // document transcript and the inspection column exactly as they were. + expect(placed[1]?.transcript).toEqual(placed[0]?.transcript); + expect(placed[2]?.transcript).toEqual(placed[0]?.transcript); + expect(placed[1]?.inspection).toEqual(placed[0]?.inspection); + expect(placed[2]?.inspection).toEqual(placed[0]?.inspection); + expect(placed[1]?.sessions).not.toEqual(placed[0]?.sessions); + }); + + it("U3: narrow Sessions mounts its own outlet and nothing of the other panes", function* () { + const { session } = yield* asking({ + review: { streaming: true }, + build: { streaming: true }, + check: { queued: true }, + }); + yield* until(session, "a started turn", () => started(session) >= 1); + const tree = yield* useReplTree(); + const view = reading(onSessions(session), session, NARROW); + yield* applied(tree, view); + const frame = layout(NARROW, replSurface(tree, view)); + + // Absent, not clipped: the entry list, the transcript and the inspection + // column are not mounted, so they are in no frame, no target map and no + // pointer's way. + const mounted = tree.mounted().map((id) => tree.keyOf(id) ?? ""); + for (const prefix of ["entries:", "entry:", "scope:", "line:", "binding:", "elicit:"]) { + expect(mounted.filter((key) => key.startsWith(prefix))).toEqual([]); + expect(keysOf(view).filter((key) => key.startsWith(prefix))).toEqual([]); + } + const drawn = frame.cells.map((cell) => tree.keyOf(cell.node) ?? ""); + expect(drawn.some((key) => key.startsWith("sessions:turn:"))).toBe(true); + expect(drawn.filter((key) => key.startsWith("entry") || key.startsWith("line:"))).toEqual([]); + // Every target this frame offers is a control, and every one of them is + // mounted: nothing offers itself to a pointer and then does nothing. + for (const cell of frame.cells.filter((one) => one.targetable)) { + const key = tree.keyOf(cell.node); + expect(key).toBeDefined(); + expect(nodeOf(tree, key ?? "")).toBe(cell.node); + expect(TURN_FACT_SUFFIXES.some((suffix) => (key ?? "").endsWith(suffix))).toBe(false); + } + }); + + it("U3: narrow REPL mounts no Sessions row", function* () { + const { session } = yield* asking({ + review: { streaming: true }, + build: { streaming: true }, + check: { queued: true }, + }); + yield* until(session, "a started turn", () => started(session) >= 1); + const tree = yield* useReplTree(); + // The ordinary REPL route, which is where this process starts. + const view = reading(initialState("agents"), session, NARROW); + yield* applied(tree, view); + const mounted = tree.mounted().map((id) => tree.keyOf(id) ?? ""); + expect(mounted.filter((key) => key.startsWith("sessions:"))).toEqual([]); + // The entry list is what this frame is for. + expect(mounted.some((key) => key.startsWith("entries:"))).toBe(true); + }); + + it("U3: the permission drawer traps focus, keeps History inside it and hides what is behind", function* () { + const { session } = yield* asking({ + review: { streaming: true, permission: { toolCallId: "call-1", kind: "edit" } }, + build: { streaming: true }, + check: { queued: true }, + }); + yield* until(session, "a request waiting", () => session.agent.requests.length === 1); + const request = session.agent.requests[0]; + if (request === undefined) { + throw new Error("no request was published"); + } + const tree = yield* useReplTree(); + const standing = onSessions(session); + yield* applied(tree, reading(standing, session, WIDE)); + // A node behind the drawer, remembered before it opens. + const behind = nodeOf(tree, "sessions:all"); + expect(behind).toBeDefined(); + + const opened = reduceRepl( + standing, + { kind: "select-permission", request: request.key }, + session.model, + liveReading(session), + WIDE, + ).state; + yield* applied(tree, reading(opened, session, WIDE)); + + // Focus is trapped: Tab all the way round and every stop is inside the modal. + const seen = new Set(); + for (let press = 0; press < 40; press++) { + const key = keyed(tree); + expect(key).toBeDefined(); + seen.add(key ?? ""); + yield* tree.dispatch({ kind: "key", key: "Tab" }); + } + for (const key of seen) { + expect(key === "drawer:open" || key.startsWith("drawer:") || key === "footer:history").toBe( + true, + ); + } + // The one History node, reachable from inside rather than duplicated beside. + expect([...seen]).toContain("footer:history"); + expect( + tree + .mounted() + .map((id) => tree.keyOf(id)) + .filter((key) => key === "footer:history"), + ).toHaveLength(1); + // And a pointer at what the drawer covers reaches nothing. + const blocked = yield* tree.dispatch({ + kind: "pointer", + target: behind ?? "", + frame: tree.frame().id, + }); + expect(blocked.ok && blocked.value.outcome === "action").toBe(false); + }); + + it("U3: every accepted frame carries one location, and too small carries only its refusal", function* () { + const { session } = yield* asking({ + review: { streaming: true }, + build: { streaming: true }, + check: { queued: true }, + }); + yield* until(session, "a started turn", () => started(session) >= 1); + const tree = yield* useReplTree(); + for (const size of [WIDE, { columns: 120, rows: 30 }, NARROW]) { + const view = reading(onSessions(session), session, size); + yield* applied(tree, view); + const frame = layout(size, replSurface(tree, view)); + const located = frame.cells.filter((cell) => + (tree.keyOf(cell.node) ?? "").startsWith("location:"), + ); + // One canonical location, once — and not repeated inside the Sessions rows. + expect(located.map((cell) => cell.text).join("")).toContain(view.location); + expect(new Set(located.map((cell) => cell.region)).size).toBe(1); + } + // Smaller than narrow draws its refusal and offers nothing to activate. + const tiny = layout( + { columns: 40, rows: 10 }, + replSurface(tree, reading(onSessions(session), session, NARROW)), + ); + expect(tiny.profile).toBe("too-small"); + expect(tiny.refusal).toBeDefined(); + expect(tiny.cells.filter((cell) => cell.targetable)).toEqual([]); + }); +}); + +/** The suffixes a turn's own read-only facts are keyed with. */ +const TURN_FACT_SUFFIXES = [":whose", ":text", ":stop", ":failed"]; + +describe("U2 — the program performs a permission, end to end", () => { + beforeAll(() => useTempFileCompiler()); + + it("U2: choosing through the running program settles the request and closes its drawer", function* () { + const stub = createStub({ + review: { + streaming: true, + permission: { toolCallId: "call-1", title: "Write", kind: "edit" }, + }, + build: { streaming: true }, + check: { queued: true }, + }); + const { terminal, install } = recordingTerminal(); + let outcome: ReplOutcome | undefined; + + yield* scoped(function* (): Operation { + yield* install(); + yield* immediateClock(); + yield* useStub(stub); + yield* useTemporaryHost(); + + const running = yield* spawn(function* (): Operation { + // `approve-reads` is the mode that leaves a non-read decision to a + // person, which is the only way a request reaches this screen at all. + const ran = yield* runReplProgram({ + installations: installations(), + permissionMode: "approve-reads", + }); + if (!ran.ok) { + throw ran.error; + } + outcome = ran.value; + }); + yield* untilDrawn(terminal); + + // One entry, submitted the way a person submits one. + terminal.bytes(BYTES.encode(THREE_SPAWNS)); + yield* settled(20); + terminal.feed("\r"); + yield* settled(40); + + yield* showing(terminal, "asks: Write"); + // Nothing opened by itself: the request is a fact on its turn, and the + // location says no drawer is up. + expect(maybeLocation(terminal)).not.toContain("+permission"); + + // A permission is answered on the Sessions surface, so that is where a + // person goes first — through the ordinary control, not a shortcut. + yield* pressUntil(terminal, "Sessions"); + terminal.feed("\r"); + yield* settled(40); + + // Activate the request fact, then choose one offered option — Tab and Enter, + // through the ordinary normalized boundary. + yield* pressUntil(terminal, "asks: Write"); + terminal.feed("\r"); + yield* settled(40); + expect(maybeLocation(terminal)).toContain("+permission"); + expect(shows(terminal, "[Allow once]")).toBe(true); + + yield* pressUntil(terminal, "[Allow once]"); + terminal.feed("\r"); + // The program performs it: `perform()` calls the session's authority, and + // only a successful call closes the drawer. + yield* answered(stub, "call-1"); + expect(stub.outcomes.get("call-1")).toEqual({ outcome: "selected", optionId: "once" }); + // Once, through one authority call: a second would answer a request that is + // already over. + expect(stub.answers.get("call-1")).toBe(1); + yield* settled(40); + + // The drawer is gone from the screen and from the location, and focus is on + // the turn that was waiting rather than on whatever opened the drawer. + expect(maybeLocation(terminal)).not.toContain("+permission"); + expect(shows(terminal, "[Allow once]")).toBe(false); + expect(focusedOn(terminal, "review ·")).toBe(true); + + terminal.end(); + yield* running; + }); + + expect(outcome?.location).toBeDefined(); + expect(outcome?.location).not.toContain("+permission"); + }); + + it("U2: Escape on the drawer denies once through the program, and the session runs on", function* () { + const stub = createStub({ + review: { + streaming: true, + permission: { toolCallId: "call-1", title: "Write", kind: "edit" }, + }, + build: { streaming: true }, + check: { queued: true }, + }); + const { terminal, install } = recordingTerminal(); + let outcome: ReplOutcome | undefined; + + yield* scoped(function* (): Operation { + yield* install(); + yield* immediateClock(); + yield* useStub(stub); + yield* useTemporaryHost(); + + const running = yield* spawn(function* (): Operation { + const ran = yield* runReplProgram({ + installations: installations(), + permissionMode: "approve-reads", + }); + if (!ran.ok) { + throw ran.error; + } + outcome = ran.value; + }); + yield* untilDrawn(terminal); + terminal.bytes(BYTES.encode(THREE_SPAWNS)); + yield* settled(20); + terminal.feed("\r"); + yield* settled(40); + yield* showing(terminal, "asks: Write"); + + // Open it the way a person does, from the surface a permission is answered + // on. + yield* pressUntil(terminal, "Sessions"); + terminal.feed("\r"); + yield* settled(40); + yield* pressUntil(terminal, "asks: Write"); + terminal.feed("\r"); + yield* settled(40); + expect(maybeLocation(terminal)).toContain("+permission"); + expect(shows(terminal, "Escape or close denies")).toBe(true); + expect(stub.outcomes.size).toBe(0); + + // Escape: the direct dismissal, which denies through the authority rather + // than closing a screen and leaving a provider waiting. + terminal.feed("\x1b"); + yield* answered(stub, "call-1"); + // One denial, and exactly one authority call made it. + expect(stub.outcomes.get("call-1")).toEqual({ outcome: "selected", optionId: "no" }); + expect(stub.answers.get("call-1")).toBe(1); + yield* settled(40); + + // The drawer closed because the request is gone, not because a key was + // pressed: it is out of the screen and out of the location, and focus is + // back on the turn that was waiting. + expect(maybeLocation(terminal)).not.toContain("+permission"); + expect(shows(terminal, "Escape or close denies")).toBe(false); + expect(focusedOn(terminal, "review ·")).toBe(true); + + // And the session is still live: dismissing one request cancelled nothing, + // so the other conversation is still there to be seen. + expect(shows(terminal, "build")).toBe(true); + expect(maybeLocation(terminal)).not.toContain("at="); + terminal.end(); + yield* running; + }); + + expect(outcome?.location).toBeDefined(); + expect(outcome?.location).not.toContain("+permission"); + }); +}); + +/** + * Wait until the screen shows this text, or say it never did. + * + * Bounded by time rather than by attempts: reaching a question runs a document, + * spawns three children and asks a provider, and how long that takes is not a + * number of turns. + */ +function* showing(terminal: Terminal, expected: string): Operation { + const deadline = Date.now() + DEADLOCK_MS; + while (!shows(terminal, expected)) { + if (Date.now() > deadline) { + throw new Error(`the screen never showed ${expected}`); + } + yield* sleep(5); + yield* settled(10); + } +} + +/** Wait until the provider has been told one decision, or say it never was. */ +function* answered(stub: Stub, toolCallId: string): Operation { + const deadline = Date.now() + DEADLOCK_MS; + while (!stub.outcomes.has(toolCallId)) { + if (Date.now() > deadline) { + throw new Error(`the provider was never told what was decided about ${toolCallId}`); + } + yield* sleep(5); + yield* settled(10); + } +} + +/** + * Tab until the row containing this text holds focus. + * + * Through the terminal, because that is how a person reaches a control: there is + * no host shortcut, and the marker on the focused row is how anybody knows where + * they are. + */ +function* pressUntil(terminal: Terminal, label: string): Operation { + for (let press = 0; press < 200; press += 1) { + if (focusedOn(terminal, label)) { + return; + } + terminal.feed("\t"); + yield* settled(6); + } + throw new Error(`focus never reached ${label}`); +} + +/** + * The program harness, mirrored from the journey suite. + * + * Copied rather than shared, because there is no shared harness module and this + * slice owns no new fixture. What it buys is the real boundary: the row below + * drives `runReplProgram()`, so a permission intent is performed by the program's + * own `perform()` and answered by the session's own authority. + */ +const BYTES = new TextEncoder(); +const TEXT = new TextDecoder(); + +/** Let every task that is ready take its turn. */ +function* settled(turns = 8): Operation { + for (let turn = 0; turn < turns; turn += 1) { + yield* sleep(0); + } +} + +/** A clock the test moves, so nothing in this suite waits on real time. */ +function immediateClock(): Operation { + return ReplClock.around( + { + // deno-lint-ignore require-yield + *now(): Operation { + return 0; + }, + // deno-lint-ignore require-yield + *wait(): Operation { + // Returns at once: this product draws when something changed, so the + // frame interval is the only thing being skipped. + }, + }, + { at: "min" }, + ); +} + +/** A terminal the test drives completely. */ +interface Terminal { + /** Everything ever presented, in order. */ + readonly presented: Uint8Array[]; + /** + * When set, the next presentation blocks here until it is released. + * + * The seam the frame-order control needs: while a frame is being written, the + * stream must not have been told that frame was applied. + */ + holdPresent: { release(): void } | undefined; + size: ReplTerminalSize; + readonly raw: boolean[]; + resets: number; + listeners: number; + readers: number; + feed(text: string): void; + bytes(raw: Uint8Array): void; + /** Make the next presentation block, so a test can look at the frame stream. */ + holdNextPresent(): void; + resized(size: ReplTerminalSize): void; + end(): void; +} + +function recordingTerminal( + size: ReplTerminalSize = { columns: 160, rows: 36 }, + interactive = true, +): { + terminal: Terminal; + install(): Operation; +} { + const queue: Uint8Array[] = []; + const watchers = new Set<() => void>(); + let waiting: ((result: IteratorResult) => void) | undefined; + let ended = false; + + let holding = false; + const terminal: Terminal = { + presented: [], + holdPresent: undefined, + size, + raw: [], + resets: 0, + listeners: 0, + readers: 0, + feed(text: string): void { + terminal.bytes(BYTES.encode(text)); + }, + holdNextPresent(): void { + holding = true; + }, + bytes(raw: Uint8Array): void { + const resolve = waiting; + if (resolve === undefined) { + queue.push(raw); + return; + } + waiting = undefined; + resolve({ done: false, value: raw }); + }, + resized(next: ReplTerminalSize): void { + terminal.size = next; + for (const watcher of watchers) { + watcher(); + } + }, + end(): void { + ended = true; + const resolve = waiting; + if (resolve !== undefined) { + waiting = undefined; + resolve({ done: true, value: undefined }); + } + }, + }; + + const host: ReplTerminalCapabilities = { + interactive: () => interactive, + size: () => terminal.size, + write(bytes: Uint8Array): Promise { + terminal.presented.push(new Uint8Array(bytes)); + if (!holding) { + return Promise.resolve(); + } + holding = false; + return new Promise((resolve) => { + terminal.holdPresent = { release: resolve }; + }); + }, + writeNow(): void { + terminal.resets += 1; + }, + setRaw(raw: boolean): void { + terminal.raw.push(raw); + }, + bytes(): AsyncIterable { + return { + [Symbol.asyncIterator](): AsyncIterator { + terminal.readers += 1; + return { + next(): Promise> { + const head = queue.shift(); + if (head !== undefined) { + return Promise.resolve({ done: false, value: head }); + } + if (ended) { + return Promise.resolve({ done: true, value: undefined }); + } + return new Promise((resolve) => { + waiting = resolve; + }); + }, + return(): Promise> { + terminal.readers -= 1; + const resolve = waiting; + waiting = undefined; + resolve?.({ done: true, value: undefined }); + return Promise.resolve({ done: true, value: undefined }); + }, + }; + }, + }; + }, + onResize(listener: () => void): () => void { + watchers.add(listener); + terminal.listeners += 1; + return () => { + watchers.delete(listener); + terminal.listeners -= 1; + }; + }, + }; + + return { terminal, install: () => installReplTerminal(host) }; +} + +/** A REPL host over a temporary directory nothing else uses. */ +function* useTemporaryHost(): Operation { + const root = yield* untilResolved(mkdtemp(join(tmpdir(), "xmd-repl-agents-"))); + yield* installReplHost({ + dataRoot: () => root, + identify: () => randomBytes(8).toString("hex"), + createExclusive: (path) => open(path, "wx").then((handle) => handle.close()), + appendRecord: (path, record) => appendFile(path, record), + }); + return root; +} + +/** + * What the screen says, by replaying what was written to it. + * + * A real buffer rather than the bytes with escapes stripped, because this + * renderer writes *diffs*: it moves the cursor to what changed and writes only + * that. Concatenating the diffs gives characters in the order they were written + * rather than the order they appear, and a character the previous frame already + * had is not written again at all — so stripped bytes read as words with letters + * missing. Interpreting the cursor moves is what makes an assertion about the + * screen an assertion about the screen. + */ +function screenOf(terminal: Terminal): string[] { + const rows: string[][] = []; + let row = 0; + let column = 0; + + const put = (character: string): void => { + while (rows.length <= row) { + rows.push([]); + } + const line = rows[row]; + while (line.length < column) { + line.push(" "); + } + line[column] = character; + column += 1; + }; + + const written = terminal.presented.map((bytes) => TEXT.decode(bytes)).join(""); + for (let index = 0; index < written.length; index += 1) { + const character = written[index]; + if (character !== "\u001B") { + if (character === "\n") { + row += 1; + column = 0; + } else if (character === "\r") { + column = 0; + } else { + put(character); + } + continue; + } + // CSI: the only sequences this renderer uses to position and to clear. + const csi = /^\u001B\[([0-9;]*)([@-~])/.exec(written.slice(index)); + if (csi !== null) { + const parameters = csi[1].split(";").map((one) => (one === "" ? 0 : Number(one))); + if (csi[2] === "H") { + row = Math.max(0, (parameters[0] ?? 1) - 1); + column = Math.max(0, (parameters[1] ?? 1) - 1); + } else if (csi[2] === "J") { + rows.length = 0; + row = 0; + column = 0; + } + index += csi[0].length - 1; + continue; + } + // OSC, and the two-byte escapes. Neither carries anything readable. + const osc = /^\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/.exec(written.slice(index)); + if (osc !== null) { + index += osc[0].length - 1; + continue; + } + index += 1; + } + return rows.map((line) => line.join("")); +} + +/** + * The canonical location the screen is showing, if it has drawn one yet. + * + * Reassembled, because a location carrying a draft is longer than a row and the + * screen shows it as consecutive rows. Which rows belong to it is decided by the + * grammar rather than by counting: the longest run that decodes *is* the location, + * and a shorter prefix of it decodes to a different route or to nothing. + */ +function maybeLocation(terminal: Terminal): string | undefined { + const rows = screenOf(terminal); + const first = rows.findIndex((line) => line.includes("xmd://repl/")); + if (first === -1) { + return undefined; + } + const at = rows[first].indexOf("xmd://repl/"); + const parts: string[] = []; + for (let row = first; row < rows.length && row < first + 24; row += 1) { + const part = (rows[row] ?? "").slice(at, at + surfaceWidth(terminal.size)); + if (part.trim().length === 0) { + break; + } + parts.push(part.trimEnd()); + } + + // The rows below a location belong to whatever is drawn under it, and a row that + // used to hold a longer location can still have that tail on the end. So the + // answer is the longest prefix that *round-trips*: the grammar accepts some + // trailing junk inside a drawer segment, but re-encoding what it decoded only + // reproduces the prefix that really was the location. + const joined = parts.join(""); + let found: string | undefined; + for (let length = joined.length; length > "xmd://repl/".length; length -= 1) { + const candidate = joined.slice(0, length); + const decoded = decodeLocation(candidate); + if (decoded.ok && encodeLocation(decoded.value) === candidate) { + found = candidate; + break; + } + } + return found; +} + +/** + * Whether the control holding focus is the one this label names. + * + * Anchored to the marker rather than matched anywhere on the line, because a + * line of this screen crosses three columns: the sidebar, the transcript and the + * inspection column all write to the same rows, so a label found *somewhere* on + * a line with a marker on it is usually a different control in a different column. + * The marker is searched for at any position for the same reason — a focused + * control in the inspection column has the sidebar's text to the left of it. + */ +function focusedOn(terminal: Terminal, label: string): boolean { + for (const line of screenOf(terminal)) { + for (let at = line.indexOf(">"); at !== -1; at = line.indexOf(">", at + 1)) { + if ( + line + .slice(at + 1) + .trimStart() + .startsWith(label) + ) { + return true; + } + } + } + return false; +} + +/** Whether any row of the screen contains this text. */ +function shows(terminal: Terminal, expected: string): boolean { + return screenOf(terminal).some((line) => line.includes(expected)); +} + +/** + * Wait until the first frame has been drawn. + * + * The command opens a terminal, a repository and a session before it can draw + * anything, and how long that takes is not a number of turns — so every test + * that reads the screen waits for it rather than assuming. + */ +function* untilDrawn(terminal: Terminal): Operation { + for (let attempt = 0; attempt < 40; attempt += 1) { + if (maybeLocation(terminal) !== undefined) { + return; + } + yield* sleep(10); + yield* settled(10); + } + throw new Error( + `the screen never drew its first frame. frames=${terminal.presented.length} rows=` + + JSON.stringify( + screenOf(terminal) + .map((l) => l.trimEnd()) + .filter((l) => l.trim().length > 0), + ), + ); +} diff --git a/packages/cli/tests/repl-forms.test.ts b/packages/cli/tests/repl-forms.test.ts index 46e1229b..0b8aa7c3 100644 --- a/packages/cli/tests/repl-forms.test.ts +++ b/packages/cli/tests/repl-forms.test.ts @@ -31,6 +31,7 @@ import { focusClaim, focusSettled, initialState, + NO_AGENT, reduceRepl, viewFor, } from "../src/repl/application.ts"; @@ -114,7 +115,7 @@ const EMPTY_MODEL: ReplModel = Object.freeze({ /** A live reading with one question waiting. */ function asking(question: ReplQuestion | undefined): ReplLive { - return { output: "", question, expansion: "playing", pausable: false }; + return { output: "", question, expansion: "playing", pausable: false, agent: NO_AGENT }; } describe("F1 — the bounded language is exact", () => { @@ -956,7 +957,9 @@ describe("F3 — focus returns to the invocation, not to where the drawer came f yield* applied(tree, reading(unclaimed, asking(undefined), recorded.model, NARROW, inside)); const landed = keyed(tree); expect(landed).not.toBe(`elicit:${recorded.marker}`); - expect(landed).toBe("sessions:heading"); + // The first focusable row of the surface this frame draws, which is neither + // the invocation nor where the drawer was opened from. + expect(landed).toBe("entries:heading"); }); }); @@ -1040,6 +1043,7 @@ function liveReading(session: ReplSession): ReplLive { question: session.overlay.question, expansion: session.expansion.state, pausable: session.controller !== undefined, + agent: session.agent, }; } diff --git a/packages/cli/tests/repl-journey.test.ts b/packages/cli/tests/repl-journey.test.ts index 7bcbdfd9..2ec6a040 100644 --- a/packages/cli/tests/repl-journey.test.ts +++ b/packages/cli/tests/repl-journey.test.ts @@ -30,7 +30,7 @@ import { installReplTerminal } from "../src/repl/terminal-host.ts"; import type { ReplTerminalCapabilities } from "../src/repl/terminal-host.ts"; import type { ReplTerminalSize } from "../src/repl/terminal.ts"; import { ReplClock } from "../src/repl/frame.ts"; -import { initialState, reduceRepl } from "../src/repl/application.ts"; +import { initialState, NO_AGENT, reduceRepl } from "../src/repl/application.ts"; import { runReplProgram } from "../src/repl/program.ts"; import type { ReplOutcome } from "../src/repl/program.ts"; import { parseDurableEvent, serializeDurableEvent } from "@executablemd/durable-streams"; @@ -2259,6 +2259,7 @@ describe("REPL journey: what it settles before it acts", () => { question: undefined, expansion: "pausing", pausable: true, + agent: NO_AGENT, }); expect(pausing.intent.kind).toBe("none"); expect(pausing.state.refusal).toContain("not paused"); @@ -2268,6 +2269,7 @@ describe("REPL journey: what it settles before it acts", () => { question: undefined, expansion: "paused", pausable: true, + agent: NO_AGENT, }); expect(held.intent.kind).toBe("continue"); expect(held.state.refusal).toBe(undefined); From 9b81fb4fec340b6183eb41037ed6b73949b06533 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 30 Sep 2026 16:32:25 -0400 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20Draw=20Agent=20work=20when?= =?UTF-8?q?=20it=20happens,=20and=20bound=20what=20the=20screen=20offers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five things the Sessions surface got wrong, and one comment that had stopped being true. **A frame nobody asked for is a frame nobody draws.** The loop woke for the history, the overlay's output, a question and expansion, and not for the Agent. A document whose only activity is a Prompt — queued, then streaming, then waiting on a permission — changed nothing else, so nothing drew the frame that would have shown it, and the screen stood still until some unrelated event happened along. `runReplProgram()` now subscribes to `session.agentChanges` in the scope that outlives the spawn and drains it there: a spawned body starts a turn later, and a turn is long enough to miss the first change. **A screen you cannot leave is not one a route may put you on.** Narrow mounts one outlet, and both surface controls used to live inside the outlets they select — so the control that goes to Sessions was mounted only on Sessions. The two are now described at every size, above whichever outlet is routed. What the route did not select is still absent everywhere: `mounted()`, the frame, the target map, `nodeOf()` and a pointer sweep. **Two readings were described whole and clipped by layout.** Every conversation, turn, fact, audit and request went into the description, and so did every choice a provider offered — `PermissionRequest.options` is unbounded. Whatever did not fit was described, focusable and pointable with nowhere to be drawn. Each now moves through a window sized from the region that will place it, with its own earlier and later controls outside the thing they move. Both offsets are process-local: they are in no route, no model, no location and no Journal, they are clamped where they are read, and a filter starts its reading again at the first row because row forty of everything is not row forty of one conversation. **A key is a reconciliation identity, not a product fact.** Targetability was decided by how a key was spelled — anything ending `:text`, `:stop`, `:failed` or `:whose` was read as one of a turn's own facts. Keys carry provider session keys and field names, so a conversation named `text` and a field named `stop` lost their pointer to a rule about suffixes. What decides it now is what the row is: a control answers Enter, so a pointer on it asks for the same thing, and a line has nothing to activate. **And the comment above `availabilityOf()` said twice what it says once**, the first copy explaining that no Agent runtime runs in the REPL — which it now does. `architecture.md` and `specs/repl-spec.md` say all of this in the present tense. --- architecture.md | 36 + packages/cli/src/repl/application.ts | 436 ++++++++--- packages/cli/src/repl/components/actions.ts | 9 + packages/cli/src/repl/components/rows.ts | 4 + packages/cli/src/repl/layout.ts | 15 + packages/cli/src/repl/program.ts | 15 + .../cli/tests/repl-agent-interface.test.ts | 681 +++++++++++++++++- packages/cli/tests/repl-forms.test.ts | 34 +- specs/repl-spec.md | 57 +- 9 files changed, 1183 insertions(+), 104 deletions(-) diff --git a/architecture.md b/architecture.md index e21e294f..cbc606d2 100644 --- a/architecture.md +++ b/architecture.md @@ -5477,6 +5477,42 @@ Freedom is vendored from a pinned unpublished commit under `packages/cli/src/repl/vendor/freedom/`, as a pristine copy and a patched copy whose every difference belongs to one named patch. See its `PROVENANCE.md`. +### One chronology, and the authority that answers it + +The Sessions reading is one list, not two appended: every Prompt this process +observes takes a slot when it is scheduled and keeps it through publication, so +a turn that becomes durable stays the same mounted node in the same place, and +concurrent Prompts that publish out of order do not reorder each other. The +slot, its order and its live key are process-local — no route, model, Journal +record or public Core Api carries one. + +Filtering is by the conversation key a provider actually issued; an authored +`` name is not one. Selecting or clearing changes `route.session` and +nothing else, and background Agent work never changes it. + +A pending request is a fact on the turn that is waiting. Arrival mounts it and +does nothing else. Activating it records that request's opaque key in +process-local state — never in the location — and opens the Sessions-only +`+permission` drawer; the reducer decides and `program.ts` alone holds the +authority that answers, so the drawer closes only when the request is really +settled and a stale, unknown or unoffered action settles nothing. A retained +permission audit is an inert reading, and a historical prefix has no live +requests to answer. + +Both readings are windowed at the layout region that will place them, rather +than described whole and clipped: only what the window holds is described, so +only that is mounted, focusable, drawn and pointable. Their window controls and +the two surface controls stay outside the moving window. A narrow frame mounts +one routed outlet and both surface controls, so the outlet the route did not +select is absent everywhere — `mounted()`, the frame, the target map — while the +way to it is not. + +Whether a pointer may activate a row is decided by what the row is, not by how +its key is spelled: a control answers Enter, so a pointer on it asks for the +same thing, and a line has nothing to activate. Keys carry provider session keys +and field names, so a rule about spelling would have taken the pointer away from +a conversation or a field whose name happened to look like something else. + ### From a frame to a terminal, and back Below the tree, one more direction: diff --git a/packages/cli/src/repl/application.ts b/packages/cli/src/repl/application.ts index ed1a753c..9cccf05d 100644 --- a/packages/cli/src/repl/application.ts +++ b/packages/cli/src/repl/application.ts @@ -28,7 +28,7 @@ import { Ok, type Result } from "effection"; import type { Json } from "@executablemd/durable-streams"; -import { describe as describeNode } from "./description.ts"; +import { describe as describeNode, fields, readDescription } from "./description.ts"; import type { ReplDescription } from "./description.ts"; import { drawerHeight, @@ -36,6 +36,7 @@ import { HISTORY_ROWS, NARROW, profileFor, + sessionsHeight, surfaceWidth, } from "./layout.ts"; import type { ReplSurface as ReplPlacedSurface, ReplSurfaceCell } from "./layout.ts"; @@ -303,6 +304,29 @@ export type ReplFocusRestore = */ | { readonly kind: "turn"; readonly turn: string }; +/** + * How far each windowed reading is scrolled, in rows. + * + * Process-local, and deliberately not in the route: where somebody scrolled to + * is not a place another process can be sent to, and a location carrying it + * would reopen somewhere else at a row describing a different reading. Two + * separate numbers because both windows are open at once — a drawer scrolls the + * request it is asking while the reading behind it keeps the row it was left on. + * + * Each is clamped where it is read, because publication, a filter, a background + * change and a resize all change how many rows there are with nobody pressing + * anything. + */ +export interface ReplViewports { + /** Rows the Sessions reading is scrolled by. */ + readonly sessions: number; + /** Rows the open permission drawer is scrolled by. */ + readonly permission: number; +} + +/** Both windows at their first row, which is where a fresh reading starts. */ +export const AT_TOP: ReplViewports = Object.freeze({ sessions: 0, permission: 0 }); + /** Everything typed and not yet committed anywhere. */ export interface ReplState { readonly route: ReplRoute; @@ -328,6 +352,8 @@ export interface ReplState { * the tree's, which is the only thing that knows where it is. */ readonly restore: ReplFocusRestore | undefined; + /** How far each windowed reading is scrolled. */ + readonly viewports: ReplViewports; } /** What the root must perform, because a component cannot. */ @@ -457,6 +483,9 @@ export function permissionSettled(state: ReplState, turn: string): ReplState { ), }), restore: Object.freeze({ kind: "turn", turn }), + // The drawer is over, so the window over it is too: the next request opens + // at its own first row rather than at wherever this one was read to. + viewports: Object.freeze({ ...state.viewports, permission: 0 }), refusal: undefined, }); } @@ -478,6 +507,7 @@ export function permissionWithdrawn(state: ReplState): ReplState { state.route.drawers.filter((drawer) => drawer.kind !== "live-permission"), ), }), + viewports: Object.freeze({ ...state.viewports, permission: 0 }), }); } @@ -504,6 +534,7 @@ export function initialState(execution: string): ReplState { refusal: undefined, permission: undefined, restore: undefined, + viewports: AT_TOP, }); } @@ -521,6 +552,10 @@ export function stateFor(location: string): Result { refusal: undefined, permission: undefined, restore: undefined, + // Never decoded: a window position is this process's, so a location read + // here opens the reading at its first row rather than at a row the + // process that wrote the location happened to be on. + viewports: AT_TOP, }), ); } @@ -606,13 +641,6 @@ export function refusedView( }); } -/** - * What this process can say about state no history holds. - * - * A live question is the only one of them this slice has: no Agent runtime runs - * in the REPL yet, so there is no pending permission request and no conversation - * that has started without settling anything. - */ /** * What this process can say about state no history holds. * @@ -798,16 +826,40 @@ export function reduceRepl( // the draft and the drawer stack are all left exactly as they stand. A key // that names no conversation is refused by the codec's own resolution, so // a stale one cannot become an empty Sessions view. - return navigate(state, model, { ...state.route, session: action.session }, live); + // + // The window goes back to the first row, because a filtered reading is a + // different list: row forty of everything is not row forty of one + // conversation, and keeping the number would open somewhere nobody chose. + return navigate(atFirstRow(state), model, { ...state.route, session: action.session }, live); } case "all-sessions": { - return navigate(state, model, { ...state.route, session: undefined }, live); + return navigate(atFirstRow(state), model, { ...state.route, session: undefined }, live); + } + case "scroll-sessions": { + // Clamped against the reading this state actually has, and stored clamped: + // an offset kept past the last window would take several presses to have + // any visible effect, so what is held is what the region is showing. + const rows = sessionContentRows(state, model, live); + const furthest = Math.max(0, rows - sessionsCapacity(state, model, size)); + const sessions = Math.min(Math.max(0, state.viewports.sessions + action.delta), furthest); + return settled({ + ...state, + viewports: Object.freeze({ ...state.viewports, sessions }), + refusal: undefined, + }); } case "select-permission": { // The key is taken first, because a `+permission` candidate resolves only // while the request it names is pending — so the state that navigates has // to be the one already holding it. A refusal keeps neither. - const holding = Object.freeze({ ...state, permission: action.request }); + const holding = Object.freeze({ + ...state, + permission: action.request, + // At its first row: this is a different request, and a window left where + // the last one was read to would open part way down a question nobody + // has read the start of. + viewports: Object.freeze({ ...state.viewports, permission: 0 }), + }); // Declared, so the drawer this adds is the one the grammar defines rather // than a string this case happens to spell the same way. const opening: ReplDrawerRef = { kind: "live-permission" }; @@ -956,6 +1008,28 @@ export function reduceRepl( }; } case "scroll": { + const open = state.route.drawers[state.route.drawers.length - 1]; + if (open?.kind === "live-permission") { + const pending = + state.permission === undefined ? undefined : offered(state, live, state.permission); + if (pending === undefined) { + return refuse(state, "no permission request is being answered."); + } + // The same clamp, over the drawer's own ordered content: the kind, the + // call, whose turn is waiting, every choice the provider offered and + // what closing does. + const rows = permissionContentRows(model, live, pending); + const furthest = Math.max(0, rows - drawerCapacity(size)); + const permission = Math.min( + Math.max(0, state.viewports.permission + action.delta), + furthest, + ); + return settled({ + ...state, + viewports: Object.freeze({ ...state.viewports, permission }), + refusal: undefined, + }); + } if (!answering) { return refuse(state, "nothing is being asked right now."); } @@ -1043,6 +1117,14 @@ function offered( return live.agent.requests.find((candidate) => candidate.key === request); } +/** The same state with the Sessions reading back at its first row. */ +function atFirstRow(state: ReplState): ReplState { + return Object.freeze({ + ...state, + viewports: Object.freeze({ ...state.viewports, sessions: 0 }), + }); +} + function settled(state: ReplState): ReplTransition { return { state: Object.freeze(state), intent: { kind: "none" } }; } @@ -1334,15 +1416,29 @@ function described(view: ReplView): readonly Described[] { const showEntry = !narrow || routed === "repl"; const turns = chronology(model, live); - if (showSessions) { - items.push( - row( - "sessions:heading", - "Sessions", - { select: "surface", surface: "sessions" }, - { here: view.focused }, - ), - ); + // Which surface to be on belongs to neither surface. A narrow frame mounts one + // outlet, so a control that lives inside the outlet can only take somebody + // where they already are — and the way back would be mounted on the screen + // they cannot reach. Both controls are described at every size; the route + // decides which outlet's rows follow them, never whether they exist. + const toSessions = row( + "sessions:heading", + "Sessions", + { select: "surface", surface: "sessions" }, + { here: view.focused }, + ); + const toEntries = row( + "entries:heading", + "Entries", + { select: "surface", surface: "repl" }, + { here: view.focused }, + ); + items.push(toSessions); + // In a narrow frame the two are one navigation bar above the routed outlet. In + // a sidebar each heading stays with the list it names, which is where a reader + // looks for it. + if (narrow) { + items.push(toEntries); } if (!showSessions) { // Nothing: this frame is showing the other surface. @@ -1352,21 +1448,45 @@ function described(view: ReplView): readonly Described[] { // feature that does not exist. items.push(line("sessions:empty", " (none retained)")); } else { - items.push(...sessionRows(view, turns, claim)); + // One window over the whole reading. Every conversation, turn, fact, audit + // and request is built in order and then windowed: a list that described all + // of them would have its tail placed nowhere, and a row layout cannot place + // is not one a person can see, focus or point at. + const content = sessionRows(state, turns, view.focused, claim); + const capacity = sessionsCapacity(state, model, view.size); + const last = Math.max(0, content.length - capacity); + const from = Math.min(Math.max(0, state.viewports.sessions), last); + // Outside the thing they move, like the drawer's: a control inside the + // window would scroll away from whoever was reaching for it. + items.push( + row( + "sessions:earlier", + " [^ earlier]", + { select: "scroll-sessions", delta: -1 }, + { + here: view.focused, + }, + ), + ); + items.push(...content.slice(from, from + capacity)); + items.push( + row( + "sessions:later", + " [v later]", + { select: "scroll-sessions", delta: 1 }, + { + here: view.focused, + }, + ), + ); } // Read whether or not this frame draws the entry list: the footer's draft says // whether an entry exists at every size and on either surface. const entry = model.entry; + if (!narrow) { + items.push(toEntries); + } if (showEntry) { - items.push( - row( - "entries:heading", - "Entries", - { select: "surface", surface: "repl" }, - { here: view.focused }, - ), - ); - if (entry === undefined) { items.push(line("entry:none", " 1. (not submitted)")); } else { @@ -1552,11 +1672,11 @@ function described(view: ReplView): readonly Described[] { * publishes keeps the node — and the focus — it already had. */ function sessionRows( - view: ReplView, + state: ReplState, turns: readonly ReplSessionTurn[], + focused: string | undefined, claim: string | undefined, ): readonly Described[] { - const { state } = view; const items: Described[] = []; const filter = state.route.session; const offered = conversations(turns); @@ -1569,7 +1689,7 @@ function sessionRows( "sessions:all", filter === undefined ? " All conversations" : " All conversations (filtered)", { select: "all-sessions" }, - { here: view.focused }, + { here: focused }, ), ); for (const key of offered) { @@ -1578,7 +1698,7 @@ function sessionRows( `sessions:conversation:${key}`, ` ${filter === key ? "> " : ""}${headline(key)}`, { select: "session", session: key }, - { here: view.focused }, + { here: focused }, ), ); } @@ -1598,7 +1718,7 @@ function sessionRows( : { select: "marker", marker: turn.marker }, // A settled permission sends focus back to the turn that was waiting, so // this is the row that may be claimed. - { here: view.focused, claim }, + { here: focused, claim }, ), ); if (turn.agent !== undefined || turn.sessionKey !== undefined) { @@ -1628,7 +1748,7 @@ function sessionRows( `sessions:request:${request.key}`, label, { select: "permission", request: request.key }, - { here: view.focused }, + { here: focused }, ) : line(`sessions:request:${request.key}`, label), ); @@ -1647,6 +1767,48 @@ function sessionRows( return items; } +/** + * How many rows the Sessions reading holds, whatever the window shows. + * + * Counted by building the same rows the window slices, so the number a scroll + * clamps against cannot disagree with the list it is clamping: one definition of + * what the reading is, asked twice. + */ +function sessionContentRows(state: ReplState, model: ReplModel, live: ReplLive): number { + return sessionRows(state, chronology(model, live), undefined, undefined).length; +} + +/** + * How many rows of the Sessions reading one frame can place. + * + * The region carries more than the reading. A narrow content region also holds + * the canonical location and both surface controls; a sidebar also holds the + * entry list under its own heading. Whatever is not the moving window is + * subtracted, because a window sized by the whole region would push exactly + * those controls out of the frame — and a described row nothing places is a + * focus stop that draws nothing. + * + * At least one row: a window showing nothing would say the reading is empty. + */ +function sessionsCapacity(state: ReplState, model: ReplModel, size: ReplTerminalSize): number { + const narrow = profileFor(size) === "narrow"; + const shared = narrow + ? chunked(encodeLocation(state.route), surfaceWidth(size)).length + : entryRows(model); + // Both controls in a narrow frame, where they are one bar above the outlet. + // In a sidebar the entry list brings its own heading, counted with it. + const navigation = narrow ? 2 : 1; + /** `[^ earlier]` and `[v later]`, which are how the window moves. */ + const controls = 2; + return Math.max(1, sessionsHeight(size) - shared - navigation - controls); +} + +/** How many rows the entry list takes in a sidebar, its heading included. */ +function entryRows(model: ReplModel): number { + const entry = model.entry; + return entry === undefined ? 2 : 2 + nested(entry, [entry.key]).length; +} + /** * How far one turn has got, in words a reader can act on. * @@ -1732,43 +1894,31 @@ function drawerFor(view: ReplView, history: Described): Described | undefined { return undefined; } title = request.title ?? "Permission"; - // What is being asked, in the provider's own words. Never `rawInput` and - // never the request object: a screen shows what a person decides about. - if (request.kind !== undefined) { - children.push(drawerLine("drawer:permission:kind", ` ${request.kind}`, width).description); - } + // One window over the whole of it. `options` is the provider's, and nothing + // bounds how many it offers: a drawer that described every choice would have + // layout clip the last ones, which are exactly the ones a person scrolled + // down to find. + const content = permissionContent(view.model, view.live, request, width, view.focused); + const capacity = drawerCapacity(view.size); + const last = Math.max(0, content.length - capacity); + const from = Math.min(Math.max(0, view.state.viewports.permission), last); children.push( - drawerLine("drawer:permission:call", ` call ${request.toolCallId}`, width).description, - ); - // Whose turn is waiting, so a decision is not made about an anonymous one. - const waiting = chronology(view.model, view.live).find( - (candidate) => candidate.key === request.turn, + row( + "drawer:scroll:up", + pad("[^ earlier]", width), + { select: "scroll", delta: -1 }, + { here: view.focused }, + ).description, ); - if (waiting !== undefined) { - const whose = - waiting.sessionKey === undefined - ? headline(waiting.prompt) - : `${headline(waiting.prompt)} · ${waiting.sessionKey}`; - children.push(drawerLine("drawer:permission:turn", ` ${whose}`, width).description); - } - // Every choice the provider offered, in its order, each one its own control. - for (const choice of request.choices) { - children.push( - row( - `drawer:permission:choice:${choice.optionId}`, - pad(`[${choice.name}]${lasting(choice.kind)}`, width), - { select: "permission-choice", request: request.key, option: choice.optionId }, - { here: view.focused }, - ).description, - ); + for (const placed of content.slice(from, from + capacity)) { + children.push(placed); } - // Said rather than implied: dismissing is a denial of this request, and the - // session goes on running either way. children.push( - drawerLine( - "drawer:permission:dismissal", - " Escape or close denies this request; the session keeps running.", - width, + row( + "drawer:scroll:down", + pad("[v later]", width), + { select: "scroll", delta: 1 }, + { here: view.focused }, ).description, ); dismissing = request.key; @@ -1950,6 +2100,76 @@ function drawerFor(view: ReplView, history: Described): Described | undefined { }; } +/** + * Everything a permission drawer holds inside its window, in order. + * + * The kind, the call, whose turn is waiting, every choice the provider offered + * and what closing does — one ordered whole rather than a fixed head and a + * scrolling tail, because a reader who has scrolled to the choices no longer + * needs the line saying which call they are for taking up a row. + */ +function permissionContent( + model: ReplModel, + live: ReplLive, + request: ReplLivePermission, + width: number, + focused: string | undefined, +): readonly ReplDescription[] { + const content: ReplDescription[] = []; + // What is being asked, in the provider's own words. Never `rawInput` and + // never the request object: a screen shows what a person decides about. + if (request.kind !== undefined) { + content.push(drawerLine("drawer:permission:kind", ` ${request.kind}`, width).description); + } + content.push( + drawerLine("drawer:permission:call", ` call ${request.toolCallId}`, width).description, + ); + // Whose turn is waiting, so a decision is not made about an anonymous one. + const waiting = chronology(model, live).find((candidate) => candidate.key === request.turn); + if (waiting !== undefined) { + const whose = + waiting.sessionKey === undefined + ? headline(waiting.prompt) + : `${headline(waiting.prompt)} · ${waiting.sessionKey}`; + content.push(drawerLine("drawer:permission:turn", ` ${whose}`, width).description); + } + // Every choice the provider offered, in its order, each one its own control. + for (const choice of request.choices) { + content.push( + row( + `drawer:permission:choice:${choice.optionId}`, + pad(`[${choice.name}]${lasting(choice.kind)}`, width), + { select: "permission-choice", request: request.key, option: choice.optionId }, + { here: focused }, + ).description, + ); + } + // Said rather than implied: dismissing is a denial of this request, and the + // session goes on running either way. + content.push( + drawerLine( + "drawer:permission:dismissal", + " Escape or close denies this request; the session keeps running.", + width, + ).description, + ); + return content; +} + +/** + * How many rows that drawer holds, whatever its window shows. + * + * The same builder, asked for its length: what a scroll clamps against is the + * list it is clamping. + */ +function permissionContentRows( + model: ReplModel, + live: ReplLive, + request: ReplLivePermission, +): number { + return permissionContent(model, live, request, 0, undefined).length; +} + /** * What a lasting choice lasts for. * @@ -2093,8 +2313,15 @@ function describeRow(entry: ReplRow): string { * pointer resolved against the drawn frame has to name. */ export function replSurface(tree: ReplTree, view: ReplView): ReplPlacedSurface { + const controls = controlsOf(view); + /** The two surface controls, which belong to neither outlet. */ + const navigation: ReplSurfaceCell[] = []; const sessions: ReplSurfaceCell[] = []; + /** The Sessions outlet without the heading above it. */ + const sessionsBody: ReplSurfaceCell[] = []; const entries: ReplSurfaceCell[] = []; + /** The entry outlet without the heading above it. */ + const entriesBody: ReplSurfaceCell[] = []; const transcript: ReplSurfaceCell[] = []; const inspection: ReplSurfaceCell[] = []; const drawer: ReplSurfaceCell[] = []; @@ -2109,16 +2336,23 @@ export function replSurface(tree: ReplTree, view: ReplView): ReplPla const placed: ReplSurfaceCell = { node: cell.node, text: cell.cell, - ...(targetable(key) ? { targetable: true } : {}), + ...(controls.has(key) ? { targetable: true } : {}), }; if (key.startsWith("drawer:")) { drawer.push(placed); } else if (key.startsWith("location:")) { located.push(placed); + } else if (key === "sessions:heading" || key === "entries:heading") { + // In both: a sidebar keeps each heading with the list it names, and a + // narrow frame shows the pair as the bar above whichever outlet is routed. + navigation.push(placed); + (key === "sessions:heading" ? sessions : entries).push(placed); } else if (key.startsWith("sessions:")) { sessions.push(placed); + sessionsBody.push(placed); } else if (key.startsWith("entries:") || key.startsWith("entry:") || key.startsWith("scope:")) { entries.push(placed); + entriesBody.push(placed); } else if (key.startsWith("line:")) { transcript.push(placed); } else if (key.startsWith("binding:") || key.startsWith("elicit:")) { @@ -2129,8 +2363,14 @@ export function replSurface(tree: ReplTree, view: ReplView): ReplPla } return { - // Narrow shows exactly the surface the route selected. - content: [...located, ...(view.state.route.surface === "sessions" ? sessions : entries)], + // Narrow shows exactly the surface the route selected, under navigation that + // is on neither of them: the outlet is what the route chooses, and the way + // out of it cannot be inside it. + content: [ + ...located, + ...navigation, + ...(view.state.route.surface === "sessions" ? sessionsBody : entriesBody), + ], sessions, entries, transcript: [...located, ...transcript], @@ -2148,26 +2388,40 @@ export function replSurface(tree: ReplTree, view: ReplView): ReplPla }; } -/** Whether a pointer may activate the row this key names. */ -function targetable(key: string): boolean { - // A line is text. Everything a pointer may activate is a control, and the lines - // that are not — the location somebody copies, the reason the last action - // changed nothing, a turn's own facts and a retained permission it was granted - // — are lines. A cell that offered itself as a target and then did nothing - // would be a control that is not one. - return ( - !key.startsWith("line:") && - !key.startsWith("sessions:audit:") && - !TURN_FACTS.some((suffix) => key.endsWith(suffix)) && - key !== "sessions:empty" && - key !== "entry:none" && - key !== "footer:location" && - key !== "footer:refused" - ); +/** + * Which of this view's rows a pointer may activate. + * + * Read from what each row *is* — the component it was described with — rather + * than from how its key happens to be spelled. A turn's own facts, a retained + * audit, the location somebody copies and the reason the last action changed + * nothing are lines, and a line has nothing to activate; every control answers + * Enter, so a pointer on one asks for exactly what Enter there asks for. + * + * Spelling could never have decided this. A conversation key carries a provider + * session key and a field key carries a field name, so a conversation called + * `text` or a field called `stop` would have lost its pointer to a rule about + * suffixes — and a row that draws a control and refuses the pointer is a control + * that is not one. + */ +function controlsOf(view: ReplView): ReadonlySet { + const keys = new Set(); + const walk = (description: ReplDescription): void => { + const read = readDescription(description); + if (read.component === SELECT_ROW || read.component === FIELD) { + keys.add(read.key); + } else if (read.component === REFUSAL && fields(read.input)?.["back"] !== undefined) { + // A refusal is a control only when it offers somewhere to go back to. + keys.add(read.key); + } + for (const child of read.children) { + walk(child); + } + }; + for (const description of describeApplication(view)) { + walk(description); + } + return keys; } -/** The suffixes a turn's own read-only facts are keyed with. */ -const TURN_FACTS = [":whose", ":text", ":stop", ":failed"]; - export { HISTORY_ROWS }; export type { ReplDrawerRef, ReplSurface }; diff --git a/packages/cli/src/repl/components/actions.ts b/packages/cli/src/repl/components/actions.ts index df713b67..619af68c 100644 --- a/packages/cli/src/repl/components/actions.ts +++ b/packages/cli/src/repl/components/actions.ts @@ -54,6 +54,15 @@ export type ReplAction = * frame's to decide, and clamping belongs where the height is known. */ | { readonly kind: "scroll"; readonly delta: number } + /** + * Move the Sessions reading by whole rows. + * + * Its own member rather than `scroll`, because the two windows are open at + * once: a drawer scrolls the question it is asking while the reading behind it + * keeps the row somebody left it on, and one action meaning either would move + * whichever the reducer guessed. + */ + | { readonly kind: "scroll-sessions"; readonly delta: number } /** * Show only the conversation this provider session key names. * diff --git a/packages/cli/src/repl/components/rows.ts b/packages/cli/src/repl/components/rows.ts index 4f49d298..2e020420 100644 --- a/packages/cli/src/repl/components/rows.ts +++ b/packages/cli/src/repl/components/rows.ts @@ -160,6 +160,10 @@ function activation(input: ReplViewData): ReplAction | undefined { const delta = named["delta"]; return typeof delta === "number" ? { kind: "scroll", delta } : undefined; } + if (select === "scroll-sessions") { + const delta = named["delta"]; + return typeof delta === "number" ? { kind: "scroll-sessions", delta } : undefined; + } if (select === "session") { const session = named["session"]; return typeof session === "string" && session.length > 0 diff --git a/packages/cli/src/repl/layout.ts b/packages/cli/src/repl/layout.ts index 820ee886..d320c2c2 100644 --- a/packages/cli/src/repl/layout.ts +++ b/packages/cli/src/repl/layout.ts @@ -185,6 +185,21 @@ export function drawerHeight(size: ReplTerminalSize): number { return body - 2 * Math.floor(body / 8); } +/** + * How many rows the region that carries the Sessions reading has, at one size. + * + * The narrow content region and the wide sidebar are both the body — everything + * above the footer — so one answer covers both. Asked by whoever windows that + * reading: a list describing more rows than this has its tail placed nowhere, + * and a row layout cannot place is not one a person can see or point at. + */ +export function sessionsHeight(size: ReplTerminalSize): number { + if (profileFor(size) === "too-small") { + return 0; + } + return size.rows - FOOTER_ROWS; +} + /** Which profile a size gets. */ export function profileFor(size: ReplTerminalSize): ReplProfile { if (size.columns < NARROW.columns || size.rows < NARROW.rows) { diff --git a/packages/cli/src/repl/program.ts b/packages/cli/src/repl/program.ts index d643b741..323090a0 100644 --- a/packages/cli/src/repl/program.ts +++ b/packages/cli/src/repl/program.ts @@ -655,6 +655,21 @@ function* build( /** Wake the loop whenever this session's history or overlay moves. */ function* watch(session: ReplSession, wakes: Wakes): Operation { + // Subscribed here, in the scope that outlives the spawn, and drained there. + // A spawned body starts a turn after the spawn returns, and a turn is long + // enough for a queued turn, a delta or a permission request to be sent to + // nobody: the Agent reading moves while the document is doing nothing else, + // so there is no other event to draw the frame that would have shown it. + const agents = yield* session.agentChanges; + yield* spawn(function* conversations(): Operation { + while (true) { + const next = yield* agents.next(); + if (next.done === true) { + return; + } + wakes.send({ kind: "session" }); + } + }); yield* spawn(function* projections(): Operation { const changes = yield* session.changes; while (true) { diff --git a/packages/cli/tests/repl-agent-interface.test.ts b/packages/cli/tests/repl-agent-interface.test.ts index eba26d65..76e8b630 100644 --- a/packages/cli/tests/repl-agent-interface.test.ts +++ b/packages/cli/tests/repl-agent-interface.test.ts @@ -69,6 +69,7 @@ import type { ReplView, } from "../src/repl/application.ts"; import { layout, NARROW, surfaceWidth } from "../src/repl/layout.ts"; +import type { ReplPlacedCell, ReplSemanticFrame } from "../src/repl/layout.ts"; import { decodeLocation, encodeLocation } from "../src/repl/route.ts"; import { installReplHost } from "../src/repl-assembly.ts"; import { installReplTerminal } from "../src/repl/terminal-host.ts"; @@ -103,6 +104,41 @@ const REVIEWER = "root.0"; const BUILDER = "root.1"; const CHECKER = "root.2"; +/** Two conversations named after the suffixes a turn's own facts are keyed with. */ +const SPAWNS_NAMED_LIKE_FACTS = [ + "", + '', + '', + "", +].join("\n"); + +/** More choices than the smallest accepted drawer can place at once. */ +const SEVEN_CHOICES: readonly PermissionOption[] = [ + { optionId: "once", name: "Allow once", kind: "allow_once" }, + { optionId: "always", name: "Allow for this session", kind: "allow_always" }, + { optionId: "conversation", name: "Allow for this conversation", kind: "allow_always" }, + { optionId: "reads", name: "Allow reads only", kind: "allow_once" }, + { optionId: "no", name: "Deny once", kind: "reject_once" }, + { optionId: "never", name: "Deny for this session", kind: "reject_always" }, + { optionId: "halt", name: "Deny and stop", kind: "reject_always" }, +]; + +/** Options named after a turn's own read-only facts. */ +const NAMED_LIKE_FACTS: readonly PermissionOption[] = [ + { optionId: "text", name: "Allow the write", kind: "allow_once" }, + { optionId: "stop", name: "Stop here", kind: "reject_once" }, + { optionId: "failed", name: "Report it failed", kind: "reject_once" }, + { optionId: "whose", name: "Ask whose this is", kind: "allow_once" }, +]; + +/** The permission drawer's own read-only rows, which are never targets. */ +const drawerContentKeys = [ + "drawer:permission:kind", + "drawer:permission:call", + "drawer:permission:turn", + "drawer:permission:dismissal", +]; + /** Every option kind a provider can offer, for the permission rows. */ const ALL_KINDS: readonly PermissionOption[] = [ { optionId: "once", name: "Allow once", kind: "allow_once" }, @@ -464,6 +500,110 @@ function keyed(tree: ReplTree): string | undefined { return node === undefined ? undefined : tree.keyOf(node); } +/** Every mounted node's key, in canonical order. */ +function mountedKeys(tree: ReplTree): string[] { + return tree.mounted().map((id) => tree.keyOf(id) ?? ""); +} + +/** The cell this frame placed for one key, or none, which is what a map holds. */ +function placedFor( + tree: ReplTree, + frame: ReplSemanticFrame, + key: string, +): ReplPlacedCell | undefined { + return frame.cells.find((cell) => tree.keyOf(cell.node) === key); +} + +/** + * Point at one key the way the renderer's map resolves a pointer. + * + * Through the frame rather than through the tree: a cell the frame did not + * place, or placed and did not offer, is not in the map at all, so reaching for + * the node directly would prove something no pointer can do. + */ +function* pointed( + tree: ReplTree, + frame: ReplSemanticFrame, + key: string, +): Operation { + const cell = placedFor(tree, frame, key); + if (cell === undefined) { + throw new Error(`this frame placed no cell for ${key}`); + } + if (!cell.targetable) { + throw new Error(`${key} is placed but is in no target map`); + } + const dispatched = yield* tree.dispatch({ + kind: "pointer", + target: cell.node, + frame: tree.frame().id, + }); + if (!dispatched.ok || dispatched.value.outcome !== "action") { + throw new Error(`the pointer on ${key} produced no action`); + } + return dispatched.value.action; +} + +/** One action, reduced at one size, refusing to carry a refusal forward. */ +function acted( + state: ReplState, + action: ReplAction, + session: ReplSession, + size = NARROW, +): ReplState { + const next = reduceRepl(state, action, session.model, liveReading(session), size); + if (next.state.refusal !== undefined) { + throw new Error(`${action.kind} was refused: ${next.state.refusal}`); + } + return next.state; +} + +/** The Sessions rows this view describes, in order. */ +function sessionKeysOf(view: ReplView): string[] { + return keysOf(view).filter( + (key) => + key.startsWith("sessions:") && + key !== "sessions:heading" && + key !== "sessions:earlier" && + key !== "sessions:later", + ); +} + +/** The permission choices this view's drawer describes, in order. */ +function drawerKeysOf(view: ReplView): string[] { + return keysOf(view).filter((key) => key.startsWith("drawer:permission:choice:")); +} + +/** Scroll the Sessions window until it is showing this row, or say it never did. */ +function scrolledTo(state: ReplState, session: ReplSession, key: string): ReplState { + let at = state; + for (let press = 0; press < 60; press += 1) { + if (sessionKeysOf(reading(at, session, NARROW)).includes(key)) { + return at; + } + const next = acted(at, { kind: "scroll-sessions", delta: 1 }, session); + if (next.viewports.sessions === at.viewports.sessions) { + break; + } + at = next; + } + throw new Error(`the Sessions window never reached ${key}`); +} + +/** Wait until nothing is painting, so the next paint is the one released. */ +function* quiet(terminal: Terminal): Operation { + let seen = -1; + for (let round = 0; round < 200; round += 1) { + const painted = terminal.presented.length; + if (painted === seen) { + return; + } + seen = painted; + yield* settled(20); + } + throw new Error("the screen never stopped painting"); +} + /** The mounted node this key names, or none. */ function nodeOf(tree: ReplTree, key: string): string | undefined { return tree.mounted().find((id) => tree.keyOf(id) === key); @@ -563,6 +703,7 @@ function turnKeyed(view: ReplView, prompt: string): string { function* asking( script: Record, permissionMode: PermissionMode = "approve-reads", + source: string = THREE_SPAWNS, ): Operation<{ readonly session: ReplSession; readonly stub: Stub; @@ -576,7 +717,7 @@ function* asking( execution: holder, installations: installations(), permissionMode, - source: THREE_SPAWNS, + source, }), ); return { session, stub, holder }; @@ -1349,13 +1490,18 @@ describe("U3 — what each accepted frame mounts, and nothing else", () => { // column are not mounted, so they are in no frame, no target map and no // pointer's way. const mounted = tree.mounted().map((id) => tree.keyOf(id) ?? ""); - for (const prefix of ["entries:", "entry:", "scope:", "line:", "binding:", "elicit:"]) { + for (const prefix of ["entry:", "scope:", "line:", "binding:", "elicit:"]) { expect(mounted.filter((key) => key.startsWith(prefix))).toEqual([]); expect(keysOf(view).filter((key) => key.startsWith(prefix))).toEqual([]); } + // The entry *outlet* is absent; the control that goes to it is not part of + // that outlet and stays, because a screen a person cannot leave is not one + // this route may put them on. + expect(mounted.filter((key) => key.startsWith("entries:"))).toEqual(["entries:heading"]); const drawn = frame.cells.map((cell) => tree.keyOf(cell.node) ?? ""); expect(drawn.some((key) => key.startsWith("sessions:turn:"))).toBe(true); - expect(drawn.filter((key) => key.startsWith("entry") || key.startsWith("line:"))).toEqual([]); + expect(drawn).toContain("entries:heading"); + expect(drawn.filter((key) => key.startsWith("entry:") || key.startsWith("line:"))).toEqual([]); // Every target this frame offers is a control, and every one of them is // mounted: nothing offers itself to a pointer and then does nothing. for (const cell of frame.cells.filter((one) => one.targetable)) { @@ -1378,9 +1524,17 @@ describe("U3 — what each accepted frame mounts, and nothing else", () => { const view = reading(initialState("agents"), session, NARROW); yield* applied(tree, view); const mounted = tree.mounted().map((id) => tree.keyOf(id) ?? ""); - expect(mounted.filter((key) => key.startsWith("sessions:"))).toEqual([]); + // No row of the Sessions reading: not a conversation, not a turn, not a + // fact, not a request, and neither window control. + expect(mounted.filter((key) => key.startsWith("sessions:"))).toEqual(["sessions:heading"]); // The entry list is what this frame is for. expect(mounted.some((key) => key.startsWith("entries:"))).toBe(true); + // And the way to the other surface is drawn and pointable from here, which + // is the whole reason it is mounted. + const frame = layout(NARROW, replSurface(tree, view)); + const drawn = frame.cells.filter((cell) => tree.keyOf(cell.node) === "sessions:heading"); + expect(drawn).toHaveLength(1); + expect(drawn[0]?.targetable).toBe(true); }); it("U3: the permission drawer traps focus, keeps History inside it and hides what is behind", function* () { @@ -1634,6 +1788,525 @@ describe("U2 — the program performs a permission, end to end", () => { }); }); +describe("U4 — the loop wakes for Agent work", () => { + beforeAll(() => useTempFileCompiler()); + + it("U4: queued, streaming and a waiting request each repaint on their own", function* () { + // Every turn is held before it produces anything, so the only thing that + // moves between the assertions below is the Agent reading: no record + // appends, nothing is printed, nothing is asked through Elicit and + // expansion stays where it is. If the screen changes, this is what changed + // it. + const stub = createStub({ + review: { queued: true, streaming: true }, + build: { + queued: true, + permission: { toolCallId: "call-1", title: "Write", kind: "edit" }, + }, + check: { queued: true }, + }); + const { terminal, install } = recordingTerminal(); + + yield* scoped(function* (): Operation { + yield* install(); + yield* immediateClock(); + yield* useStub(stub); + yield* useTemporaryHost(); + + const running = yield* spawn(function* (): Operation { + const ran = yield* runReplProgram({ + installations: installations(), + permissionMode: "approve-reads", + }); + if (!ran.ok) { + throw ran.error; + } + }); + yield* untilDrawn(terminal); + terminal.bytes(BYTES.encode(THREE_SPAWNS)); + yield* settled(20); + terminal.feed("\r"); + yield* settled(40); + + // A stable frame: all three observed, none of them started. + yield* showing(terminal, "review · queued"); + yield* showing(terminal, "check · queued"); + yield* quiet(terminal); + + // One turn starts. Nobody pressed anything, nothing was recorded, and the + // screen has to say so. + const beforeStart = terminal.presented.length; + stub.start(REVIEWER); + yield* showing(terminal, "review · streaming"); + expect(terminal.presented.length).toBeGreaterThan(beforeStart); + // And only that turn moved. + expect(shows(terminal, "check · queued")).toBe(true); + yield* quiet(terminal); + + // One turn asks for permission. The fact appears on the turn that is + // waiting, in a frame nothing else asked for. + const beforeAsking = terminal.presented.length; + stub.start(BUILDER); + yield* showing(terminal, "asks: Write"); + expect(terminal.presented.length).toBeGreaterThan(beforeAsking); + // Arriving opened nothing and moved nobody: the location is unchanged and + // no drawer is up. + expect(maybeLocation(terminal)).not.toContain("+permission"); + + terminal.end(); + yield* running; + }); + }); +}); + +describe("U5 — navigation is outside the outlet it leaves", () => { + beforeAll(() => useTempFileCompiler()); + + it("U5: a narrow frame is left in both directions, by key and by pointer", function* () { + const { session } = yield* asking({ + review: { streaming: true }, + build: { streaming: true }, + check: { queued: true }, + }); + yield* until(session, "a started turn", () => started(session) >= 1); + + // The narrow REPL route, which mounts the entry outlet and no Sessions row. + const onRepl = initialState("agents"); + const tree = yield* useReplTree(); + const replView = reading(onRepl, session, NARROW); + yield* applied(tree, replView); + const replFrame = layout(NARROW, replSurface(tree, replView)); + expect(mountedKeys(tree).filter((key) => key.startsWith("sessions:"))).toEqual([ + "sessions:heading", + ]); + + // Enter on the control and a pointer resolved from the frame ask for the + // same thing, and both are available from the outlet this route mounts. + yield* focusTo(tree, "sessions:heading"); + const pressed = yield* activate(tree); + const aimed = yield* pointed(tree, replFrame, "sessions:heading"); + expect(pressed).toEqual({ kind: "select-surface", surface: "sessions" }); + expect(aimed).toEqual(pressed); + + // Which takes the person to Sessions, where the way back is mounted too. + const onSessionsNow = acted(onRepl, pressed, session); + expect(onSessionsNow.route.surface).toBe("sessions"); + const sessionsView = reading(onSessionsNow, session, NARROW); + yield* applied(tree, sessionsView); + const sessionsFrame = layout(NARROW, replSurface(tree, sessionsView)); + // The entry outlet is absent; the control that goes to it is not. + expect(mountedKeys(tree).filter((key) => key.startsWith("entry:"))).toEqual([]); + expect(mountedKeys(tree).filter((key) => key.startsWith("scope:"))).toEqual([]); + yield* focusTo(tree, "entries:heading"); + const back = yield* activate(tree); + expect(back).toEqual({ kind: "select-surface", surface: "repl" }); + expect(yield* pointed(tree, sessionsFrame, "entries:heading")).toEqual(back); + expect(acted(onSessionsNow, back, session).route.surface).toBe("repl"); + }); + + it("U5: a request arriving on the other surface is still reachable from this one", function* () { + const { session } = yield* asking({ + review: { streaming: true, permission: { toolCallId: "call-1", title: "Write" } }, + build: { streaming: true }, + check: { queued: true }, + }); + yield* until(session, "a request waiting", () => session.agent.requests.length === 1); + const request = session.agent.requests[0]; + if (request === undefined) { + throw new Error("no request was published"); + } + + // Standing on the narrow REPL route when it arrives: no route changed, no + // drawer opened, and the fact is not a control here. + const onRepl = initialState("agents"); + const tree = yield* useReplTree(); + const replView = reading(onRepl, session, NARROW); + yield* applied(tree, replView); + expect(replView.state.route).toEqual(onRepl.route); + expect(replView.state.route.drawers).toEqual([]); + + // The person goes to Sessions through the mounted control, and the request + // is there to activate. + yield* focusTo(tree, "sessions:heading"); + const moved = acted(onRepl, yield* activate(tree), session); + const sessionsView = reading(moved, session, NARROW); + yield* applied(tree, sessionsView); + const key = `sessions:request:${request.key}`; + const at = scrolledTo(moved, session, key); + const view = reading(at, session, NARROW); + yield* applied(tree, view); + yield* focusTo(tree, key); + expect(yield* activate(tree)).toEqual({ kind: "select-permission", request: request.key }); + }); +}); + +describe("U6 — the Sessions reading is windowed", () => { + beforeAll(() => useTempFileCompiler()); + + it("U6: a request past the first window is reached by scrolling, and the top comes back", function* () { + // Every turn asks. Which one the scheduler reaches first is its own + // business, so what this row relies on is only that the reading is longer + // than the smallest accepted frame can place — and one of the requests is + // therefore past its first window. + const { session } = yield* asking({ + review: { permission: { toolCallId: "call-1", title: "Write", kind: "edit" } }, + build: { permission: { toolCallId: "call-2", title: "Move", kind: "edit" } }, + check: { permission: { toolCallId: "call-3", title: "Delete", kind: "edit" } }, + }); + // Every turn observed and started, so the reading is the whole one this row + // is about rather than however much of it had arrived first. + yield* until(session, "all three Prompts being observed", () => observed(session) === 3); + yield* until(session, "all three turns being started", () => started(session) === 3); + yield* until(session, "all three requests waiting", () => session.agent.requests.length === 3); + + const standing = onSessions(session); + const whole = sessionKeysOf(reading(standing, session, WIDE)); + const first = reading(standing, session, NARROW); + const shown = sessionKeysOf(first); + // There is more reading than this frame can place, and what it places is a + // prefix of the whole thing rather than a sample of it. + expect(whole.length).toBeGreaterThan(shown.length); + expect(whole.slice(0, shown.length)).toEqual(shown); + + // The exact request whose row this window does not reach. + const request = session.agent.requests.find( + (candidate) => !shown.includes(`sessions:request:${candidate.key}`), + ); + if (request === undefined) { + throw new Error("every request was inside the first window"); + } + const key = `sessions:request:${request.key}`; + // Past the window: not described, so not mounted, not focusable, not drawn + // and not in any target map. + expect(whole).toContain(key); + expect(shown).not.toContain(key); + const tree = yield* useReplTree(); + yield* applied(tree, first); + expect(nodeOf(tree, key)).toBe(undefined); + expect(placedFor(tree, layout(NARROW, replSurface(tree, first)), key)).toBe(undefined); + + // Scrolling reaches it, and the control it becomes is the exact one that + // answers this request — by pointer, resolved from the frame that drew it. + const at = scrolledTo(standing, session, key); + const view = reading(at, session, NARROW); + yield* applied(tree, view); + const frame = layout(NARROW, replSurface(tree, view)); + const placed = placedFor(tree, frame, key); + expect(placed).toBeDefined(); + expect(placed?.targetable).toBe(true); + expect(yield* pointed(tree, frame, key)).toEqual({ + kind: "select-permission", + request: request.key, + }); + // The window controls never scroll away from whoever is using them. + expect(placedFor(tree, frame, "sessions:earlier")).toBeDefined(); + expect(placedFor(tree, frame, "sessions:later")).toBeDefined(); + expect(placedFor(tree, frame, "sessions:heading")).toBeDefined(); + + // And scrolling back recovers what was there before, rather than leaving a + // window that only travels one way. + let back = at; + for (let press = 0; press < 40 && back.viewports.sessions > 0; press += 1) { + back = acted(back, { kind: "scroll-sessions", delta: -1 }, session); + } + expect(back.viewports.sessions).toBe(0); + expect(sessionKeysOf(reading(back, session, NARROW))).toEqual(shown); + }); + + it("U6: the stored offset is clamped when the reading it was taken against changes", function* () { + const { session, stub } = yield* asking({ + review: { permission: { toolCallId: "call-1", title: "Write", kind: "edit" } }, + build: { permission: { toolCallId: "call-2", title: "Move", kind: "edit" } }, + check: { permission: { toolCallId: "call-3", title: "Delete", kind: "edit" } }, + }); + yield* until(session, "all three Prompts being observed", () => observed(session) === 3); + yield* until(session, "all three turns being started", () => started(session) === 3); + yield* until(session, "all three requests waiting", () => session.agent.requests.length === 3); + const standing = onSessions(session); + const shown = sessionKeysOf(reading(standing, session, NARROW)); + const beyond = session.agent.requests.find( + (candidate) => !shown.includes(`sessions:request:${candidate.key}`), + ); + if (beyond === undefined) { + throw new Error("every request was inside the first window"); + } + const scrolled = scrolledTo(standing, session, `sessions:request:${beyond.key}`); + expect(scrolled.viewports.sessions).toBeGreaterThan(0); + + // Filtering is a different list, so the window starts again at its first + // row rather than at a number taken against the other one. + const filtered = acted(scrolled, { kind: "select-session", session: "stub:builder" }, session); + expect(filtered.viewports.sessions).toBe(0); + expect(acted(filtered, { kind: "all-sessions" }, session).viewports.sessions).toBe(0); + + // A window cannot be scrolled past the end of the reading it is over, and + // what is stored is what is being shown: one press back moves it. + let far = scrolled; + for (let press = 0; press < 60; press += 1) { + far = acted(far, { kind: "scroll-sessions", delta: 1 }, session); + } + const furthest = far.viewports.sessions; + const stepped = acted(far, { kind: "scroll-sessions", delta: -1 }, session); + expect(stepped.viewports.sessions).toBe(furthest - 1); + expect(sessionKeysOf(reading(stepped, session, NARROW))).not.toEqual( + sessionKeysOf(reading(far, session, NARROW)), + ); + // Nothing about any of this reached the location. + expect(reading(far, session, NARROW).location).not.toContain(String(furthest)); + expect(stub.outcomes.size).toBe(0); + }); +}); + +describe("U7 — the permission drawer is windowed", () => { + beforeAll(() => useTempFileCompiler()); + + it("U7: every offered choice becomes placed and pointable, in the provider's order", function* () { + const { session, stub } = yield* asking({ + review: { + streaming: true, + permission: { + toolCallId: "call-1", + title: "Write", + kind: "edit", + options: SEVEN_CHOICES, + }, + }, + build: { streaming: true }, + check: { queued: true }, + }); + yield* until(session, "a request waiting", () => session.agent.requests.length === 1); + const request = session.agent.requests[0]; + if (request === undefined) { + throw new Error("no request was published"); + } + expect(request.choices).toHaveLength(SEVEN_CHOICES.length); + + const opened = acted( + onSessions(session), + { kind: "select-permission", request: request.key }, + session, + ); + const tree = yield* useReplTree(); + // More content than the smallest accepted drawer can place, so the first + // window is a prefix and the rest is reached by scrolling. + const firstWindow = drawerKeysOf(reading(opened, session, NARROW)); + expect(firstWindow.length).toBeLessThan(SEVEN_CHOICES.length); + + const reached: string[] = []; + let at = opened; + for (let press = 0; press < 20; press += 1) { + const view = reading(at, session, NARROW); + yield* applied(tree, view); + const frame = layout(NARROW, replSurface(tree, view)); + for (const choice of SEVEN_CHOICES) { + const key = `drawer:permission:choice:${choice.optionId}`; + const placed = placedFor(tree, frame, key); + if (placed !== undefined && !reached.includes(choice.optionId)) { + // Placed means pointable: a choice a person can read is a choice they + // can take. + expect(placed.targetable).toBe(true); + expect(yield* pointed(tree, frame, key)).toEqual({ + kind: "choose-permission", + request: request.key, + option: choice.optionId, + }); + reached.push(choice.optionId); + } + } + // Leaving is never scrolled away from, whatever the window is showing. + expect(placedFor(tree, frame, "drawer:close")).toBeDefined(); + // What the window is not showing is in no target map at all. + for (const key of drawerContentKeys) { + const described = keysOf(view).includes(key); + if (!described) { + expect(placedFor(tree, frame, key)).toBe(undefined); + expect(nodeOf(tree, key)).toBe(undefined); + } + } + if (reached.length === SEVEN_CHOICES.length) { + break; + } + at = acted(at, { kind: "scroll", delta: 1 }, session); + } + // Every one of them, in the order the provider offered them. + expect(reached).toEqual(SEVEN_CHOICES.map((choice) => choice.optionId)); + + // And taking one calls the authority exactly once. + const chose = reduceRepl( + at, + { kind: "choose-permission", request: request.key, option: "never" }, + session.model, + liveReading(session), + NARROW, + ); + expect(answer(session, chose.intent)).toBe(true); + yield* until(session, "the request being answered", () => stub.outcomes.size === 1); + expect(stub.outcomes.get("call-1")).toEqual({ outcome: "selected", optionId: "never" }); + expect(stub.answers.get("call-1")).toBe(1); + // The window over it is gone with it: the next request opens at its own + // first row. + expect(permissionSettled(chose.state, request.turn).viewports.permission).toBe(0); + }); + + it("U7: closing a scrolled drawer denies exactly once", function* () { + const { session, stub } = yield* asking({ + review: { + streaming: true, + permission: { + toolCallId: "call-1", + title: "Write", + kind: "edit", + options: SEVEN_CHOICES, + }, + }, + build: { streaming: true }, + check: { queued: true }, + }); + yield* until(session, "a request waiting", () => session.agent.requests.length === 1); + const request = session.agent.requests[0]; + if (request === undefined) { + throw new Error("no request was published"); + } + let at = acted( + onSessions(session), + { kind: "select-permission", request: request.key }, + session, + ); + at = acted(at, { kind: "scroll", delta: 1 }, session); + at = acted(at, { kind: "scroll", delta: 1 }, session); + expect(at.viewports.permission).toBeGreaterThan(0); + + const tree = yield* useReplTree(); + const view = reading(at, session, NARROW); + yield* applied(tree, view); + const frame = layout(NARROW, replSurface(tree, view)); + // Still there, whatever the window is showing, and it denies this request + // rather than meaning "this changed nothing". + const closing = yield* pointed(tree, frame, "drawer:close"); + expect(closing).toEqual({ kind: "dismiss-permission", request: request.key }); + const dismissed = reduceRepl(at, closing, session.model, liveReading(session), NARROW); + expect(answer(session, dismissed.intent)).toBe(true); + yield* until(session, "the request being denied", () => stub.outcomes.size === 1); + expect(stub.answers.get("call-1")).toBe(1); + expect(session.permissions.dismiss(request.key)).toBe(false); + expect(stub.answers.get("call-1")).toBe(1); + expect(session.live).toBe(true); + }); +}); + +describe("U8 — a target is a control, whatever its key is spelled", () => { + beforeAll(() => useTempFileCompiler()); + + it("U8: values named like a turn's facts are still pointer-equivalent to Enter", function* () { + // Conversations named after the suffixes a turn's own read-only facts carry, + // and options named the same way: a rule about spelling would take the + // pointer away from every one of them. + const { session } = yield* asking( + { + review: { + streaming: true, + permission: { + toolCallId: "call-1", + title: "Write", + kind: "edit", + options: NAMED_LIKE_FACTS, + }, + }, + build: { streaming: true }, + }, + "approve-reads", + SPAWNS_NAMED_LIKE_FACTS, + ); + yield* until(session, "a request waiting", () => session.agent.requests.length === 1); + const request = session.agent.requests[0]; + if (request === undefined) { + throw new Error("no request was published"); + } + + const standing = onSessions(session); + const tree = yield* useReplTree(); + const view = reading(standing, session, WIDE); + yield* applied(tree, view); + const frame = layout(WIDE, replSurface(tree, view)); + + // A conversation whose provider key ends in `:text`. + const conversation = "sessions:conversation:stub:text"; + expect(keysOf(view)).toContain(conversation); + yield* focusTo(tree, conversation); + const pressed = yield* activate(tree); + expect(pressed).toEqual({ kind: "select-session", session: "stub:text" }); + expect(yield* pointed(tree, frame, conversation)).toEqual(pressed); + + // And every option named after one of those facts. + const opened = acted(standing, { kind: "select-permission", request: request.key }, session); + const drawer = reading(opened, session, WIDE); + yield* applied(tree, drawer); + const drawerFrame = layout(WIDE, replSurface(tree, drawer)); + for (const choice of NAMED_LIKE_FACTS) { + const key = `drawer:permission:choice:${choice.optionId}`; + yield* focusTo(tree, key); + const chose = yield* activate(tree); + expect(chose).toEqual({ + kind: "choose-permission", + request: request.key, + option: choice.optionId, + }); + expect(yield* pointed(tree, drawerFrame, key)).toEqual(chose); + } + }); + + it("U8: a fact is read, not activated, wherever it is drawn", function* () { + const { session } = yield* asking({ + review: { + streaming: true, + permission: { toolCallId: "call-1", title: "Write", kind: "edit" }, + }, + build: { streaming: true }, + check: { queued: true }, + }); + yield* until(session, "a request waiting", () => session.agent.requests.length === 1); + const request = session.agent.requests[0]; + if (request === undefined) { + throw new Error("no request was published"); + } + const tree = yield* useReplTree(); + + // On the REPL surface the pending request is a fact: the grammar answers one + // on Sessions, so a control here would be a target that refuses. + const onRepl = reading(initialState("agents"), session, WIDE); + yield* applied(tree, onRepl); + const replFrame = layout(WIDE, replSurface(tree, onRepl)); + const fact = placedFor(tree, replFrame, `sessions:request:${request.key}`); + expect(fact).toBeDefined(); + expect(fact?.targetable).toBe(false); + + // Inside the drawer, what a person decides *about* is read the same way. + const opened = acted( + onSessions(session), + { kind: "select-permission", request: request.key }, + session, + ); + const drawer = reading(opened, session, WIDE); + yield* applied(tree, drawer); + const drawerFrame = layout(WIDE, replSurface(tree, drawer)); + for (const key of drawerContentKeys) { + const placed = placedFor(tree, drawerFrame, key); + expect(placed).toBeDefined(); + expect(placed?.targetable).toBe(false); + } + // A turn's own facts and a retained audit are lines wherever they appear. + const sessions = reading(onSessions(session), session, WIDE); + yield* applied(tree, sessions); + const sessionsFrame = layout(WIDE, replSurface(tree, sessions)); + for (const cell of sessionsFrame.cells) { + const key = tree.keyOf(cell.node) ?? ""; + if (TURN_FACT_SUFFIXES.some((suffix) => key.endsWith(suffix))) { + expect(cell.targetable).toBe(false); + } + } + }); +}); + /** * Wait until the screen shows this text, or say it never did. * diff --git a/packages/cli/tests/repl-forms.test.ts b/packages/cli/tests/repl-forms.test.ts index 0b8aa7c3..24942dc5 100644 --- a/packages/cli/tests/repl-forms.test.ts +++ b/packages/cli/tests/repl-forms.test.ts @@ -93,6 +93,17 @@ const DETAILS_SCHEMA: Json = { additionalProperties: false, }; +/** Fields named after the suffixes a turn's own read-only facts are keyed with. */ +const NAMED_LIKE_FACTS_SCHEMA: Json = { + type: "object", + properties: { + text: { type: "string", title: "Text" }, + stop: { type: "string", title: "Stop" }, + }, + required: ["text"], + additionalProperties: false, +}; + /** Confirmation: one required enum. */ const CONFIRM_SCHEMA: Json = { type: "object", @@ -683,6 +694,22 @@ describe("F3 — complete content and reachable navigation", () => { "footer:history", ]; + it("F3: a field named after a turn's facts is still pointer-targetable", function* () { + // The key of this field's editable line ends in `:text`, which is how a + // turn's own read-only text is keyed too. What decides whether a pointer may + // activate a row is what the row is, so the field keeps its pointer and the + // fact never had one. + const asked = yield* askingFor(NAMED_LIKE_FACTS_SCHEMA); + const live = asking(asked.question); + const tree = yield* useReplTree(); + const keys = yield* placedKeys(tree, reading(opened(live), live, EMPTY_MODEL, NARROW)); + expect(keys.get("drawer:field:text")).toBe(true); + expect(keys.get("drawer:value:text")).toBe(true); + expect(keys.get("drawer:field:stop")).toBe(true); + // And what a person only reads inside the same drawer is not a target. + expect(keys.get("drawer:message:0")).toBe(false); + }); + it("F3: scrolling places every essential control in the narrow frame", function* () { const asked = yield* askingFor(PLAN_SCHEMA, DRAFT); const live = asking(asked.question); @@ -957,9 +984,10 @@ describe("F3 — focus returns to the invocation, not to where the drawer came f yield* applied(tree, reading(unclaimed, asking(undefined), recorded.model, NARROW, inside)); const landed = keyed(tree); expect(landed).not.toBe(`elicit:${recorded.marker}`); - // The first focusable row of the surface this frame draws, which is neither - // the invocation nor where the drawer was opened from. - expect(landed).toBe("entries:heading"); + // The first focusable row this frame draws, which is neither the invocation + // nor where the drawer was opened from. On a narrow route that is the + // surface navigation, which is mounted above whichever outlet is routed. + expect(landed).toBe("sessions:heading"); }); }); diff --git a/specs/repl-spec.md b/specs/repl-spec.md index 15e1cb1c..e1a9f551 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -50,6 +50,48 @@ to the head. When the root settles, its recorded output is what the transcript shows, and the screen stops asking for frames. +## Agent conversations, and what one turn is waiting on + +When the entry runs Agent work, the Sessions surface is one chronology of it: +every turn this process observed and every turn the history holds, in the order +their Prompts were scheduled. A turn keeps its place when it publishes — the +same row, where it already was, reading from the record once there is one — +because a turn that has been recorded is still the turn you were looking at, and +a list that appended the live ones to the retained ones would reorder Prompts +that finished out of order. + +Each turn says what is known about it at that moment: its prompt, whether it is +queued, streaming, finished or recorded, what it has said so far, which agent and +which conversation it joined, and how it ended. + +The conversations you can filter by are the ones a provider actually started. A +queued turn belongs to none of them yet, and the name the document gave its +`` is not an answer to which conversation a provider opened, so a turn +without one appears under **All conversations** and nowhere else. Choosing a +conversation changes the filter and nothing else — not the surface, the selected +scope, the history position, the draft, the drawers or where focus is — and work +going on behind the screen never chooses or clears one for you. + +A turn waiting for permission says so on its own row. It opens nothing: nothing +moves, nothing takes focus, and nothing else stops. Activate it and a drawer +shows what is being asked — its kind, the call, whose turn is waiting and every +choice the provider offered, in the provider's order. A choice that lasts says it +lasts *for this Agent session*, because nothing here can make a rule that +outlives the conversation asking. Closing or pressing Escape denies the request +while the session keeps running, and the drawer closes only when the request is +really settled: a screen that closed first would be claiming an answer nobody +gave. Afterwards focus returns to the turn that was waiting. + +A permission the history already holds is a record of what a turn was granted. +It is read, never answered, and a view frozen at a history position shows no +live request at all. + +Both readings are windowed rather than clipped. The Sessions list and the +permission drawer each move through as many rows as the frame can place, with +their earlier and later controls — and the way to the other surface — staying +put while the rows move beneath them. What the window is not showing is not +drawn, not focusable and reaches no pointer. + ## One cold journey The command prints the location it ended at. Pass that location to a new @@ -70,12 +112,14 @@ started. | --- | --- | | `160x36` and larger | Sessions/Entries sidebar, transcript, bindings and recorded questions, the drawer layer, and a fixed full-width footer holding five History rows and the input | | `120x30` and larger | the same, with a narrower sidebar and inspection column | -| `72x20` and larger | one routed surface — the one the route selected — with the same drawer and footer | +| `72x20` and larger | one routed surface — the one the route selected — under the two surface controls, with the same drawer and footer | | smaller than `72x20` | a refusal saying the minimum, showing nothing else; it recovers when the window grows | -At narrow, the surfaces the route did not select are still there in the model and -are not on the screen: they are in no cell, in no target map, and no pointer -reaches them. The History band is five rows at every size; when the labels of +At narrow, the surface the route did not select is still there in the model and +is not on the screen: it is in no cell, in no target map, and no pointer reaches +it. What stays is the way between them: both surface controls are on the screen +at every size, because a screen you cannot leave is not one this route may put +you on. The History band is five rows at every size; when the labels of several positions cannot all fit, they share a label and every position keeps its own identity. @@ -86,8 +130,9 @@ was refused. It does not replace the screen. ## One entry, and nothing else This product admits zero or one entry per execution. There is no second entry, no -catalog of past runs, no fork, no agent, no snapshot and no sidecar file. The -Sessions surface exists and says it is empty. +catalog of past runs, no fork, no snapshot and no sidecar file. The Sessions +surface presents the Agent work that entry did, and says it is empty until there +is some. ## What is retained, and what is not From b355ea1629994f2e78abba2b79cc6c207567ef28 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 30 Sep 2026 17:16:37 -0400 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20Bound=20the=20narrow=20locat?= =?UTF-8?q?ion,=20and=20move=20a=20window=20from=20where=20it=20is=20drawn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways a viewport still lied about what a person could reach. **A long draft took both surface controls off the screen.** The location is the draft's home until an entry exists, so a thousand-character draft is a thousand-character URL — fifteen rows at 72 columns, in a region that has thirteen. Everything under it was described, mounted and focusable with nowhere to be drawn: not the way to Sessions, not the way to Entries, not one row of the outlet the route had selected. Reserving a minimum row for the reading could not help, because the fixed content had already used the whole region. A narrow frame now draws at most three rows of the location and says how many characters it is not showing. A prefix of a location is no use to somebody copying it — which is why it is still drawn whole wherever there is room, and why the command still prints all of it — but a person with a URL and no controls cannot do anything at all, including get to a window where the whole thing would fit. **The first press after a resize could move nothing.** A frame draws the clamped offset, while both reducers added the delta to the stored one. Growing `72x20` to `72x21` makes each window hold one row more, so the last window starts one row earlier: the screen was already showing that position while the stored number was past it, and pressing `[^ earlier]` only normalized state nobody could see. Both windows now move from the position their frame is drawing, through the one clamp that decides where a window is. --- architecture.md | 8 +- packages/cli/src/repl/application.ts | 91 ++++- .../cli/tests/repl-agent-interface.test.ts | 322 +++++++++++++++++- specs/repl-spec.md | 9 +- 4 files changed, 410 insertions(+), 20 deletions(-) diff --git a/architecture.md b/architecture.md index cbc606d2..1c13327d 100644 --- a/architecture.md +++ b/architecture.md @@ -5501,7 +5501,13 @@ requests to answer. Both readings are windowed at the layout region that will place them, rather than described whole and clipped: only what the window holds is described, so -only that is mounted, focusable, drawn and pointable. Their window controls and +only that is mounted, focusable, drawn and pointable. A window moves from the +position its frame is drawing rather than from the number stored, because a +resize changes what a window holds and a delta added to a stale number would +spend a press on state nobody can see. The canonical location is bounded the +same way and for the same reason: in a narrow frame it shares one region with +every control on the screen, so it takes at most three rows and says what it is +not showing. Their window controls and the two surface controls stay outside the moving window. A narrow frame mounts one routed outlet and both surface controls, so the outlet the route did not select is absent everywhere — `mounted()`, the frame, the target map — while the diff --git a/packages/cli/src/repl/application.ts b/packages/cli/src/repl/application.ts index 9cccf05d..7a2d9059 100644 --- a/packages/cli/src/repl/application.ts +++ b/packages/cli/src/repl/application.ts @@ -841,7 +841,15 @@ export function reduceRepl( // any visible effect, so what is held is what the region is showing. const rows = sessionContentRows(state, model, live); const furthest = Math.max(0, rows - sessionsCapacity(state, model, size)); - const sessions = Math.min(Math.max(0, state.viewports.sessions + action.delta), furthest); + // From where the frame is, not from the number that was stored. A resize + // changes what a window holds, and the region is already drawing the + // clamped position — so a delta added to a stale larger number would + // spend a press normalizing state nobody can see, and the screen would + // not move. + const sessions = clamped( + clamped(state.viewports.sessions, furthest) + action.delta, + furthest, + ); return settled({ ...state, viewports: Object.freeze({ ...state.viewports, sessions }), @@ -1020,8 +1028,9 @@ export function reduceRepl( // what closing does. const rows = permissionContentRows(model, live, pending); const furthest = Math.max(0, rows - drawerCapacity(size)); - const permission = Math.min( - Math.max(0, state.viewports.permission + action.delta), + // From where the drawer is, for the same reason. + const permission = clamped( + clamped(state.viewports.permission, furthest) + action.delta, furthest, ); return settled({ @@ -1117,6 +1126,16 @@ function offered( return live.agent.requests.find((candidate) => candidate.key === request); } +/** + * One offset, inside the window it is over. + * + * The one place either window decides where it is, so what a reducer moves from + * and what a frame draws cannot be two different rows. + */ +function clamped(offset: number, furthest: number): number { + return Math.min(Math.max(0, offset), furthest); +} + /** The same state with the Sessions reading back at its first row. */ function atFirstRow(state: ReplState): ReplState { return Object.freeze({ @@ -1454,8 +1473,7 @@ function described(view: ReplView): readonly Described[] { // is not one a person can see, focus or point at. const content = sessionRows(state, turns, view.focused, claim); const capacity = sessionsCapacity(state, model, view.size); - const last = Math.max(0, content.length - capacity); - const from = Math.min(Math.max(0, state.viewports.sessions), last); + const from = clamped(state.viewports.sessions, Math.max(0, content.length - capacity)); // Outside the thing they move, like the drawer's: a control inside the // window would scroll away from whoever was reaching for it. items.push( @@ -1619,13 +1637,10 @@ function described(view: ReplView): readonly Described[] { ); } - // The canonical location, as it stands and in full. It is the one thing a - // person copies out of this screen — how they come back to exactly this view, - // here or in another process — so a prefix of it is no use to them. A location - // carrying a draft is longer than a row, so it is as many rows as it needs, - // above whatever surface is being shown rather than in the footer, which is - // seven rows and has controls in them. - for (const [offset, part] of chunked(view.location, surfaceWidth(view.size)).entries()) { + // The canonical location: how a person comes back to exactly this view, here + // or in another process. It goes above whatever surface is being shown rather + // than in the footer, which is seven rows and has controls in them. + for (const [offset, part] of locationRows(view.location, view.size).entries()) { items.push(line(`location:${offset}`, part)); } @@ -1767,6 +1782,51 @@ function sessionRows( return items; } +/** + * How many rows a narrow frame gives the canonical location. + * + * Three, and the region is thirteen. A narrow frame draws the location, both + * surface controls, the two window controls and the routed outlet in one + * region, so what the location takes is what the rest cannot have. + */ +const NARROW_LOCATION_ROWS = 3; + +/** + * The canonical location, as the rows one frame places it in. + * + * In full wherever there is room: it is the one thing a person copies out of + * this screen, and a prefix of it takes them somewhere else. A narrow frame is + * where there is not room — the location shares its region with every control + * on the screen, and a draft long enough to fill that region would leave the + * surface controls and the whole outlet mounted, focusable and drawn nowhere, + * which is a screen with no way off it. + * + * So a narrow frame bounds it and says what it is not showing. A person who + * cannot see the whole location can still read that fact and act on it; a + * person whose controls are all off the bottom of the screen cannot do + * anything at all. + */ +function locationRows(location: string, size: ReplTerminalSize): readonly string[] { + const rows = chunked(location, surfaceWidth(size)); + if (profileFor(size) !== "narrow" || rows.length <= NARROW_LOCATION_ROWS) { + return rows; + } + const shown = rows.slice(0, NARROW_LOCATION_ROWS - 1); + const hidden = location.length - shown.join("").length; + // To the same width as the rows above it. This is the one location row whose + // length changes — a count that loses a digit makes it shorter — and what + // this application describes is a complete row either way: `chunked` already + // pads every ordinary one, and covering what a shorter row no longer reaches + // is this boundary's job rather than something to leave to whichever renderer + // happens to draw it. The renderer in use fills a placed cell to its bounds, + // so it repaints this cleanly whether or not the row arrives padded; that is + // its behavior, and this is the contract. + return Object.freeze([ + ...shown, + pad(`… ${hidden} more characters, in a wider window`, surfaceWidth(size)), + ]); +} + /** * How many rows the Sessions reading holds, whatever the window shows. * @@ -1792,9 +1852,7 @@ function sessionContentRows(state: ReplState, model: ReplModel, live: ReplLive): */ function sessionsCapacity(state: ReplState, model: ReplModel, size: ReplTerminalSize): number { const narrow = profileFor(size) === "narrow"; - const shared = narrow - ? chunked(encodeLocation(state.route), surfaceWidth(size)).length - : entryRows(model); + const shared = narrow ? locationRows(encodeLocation(state.route), size).length : entryRows(model); // Both controls in a narrow frame, where they are one bar above the outlet. // In a sidebar the entry list brings its own heading, counted with it. const navigation = narrow ? 2 : 1; @@ -1900,8 +1958,7 @@ function drawerFor(view: ReplView, history: Described): Described | undefined { // down to find. const content = permissionContent(view.model, view.live, request, width, view.focused); const capacity = drawerCapacity(view.size); - const last = Math.max(0, content.length - capacity); - const from = Math.min(Math.max(0, view.state.viewports.permission), last); + const from = clamped(view.state.viewports.permission, Math.max(0, content.length - capacity)); children.push( row( "drawer:scroll:up", diff --git a/packages/cli/tests/repl-agent-interface.test.ts b/packages/cli/tests/repl-agent-interface.test.ts index 76e8b630..50edd233 100644 --- a/packages/cli/tests/repl-agent-interface.test.ts +++ b/packages/cli/tests/repl-agent-interface.test.ts @@ -84,6 +84,8 @@ import { join } from "node:path"; import { randomBytes } from "node:crypto"; import { fields, readDescription } from "../src/repl/description.ts"; import type { ReplDescription } from "../src/repl/description.ts"; +import { snapshotRender, useReplRenderer } from "../src/repl/renderer.ts"; +import type { ReplRenderer } from "../src/repl/renderer.ts"; import { useReplTree } from "../src/repl/reconcile.ts"; import type { ReplTree } from "../src/repl/reconcile.ts"; @@ -574,6 +576,68 @@ function drawerKeysOf(view: ReplView): string[] { return keysOf(view).filter((key) => key.startsWith("drawer:permission:choice:")); } +/** The same state carrying one draft, which is where a location gets long. */ +function withDraft(state: ReplState, draft: string): ReplState { + return Object.freeze({ + ...state, + draft, + route: Object.freeze({ ...state.route, draft }), + }); +} + +/** Draw one laid-out frame through this renderer, and keep what it wrote. */ +function* painted( + renderer: ReplRenderer, + frame: ReplSemanticFrame, + tree: ReplTree, +): Operation { + const drawn = yield* renderer.render( + snapshotRender({ + frame, + tree: tree.frame().id, + mounted: tree.mounted(), + deltaTime: 0, + pointer: undefined, + }), + ); + if (!drawn.ok) { + throw drawn.error; + } + return drawn.value.output; +} + +/** The row this screen is showing the location's omission summary on. */ +function summaryOn(rows: readonly string[]): string { + const found = rows.find((row) => row.includes("more characters")); + if (found === undefined) { + throw new Error("the screen is showing no omission summary"); + } + return found; +} + +/** The keys this view describes with one `select`, which is what a row asks for. */ +function keysSelecting(view: ReplView, select: string): string[] { + const found: string[] = []; + const walk = (description: ReplDescription): void => { + const read = readDescription(description); + if (fields(read.input)?.["select"] === select) { + found.push(read.key); + } + for (const child of read.children) { + walk(child); + } + }; + for (const description of describeApplication(view)) { + walk(description); + } + return found; +} + +/** Everything inside the drawer's window, which is what moving it changes. */ +function drawerWindowOf(view: ReplView): string[] { + return keysOf(view).filter((key) => key.startsWith("drawer:permission:")); +} + /** Scroll the Sessions window until it is showing this row, or say it never did. */ function scrolledTo(state: ReplState, session: ReplSession, key: string): ReplState { let at = state; @@ -2055,6 +2119,210 @@ describe("U6 — the Sessions reading is windowed", () => { expect(reading(far, session, NARROW).location).not.toContain(String(furthest)); expect(stub.outcomes.size).toBe(0); }); + + it("U6: a draft long enough to fill the region still leaves every control placed", function* () { + const { session } = yield* asking({ + review: { permission: { toolCallId: "call-1", title: "Write", kind: "edit" } }, + build: { streaming: true }, + // One turn runs all the way to its record, so the outlet holds a control + // whose action is the turn's own — a position in the history that no + // surface selector can ask for. + check: {}, + }); + yield* until(session, "all three Prompts being observed", () => observed(session) === 3); + yield* until(session, "a request waiting", () => session.agent.requests.length === 1); + yield* until(session, "one turn being recorded", () => recorded(session) === 1); + + // A draft of a thousand characters, which is a location of more than a + // thousand: at 72 columns that is more rows than the whole narrow region + // has, so drawing all of it would take every control off the screen while + // leaving each one mounted, focusable and reachable by nothing. + const standing = onSessions(session); + const drafted: ReplState = Object.freeze({ + ...standing, + draft: "x".repeat(1000), + route: Object.freeze({ ...standing.route, draft: "x".repeat(1000) }), + }); + const view = reading(drafted, session, NARROW); + expect(view.location.length).toBeGreaterThan(1000); + + const tree = yield* useReplTree(); + yield* applied(tree, view); + const frame = layout(NARROW, replSurface(tree, view)); + const body = NARROW.rows - 7; + expect(frame.cells.filter((cell) => cell.region === "content").length).toBeLessThanOrEqual( + body, + ); + + // Both ways off this screen are drawn and pointable. + for (const key of ["sessions:heading", "entries:heading"]) { + const placed = placedFor(tree, frame, key); + expect(placed).toBeDefined(); + expect(placed?.targetable).toBe(true); + } + expect(yield* pointed(tree, frame, "entries:heading")).toEqual({ + kind: "select-surface", + surface: "repl", + }); + + // And so is the outlet the route selected — not merely present in it: a + // control of the reading itself is placed, offered to a pointer, and asks + // for what that turn asks for. + const drawn = frame.cells.map((cell) => tree.keyOf(cell.node) ?? ""); + const turns = frame.cells + .filter((cell) => (tree.keyOf(cell.node) ?? "").startsWith("sessions:turn:")) + .map((cell) => ({ key: tree.keyOf(cell.node) ?? "", targetable: cell.targetable })); + expect(turns.length).toBeGreaterThan(0); + // Every placed turn control is offered to a pointer, and the facts beneath + // them are not. + for (const one of turns) { + expect(one.targetable).toBe(TURN_FACT_SUFFIXES.every((end) => !one.key.endsWith(end))); + } + + // One of them, activated: the pointer resolved against this exact frame + // asks for exactly what Enter on it asks for. + const control = turns.find((one) => one.targetable); + expect(control).toBeDefined(); + yield* focusTo(tree, control?.key ?? ""); + const pressed = yield* activate(tree); + expect(yield* pointed(tree, frame, control?.key ?? "")).toEqual(pressed); + + expect(placedFor(tree, frame, "sessions:earlier")).toBeDefined(); + expect(placedFor(tree, frame, "sessions:later")).toBeDefined(); + // The location says what it is not showing rather than showing none of it. + const location = drawn.filter((key) => key.startsWith("location:")); + expect(location.length).toBeLessThanOrEqual(3); + const shown = rowsOf(describeApplication(view)) + .filter((one) => one.key.startsWith("location:")) + .map((one) => one.label); + expect(shown.at(0)).toContain("xmd://repl/"); + expect(shown.at(-1)).toContain("more characters"); + // Every row this frame describes is one it places: nothing is mounted with + // nowhere to be. + for (const key of keysOf(view).filter((one) => one.startsWith("sessions:"))) { + expect(placedFor(tree, frame, key)).toBeDefined(); + } + + // And the outlet stays usable at this size rather than merely present: the + // recorded turn's own control is reached by walking the window, and asks + // for a position in the history — the one action neither surface selector + // can ask for. + const recordedKey = keysSelecting(reading(drafted, session, WIDE), "marker").find((key) => + key.startsWith("sessions:turn:"), + ); + expect(recordedKey).toBeDefined(); + const walked = scrolledTo(drafted, session, recordedKey ?? ""); + const scrolled = reading(walked, session, NARROW); + yield* applied(tree, scrolled); + const scrolledFrame = layout(NARROW, replSurface(tree, scrolled)); + expect(placedFor(tree, scrolledFrame, recordedKey ?? "")?.targetable).toBe(true); + expect((yield* pointed(tree, scrolledFrame, recordedKey ?? "")).kind).toBe("select-marker"); + // Walking it changed no location and took no control off the screen. + expect(scrolled.location).toBe(view.location); + for (const key of ["sessions:heading", "entries:heading"]) { + expect(placedFor(tree, scrolledFrame, key)?.targetable).toBe(true); + } + }); + + it("U6: a shrinking omission summary repaints cleanly, and is a complete row", function* () { + const { session } = yield* asking({ + review: { streaming: true }, + build: { streaming: true }, + check: {}, + }); + yield* until(session, "all three Prompts being observed", () => observed(session) === 3); + yield* until(session, "one turn being recorded", () => recorded(session) === 1); + + // Two drafts whose omission counts have different numbers of digits, so the + // row that says how much is hidden gets shorter as the draft does. That row + // is the only location row whose length changes, and these two facts about + // it are separate: what the terminal ends up showing, and what this + // application described for it to show. + const standing = onSessions(session); + const longer = withDraft(standing, "x".repeat(1200)); + const shorter = withDraft(standing, "x".repeat(1050)); + + const tree = yield* useReplTree(); + const renderer = yield* useReplRenderer(NARROW); + /** Everything written to this one terminal, in order. */ + const written: Uint8Array[] = []; + + const first = reading(longer, session, NARROW); + yield* applied(tree, first); + const before = layout(NARROW, replSurface(tree, first)); + written.push(yield* painted(renderer, before, tree)); + const four = summaryOn(screenFrom(written)); + expect(four).toMatch(/^… \d{4} more characters/); + + // The same terminal, drawn again, with nothing clearing it between the two. + // What comes back is the shorter summary and nothing of the longer one — + // measured, and true of this renderer either way: it fills a placed cell to + // its bounds, so it is not the padding below that makes this pass. + const second = reading(shorter, session, NARROW); + yield* applied(tree, second); + const after = layout(NARROW, replSurface(tree, second)); + written.push(yield* painted(renderer, after, tree)); + + const three = summaryOn(screenFrom(written)); + expect(three).toMatch(/^… \d{3} more characters/); + + // And the row this application described is itself a complete row, as wide + // as the ones around it. This is the assertion that discriminates: the + // rendered screen above is clean whether or not the summary arrives padded, + // so it says what this renderer does, while this says what the application + // owns — the same full-width contract `chunked` gives every other location + // row, rather than one inherited from whatever draws it. + const located = rowsOf(describeApplication(second)) + .filter((one) => one.key.startsWith("location:")) + .map((one) => one.label); + expect(located.length).toBe(3); + for (const row of located) { + expect(row.length).toBe(surfaceWidth(NARROW)); + } + // Exactly the new summary, with nothing of the longer one left on the end + // of it: the row is the row, not the row plus whatever it stopped short of. + const hidden = /… (\d+) more characters/.exec(three)?.[1] ?? ""; + expect(three.trimEnd()).toBe(`… ${hidden} more characters, in a wider window`); + expect(three.trimEnd().endsWith("window")).toBe(true); + + // And the screen is still one a person can use: both ways off it, and a + // control of the reading itself. + for (const key of ["sessions:heading", "entries:heading"]) { + expect(placedFor(tree, after, key)?.targetable).toBe(true); + } + const outlet = after.cells.filter( + (cell) => (tree.keyOf(cell.node) ?? "").startsWith("sessions:turn:") && cell.targetable, + ); + expect(outlet.length).toBeGreaterThan(0); + }); + + it("U6: the first press after a resize moves the window, not the stored number", function* () { + const { session } = yield* asking({ + review: { permission: { toolCallId: "call-1", title: "Write", kind: "edit" } }, + build: { permission: { toolCallId: "call-2", title: "Move", kind: "edit" } }, + check: { permission: { toolCallId: "call-3", title: "Delete", kind: "edit" } }, + }); + yield* until(session, "all three Prompts being observed", () => observed(session) === 3); + yield* until(session, "all three requests waiting", () => session.agent.requests.length === 3); + + // As far down as the smallest frame goes. + let at = onSessions(session); + for (let press = 0; press < 60; press += 1) { + at = acted(at, { kind: "scroll-sessions", delta: 1 }, session); + } + const furthest = at.viewports.sessions; + expect(furthest).toBeGreaterThan(0); + + // One row taller holds one row more, so the last window starts one row + // earlier and the frame is already drawing that. The stored number is now + // past it. + const taller: ReplTerminalSize = { columns: NARROW.columns, rows: NARROW.rows + 1 }; + const before = sessionKeysOf(reading(at, session, taller)); + const pressed = acted(at, { kind: "scroll-sessions", delta: -1 }, session, taller); + // Moved, rather than spending the press normalizing state nobody can see. + expect(sessionKeysOf(reading(pressed, session, taller))).not.toEqual(before); + expect(pressed.viewports.sessions).toBeLessThan(furthest); + }); }); describe("U7 — the permission drawer is windowed", () => { @@ -2192,6 +2460,46 @@ describe("U7 — the permission drawer is windowed", () => { expect(stub.answers.get("call-1")).toBe(1); expect(session.live).toBe(true); }); + + it("U7: the first press after a resize moves the drawer, not the stored number", function* () { + const { session } = yield* asking({ + review: { + streaming: true, + permission: { + toolCallId: "call-1", + title: "Write", + kind: "edit", + options: SEVEN_CHOICES, + }, + }, + build: { streaming: true }, + check: { queued: true }, + }); + yield* until(session, "a request waiting", () => session.agent.requests.length === 1); + const request = session.agent.requests[0]; + if (request === undefined) { + throw new Error("no request was published"); + } + let at = acted( + onSessions(session), + { kind: "select-permission", request: request.key }, + session, + ); + for (let press = 0; press < 20; press += 1) { + at = acted(at, { kind: "scroll", delta: 1 }, session); + } + const furthest = at.viewports.permission; + expect(furthest).toBeGreaterThan(0); + + // One row taller holds one row more, so the last window starts one row + // earlier and the drawer is already showing that. The stored number is now + // past it, and the first press has to move what is drawn. + const taller: ReplTerminalSize = { columns: NARROW.columns, rows: NARROW.rows + 1 }; + const before = drawerWindowOf(reading(at, session, taller)); + const pressed = acted(at, { kind: "scroll", delta: -1 }, session, taller); + expect(drawerWindowOf(reading(pressed, session, taller))).not.toEqual(before); + expect(pressed.viewports.permission).toBeLessThan(furthest); + }); }); describe("U8 — a target is a control, whatever its key is spelled", () => { @@ -2551,6 +2859,18 @@ function* useTemporaryHost(): Operation { * screen an assertion about the screen. */ function screenOf(terminal: Terminal): string[] { + return screenFrom(terminal.presented); +} + +/** + * Replay written bytes into the rows a terminal would be showing. + * + * One buffer across every chunk, because that is what a terminal is: a renderer + * writes what changed, and what it did not write is still whatever was there. + * Reading the rows back is how a test sees the screen a person sees rather than + * the row an application described. + */ +function screenFrom(chunks: readonly Uint8Array[]): string[] { const rows: string[][] = []; let row = 0; let column = 0; @@ -2567,7 +2887,7 @@ function screenOf(terminal: Terminal): string[] { column += 1; }; - const written = terminal.presented.map((bytes) => TEXT.decode(bytes)).join(""); + const written = chunks.map((bytes) => TEXT.decode(bytes)).join(""); for (let index = 0; index < written.length; index += 1) { const character = written[index]; if (character !== "\u001B") { diff --git a/specs/repl-spec.md b/specs/repl-spec.md index e1a9f551..70a4fb89 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -119,7 +119,14 @@ At narrow, the surface the route did not select is still there in the model and is not on the screen: it is in no cell, in no target map, and no pointer reaches it. What stays is the way between them: both surface controls are on the screen at every size, because a screen you cannot leave is not one this route may put -you on. The History band is five rows at every size; when the labels of +you on. + +The location is drawn in full wherever there is room for it. A narrow frame +draws it in at most three rows and says how much it is not showing: there, the +location shares one region with every control on the screen, and a draft long +enough to fill that region would leave you with a URL and no way to do anything +else. The command still prints the whole location when it ends, and a wider +window still shows all of it. The History band is five rows at every size; when the labels of several positions cannot all fit, they share a label and every position keeps its own identity.