Mark the post-tool status handoff so silent reasoning never shows a stale tool status

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.
This commit is contained in:
rcourtman
2026-06-11 16:52:02 +01:00
parent b53e1a3e0e
commit da10d9a5dd
3 changed files with 31 additions and 0 deletions
@@ -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
+10
View File
@@ -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++ {
@@ -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",