Repository navigation
feat(tui): say something to the agent while it is still working - #3707
Conversation
Typing during an answer was starved, and Enter mid-turn only parked the
message locally until the whole turn finished — so a correction to a
five-minute task arrived after the work it was meant to redirect.
Two independent causes:
- Every streamed token called updateViewport(), which rebuilds the entire
transcript and re-lays-out the growing answer. Bubble Tea runs Update on
one goroutine, so that work and the user's keystrokes share a queue.
Tokens now coalesce and repaint on the tick that already runs ten times a
second for the whole turn.
- Enter mid-turn appended to a local slice drained at turn end. It now
POSTs to the running turn (contract 2.13, POST
/v1/<agent>/query/{run_id}/followup) and the agent folds the text into
that turn's context at its next agent-loop step boundary — the same
boundary the cooperative cancel is checked at. Nothing is interrupted and
no second turn starts; the sidecar serialises turns per session, so a
second /query would 409 anyway.
The text is labelled as arriving mid-task before it reaches the model.
Unlabelled, a user message appearing in the middle of a tool sequence is
indistinguishable from a new request and the model abandons the work done.
Delivery is never assumed: the transcript line appears only once the sidecar
has the message, an undelivered one is reported with why and put back (the
queue while a turn runs, the composer once it has ended), and a peer below
2.13 keeps the local queue and is named along with the command to update it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t the wiring Three gaps found reviewing the previous commit. A cancelled turn consumed a follow-up it could no longer answer: the drain ran above the cooperative-cancel check, so the message left the queue and the loop broke out without ever sending it. It now runs below that check. Nothing proved the agent loop reaches the drain at all — every test called _drain_followups directly, so deleting the call site left the feature silently dead with a green suite. Three tests now drive the real process_query loop. The caller-auth test claimed to cover "every route on the router" while naming three by hand, which is how /followup shipped ungated by any test. It now enumerates the router, so a route added without a token check fails here whether or not its author thought to come back. Both new assertions were verified by mutation: restoring the ordering bug fails the cancel test, removing the call site fails the wiring test, and neither is caught by anything else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Verdict: Request changes — two behaviour gaps, both fixable in this PR. This makes the composer stay responsive while an answer streams and lets Enter hand a message to the turn that is already running. The design is careful and the contract/version negotiation is done properly. What needs another pass is the promise the UI makes when the message can't actually be picked up. The agent doesn't always get a message it accepted. The running turn only looks for mid-turn messages between steps. A turn that answers in one step — an ordinary conversational reply, which is most turns and can take a minute or two on a local model — has no "between", so anything typed during it is accepted, shown in the transcript, and never reaches the model on that turn. The user is told it was picked up, and it isn't. The words do survive into the next turn, so nothing is lost, but the headline behaviour silently doesn't happen for the commonest turn shape. Either look again for a message once the answer is formed and keep going if one arrived, or stop accepting once the turn can no longer act on it. Pressing Esc can start the very turn Esc was meant to prevent. Press Enter mid-answer, then Esc before the message lands: the delivery is refused, and because the UI still counts the turn as running it parks the text in the queue — which fires it as a brand-new turn as soon as the cancel completes. That is the outcome the code explicitly set out to avoid, and it is one condition short of being avoided. I reproduced it locally. Real-world evidenceNo evidence bundle was produced for this PR, and the description's test plan is entirely unticked — including the two live checks that cover this feature's actual surface (typing through a streaming answer, and the older-sidecar fallback). The verdict below rests on static review plus the tests I ran myself: The Esc finding came from a throwaway probe test in the same package (removed afterwards): The Python suites ( 🔍 Technical details🟡 A follow-up accepted after the last step boundary is never drained (
|
|
Verdict: Approve — two independent bugs fixed, a new route added with correct auth coverage, and 15 new tests that pin the properties that matter. Nothing blocking. The implementation handles the hard edges well: the cancel-before-drain ordering is correct, the queue-unwire race is acknowledged with a comment and a test, and the negotiation gate means an older sidecar gets the message queued rather than silently dropped. Token coalescing via Two things worth a follow-up before the next release (neither is a merge blocker): 🟢 Auth test floor is exactly the current route count. 🟢 Narrow TOCTOU yields 409 where 404 is the more accurate status. When a follow-up arrives in the window after 🔍 Technical detailsAuth floor — TOCTOU 409/404 — Both are small; neither is a correctness bug in the feature itself. |
Three conflicts, all from the mouse and confirmation-pinning work landing on main while this branch was open: - `updateViewport`: both sides added a prologue. Kept both — this branch's render-throttle bookkeeping (`viewDirty`/`lastRender`) and main's `syncViewportHeight(chatChromeRows())`, plus `markDirty` and `chatChromeRows`/`syncViewportHeight`/`msgSpan` alongside each other. - `terminal-hub.mdx`: kept the new mid-turn-input section, and took main's mouse paragraph — amd#3699 inverted that behaviour (GAIA owns the mouse now), and it is the lead-in to the mouse table below it. - CHANGELOG: both sides added entries; kept both.
# Conflicts: # hub/agents/gaia/npm/CHANGELOG.md # hub/agents/gaia/npm/test/cli.test.ts # hub/agents/gaia/python/gaia_agent/server.py # tui/internal/client/negotiate.go
|
Merged current Mid-turn follow-ups now need contract 2.14, not 2.13. 🔍 Technical details
|
|
🟡 The Esc-before-delivery race still starts the turn it was meant to prevent. The prior "Approve" review said "two independent bugs fixed", but the fix for this one was never applied. The path is: press Enter mid-answer (delivery in
🔍 Technical details
// current — wrong when a cancel is pending
if m.streaming {
m.queued = append(m.queued, msg.text)With A test to add (parallel to the existing func TestAFollowUpRefusedDuringACancelGoesBackToTheComposer(t *testing.T) {
c := &followUpClient{supported: true, err: errors.New("run ended")}
m := typeInto(t, newFollowUpChat(t, c), "and the calendar")
m, cmd := press(t, m, tea.KeyEnter)
m.cancelPending = true // Esc was pressed while the POST was in flight
m = run(t, m, cmd)
if len(m.queued) != 0 {
t.Fatalf("message queued behind a cancelled turn: %q", m.queued)
}
if got := m.input.Value(); got != "and the calendar" {
t.Errorf("message not restored to composer: %q", got)
}
} |
|
Merged 🔍 Technical detailsMerge commit only — no hand resolution. Net vs main: 25 files (+1,811/−50). Tests on the merge result: |
# Conflicts: # hub/agents/gaia/npm/CHANGELOG.md
|
🟡 The version guard in The docstring says "The TUI gates the POST on this — an unbumped version means the feature is present and never used, which is the worse of the two failures" — which makes a passing test on 🔍 Technical details
assert (major, minor) >= (
2,
13, # ← should be 14; 2.13 is the version that PREDATES this feature
), f"apiVersion {reported} predates mid-turn follow-ups"
|
main added a closed-set guard that refuses to render the capability matrix
until every REST op is annotated, so this branch's new
`query/{run_id}/followup` broke it on merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Main shipped contract 2.14 (tool_decision, bypass, the claude provider) while this branch was also claiming 2.14 for its follow-up route, so the feature is renumbered to 2.15 and every conflict keeps both sides -- main's routes and this branch's live alongside each other. The sidecar REST surface is now 7 functional verbs and 11 in-contract operations; CAPABILITY_MATRIX.md is regenerated from the code rather than hand-edited, and the counts its test pins were derived the same way.
Pressing Enter mid-answer and then Esc could start a brand-new turn: the delivery failed while the turn was still streaming, so the text went back to the queue -- which fires as a fresh turn the moment the turn settles. Esc had already emptied that queue, so its own recovery was undone by a message still in flight. The text now goes back to the composer instead, where Esc puts everything else. The existing test missed this because it cleared m.streaming by hand, which is not what a cancel does -- the agent stops at its next step boundary, so the turn is still streaming when the refused delivery lands. The new test drives the real Esc path and fails without the fix. Also raises two test floors that had drifted below the surface they guard: the follow-up version guard accepted 2.13 for a 2.15 feature, so reverting the API_VERSION bump would have passed, and the caller-auth route enumeration accepted 4 checked routes against 8 real ones.
A turn that answers in a single step dropped anything the user typed while it was working. The queue was drained only at the top of each agent-loop iteration, so a message arriving during the only model call had nothing left to pick it up -- the route had already returned delivered:true and the TUI had already written it into the transcript, so the user watched their words be accepted and then silently go nowhere. That is the commonest turn shape, and a silent drop behind an affirmed delivery. The loop now looks again at the point the answer is formed, beside the two correction guards that already live there, and takes one more step when something arrived. Two things it deliberately will not do: drain a turn whose cancel event is set (the message stays queued for a turn that can answer it), or drain on the last step, where consuming it into a context no model call reads would be the same silent drop one step later. The existing loop test only queued before process_query, which the top-of-loop drain always caught, so it never covered mid-call arrival at all.
|
Merged current Verified against a live Gemma-4-E4B on a Strix Halo GPU, same question and same machine, before and after the fix:
Both said Also in this push: the contract number moved to 2.15 (main took 2.14 for CI: everything green except 🔍 Technical detailsThe bug.
The fix ( Evidence, live route. Sidecar built from this branch ( Stream, after the fix: Same run against the pre-fix commit The loud refusal path also checked live — a follow-up for a run that is not in flight: Merge resolution. Every conflict was keep-both (main's Test runs on the merge result:
The two earlier non-blocking nits are both closed. |
itomek
left a comment
There was a problem hiding this comment.
Approving. The merge conflict is resolved (contract renumbered to 2.15), both open review findings are fixed, and a third — a mid-turn message silently dropped on a one-step turn — is fixed and proven against a live model with before/after evidence in the comment above.
CI is 54 pass / 1 fail; the failure is Unit Tests (Windows smoke), which fails identically on unrelated open PRs (#4359, #4353) and is a pre-existing main issue, not this branch. behavior-e2e failed once on an LLM-judged Builder scenario (4 true-success / 1 honest-failure) and passed on re-run — the base-loop change is a strict no-op on that path, since the follow-up queue is only ever wired by the HTTP sidecar.
|
Post-merge review turned up a second defect in the same delivery path, filed as #4364. A mid-turn message that was confirmed delivered vanishes if the user then presses Esc, or if the turn ends on an error — gone from the agent's context, gone from the pushed transcript, and still visible on their screen. It is not the Esc-before-delivery race this PR fixed; that one is about whether to send at all, this is about what happens to one that did send. 🔍 Technical detailsBoth sides drop it independently. Host:
#4364 also carries the understated unwire comment and two small nits. |
…md#4374) Replicates amd#4145 (`kovtcharov/gaia` fork, push access blocked) onto a branch this session can push to. No content changes beyond conflict resolution against current `main`. GAIA could claim it saved a file it never wrote this turn, or turn a long workshop transcript into a condensed list presented as complete. Now a save counts only when that exact file was written and read back, and a long document is extracted page by page into a full inventory in which every item keeps a verbatim quote from the source. The result is kept in memory, and anything unfinished is reported as incomplete. Ordinary questions ("What time does the store close?") behave exactly as on `main`. Fixes amd#3983 and amd#4141; related to amd#4009. Closes amd#4145. ## Test plan - [x] `python -m pytest tests/unit/agents/ -q`: 1,663 pass, 51 skip. The one failure (`test_symlinked_directory_is_the_same_output`) is this Windows box lacking the symlink privilege, not the code. - [x] `python -m pytest tests/unit/ -k "memory" -q`: 1,136 pass. The one failure (`test_memory_discovery.py::TestCredentialManagerContractShape`) reproduces on pristine `main`. - [x] `python -m pytest tests/unit/ -k "inventory or transcript or overlap or youtube or extract" -q`: 547 pass; remaining failures are a local `mcp` package version mismatch and a live-Lemonade ASR test. - [x] `black --check` / `isort --check-only` clean on every touched file. - [ ] Reporter check: Patrick reruns the original workshop transcript. - [ ] Judged `gaia eval agent` run: deferred by maintainer; the completion gate changes final answers, so decide before merge. <details> <summary>🔍 Technical details</summary> **What a reviewer should know** - **Saves** - A request that names a file, folder or disk is a hard obligation: the file must be written and read back this turn. - A save claim is checked hard when a save was requested, a disk tool ran, or the answer says "I" or "we" saved something. Any other claim gets one correction and then stands, as on `main`. - A file written through the shell counts only when a successful call modified it. The agent's scratch folder never counts as the user's save. - **Extraction** - Pages are 4K characters with 600 characters of overlap, snapped to a line, sentence or word boundary. Each page gets two passes, a retry on an unusable reply, and a 16K-token output budget. - Cloud models are asked for no reasoning: a reasoning model spent 50–100 s per page and ran past the 30-minute limit on a 74K file. - Quote matching ignores case and spacing, but only on whole words. The stored quote is always the source text at its exact offsets. - An item is placed by the span of its name when the name is copied from the quote, otherwise by its quote. - Merging never fails a run and never drops an item. Differing descriptions are both kept, and uncertain cases keep both entries, marked "(may repeat item N)". - **Exports** - JSON records give each field its own key and CSV gives each field its own column. The framework reads the saved file back in full and requires an exact match, and hand edits to it are refused. **Adversarial coverage (from amd#4145)** - A planted-item oracle (about 20,000 items, repeated names, paraphrased labels, repeats across pages and passes) finds no lost, absorbed or wrongly merged items. - In 60,000 hostile replies, no exception escaped the tool, and every quote matched the source at its offsets. - Live runs on Fireworks `deepseek-v4p1-flash` against real YouTube auto-captions of 13K, 21K and 74K characters; the 74K end-to-end agent run saved and verified 111 items in 122 s. Local Gemma-4-E4B completed the 13K file (63 items). **Conflicts resolved against current `main`** Two, both in `src/gaia/agents/base/agent.py`, plus an additive one in the flagship `CHANGELOG.md`: 1. **Turn-sealing order vs. amd#3707 (mid-turn followups).** `main` added a `_drain_followups` check that re-enters the loop rather than sealing the answer; amd#4145 replaced the same lines with `finalize_answer` + the completion-evidence gate. Kept both, drain first: a late user message still gets folded into the running turn, and the completion gate then runs on the answer that actually seals. 2. **Duplicated `_refresh_active_skill_filter` vs. amd#4369 (a loaded skill brings its tools).** amd#4369 hoisted that call to *before* `_refresh_active_tool_filter`; amd#4145 still carried it in its old position below, so a naive merge called it twice per turn. Dropped the duplicate and kept `main`'s hoisted call — the extraction block that follows reads `_active_skill_filter`, so running it earlier is strictly correct. **Verification of the resolution** - Per-file diff of (merge-base → amd#4145 head) against (current `main` → this branch): byte-identical on every file except the one blank line the de-duplication above removes. That rules out the silent near-identical duplication a large auto-merge can introduce. - `compile()` (not just `ast.parse`, which does not reject repeated keyword arguments) over every `.py` in `src/`, `tests/`, `hub/` and `scripts/`: clean. Repo-wide sweep for stray conflict markers: none. </details> --------- Co-authored-by: Kalin Ovtcharov <kalin@Kalins-Mac-mini.local> Co-authored-by: f <f@f>
Typing during an answer was starved and Enter mid-turn could not actually send, so a correction to a five-minute task ("actually, only the unread ones") arrived after the work it was meant to redirect. The composer now stays responsive while text streams, and Enter hands the message to the turn already running — the agent folds it in at its next step boundary and answers it alongside what it was doing. Nothing is interrupted and no second turn starts.
Three independent causes, all fixed here:
Updateon one goroutine — so that work and your keystrokes shared a queue. Tokens now coalesce onto the repaint tick that already runs ten times a second.delivered: trueand the transcript already showed it. The loop now looks again once the answer is formed and takes one more step when something arrived.Delivery is never assumed: the transcript line appears only once the sidecar has the message, a refusal says why and puts the text back, and a peer below 2.15 keeps today's queue and is named along with the command to update it.
Two notes for the reviewer:
y/n/a, so letting prose through would let "check the calendar too" approve a destructive tool call. Doing it safely needs a focus model on a documented security control — its own PR.Test plan
cd tui && go vet ./... && go test ./... -race -count=1— passes, including thetui/testintegration packagepytest tests/unit/agents/ hub/agents/gaia/python/tests/ -q— 1887 passed, 53 skippedpython util/lint.py --black --isortPOST /v1/gaia/query/{run_id}/followupmid-stream returns200 {"delivered":true}, the stream showsPicked up your follow-up: …, the turn takes a second step, and the answer obeys the correction. Same run on the pre-fix commit never picks it up and ignores the correction — see the evidence comment below.404naming what to do instead, not a quiet accept⏎ sends to the running turn— the route path above is verified end-to-end; the keystroke path is still covered only bytui/internal/ui/chattestsgaia hub uninstall/install gaia