Skip to content

feat(tui): say something to the agent while it is still working - #3707

Merged
itomek merged 10 commits into
amd:mainfrom
kovtcharov:claude/gaia-tui-concurrent-input-6c74f5
Sep 25, 2026
Merged

itomek merged 10 commits into
amd:mainfrom
kovtcharov:claude/gaia-tui-concurrent-input-6c74f5

Conversation

@kovtcharov

@kovtcharov kovtcharov commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

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:

  • Typing was starved, not blocked. Every streamed token rebuilt the whole transcript, and Bubble Tea runs Update on 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.
  • Enter only parked the message locally until the turn ended. It now POSTs to the running turn via a new route (contract 2.15), which the daemon relays as-is.
  • A one-step turn accepted the message and threw it away. The queue was drained only at the top of each agent-loop iteration, so a turn that formed its answer in a single step — the commonest shape — had nothing left to pick up what you typed during it. The route had already returned delivered: true and 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:

  • The text is labelled as arriving mid-task before it reaches the model. Unlabelled, a user message landing in the middle of a tool sequence is indistinguishable from a new request and the model abandons the work already done.
  • Type-ahead beside the tool-permission modal was considered and deliberately left out. Its answers are the single letters 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 the tui/test integration package
  • pytest tests/unit/agents/ hub/agents/gaia/python/tests/ -q — 1887 passed, 53 skipped
  • python util/lint.py --black --isort
  • Live against a real model (Gemma-4-E4B on a Strix Halo GPU, sidecar built from this branch): POST /v1/gaia/query/{run_id}/followup mid-stream returns 200 {"delivered":true}, the stream shows Picked 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.
  • Live: a follow-up for a run that is not in flight is a loud 404 naming what to do instead, not a quiet accept
  • Live in the TUI: type through the streaming answer (keystrokes keep up), press Enter, and confirm the row reads ⏎ sends to the running turn — the route path above is verified end-to-end; the keystroke path is still covered only by tui/internal/ui/chat tests
  • Live against a pre-2.15 sidecar: Enter still queues, and the notice names the version floor and gaia hub uninstall/install gaia

