From da10d9a5ddcc1874fce7f78c365af33430d6ada0 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 11 Jun 2026 16:52:02 +0100 Subject: [PATCH] Mark the post-tool status handoff so silent reasoning never shows a stale tool status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a tool round, the next model turn can reason server-side for tens of seconds without streaming a single event. The footer kept the last tool's status counting up ('Reading inventory. (40s)') while the model was already composing the answer — observed live on the OpenRouter reasoning route. OpenCode always shows a thinking state here; Pulse now emits a model_processing workflow state at the start of every post-tool turn ('Working on the response with the gathered results.'), which model_thinking upgrades when reasoning deltas actually arrive. Pinned by a new interaction-quality corpus scenario. --- .../v6/internal/subsystems/ai-runtime.md | 8 ++++++++ internal/ai/chat/agentic.go | 10 ++++++++++ .../ai/chat/interaction_scenario_corpus_test.go | 13 +++++++++++++ 3 files changed, 31 insertions(+) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 7e5ee62db9..2d3aed4429 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -2388,6 +2388,14 @@ deriving an older display status from `workflowStatusHistory`. ## Current State +Post-tool model turns mark the status handoff: every agentic turn after the +first emits a `model_processing` workflow state ("Working on the response +with the gathered results.") before the provider call, so a provider that +reasons server-side without streaming events can never leave the previous +tool's status stale on screen; `model_thinking` still upgrades the status +when reasoning deltas actually arrive. Pinned by the interaction-quality +corpus scenario "post-tool model turn marks the status handoff". + Session titles are model-generated after the first exchange (`internal/ai/chat/session_title.go`): the chat runtime upgrades the auto-truncated first-prompt placeholder once the done event has reached the diff --git a/internal/ai/chat/agentic.go b/internal/ai/chat/agentic.go index 5219690767..07d079d9ff 100644 --- a/internal/ai/chat/agentic.go +++ b/internal/ai/chat/agentic.go @@ -857,6 +857,16 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me req = requestSanitizer(req) } + // Post-tool model turns can reason server-side for a long time without + // streaming a single event; without a fresh status the footer keeps + // the last tool's message ("Reading inventory. (40s)") while the model + // is already composing the answer. Mark the handoff explicitly. The + // model_thinking status still upgrades this when reasoning deltas + // actually arrive. + if turn > 0 { + emitWorkflowState(callback, "model_processing", "Working on the response with the gathered results.", sessionFSMState(a.sessionFSM), "") + } + maxProviderAttempts := 2 err := error(nil) for attempt := 1; attempt <= maxProviderAttempts; attempt++ { diff --git a/internal/ai/chat/interaction_scenario_corpus_test.go b/internal/ai/chat/interaction_scenario_corpus_test.go index ca0f9b0429..41c1e196a8 100644 --- a/internal/ai/chat/interaction_scenario_corpus_test.go +++ b/internal/ai/chat/interaction_scenario_corpus_test.go @@ -137,6 +137,19 @@ func interactionScenarios() []interactionScenario { answerMustNotContain: []string{"call_a1b2c3d4"}, streamMustContain: []string{"pulse_query"}, }, + { + name: "post-tool model turn marks the status handoff", + promise: "after tools run, the footer status hands off from the last tool to the model composing the answer — a provider that reasons server-side without streaming events never leaves a stale tool status on screen", + prompt: "summarize my inventory", + calls: []scriptedProviderCall{ + providerQueryToolCall("call_e5f6a7b8"), + providerContentDone("Inventory summarized."), + }, + maxTurns: 4, + orderedTypes: []string{"session", "tool_start", "tool_end", "workflow_state", "content", "done"}, + forbiddenTypes: []string{"error"}, + streamMustContain: []string{"model_processing", "Working on the response with the gathered results"}, + }, { name: "no-narrative turn falls back to a clean operator sentence", promise: "when the model runs tools but never writes an answer, the user reads one clean sentence — not raw JSON, tool-result dumps, or provider call ids",