diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 77bc2fe5b..e263e917a 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -743,6 +743,22 @@ runtime cost control, and shared AI transport surfaces. arrives; it must not flash as run-on prose such as `I'llcheckthedevicenodes...` while the actual governed tool row is still being assembled. + A pre-tool assistant preamble such as "I'll check that" does not satisfy the + final-answer contract after Pulse executes tools. The final-response guard + must require non-empty assistant text after the latest user/tool-result + anchor; otherwise it must make the bounded no-tools summary call or emit the + deterministic fallback summary. This adapts the referenced OpenCode source + at fetched `origin/dev` commit + `06d7840d1d42c9815d2d2e45e7fa4090ca4e3577`: `packages/opencode/src/session/llm/ai-sdk.ts` + maps tool input/call/result stream events into typed tool events (lines + 190-246), `packages/opencode/src/session/message-v2.ts` serializes completed + tool results and reasoning as ordered provider message parts (lines + 340-390), and `packages/ui/src/components/message-part.tsx` only renders + non-empty typed text/reasoning/tool parts through `renderable` and + `AssistantParts` (lines 607-728). Pulse adapts that ordered-part invariant + inside `internal/ai/chat/agentic_final.go` because Pulse's transcript is + persisted as messages plus tool-result anchors rather than OpenCode's + per-part store. Streamed provider startup and mid-stream progress must be bounded by the configured Assistant request timeout, the OpenAI-compatible SSE response-header guard, and the per-chunk SSE body-read guard adapted from diff --git a/internal/ai/chat/agentic_additional_test.go b/internal/ai/chat/agentic_additional_test.go index 3c5dd423e..1ef9f4b25 100644 --- a/internal/ai/chat/agentic_additional_test.go +++ b/internal/ai/chat/agentic_additional_test.go @@ -187,6 +187,112 @@ func TestEnsureFinalTextResponse(t *testing.T) { } } +func TestEnsureFinalTextResponseRequiresAssistantTextAfterLatestToolResult(t *testing.T) { + provider := &stubStreamingProvider{} + loop := &AgenticLoop{provider: provider, baseSystemPrompt: "prompt"} + + var emitted strings.Builder + result := loop.ensureFinalTextResponse( + context.Background(), + "session-pre-tool-preamble", + []Message{ + {Role: "user", Content: "how many devices are in this?"}, + { + Role: "assistant", + Content: "I'll check the device nodes.", + ToolCalls: []ToolCall{ + {ID: "call-1", Name: "pulse_read", Input: map[string]interface{}{"command": "ls /dev | wc -l"}}, + }, + }, + { + Role: "user", + ToolResult: &ToolResult{ToolUseID: "pulse_read_0", Content: "4358", IsError: false}, + }, + {Role: "assistant", Content: ""}, + }, + []providers.Message{ + {Role: "user", Content: "how many devices are in this?"}, + { + Role: "assistant", + Content: "I'll check the device nodes.", + ToolCalls: []providers.ToolCall{ + {ID: "call-1", Name: "pulse_read", Input: map[string]interface{}{"command": "ls /dev | wc -l"}}, + }, + }, + {Role: "tool", ToolResult: &providers.ToolResult{ToolUseID: "call-1", Content: "4358"}}, + {Role: "assistant", Content: ""}, + }, + func(event StreamEvent) { + if event.Type != "content" { + return + } + var data ContentData + if err := json.Unmarshal(event.Data, &data); err == nil { + emitted.WriteString(data.Text) + } + }, + ) + + if len(result) != 5 { + t.Fatalf("expected final summary to be appended after tool result, got %d messages", len(result)) + } + if result[len(result)-1].Content != "summary" { + t.Fatalf("expected provider summary to be appended, got %q", result[len(result)-1].Content) + } + if emitted.String() != "summary" { + t.Fatalf("expected summary to be streamed, got %q", emitted.String()) + } +} + +func TestHasFinalAssistantText(t *testing.T) { + tests := []struct { + name string + messages []Message + want bool + }{ + { + name: "assistant text without tool results is final", + messages: []Message{{Role: "assistant", Content: "done"}}, + want: true, + }, + { + name: "assistant text before latest user prompt is not final", + messages: []Message{ + {Role: "assistant", Content: "previous answer"}, + {Role: "user", Content: "new question"}, + }, + want: false, + }, + { + name: "pre tool text before latest tool result is not final", + messages: []Message{ + {Role: "user", Content: "check it"}, + {Role: "assistant", Content: "I'll check", ToolCalls: []ToolCall{{Name: "pulse_query"}}}, + {Role: "user", ToolResult: &ToolResult{ToolUseID: "pulse_query_0", Content: "ok"}}, + }, + want: false, + }, + { + name: "assistant text after latest tool result is final", + messages: []Message{ + {Role: "user", Content: "check it"}, + {Role: "assistant", Content: "I'll check", ToolCalls: []ToolCall{{Name: "pulse_query"}}}, + {Role: "user", ToolResult: &ToolResult{ToolUseID: "pulse_query_0", Content: "ok"}}, + {Role: "assistant", Content: "The result is ok."}, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hasFinalAssistantText(tt.messages); got != tt.want { + t.Fatalf("hasFinalAssistantText() = %v, want %v", got, tt.want) + } + }) + } +} + func TestEnsureFinalTextResponseAppliesRequestSanitizer(t *testing.T) { provider := &stubStreamingProvider{} loop := &AgenticLoop{provider: provider, baseSystemPrompt: "raw-host"} diff --git a/internal/ai/chat/agentic_final.go b/internal/ai/chat/agentic_final.go index bd13d82e6..15484ac85 100644 --- a/internal/ai/chat/agentic_final.go +++ b/internal/ai/chat/agentic_final.go @@ -13,9 +13,9 @@ import ( "github.com/rs/zerolog/log" ) -// ensureFinalTextResponse checks if the result messages contain any assistant text. -// If not, it makes one last LLM call without tools so the model can summarize -// its findings. +// ensureFinalTextResponse checks if the result messages contain assistant text +// after the latest user/tool-result anchor. If not, it makes one last LLM call +// without tools so the model can summarize its findings. // This prevents the loop from exiting silently after making tool calls without answering. // // cost-recording-exempt: any tokens this final summary turn consumes @@ -30,11 +30,8 @@ func (a *AgenticLoop) ensureFinalTextResponse( providerMessages []providers.Message, callback StreamCallback, ) []Message { - // Check if any assistant message has text content - for i := len(resultMessages) - 1; i >= 0; i-- { - if resultMessages[i].Role == "assistant" && strings.TrimSpace(resultMessages[i].Content) != "" { - return resultMessages // Already has text — nothing to do - } + if hasFinalAssistantText(resultMessages) { + return resultMessages } // No text content from the model. Make a final text-only call. @@ -147,6 +144,26 @@ func (a *AgenticLoop) ensureFinalTextResponse( return resultMessages } +func hasFinalAssistantText(resultMessages []Message) bool { + anchor := -1 + for i, msg := range resultMessages { + if msg.ToolResult != nil { + anchor = i + continue + } + if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" { + anchor = i + } + } + + for i := len(resultMessages) - 1; i > anchor; i-- { + if resultMessages[i].Role == "assistant" && strings.TrimSpace(resultMessages[i].Content) != "" { + return true + } + } + return false +} + func buildAutomaticFallbackSummary(resultMessages []Message) string { successCount := 0 errorCount := 0