Unit Tests (Windows smoke) is red here and on unrelated open PRs (#4359, #4353) — a pre-existing main failure in a Windows symlink test, not this branch.

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>
@github-actions github-actions Bot added documentation Documentation changes tests Test changes agents tui Go terminal UI (gaia-tui) labels Sep 12, 2026
…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>
@github-actions

Copy link
Copy Markdown
Contributor

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 evidence

No 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:

$ cd tui && go vet ./... && go test ./internal/ui/chat/... ./internal/client/... -count=1
ok  	github.com/amd/gaia/tui/internal/ui/chat	0.540s
ok  	github.com/amd/gaia/tui/internal/client	1.958s

The Esc finding came from a throwaway probe test in the same package (removed afterwards):

after Esc:     streaming=true cancelPending=true sending=["and the calendar"] queued=[]
after failure: streaming=true cancelPending=true sending=[]                   queued=["and the calendar"]

The Python suites (test_server_followup.py, test_midturn_followup.py) could not run here — no pytest in this environment — so they are unverified from my side; CI covers them. Nothing exercised the real terminal against a live sidecar, so the streaming-responsiveness claim is unconfirmed outside unit tests.

🔍 Technical details

🟡 A follow-up accepted after the last step boundary is never drained (src/gaia/agents/base/agent.py:4977)

_drain_followups runs only at the top of the while steps_taken < steps_limit loop. The parsed-answer seam (agent.py:6835) sets final_answer, prints it and breaks without a final drain, so for a single-step turn the drain happens once at t=0 and never again. The sidecar's unwire in the finally (server.py:691) closes the window at stream teardown, which is later — so during the whole model call _followup_queue is non-None, queue_followup returns True, and the route answers 200 {delivered: true}.

Downstream, handleFollowUpSent writes the transcript line and the status line "picks it up at its next step, without restarting what it is doing" — for a turn that has no next step.

test_a_finished_run_stops_accepting_before_it_leaves_the_run_table covers the post-loop window but not this one, because _ParkedAgent.process_query never models a step boundary.

Two shapes of fix:

  • Re-drain at the answer seam and reopen the loop if anything arrived — if self._drain_followups(messages, conversation) > 0: final_answer = None; continue before the break. This actually delivers the documented behaviour.
  • Or make queue_followup refuse once the loop has committed to an answer, so the 409 is loud and the TUI queues for the next turn.

Whichever you pick, SPEC §5.6 / SKILL §7 / the contract §5.2 all say "folds it in at its next step boundary" — if one-step turns are out of scope, that has to be stated in all three (CLAUDE.md's bundled-docs rule).

🟡 A refused follow-up during a pending cancel becomes a new turn (tui/internal/ui/chat/followup.go:124)

requestCancel sets cancelPending but leaves m.streaming true until the terminal event, so handleFollowUpFailed takes the m.streaming branch and appends to m.queued. When the cancelled turn settles, Update's drain (model.go:601-624) submits it — exactly what the function's own doc comment says must not happen. TestAFollowUpRefusedAfterTheTurnEndedGoesBackToTheComposer passes only because it sets m.streaming = false by hand, which the Esc path never does.

forceLocalAbort has the mirror-image gap: it restores m.queued but leaves m.sending populated, so a still-in-flight delivery (up to followUpTimeout, 15s) lands after the abort and takes the same path.

	holdForThisTurn := m.streaming && !m.cancelPending

	landed := "holding it until this turn ends"
	if !holdForThisTurn {
		landed = "put it back in the composer"
	}
	m.messages = append(m.messages, Message{
		Role: RoleStatus,
		Content: fmt.Sprintf("[!] %s — %s.",
			sanitizeErrorText(msg.err.Error()), landed),
	})

	if holdForThisTurn {

Worth a test that presses Esc with a delivery in flight rather than mutating m.streaming directly.

🟡 No evidence for the surface that changed

See the visible section. The two live items in the test plan are the ones that matter here; unit tests gate the logic, not the terminal.

🟢 Nits

  1. The PR description ends with a 🤖 Generated with [Claude Code] footer. CLAUDE.md prohibits Claude attribution in PR bodies — please drop the line.
  2. viewDirty (model.go:178) is set by markDirty and cleared by updateViewport, but nothing in non-test code reads it; the flush is driven entirely by the spinner tick. Either drop it or say in the comment that it exists as a test seam.
  3. Contract §5.2 (docs/spec/agent-ui-query-sse-contract.md:395) sits under ## 5. needs_confirmation and the confirmation model, which it has nothing to do with. A top-level ## 5b/## 6 would read better next to cancel and respond.
  4. renderQueuedRow shows only m.sending when both it and m.queued are non-empty. Intentional per the comment, but the queued count disappears entirely — ⏎ sending · … (+2 queued) would keep it honest.

Strengths

  • The failure modes are the part that was thought through hardest: the transcript line is written on the 200 and not on Enter, both refusals are loud on both sides of the wire, and the older-peer path names the version floor and the update command. That is the opposite of a silent fallback.
  • Route negotiation is done right — followUpContractMajor/Minor mirrors the existing session/questions gates, and the spec gained a general rule for negotiating a new route (404 is ambiguous with "run ended"), which is a genuinely useful addition beyond this feature.
  • The token-coalescing change is minimal and the early return is behaviourally identical to the shared updateViewport tail it skips; reading_test.go was correctly updated to assert through the repaint tick rather than reaching past it.

@github-actions

Copy link
Copy Markdown
Contributor

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 markDirty + spinner tick is the right shape for Bubble Tea.

Two things worth a follow-up before the next release (neither is a merge blocker):

🟢 Auth test floor is exactly the current route count. test_every_route_on_the_router_is_gated asserts checked >= 4 — which is the current count, not a floor above it. A future route that slips past auth would only be caught when someone notices the count is still 4. >= 5 after the next route lands is fine; the meaningful guard is that the test fails vacuously (the comment already names this).

🟢 Narrow TOCTOU yields 409 where 404 is the more accurate status. When a follow-up arrives in the window after signal_done() but before the queue is unwired, queue_followup returns False (queue is None) and the route returns 409 ("agent cannot take one") instead of 404 ("run ended"). The caller's recovery is identical for both, and the comment acknowledges the race — but the client error text for 409 says "update the agent", which would confuse a user whose turn simply finished fast. The test already allows either (status_code in (404, 409)). One fix: let _drain_followups set a closed flag on the queue that queue_followup checks, and return the queue-closed path as 404 rather than 409.

🔍 Technical details

Auth floor — hub/agents/gaia/python/tests/test_caller_auth.py:452: the >= 4 is load-bearing today but becomes wrong the moment a fifth route lands without a matching bump. The test's comment ("the enumeration is not working") names the right failure mode, but the assertion won't fire until checked drops below 4, not when it fails to grow.

TOCTOU 409/404 — hub/agents/gaia/python/gaia_agent/server.py:357-358 unwires the queue in finally after signal_done(). Between those two lines, _registry still has the run; a concurrent POST finds the run, calls queue_followup, sees _followup_queue is None, returns False, and the route raises 409. A minimal fix: introduce a _followup_closed: bool field set in finally, and have queue_followup return a sentinel that tells the server to raise 404 instead.

Both are small; neither is a correctness bug in the feature itself.

Ovtcharov and others added 2 commits September 12, 2026 16:36
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
@kovtcharov

Copy link
Copy Markdown
Contributor Author

Merged current main into this branch; it's up to date and conflict-free again.

Mid-turn follow-ups now need contract 2.14, not 2.13. main shipped 2.13 for the flagship's /memory route (#3978). A sidecar on 2.13 has that route but no /followup, so this PR had to move to the next number. The sidecar, npm package, TUI gate, SPEC/SKILL/CHANGELOG and both guides now say 2.14. Memory stays at 2.13.

🔍 Technical details
  • Merge commit 826e9e75 (main at 1a4cccaa).
  • tui/internal/client/negotiate.go: kept both features: the memory constants, memoryAgentID, answered and agentVersion from main, plus supportsFollowUp with a floor of 2.14. Both notice helpers kept.
  • gaia_agent/server.py: API_VERSION = "2.14", and the version comment names both bumps. Follow-up docstrings updated to match.
  • npm/test/cli.test.ts: took main's version, which reads API_VERSION from server.py instead of hard-coding it, so it now checks 2.14. lifecycle.ts is bumped to match.
  • npm/CHANGELOG.md: kept both Added entries (follow-ups and main's run_python).
  • followup_test.go: the fake peers are now on 2.14. The "older peer" test uses 2.13, which is the memory-only flagship that main actually ships.
  • Unchanged behavior worth knowing: the follow-up gate still checks only the version number, and the email sidecar reports 2.14. So the TUI treats email as able to take follow-ups, as it already did at 2.13. main's memory gate also checks the agent id for this reason.
  • Tests: go vet ./..., gofmt -l clean, go test ./... -count=1 all packages ok, go test -race on internal/client and internal/ui/chat ok. Pytest: test_server_followup.py, test_midturn_followup.py, test_caller_auth.py, test_server_query.py, test_cancel_midstream.py: 63 passed. hub/agents/gaia/python/tests + tests/unit/agents: 1336 passed, 3 failed. The failures are the known flagship tool-count and tool-bundle checks that need npx, which this Mac doesn't have. Black/isort clean; internal doc links valid. I couldn't run the npm vitest here (no Node).

@github-actions

Copy link
Copy Markdown
Contributor

🟡 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 m.sending) → press Esc → delivery returns a failure while m.streaming is still true → handleFollowUpFailed appends to m.queued → turn settles → Update submits m.queued[0] as a brand-new turn. The user pressed Esc; a new turn is the opposite of that.

requestCancel does clear m.queued at line 1449, but that runs before the delivery failure arrives and re-populates it. settleTurn never touches m.queued, so when Update checks after the turn ends the queue is non-empty and streaming is false — and it fires.

TestAFollowUpRefusedAfterTheTurnEndedGoesBackToTheComposer passes only because it sets m.streaming = false by hand before the failure arrives; the Esc path leaves m.streaming = true until the turn actually settles, so the test does not cover the real scenario.

🔍 Technical details

followup.go:124:

// current — wrong when a cancel is pending
if m.streaming {
    m.queued = append(m.queued, msg.text)
if m.streaming && !m.cancelPending {
    m.queued = append(m.queued, msg.text)

With cancelPending true, the else-branch runs and calls restoreToComposer — which is where Esc puts everything else and exactly what the function's own comment describes wanting.

A test to add (parallel to the existing TestAFollowUpRefusedAfterTheTurnEndedGoesBackToTheComposer, but leaving m.streaming = true and setting m.cancelPending = true instead):

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)
    }
}

@kovtcharov-amd

Copy link
Copy Markdown
Collaborator

Merged main in — 24 commits behind, no conflicts.

🔍 Technical details

Merge commit only — no hand resolution. Net vs main: 25 files (+1,811/−50).

Tests on the merge result: cd tui && go build ./... && go test ./... — all packages ok. Python: tests/unit/agents/test_midturn_followup.py, hub/agents/gaia/python/tests/test_server_followup.py, test_caller_auth.py — 41 passed.

# Conflicts:
#	hub/agents/gaia/npm/CHANGELOG.md
@github-actions

Copy link
Copy Markdown
Contributor

🟡 The version guard in test_the_version_advertises_the_capability checks >= 2.13, but the feature requires 2.14. The test would pass even if the API_VERSION bump were reverted to the pre-PR value, defeating the one scenario it exists to catch.

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 "2.13" exactly the failure it describes.

🔍 Technical details

hub/agents/gaia/python/tests/test_server_followup.py:708:

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"

server.py correctly sets API_VERSION = "2.14", but the test floor is one minor behind: (2, 13) >= (2, 13) is True, so reverting the version bump passes the test. Change the floor to (2, 14).

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>
@github-actions github-actions Bot added the sidecar Agent sidecar contract / harness label Sep 24, 2026
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.
@itomek

itomek commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Merged current main and fixed three things. The important one is a bug the earlier reviews left open: a message typed during a one-step turn was reported as delivered and then silently thrown away — which is the commonest turn shape, so the headline feature was failing for most turns while telling the user it had worked.

Verified against a live Gemma-4-E4B on a Strix Halo GPU, same question and same machine, before and after the fix:

before after
Follow-up POST 200 {"delivered":true} 200 {"delivered":true}
Agent actually picked it up never yes
Steps in the turn 1 2
Answer 4,890 chars — correction ignored one sentence — correction obeyed

Both said delivered: true; only the fixed one delivers. That gap is the whole defect.

Also in this push: the contract number moved to 2.15 (main took 2.14 for tool_decision/bypass while this sat open), the Esc-before-delivery race is fixed, and the version-floor test that would have passed on an un-bumped API_VERSION now asserts the right floor.

CI: everything green except Unit Tests (Windows smoke), which fails identically on unrelated open PRs (#4359, #4353) — a pre-existing main failure in a Windows symlink test, not this branch.

🔍 Technical details

The bug. _drain_followups was called only at the top of each agent-loop iteration (src/gaia/agents/base/agent.py:5753), inside while steps_taken < steps_limit and final_answer is None:. A turn that forms its final answer in one step therefore drains exactly once — before its only model call — and then exits. A message arriving during that call was accepted by the route (the queue is wired, so queue_followup returns True), reported delivered: true, written into the transcript, and then dropped when server.py:809-810 unwired the queue.

test_the_agent_loop_hands_a_followup_to_the_model passed only because it queued the text before process_query, which the top-of-loop drain always catches. Mid-call arrival was never covered.

The fix (src/gaia/agents/base/agent.py, commit 2684db74): re-check the queue at the point the answer is formed and take one more step if something arrived. Two deliberate refusals — it will not drain a turn whose cancel event is set (the message stays queued for a turn that can answer it), and it will not drain on the last step, where consuming into a context no model call reads would be the same silent drop one step later. Bounded by steps_limit.

Evidence, live route. Sidecar built from this branch (apiVersion 2.15), pointed at Lemonade 11.0.0 serving Gemma-4-E4B-it-GGUF on GPU:

POST /v1/gaia/query/{run_id}/followup
{"text":"Actually, just give me one sentence."}

HTTP 200
{"run_id":"c3dec02d-...","delivered":true}

Stream, after the fix:

data: {"type": "status", "message": "Working out how to answer"}
data: {"type": "status", "message": "Picked up your follow-up: Actually, just give me one sentence."}
data: {"type": "status", "message": "Working out how to answer"}
data: {"type": "final", "answer": "Fine. You want brevity over brilliance, apparently. ...",
       "usage": {"steps": 2, "elapsed": 50.7, "tok_per_s": 46.7}}

Same run against the pre-fix commit 1c4fd356: "Picked up your follow-up" appears 0 times, steps: 1, and a 4,890-character answer that ignores the correction entirely. No second turn started in either case.

The loud refusal path also checked live — a follow-up for a run that is not in flight:

HTTP 404
{"detail":"No run '793507c5-...' is in flight, so the follow-up was not delivered.
  It may have already finished or been cancelled — send it as a new query instead."}

Merge resolution. Every conflict was keep-both (main's tool_decision/bypass/claude next to this branch's followup), plus the renumber. REST functional verbs are now 7 (11 total in the sidecar contract); test_capability_matrix asserts both against the real route set. 2.13 (memory) and 2.14 (tool decisions) claims left untouched.

Test runs on the merge result:

  • cd tui && go build ./... && go vet ./... && go test ./... -race -count=1 — all packages ok
  • pytest tests/unit/agents/ hub/agents/gaia/python/tests/ -q — 1887 passed, 53 skipped
  • python util/lint.py --black --isort — all quality checks passed
  • gofmt -l tui/ — only internal/control/state.go, untouched by this branch and already unformatted on main

The two earlier non-blocking nits are both closed. test_every_route_on_the_router_is_gated now floors at checked >= 8 against 8 real routes, with a comment saying to raise it with the route set. The 409 text no longer says "update the agent" — it reads "its agent is not accepting mid-turn input. Send it as a new query instead", which is accurate whichever side of the signal_done() window the request lands on, and the caller's recovery is the same either way.

@itomek itomek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@itomek
itomek added this pull request to the merge queue Sep 25, 2026
Merged via the queue into amd:main with commit 9d2d4c3 Sep 25, 2026
69 of 76 checks passed
@itomek

itomek commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

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 details

Both sides drop it independently. Host: appendTurn is gated on a final terminal (tui/internal/client/sse.go:423, called at :432); an error terminal returns without it and a cancel falls through to the switch at :448, which only logs. Agent: the drain sits after the cancel check by design, so a cancelled turn leaves the text queued — but that queue is per-run and unwired at server.py:809-810, so it is garbage-collected rather than carried anywhere.

test_a_cancelled_turn_does_not_swallow_a_followup passes because it asserts the queue is still non-empty, which is the right property for the agent object alone but does not mean the user's words survived.

#4364 also carries the understated unwire comment and two small nits.

pull Bot pushed a commit to bhardwajRahul/gaia that referenced this pull request Sep 27, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents documentation Documentation changes sidecar Agent sidecar contract / harness tests Test changes tui Go terminal UI (gaia-tui)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants