-
Notifications
You must be signed in to change notification settings - Fork 3
plat 179
PLAT-179 — a retained (tmux-delivered) coding-agent turn is declared complete on the first non-empty assistant text, even explicit intermediate commentary, before a queued tool call runs
| Coordination | Value |
|---|---|
| Assigned agent | unassigned |
| Ticket state |
fixed — root cause confirmed live, fix confirmed live end-to-end for pi-cli, build/test verified |
| Last synchronized | 2026-08-22 |
- Priority: P0 — silently returns the wrong answer and ends the turn early, for every tmux-backed coding-agent provider using retained/live-steer delivery (claude-code, codex-cli, cursor-cli, agy-cli, pi-cli), not one provider. A caller has no way to tell a genuinely-final reply from a truncated one without independently re-verifying.
-
Owner:
mcpagent/agent/retainedturn/retained_turn.go(the actual fix);multi-llm-provider-go/pkg/adapters/picli/picli_transcript.go(the paired change pi-cli needed to feed that fix real data). claude-code and cursor-cli need the same transcript-preservation audit — see Related Work. - Related: PLAT-174), PLAT-175), PLAT-176) (same investigation session — started as a request to validate pi-cli against OpenRouter, surfaced this along the way). PLAT-180 (a second, separate defect surfaced once this one was actually fixed and the test could run further).
Testing whether pi-cli works correctly with an OpenRouter model
(stealth/ox-alpha), via mcp-agent test coding-agent-chat-e2e --provider pi-cli --retained-window-p0-only, which exercises the IC-11
cross-repository retained-session contract.
The investigation took three wrong turns before finding the real cause, recorded here so a future reader doesn't retrace them:
- First hypothesis: OpenRouter-specific. Disproven — identical failure with the catalogued Gemini baseline model.
- Second hypothesis: pi-cli chat uses "structured" (no-pane) transport, so
the tmux-liveness check in IC-11 doesn't apply to it. Disproven —
[QUERY->LIVE] ... provider=pi-cli transport=tmuxin the server log is computed by mcpagent's own delivery code; pi-cli chat sessions genuinely use tmux, same as the other three providers. - Third hypothesis (after #2): pi-cli's tmux session isn't registering with
/api/terminals, a real gap in event-driven terminal-store population. Disproven —/api/terminalswas returning a deliberatehttp.NotFoundfromruntimeDiagnosticsHandlerbecauseAGENTWORKS_RUNTIME_DEBUGwasn't set on the local dev server. Not a bug; a missing local env var. Setting it madeassertRetainedTmuxLivepass immediately.
With that noise cleared, the real failure was one step further in:
Error: IC-11 retained-window P0 failed: retained turn completed before
execute_shell_command started; intermediate commentary was treated as final
The IC-11 prompt asks for three things in order: (1) a brief progress update,
explicitly labeled "intermediate commentary, not your final answer", (2)
one execute_shell_command call running sleep 2; printf <token>, (3) only
then the real final answer. The turn reported "status":"completed" after
2.2 seconds — not enough time for even the sleep 2 to finish — with
final_result set to the progress-update text, not the real answer.
Traced through both repositories:
-
mcpagent/agent/turn_session.go'sstartRetainedCompletionWatchpolls every 100ms and calls a reader function. The moment that reader returns any non-empty string, it callscompleteRetainedTurnand emits acanonical_turn_completionevent — unconditionally:case <-ticker.C: finalResult := strings.TrimSpace(reader(provider, s.agent.sessionID, startedAt)) if finalResult == "" { continue } s.completeRetainedTurn(lifecycle, seq, input, finalResult, provider, transport, startedAt) return
This loop's own contract — "empty means not done yet, keep polling" — is sound.
-
The reader is
mcpagent/agent/retainedturn.FinalResponse→finalResponse(), which reads the provider's reconstructed messages (viaReadCodingAgentRetainedTurnMessages) and picks the last AI-role message with any non-empty text. Coding-agent providers can legitimately bundle intermediate commentary and the tool call it introduces into ONE assistant message — confirmed live for pi-cli:{"role":"assistant","content":[{"type":"text","text":"<progress update>"}, {"type":"toolCall","id":"call_...",...}]}and true by construction for claude-code and cursor-cli too, which group a single LLM call's content blocks into one
MessageContentthe same way.finalResponse()only looked at the text; a message that also carried a pending tool call was indistinguishable from a genuinely finished reply.
The first fix gated ReadRetainedTurnMessages (pi-cli only) on the coding
CLI's own tmux pane reporting "idle" text, reusing piPaneReadyForInput — the
same check the ordinary non-retained turn flow already trusts
(picli_interactive_adapter.go:1250).
This failed live, in the opposite direction from the original bug. Watched
the actual tmux pane in real time while pi genuinely finished — the tool call
had succeeded, the correct final answer was sitting right there on screen —
and captured the full pane text: it contained zero occurrences of the word
"idle" anywhere, in this pi CLI build's status line format. The gate never
fired. The retained turn hung for the full 5-minute test timeout instead of
completing — worse than the bug it replaced, which at least returned promptly
with the wrong text. git commit b71d825 (multi-llm-provider-go) shipped
this; commit c4ae920 reverted it once the real cause was found.
The deeper lesson: pane-text matching is provider-UI-format-fragile and was
never how the two already-correct providers solved this same problem —
codex-cli filters on phase=="final_answer" and claude-code has an (unwired)
stop_reason=="end_turn" check — both transcript-level facts, neither
touches a pane. The real fix needed no pane inspection either.
finalResponse() (mcpagent/agent/retainedturn/retained_turn.go) now treats
the newest AI-role message as authoritative. If that message also carries a
ToolCall part, the turn is still in progress and the reader returns empty:
if messageHasToolCall(messages[i]) {
return ""
}It must not continue scanning older AI messages: an older commentary-only
message may exist, and returning it would reproduce the premature completion
under a different transcript shape.
This is a shared, single-point fix — every retained/live-steer provider's reconstruction bundles text+toolCall into one message the same way, so this correctly protects claude-code and cursor-cli too, not just pi-cli, without duplicating a heuristic per provider.
It needed one paired change to actually take effect for pi-cli:
multi-llm-provider-go/pkg/adapters/picli/picli_transcript.go's
piTranscriptText extracted only "text"-typed content blocks, silently
discarding a "toolCall" block in the same message — so by the time a
message reached finalResponse(), the very fact this fix needed to see was
already gone. Replaced with piTranscriptParts, which preserves a toolCall
block as a real llmtypes.ToolCall (ID + function name only — nothing
downstream needs to replay the call) alongside any text, in the order they
appeared in the raw content array.
-
claude-code and cursor-cli's own transcript readers were not audited for
the same "does it preserve a ToolCall part in the same message" question
pi-cli needed fixed. The shared
finalResponse()fix protects them only if their readers actually hand it aToolCallpart when one exists — claude-code'sreadClaudeTranscriptMessages(used byReadRetainedTurnMessages) appears to viaassistantBlocksToPartsbased on a read of the code, but this was not live-verified for either provider, only for pi-cli. -
A chat-mode E2E artifact for "structured" transport was written, then
reverted. While investigating hypothesis #2, a new
--structured-resume-p0-onlytest mode was added tocmd/testing/coding_agent_chat_e2e.go(mcp-agent-builder-go), assertingDeliveryTransport == "structured". Once #2 was disproven — chat sessions use"tmux"transport, not"structured"— that assertion could never legitimately pass through this harness (structured transport is a workflow-step-only path,MetadataKeyStructuredTransport, never reachable via/api/query). Reverted rather than left in the tree as a misleading, permanently-failing artifact. -
A temporary, env-gated test bypass (
AGENTWORKS_CURSOR_FORCE_TMUX_TEST,agent_go/cmd/server/coding_agent_modes.go) was used to force cursor-cli through the tmux-retained path — which it never takes in real product usage (codingAgentUsesStructuredTransportdeliberately routes it to structured transport) — specifically to reproduce and confirm this same bug class for cursor-cli. Reverted after use; not a product change.
-
mcpagent/agent/retainedturn/retained_turn_test.go:TestFinalResponseSkipsAnAIMessageThatAlsoHasAToolCallandTestFinalResponseReturnsEmptyWhenOnlyMessageHasAPendingToolCall. The latter is the real regression catcher — confirmed failing before the fix (returned the commentary text instead of empty) and passing after. -
TestFinalResponseReturnsEmptyWhenNewestAssistantMessageHasToolCallproves older progress text is not mistaken for the final answer while the newest assistant message has a pending tool call; a paired test proves text after that tool call completes is returned normally. -
multi-llm-provider-go/pkg/adapters/picli/picli_transcript_test.go:TestPiTranscriptPartsPreservesAToolCallAlongsideTextandTestPiTranscriptPartsTextOnlyMessageHasNoToolCall. Confirmed failing before (piTranscriptPartsundefined) and passing after. - Full
pkg/adapters/piclisuite passes (includes real-tmux tests).go build ./...clean in both repositories. - Confirmed live end-to-end for pi-cli: watched the retained turn via direct tmux pane capture — genuine progress commentary, tool call, then the real final answer — and confirmed via the E2E harness that the turn no longer completes on the commentary. (It now correctly proceeds past this check and hits a second, separate, already-filed defect — PLAT-180 — confirming this fix works: the test could not have reached that later check while this one was still broken.)
- A retained pi-cli turn does not complete on intermediate commentary alone; it waits for the actual final (non-tool-call) message.
- Live: confirmed pi-cli no longer returns the progress-update text as
final_result. - claude-code and cursor-cli's transcript readers audited to confirm they
hand
finalResponse()aToolCallpart when one exists, matching what pi-cli needed.
Auto-synced from docs/ on main. Edit there, not here.