Skip to content

✨ Slice D of #854: present Agent conversations, turns and permissions in the REPL - #861

Merged
taras merged 3 commits into
mainfrom
agent/issue-854-sessions
Sep 30, 2026
Merged

taras merged 3 commits into
mainfrom
agent/issue-854-sessions

Conversation

@taras

@taras taras commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Part of #854. Slice D of five.

Draft: test-weights.json is still outstanding. The measurement dispatched
against the first head was cancelled when this correction landed, because an
artifact measured against superseded code describes nothing. A replacement is
measured against the delivery head before this leaves draft.

Stacked on four merged foundations, all already on main:

Why

The REPL could run a document, follow its Agent turns and ask an <Elicit>, but
the Sessions surface said (none retained) whatever the process was doing. The
turns this process was watching, the conversations they belonged to and the
permission a turn was blocked on were all real and none of them were readable.

What changes

Before:

  • Sessions said (none retained). A pending permission request could be waiting
    with nothing in the interface to answer it.

After:

  • Sessions presents one chronology of Agent work — every turn this process
    observed and every turn the history holds — with the conversations to filter
    them by, and answers the permission request a turn is waiting on.

How it works

A reading is not a concatenation of two lists. Prompts running beside each
other publish in whatever order their providers answer, so an earlier Prompt can
still be live while a later one is already durable; appending the live turns to
the retained ones would put it second. Each observed Prompt takes a slot when
it is scheduled, and the slot survives publication: the same mounted node, in
the same place, sourcing its facts from the record once there is one. The slot
also keeps the last live facts it had, so no frame between the append and its
projection shows a turn as gone. Turns this process never observed are in the
prefix it replayed and come first, in the order their records state.

Slots are process-local. The live key and the order never enter ReplModel, a
route, Journal bytes, an error string or a public Core API.

Filtering is a semantic action resolved at the root. Selecting a
conversation carries only its non-empty provider session key and changes only
route.session; clearing to All is its own action and removes only that member.
Neither touches the surface, scope path, history marker, inspection flag, draft,
drawer stack or focus, and background queued/start/delta/permission/terminal
changes never select or clear a filter. A queued turn has no conversation
selector until the provider's started event supplies an actual session key —
it is never inferred from the authored Session name, prompt text, agent or
position.

A pending request appears inline on the turn that is waiting. Arriving opens
nothing, moves nothing and claims no focus. Activating it records the request's
opaque key in process-local state and opens the Sessions-only +permission
drawer, which draws every choice the provider offered — always scoped to this
Agent session, never to a machine — and says that closing denies while the
session keeps running. The key is never encoded in the route.

Only a successful authority call closes the drawer: the reducer decides, the
program's own perform() calls choose or dismiss exactly once, and focus
returns to the turn that was waiting, spent once so later traversal is not
pulled back. An unknown, stale, already-settled or unoffered action settles
nothing and calls nobody. A request that stops existing without a UI action
withdraws its drawer without inventing a choice or a denial. Retained audits are
read and never answered.

Narrow gives its whole screen to one routed surface, so the other outlet is
not described at all — not clipped, and not placed in a region the frame does
not have. A node nothing can show would be a focus stop that draws nothing and a
target behind nothing. What is not part of either outlet is the pair of
surface controls: they are described at every size, above whichever outlet is
routed, because a screen a person cannot leave is not one this route may put
them on.

Both readings are windowed, not clipped. Every conversation, turn, fact,
audit and request is built in order and then moved through a window sized from
the region that will place it; the permission drawer does the same over its own
ordered content, because PermissionRequest.options is the provider's and
nothing bounds it. Their earlier and later controls — and [close], and the
surface controls — stay outside the thing they move. Only what a window holds is
described, so only that is mounted, focusable, drawn and pointable. Both offsets
are process-local, clamped where they are read, and absent from the route, the
model, the location and the Journal; a filter starts its reading again at the
first row, because row forty of everything is not row forty of one conversation.
A window moves from the position its frame is drawing rather than from the
number stored, so the first press after a resize moves the screen instead of
normalizing state nobody can see.

The canonical location is bounded where it shares a region with the
controls.
It is drawn whole wherever there is room and the command still
prints all of it, but a narrow frame draws at most three rows of it and says how
many characters it is not showing: the draft lives in the location until an
entry exists, and a thousand-character draft is fifteen rows in a region that
has thirteen — which left both surface controls and the whole routed outlet
mounted, focusable and drawn nowhere.

