diff --git a/architecture.md b/architecture.md index e21e294f..1c13327d 100644 --- a/architecture.md +++ b/architecture.md @@ -5477,6 +5477,48 @@ 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. 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 +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/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..7a2d9059 100644 --- a/packages/cli/src/repl/application.ts +++ b/packages/cli/src/repl/application.ts @@ -28,9 +28,17 @@ 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, drawerWidth, HISTORY_ROWS, NARROW, surfaceWidth } from "./layout.ts"; +import { + drawerHeight, + drawerWidth, + HISTORY_ROWS, + NARROW, + profileFor, + sessionsHeight, + 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 +49,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 +75,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 +294,38 @@ 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 }; + +/** + * 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 { @@ -131,6 +336,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. * @@ -139,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. */ @@ -147,7 +362,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 +465,52 @@ 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 }), + // 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, + }); +} + +/** + * 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"), + ), + }), + viewports: Object.freeze({ ...state.viewports, permission: 0 }), + }); +} + /** The empty route one fresh execution starts at. */ export function initialRoute(execution: string): ReplRoute { return Object.freeze({ @@ -258,7 +532,9 @@ export function initialState(execution: string): ReplState { draft: "", form: EMPTY_FORM, refusal: undefined, + permission: undefined, restore: undefined, + viewports: AT_TOP, }); } @@ -274,7 +550,12 @@ export function stateFor(location: string): Result { draft: decoded.value.draft ?? "", form: EMPTY_FORM, 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, }), ); } @@ -296,7 +577,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 +591,7 @@ export function viewFor( question: undefined, expansion: live.expansion, pausable: false, + agent: NO_AGENT, }; return Ok( Object.freeze({ @@ -350,6 +632,7 @@ export function refusedView( question: undefined, expansion: "playing", pausable: false, + agent: NO_AGENT, }), location: encodeLocation(state.route), refusal: reason, @@ -361,15 +644,30 @@ 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. + * `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(live: ReplLive): ReplLiveAvailability { +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 +782,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 +821,102 @@ 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. + // + // 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(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)); + // 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 }), + 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, + // 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" }; + 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. @@ -600,6 +1016,29 @@ 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)); + // From where the drawer is, for the same reason. + const permission = clamped( + clamped(state.viewports.permission, furthest) + 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."); } @@ -669,6 +1108,42 @@ 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); +} + +/** + * 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({ + ...state, + viewports: Object.freeze({ ...state.viewports, sessions: 0 }), + }); +} + function settled(state: ReplState): ReplTransition { return { state: Object.freeze(state), intent: { kind: "none" } }; } @@ -702,7 +1177,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 +1271,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 +1425,114 @@ 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 }, - ), + // 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); + // 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 }, ); - // 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 toEntries = 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)")); + 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. + } 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 { + // 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 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( row( - "entry:1", - ` 1. ${entry.name}`, - { select: "scope", scopes: [entry.key] }, - { here: view.focused }, + "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, + }, ), ); - for (const scope of nested(entry, [entry.key])) { + } + // 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) { + 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 +1543,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( @@ -1094,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)); } @@ -1139,6 +1679,222 @@ 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( + state: ReplState, + turns: readonly ReplSessionTurn[], + focused: string | undefined, + claim: string | undefined, +): readonly Described[] { + 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: focused }, + ), + ); + for (const key of offered) { + items.push( + row( + `sessions:conversation:${key}`, + ` ${filter === key ? "> " : ""}${headline(key)}`, + { select: "session", session: key }, + { here: 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: 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: 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 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. + * + * 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 ? 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; + /** `[^ 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. + * + * 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 +1913,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 +1940,45 @@ 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"; + // 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 from = clamped(view.state.viewports.permission, Math.max(0, content.length - capacity)); + children.push( + row( + "drawer:scroll:up", + pad("[^ earlier]", width), + { select: "scroll", delta: -1 }, + { here: view.focused }, + ).description, + ); + for (const placed of content.slice(from, from + capacity)) { + children.push(placed); + } + children.push( + row( + "drawer:scroll:down", + pad("[v later]", width), + { select: "scroll", delta: 1 }, + { here: view.focused }, + ).description, + ); + dismissing = request.key; } else if (open.kind === "history") { title = "History"; for (const checkpoint of view.model.checkpoints) { @@ -1334,7 +2137,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 +2157,87 @@ 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. + * + * 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. * @@ -1484,8 +2370,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[] = []; @@ -1500,16 +2393,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:")) { @@ -1520,8 +2420,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], @@ -1539,18 +2445,39 @@ 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. - return ( - !key.startsWith("line:") && - 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; } export { HISTORY_ROWS }; diff --git a/packages/cli/src/repl/components/actions.ts b/packages/cli/src/repl/components/actions.ts index 7244de5f..619af68c 100644 --- a/packages/cli/src/repl/components/actions.ts +++ b/packages/cli/src/repl/components/actions.ts @@ -53,4 +53,33 @@ 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 } + /** + * 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. + * + * 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..2e020420 100644 --- a/packages/cli/src/repl/components/rows.ts +++ b/packages/cli/src/repl/components/rows.ts @@ -160,6 +160,34 @@ 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 + ? { 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/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 80f9fd49..323090a0 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, }; } @@ -621,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) { @@ -710,6 +759,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 +814,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..50edd233 --- /dev/null +++ b/packages/cli/tests/repl-agent-interface.test.ts @@ -0,0 +1,3026 @@ +/** + * 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 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"; +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 { 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"; + +/** 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"; + +/** 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" }, + { 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); +} + +/** 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:")); +} + +/** 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; + 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); +} + +/** 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", + source: string = THREE_SPAWNS, +): 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, + }), + ); + 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 ["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).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)) { + 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) ?? ""); + // 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* () { + 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"); + }); +}); + +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); + }); + + 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", () => { + 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); + }); + + 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", () => { + 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. + * + * 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[] { + 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; + + 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 = chunks.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..24942dc5 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"; @@ -92,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", @@ -114,7 +126,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", () => { @@ -682,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); @@ -956,6 +984,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}`); + // 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"); }); }); @@ -1040,6 +1071,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); diff --git a/specs/repl-spec.md b/specs/repl-spec.md index 15e1cb1c..70a4fb89 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,21 @@ 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 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. @@ -86,8 +137,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