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",