The loop wakes for Agent work. runReplProgram() subscribes to
session.agentChanges in the scope that outlives the spawn and drains it there
— a spawned body starts a turn later, and a turn is long enough to miss the
first change. A document whose only activity is a Prompt changes nothing else,
so without this its queued, streaming and waiting-for-permission states sat
unseen until some unrelated event happened to draw a frame.

A pointer follows what a row is, not how its key is spelled. Keys carry
provider session keys and field names, so a rule about :text, :stop,
:failed and :whose suffixes took the pointer away from a conversation named
text and a field named stop. A control answers Enter, so a pointer on it
asks for exactly what Enter there asks for; a line has nothing to activate.

runReplProgram() accepts the permission mode it passes to the session. Without
one every REPL is deny-all, so no request could reach a person and the
end-to-end row could not exist. Which mode a command selects is Slice E's.

Review guide

Start with: packages/cli/tests/repl-agent-interface.test.ts

Then review:

  1. packages/cli/src/repl/agent.ts — the private ReplAgentSlot seam: what it
    keeps, and that it keeps it process-locally.
  2. packages/cli/src/repl/program.ts — the Agent subscription, acquired before
    the spawn that drains it.
  3. packages/cli/src/repl/application.ts — the Sessions reading, the filter and
    permission actions, and the narrow composition.
  4. packages/cli/src/repl/program.ts — the only place session authority is
    performed.
  5. packages/cli/src/repl/components/{actions,rows}.ts — the widened closed
    action vocabulary.

Look carefully at:

  • the live-to-durable transition in consume(): one slot, one mounted identity,
    no duplicate and no blank frame;
  • that no action carries a model turn, live reading, permission request,
    authority, session, provider callback or scope — only scalar keys and IDs.

