ADR-018: A question is answered with reply buttons 

Status: Accepted

Date: 2026-10-08

Context 

nr-llm lets a tool stop a run to ask the user for typed input (nr-llm ADR-105): the run is suspended WAITING_FOR_INPUT with the tool's input schema, and AgentRuntimeInterface::submitInput() continues it with the user's values, bound to the question shown by a digest (nr-llm ADR-150).

The chat did not handle that pause. ChatService::applyResult() mapped the AWAITING_INPUT outcome to failed with "The assistant run did not complete (awaiting_input)", and the approval card dropped an input view.

A guided tour — analyse a page, then one point at a time — has two kinds of interaction. Choices that change nothing (which point to start with, a summary or the next page) are questions the run asks. A proposed change with the answers Übernehmen, Andere Variante and Überspringen is a write, and a write goes through an approval: nr-llm forbids a writing tool from also asking for input (nr-llm ADR-134), and an approval's continuation carries no input. The chat needs both: the question answered with buttons, and the approval card denied with a reason.

Decision 

A run that asks parks the conversation. The new status awaiting_input is set with the run's uuid, like awaiting_approval. It is neither active nor resumable: Retry would start a second run and leave the question unanswered. When the schema states a question (its title, else its description), it is appended as the assistant's message, so the reader sees what is asked and later turns see what an answer answered.

The answer goes through the worker, like a decision. The request checks the answer against the question as it stands, records it on the row (approval_decision = 'input', the digest in approval_turn_digest, the values in the new column pending_input), appends it to the transcript and dispatches the worker. The worker calls submitInput(), which drives the whole continuation. Recording it as a decision of its own kind means everything that guards a recorded approval guards the answer too: Retry refuses while it is in flight, and reconcile() hands the question back if no worker ever took it.

A refusal brings the question back. nr-llm refuses a stale question, an answer that does not validate, a submitter who may not run the tool, a lost race, a deactivated configuration and a run that no longer waits, and leaves the run waiting in each case. The chat restores awaiting_input, takes the undelivered answer out of the transcript and stores the reason as a code (InputHandBackReason), rendered as a sentence in the reader's language.

The schema contract the reply buttons are read from (InputPauseForm):

  • Choice: an object schema with one choice property — enum of scalars, or oneOf/anyOf of {const, title} branches for labelled options — and at most one optional free-text property (a string without enum, oneOf, anyOf or const). Each option is a button. When the choice is not required, text typed into the chat input is submitted as the free-text property. Exactly one of the two is ever submitted.
  • Form: any other object schema of scalar properties, one field per property, submitted together.
  • Unsupported: anything else, and a pause nr-llm cannot render. The chat says it cannot show the question.

A tool that wants reply buttons — the generic "ask the user to choose" tool nr-llm will add, for one — has to put its options into the input schema the run is suspended on.

Typed text without a free-text property is a message of its own. It leaves the question unanswered and starts a new turn, as a message leaves a pending approval behind; the reply options say so ("A message of your own ends the question"). Refusing the message instead would break the promise that free text is always possible.

A write is never a question. "Übernehmen / Andere Variante / Überspringen" for a proposed change is not one input pause: nr-llm forbids a tool that writes from also asking for input (nr-llm ADR-134), and an approval's continuation carries no input. The change goes through the approval card, where the approve button applies it. In a process run, and only when a pending call writes (nr-llm ADR-214, item 9), the card's denial is two buttons, Andere Variante and Überspringen; every other card keeps Abbrechen. Whether a call writes is what nr-llm's view states for it (PendingCallView::$declaresWrite, nr-llm 0.41); a tool that is no longer registered counts as a write. Whether the conversation is a process run is asked through ProcessRunDetectorInterface; nothing implements it yet, so until a conversation carries the skill it runs, every card is a plain one. The server decides which answers a card has, and a reason sent for a card that does not offer it is a plain denial. The reason (variant or skip, DenyReason) is recorded with the decision and its label goes into the transcript as the reader's message. ApprovalDecisionFactory hands the reason to nr-llm as its ApprovalDenialReason (nr-llm ADR-214, nr-llm 0.41) through ApprovalDecision's denialReason. Input pauses with reply buttons stay for choices that write nothing.

The option check is the chat's. nr-llm validates a submission by structure only (nr-llm ADR-105). Membership in the offered options, and the type of a form field, are checked before the answer is recorded; otherwise a crafted request could put any text into a field the user was only offered buttons for.

An answer is not gated like an approval. mayDecideApprovals() guards who may release a write fence. An input-requiring tool cannot declare a write (nr-llm ADR-105, ADR-134); the owner may act on their own run; and nr-llm checks that the submitter may run the tool the question belongs to (nr-llm ADR-150). nr-llm calls its own submit entry point admin-gated as the injection mitigation for submitted values. In the chat the submitter is the conversation's owner, who can already send the model any text as a message, so the answer adds no new way in.

A tour's waiting run is cancelled by a new message. In a process run, a message while the run waits on a card or a question claims the conversation and then cancels the run (AgentRuntimeInterface::cancel()), so the tour's proposal cannot be decided after the tour has moved on (nr-llm ADR-214, item 9). Only a run that still waits is cancelled; when it is being carried on, or a decision on the card won the race, the row goes back to what it was and the message is refused as busy. An ordinary chat keeps the abandoned run waiting, as before. A "weiter" while the card waits stays what ADR-017 made it: a hint to decide on the card, not a new turn.

Consequences 

  • For a write in a process run, the approval card's Abbrechen is replaced by Andere Variante and Überspringen; new column approval_deny_reason.
  • The cancel is guarded on the run still waiting through WaitingRunCancellerInterface; NrLlmWaitingRunCanceller calls nr-llm's AgentRuntimeInterface::cancelIfWaiting() (nr-llm 0.41), and the run status nr-llm reports with a lost attempt decides: queued, running or still waiting keeps the conversation (409), an ended or unknown run releases it. The claim comes first.
  • New status awaiting_input, new column pending_input, new route ai_chat_conversation_input (POST /ai-chat/conversations/input). Run the database analyzer after upgrading.
  • The poll's fast path answers the first poll after a question with the full response, as it does for an approval, so the buttons do not wait for a reload.
  • Both chat surfaces render the answers from one module (chat-reply-options.js): real buttons in a labelled group, disabled while an answer travels, above the input; focus stays where it is.
  • A run that asks two questions in one turn is refused by nr-llm on the second (nr-llm ADR-105); the chat shows whatever the run is suspended on.