What must stay true

  • Chronology is slot order, not activity order — enforced by the slot taken
    at observation and checked by
    U1: an earlier live turn stays before a later recorded one, and survives its own record.

  • Presentation holds no authority — enforced by components returning actions
    and program.ts alone receiving ReplSession, checked by
    U2: an unknown request or an unoffered option changes nothing and calls nobody.

  • A retained audit is inert — checked by
    U2: a recorded permission audit is read, never answered.

  • An inactive outlet in a narrow frame does not exist, and the way out of the
    active one does
    — checked by
    U3: narrow Sessions mounts its own outlet and nothing of the other panes,
    U3: narrow REPL mounts no Sessions row and
    U5: a narrow frame is left in both directions, by key and by pointer.

  • A window bounds what is offered — checked by U6 and U7, which place a
    request and every provider choice beyond the first window and reach each of
    them by scrolling.

  • A frame is drawn when Agent work moves — checked by
    U4: queued, streaming and a waiting request each repaint on their own, with
    nothing else able to wake the loop.

  • Every control this frame describes is one it places, and the outlet stays
    usable
    — checked by
    U6: a draft long enough to fill the region still leaves every control placed,
    which takes a placed sessions:turn:* control, resolves a pointer against
    that exact frame to the same action Enter asks for, and then walks the window
    to the recorded turn, whose select-marker is the one action neither surface
    selector can ask for — all while the canonical location is unchanged.

  • A window moves from where it is drawn — checked by
    U6/U7: the first press after a resize moves the window, not the stored number.

  • The omission summary is a complete row — checked by
    U6: a shrinking omission summary repaints cleanly, and is a complete row,
    which renders a four-digit summary and then a three-digit one through one
    renderer and reads the screen back, then asserts the described row's own
    width. Every other location row is padded by chunked(); this is the one row
    whose length changes, so it is padded where it is described.

    Two separate measured facts, and the row keeps them apart. The renderer
    repaints it cleanly either way
    : it fills a placed cell to its bounds, and
    under the unpadded control the second paint writes
    ESC[3;3H939 more characters, in a wider window· — the trailing space
    covering the longer row exactly. So the rendered screen says what this
    renderer does; the width assertion is what discriminates, and says the
    application owns the full-width contract itself rather than inheriting it
    from whatever draws the row.

How to verify it

deno task test packages/cli/tests/repl-agent-interface.test.ts \
  packages/cli/tests/repl-composition.test.ts \
  packages/cli/tests/repl-terminal.test.ts \
  packages/cli/tests/repl-route.test.ts \
  packages/cli/tests/repl-forms.test.ts

repl-agent-interface.test.ts drives controlled real ReplSession/Journal
state and the real Freedom tree — not a shaped object or a screenshot:

  • U1 proves one chronology under <All>/<Spawn>, ordering conversations
    by earliest turn, and that background start/delta/permission/terminal changes
    preserve the whole route, the filter and the exact focused node. It fails if
    selection or order is taken from latest activity, or if a published turn
    changes its mounted key.
  • U2 proves Enter and a pointer resolved from the drawn frame produce equal
    actions, that arrival opens nothing, that one choice settles once and closes,
    that Escape denies exactly once through dismiss, and that a stale request or
    unoffered option calls no authority. Two of its rows run the real program end
    to end. It fails if the drawer auto-opens or a pointer is routed at a target
    instead of through mounted dispatch.
  • U3 proves the wide/medium sidebar leaves the transcript and inspection
    readings alone, that narrow mounts only its routed outlet, that the drawer
    traps focus while keeping the one History node reachable inside it, and that
    every accepted frame carries exactly one canonical location. It fails if
    inactive panes stay mounted in narrow composition.
  • U4 drives the real program to a stable frame and then releases one Agent
    transition at a time, with no input and nothing else moving. It fails if the
    loop does not wake for the Agent.
  • U5 proves both surface controls are reachable by keyboard and by a pointer
    resolved from the drawn frame, in both directions, and that a request arriving
    on the other surface opens nothing and is still reachable from this one.
  • U6 and U7 prove a request and every provider choice past the first
    window become placed and pointer-targetable by scrolling, that the window
    controls and [close] never scroll away, that off-window rows are in no target
    map, and that choosing or dismissing calls the authority exactly once.
  • U8 proves values named after a turn's own facts stay pointer-equivalent to
    Enter, and that a fact stays a fact wherever it is drawn.

Scope

Included

  • The Sessions chronology, conversation filtering and inline permission facts.
  • The +permission drawer and the one path that answers it.
  • The narrow composition for both routes, with surface navigation outside them.
  • The Sessions and permission viewports.
  • The Agent wake, so a frame is drawn when Agent work moves.
  • architecture.md and specs/repl-spec.md, in the present tense.
  • One remeasurement of test-weights.json at the delivery head.

Intentionally unchanged

  • Core, route.ts, model.ts, the durable Prompt record, the Agent provider
    stack, the Elicit parser, the CLI grammar, runtime entrypoints and packaged
    Plan.md.
  • No dependency, lockfile or vendor snapshot moves.
  • The REPL command grammar and packaged Plan journey are Slice E.

New abstractions

  • ReplAgentSlot exists because a reading that concatenated live turns with
    retained ones cannot say which durable row replaced which live one, and would
    reorder concurrent Prompts by completion. Its only consumer is the Sessions
    reading; it is private to the REPL and reaches no route, model or Journal.

Generated or mechanical changes

  • test-weights.json will come from the Measure test weights workflow and is
    committed unchanged with the provenance that run supplied. It also repairs
    Slice C's omitted measurement: it is the first measurement of a corpus
    containing both repl-forms.test.ts and repl-agent-interface.test.ts.

Risks and limitations

  • The slot seam is private and deliberately minimal. It keeps a live key, an
    order and the last live facts per observed Prompt; a reader that needs more
    than that from a published turn should read the record.
  • An unassigned retained failed or cancelled turn appears only under All, by
    design: nothing supplies a conversation to file it under.
  • A focus claim naming a row the Sessions window is not showing stays silent
    rather than scrolling to it. Restoration after a permission is answered lands
    on the owning turn when that turn is in the window, which is where the person
    answering it was.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

The Sessions surface said `(none retained)` whatever the process was doing. It now
presents one chronology of Agent work: every turn this process observed and every
turn the history holds, with the conversations to filter them by and the permission
request a turn is waiting on.

A turn is not a concatenation of two lists. Prompts running beside each other
publish in whatever order their providers answer, so an earlier Prompt can still be
live while a later one is already durable — appending the live turns to the retained
ones would put it second. Each observed Prompt keeps a slot, taken when it was
scheduled, and a slot survives publication: the same mounted node, in the same
place, sourcing its facts from the record once there is one. The slot also keeps the
last live facts it had, so no frame between the append and its projection shows a
turn as gone. Turns this process never observed are in the prefix it replayed, and
come first in the order their records state.

A pending request appears inline on the turn that is waiting. Arriving opens
nothing, moves nothing and claims no focus. Activating it records the request's
opaque key in process-local state and opens the Sessions-only `+permission` drawer,
which draws every choice the provider offered — `always` scoped to this Agent
session, never to a machine — and says that closing denies while the session keeps
running. Only a successful authority call closes it: the reducer decides, the
program's own `perform()` calls `choose` or `dismiss` once, and focus returns to the
turn that was waiting. A request that stops existing without a UI action withdraws
its drawer without claiming a choice or a denial. Retained audits are read and never
answered.

Narrow gives its whole screen to one routed surface, so the other one is not
described at all — not clipped, not placed in a region the frame does not have. A
node nothing can show is a focus stop that draws nothing and a target behind
nothing.

`runReplProgram()` accepts the permission mode it passes to the session. Without
one every REPL is `deny-all`, so no request could ever reach a person and the
end-to-end row could not exist; which mode a *command* selects is still Slice E's.
Five things the Sessions surface got wrong, and one comment that had stopped
being true.

**A frame nobody asked for is a frame nobody draws.** The loop woke for the
history, the overlay's output, a question and expansion, and not for the Agent.
A document whose only activity is a Prompt — queued, then streaming, then
waiting on a permission — changed nothing else, so nothing drew the frame that
would have shown it, and the screen stood still until some unrelated event
happened along. `runReplProgram()` now subscribes to `session.agentChanges` in
the scope that outlives the spawn and drains it there: a spawned body starts a
turn later, and a turn is long enough to miss the first change.

**A screen you cannot leave is not one a route may put you on.** Narrow mounts
one outlet, and both surface controls used to live inside the outlets they
select — so the control that goes to Sessions was mounted only on Sessions. The
two are now described at every size, above whichever outlet is routed. What the
route did not select is still absent everywhere: `mounted()`, the frame, the
target map, `nodeOf()` and a pointer sweep.

**Two readings were described whole and clipped by layout.** Every conversation,
turn, fact, audit and request went into the description, and so did every choice
a provider offered — `PermissionRequest.options` is unbounded. Whatever did not
fit was described, focusable and pointable with nowhere to be drawn. Each now
moves through a window sized from the region that will place it, with its own
earlier and later controls outside the thing they move. Both offsets are
process-local: they are in no route, no model, no location and no Journal, they
are clamped where they are read, and a filter starts its reading again at the
first row because row forty of everything is not row forty of one conversation.

**A key is a reconciliation identity, not a product fact.** Targetability was
decided by how a key was spelled — anything ending `:text`, `:stop`, `:failed`
or `:whose` was read as one of a turn's own facts. Keys carry provider session
keys and field names, so a conversation named `text` and a field named `stop`
lost their pointer to a rule about suffixes. What decides it now is what the row
is: a control answers Enter, so a pointer on it asks for the same thing, and a
line has nothing to activate.

**And the comment above `availabilityOf()` said twice what it says once**, the
first copy explaining that no Agent runtime runs in the REPL — which it now
does.

`architecture.md` and `specs/repl-spec.md` say all of this in the present tense.
@taras
taras force-pushed the agent/issue-854-sessions branch 2 times, most recently from 28f77af to ebbcc7c Compare September 30, 2026 22:08
Two ways a viewport still lied about what a person could reach.

**A long draft took both surface controls off the screen.** The location is the
draft's home until an entry exists, so a thousand-character draft is a
thousand-character URL — fifteen rows at 72 columns, in a region that has
thirteen. Everything under it was described, mounted and focusable with nowhere
to be drawn: not the way to Sessions, not the way to Entries, not one row of the
outlet the route had selected. Reserving a minimum row for the reading could not
help, because the fixed content had already used the whole region.

A narrow frame now draws at most three rows of the location and says how many
characters it is not showing. A prefix of a location is no use to somebody
copying it — which is why it is still drawn whole wherever there is room, and
why the command still prints all of it — but a person with a URL and no
controls cannot do anything at all, including get to a window where the whole
thing would fit.

**The first press after a resize could move nothing.** A frame draws the
clamped offset, while both reducers added the delta to the stored one. Growing
`72x20` to `72x21` makes each window hold one row more, so the last window
starts one row earlier: the screen was already showing that position while the
stored number was past it, and pressing `[^ earlier]` only normalized state
nobody could see. Both windows now move from the position their frame is
drawing, through the one clamp that decides where a window is.
@taras
taras force-pushed the agent/issue-854-sessions branch from ebbcc7c to b355ea1 Compare September 30, 2026 22:15
@taras
taras marked this pull request as ready for review September 30, 2026 22:36
@taras
taras merged commit f5f4f2d into main Sep 30, 2026
43 of 44 checks passed
@taras
taras deleted the agent/issue-854-sessions branch September 30, 2026 22:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant