Copilot commented on code in PR #1496:
URL: https://github.com/apache/dubbo-admin/pull/1496#discussion_r3563521273
##########
ai/component/server/component.go:
##########
@@ -106,6 +106,12 @@ func (s *ServerComponent) Init(rt *runtime.Runtime) error {
}
func (s *ServerComponent) Start() error {
+ // Check if server is already running (idempotent Start)
+ if s.srv != nil {
+ s.rt.GetLogger().Info("Server already running", "addr",
fmt.Sprintf("%s:%d", s.host, s.port))
+ return nil
+ }
Review Comment:
Start() now returns early when s.srv != nil, but Stop() never resets s.srv
to nil after shutdown. This makes the server component impossible to restart
after calling Stop(), which is surprising given the new 'idempotent Start'
semantics.
##########
ai/component/server/engine/handlers.go:
##########
@@ -123,6 +163,26 @@ func (h *AgentHandler) StreamChat(c *gin.Context) {
// MessageDelta finishes stream and handles usage
func (h *AgentHandler) MessageDelta(sseHandler *sse.SSEHandler, output
schema.Schema) {
stopReason := "end_turn"
+
+ // If the output is an Observation with a final answer, stream it as
text delta first
+ switch v := output.(type) {
+ case *schema.Observation:
+ // Send Summary as text if present
+ if v.Summary != "" {
+ if err := sseHandler.HandleText(v.Summary+"\n", 0); err
!= nil {
+ rt.GetLogger().Error("Failed to stream summary
in MessageDelta", "error", err)
+ }
+ }
+ // Send FinalAnswer as text if present
+ if v.FinalAnswer != "" {
+ if err := sseHandler.HandleText(v.FinalAnswer+"\n", 0);
err != nil {
+ rt.GetLogger().Error("Failed to stream final
answer in MessageDelta", "error", err)
+ }
+ }
+ default:
+ rt.GetLogger().Info("MessageDelta: unexpected output type",
"type", fmt.Sprintf("%T", output))
+ }
+
if err := sseHandler.MessageDeltaWithUsage(stopReason, output); err !=
nil {
sseHandler.HandleError("finish_stream_error",
fmt.Sprintf("failed to finish stream: %v", err))
}
Review Comment:
MessageDelta streams Observation.Summary/FinalAnswer via HandleText and then
also emits message_delta. However, the observe stage already streams
Summary/FinalAnswer into UserRespChan (see
ai/component/agent/react/react.go:onOutput2Flow) before StreamFinal is sent
(ai/component/agent/agent.go). This risks duplicating the final answer in SSE
clients.
##########
ai/component/rag/config.go:
##########
@@ -32,13 +32,20 @@ type QueryProcessorConfig = query.QueryProcessorConfig
// RAGSpec defines RAG component configuration with recursive structure
// Each subcomponent uses the standard Config pattern (type + spec)
type RAGSpec struct {
- Embedder *config.Config `yaml:"embedder"`
- Loader *config.Config `yaml:"loader"`
- Splitter *config.Config `yaml:"splitter"`
- Indexer *config.Config `yaml:"indexer"`
- Retriever *config.Config `yaml:"retriever"`
- Reranker *config.Config `yaml:"reranker,omitempty"`
- QueryProcessor *config.Config `yaml:"query_processor,omitempty"`
+ Embedder *config.Config `yaml:"embedder"`
+ Loader *config.Config `yaml:"loader"`
+ Splitter *config.Config `yaml:"splitter"`
+ Indexer *config.Config `yaml:"indexer"`
+ Retriever *config.Config `yaml:"retriever"`
+ Reranker *config.Config `yaml:"reranker,omitempty"`
+ QueryProcessor *config.Config `yaml:"query_processor,omitempty"`
+ Seed *SeedSpec `yaml:"seed,omitempty"`
+}
+
+// SeedSpec controls auto-seeding the local vector store with bundled knowledge
+// documents at startup, so retrieval is never empty on a fresh deployment.
+type SeedSpec struct {
+ Enabled bool `yaml:"enabled"`
Review Comment:
SeedSpec.Enabled is a bool, so if a user config includes `seed: {}` (or
`seed:` without `enabled`) YAML decoding will set Enabled=false and silently
disable seeding, even though the JSON schema declares default=true and the
comments say seeding is on by default. To make 'enabled by default' real,
consider using `*bool` (nil => default true) and updating seedEnabled()
accordingly.
##########
ai/component/agent/react/react.go:
##########
@@ -393,26 +469,111 @@ func observe(g *genkit.Genkit, observePrompt ai.Prompt)
agent.StreamFlow {
return nil, fmt.Errorf("history is empty")
}
- resp, err := observePrompt.Execute(ctx,
+ // Create context with timeout for observe stage
+ observeCtx, cancel := context.WithTimeout(ctx,
observeTimeout)
+ defer cancel()
+
+ resp, err := observePrompt.Execute(observeCtx,
ai.WithMessages(history.WindowMemory(sessionID)...),
)
+ // Check for timeout or other errors
if err != nil {
+ // If timeout occurred, return fallback
observation
+ if errors.Is(err, context.DeadlineExceeded) ||
errors.Is(err, context.Canceled) {
+ runtime.GetLogger().Warn("Observe stage
timeout, returning fallback response", "timeout", observeTimeout)
+ fallbackObs :=
generateFallbackObservation(in)
+ msg :=
fallback.NewHandler(nil).MarshalObservation(&fallbackObs)
+ history.AddHistory(sessionID, msg)
+ return fallbackObs, nil
+ }
return nil, fmt.Errorf("failed to execute
observe prompt: %w", err)
}
- // Parse output
- var observation schema.Observation
- observation.UsageInfo = &ai.GenerationUsage{}
- err = resp.Output(&observation)
+ // Parse output with fallback
+ fbHandler := fallback.NewHandler(nil)
+ observation, err := fbHandler.ParseObservation(resp)
if err != nil {
- return nil, fmt.Errorf("failed to parse observe
prompt response: %w", err)
+ // If parsing fails, generate fallback
+ runtime.GetLogger().Warn("Failed to parse
observation, returning fallback", "error", err)
+ fallbackObs := generateFallbackObservation(in)
+ msg :=
fallback.NewHandler(nil).MarshalObservation(&fallbackObs)
+ history.AddHistory(sessionID, msg)
+ return fallbackObs, nil
}
runtime.GetLogger().Info("Observe out:", "out",
observation)
- history.AddHistory(sessionID, resp.Message)
+ // Use fallback marshal if JSON marshaling fails
+ msg := fbHandler.MarshalObservation(observation)
+ history.AddHistory(sessionID, msg)
schema.AccumulateUsage(observation.UsageInfo,
resp.Usage, in.Usage())
return observation, err
})
}
+
+// generateFallbackObservation creates a fallback observation when observe
stage times out
+func generateFallbackObservation(input schema.Schema) schema.Observation {
+ fallback := schema.Observation{
+ Heartbeat: false,
+ FinalAnswer: "",
+ Summary: "Generate response based on available context",
+ Evidence: "Timeout - using available context",
+ }
+
+ // Try to extract information from input to generate a meaningful
response
+ switch v := input.(type) {
+ case schema.ThinkOutput:
+ // If we have think output, use the thought to generate a
response
+ if v.Thought != "" {
+ fallback.FinalAnswer =
generateResponseFromThought(v.Thought, v.Intent)
+ }
+ case schema.Observation:
+ // If we have previous observation, reuse it if it has final
answer
+ if v.FinalAnswer != "" {
+ fallback.FinalAnswer = v.FinalAnswer
+ fallback.Evidence = v.Evidence
+ } else {
+ // Continue from previous observation
+ fallback.Heartbeat = true
+ fallback.Focus = "Continue processing with available
information"
+ }
+ case schema.ToolOutputs:
+ // If we have tool outputs, generate response from them
+ if len(v.Outputs) > 0 {
+ fallback.FinalAnswer =
generateResponseFromToolOutputs(v.Outputs)
+ }
+ default:
+ // Default fallback for any other input type
+ fallback.FinalAnswer = "I apologize, but I need more time to
process your request. Based on the available context, I cannot provide a
complete answer at this moment."
+ }
Review Comment:
generateFallbackObservation only matches value types (schema.ThinkOutput /
schema.ToolOutputs / schema.Observation), but the pipeline frequently passes
pointers (e.g. *schema.Observation from ParseObservation, *schema.ThinkOutput
from ParseThinkOutput). When observe times out, this makes the fallback almost
always hit the default branch and ignore available context.
##########
ai/component/server/engine/handlers.go:
##########
@@ -92,7 +92,14 @@ func (h *AgentHandler) StreamChat(c *gin.Context) {
channels.UserRespChan = nil
continue
}
+ rt.GetLogger().Info("Handler received feedback",
+ "session_id", sessionID,
+ "text", feedback.Text(),
+ "done", feedback.IsDone(),
+ "final", feedback.IsFinal(),
+ "final_nil", feedback.Final() == nil)
Review Comment:
The per-chunk "Handler received feedback" log includes the full streamed
text (feedback.Text()), which can leak user content / model output into logs
and may be large/noisy at Info level. Consider logging only metadata
(index/done/final/text length) and/or lowering to Debug.
##########
ai/test/e2e/multi_turn_conversation_test.go:
##########
@@ -0,0 +1,583 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package e2e
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ appruntime "dubbo-admin-ai/runtime"
+)
+
+// TestMultiTurnConversation tests 10 rounds of conversation
+// This test verifies the agent's ability to maintain context and provide
coherent responses
+func TestMultiTurnConversation(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping e2e test in short mode")
+ }
+
+ // Bootstrap runtime
+ ctx := context.Background()
+ configPath, _ := createTestConfig(t)
+ rt, err := appruntime.Bootstrap(configPath, registerFactories)
+ if err != nil {
+ t.Fatalf("Failed to bootstrap runtime: %v", err)
+ }
+ defer rt.StopAll()
+
+ // Get server component
+ _, err = rt.GetComponent("server")
+ if err != nil {
+ t.Fatalf("Failed to get server component: %v", err)
+ }
+
+ // Note: Server is already started by Bootstrap, no need to call
Start() again
+ // Runtime.Bootstrap automatically calls Start() on all components
+
+ // Wait for server to be ready (server starts asynchronously)
+ // Use health check to ensure server is actually ready
+ baseURL := fmt.Sprintf("http://127.0.0.1:8880")
+ if !waitForServerReady(t, ctx, baseURL, 30*time.Second) {
+ t.Fatalf("Server did not become ready in time")
+ }
+
+ t.Run("create_and_chat_10_rounds", func(t *testing.T) {
+ // Create a new session
+ sessionID := createSession(t, ctx, baseURL)
+ t.Logf("Created session: %s", sessionID)
+
+ // Define 10 rounds of conversation
+ // Each round tests different capabilities
+ conversationRounds := []ConversationRound{
+ {
+ Name: "Round 1: Basic Introduction",
+ Message: "Hello, what is Dubbo?",
+ Expected: []string{"Dubbo", "RPC", "framework"},
+ },
+ {
+ Name: "Round 2: Follow-up on Architecture",
+ Message: "What are the main components of
Dubbo?",
+ Expected: []string{"Provider", "Consumer",
"Registry", "Monitor"},
+ },
+ {
+ Name: "Round 3: Service Governance",
+ Message: "Tell me about Dubbo's service
governance features",
+ Expected: []string{"load balancing", "circuit
breaking", "service degradation"},
+ },
+ {
+ Name: "Round 4: Load Balancing Details",
+ Message: "What load balancing strategies does
Dubbo support?",
+ Expected: []string{"random", "round-robin",
"least active", "consistent hashing"},
+ },
+ {
+ Name: "Round 5: Protocol Support",
+ Message: "Which protocols does Dubbo support?",
+ Expected: []string{"Dubbo", "REST", "HTTP",
"gRPC"},
+ },
+ {
+ Name: "Round 6: Fault Tolerance",
+ Message: "What are the cluster fault tolerance
strategies?",
+ Expected: []string{"Failover", "Failfast",
"Failsafe", "Failback"},
+ },
+ {
+ Name: "Round 7: Context Retention Check",
+ Message: "Based on what we discussed, which
load balancing is the default?",
+ Expected: []string{"random", "default"},
+ },
+ {
+ Name: "Round 8: Advanced Features",
+ Message: "How does Dubbo handle service
registration and discovery?",
+ Expected: []string{"registry", "Nacos",
"Zookeeper"},
+ },
+ {
+ Name: "Round 9: Performance Question",
+ Message: "What makes Dubbo perform well in
high-concurrency scenarios?",
+ Expected: []string{"NIO", "async", "long
connection"},
+ },
+ {
+ Name: "Round 10: Summary Request",
+ Message: "Can you summarize the key points
about Dubbo we discussed?",
+ Expected: []string{"architecture", "protocols",
"governance", "fault tolerance"},
+ },
+ // Rounds 11-14 ask for precise, documentation-grade
details that live in
+ // the seeded knowledge base
(component/rag/seeds/*.md). Per the Think
+ // stage's tool-use policy these should classify as
DOCUMENTATION_QUERY and
+ // trigger query_knowledge_base, exercising retrieval
against seed data.
+ {
+ Name: "Round 11: Precise Config Keys (RAG)",
+ Message: "What are the exact dubbo.provider
config keys and their default values for timeout, retries and loadbalance?",
+ Expected: []string{"timeout", "retries",
"loadbalance"},
+ },
+ {
+ Name: "Round 12: Serialization Options
(RAG)",
+ Message: "Which serialization protocols does
Dubbo support and which one is the default?",
+ Expected: []string{"hessian2", "protobuf",
"kryo"},
+ },
+ {
+ Name: "Round 13: Register Mode Default
(RAG)",
+ Message: "In Dubbo 3, what is the default
register-mode and what values can it take?",
+ Expected: []string{"instance", "interface",
"all"},
+ },
+ {
+ Name: "Round 14: Dubbo Admin Connection
Config (RAG)",
+ Message: "Which addresses must Dubbo Admin be
configured with to manage a cluster, and what are the config keys?",
+ Expected: []string{"registry", "config-center",
"metadata-report"},
+ },
+ }
+
+ // Track test results
+ results := &TestResults{
+ SessionID: sessionID,
+ Rounds: make([]RoundResult, 0,
len(conversationRounds)),
+ }
+
+ // Execute each round
+ for i, round := range conversationRounds {
+ t.Logf("\n=== %s ===", round.Name)
+
+ startTime := time.Now()
+
+ // Send message
+ response := sendMessage(t, ctx, baseURL, sessionID,
round.Message)
+
+ duration := time.Since(startTime)
+
+ // Record result
+ roundResult := RoundResult{
+ RoundNumber: i + 1,
+ Name: round.Name,
+ UserMessage: round.Message,
+ Response: response,
+ Duration: duration,
+ Timestamp: startTime,
+ Expected: round.Expected,
+ }
+
+ // Validate response
+ roundResult.Success = validateResponse(response,
round.Expected)
+ roundResult.MatchedKeywords =
findMatchedKeywords(response, round.Expected)
+
+ results.Rounds = append(results.Rounds, roundResult)
+
+ // Log results
+ if roundResult.Success {
+ t.Logf("✓ Response received in %v (matched
keywords: %v)",
+ duration, roundResult.MatchedKeywords)
+ } else {
+ t.Logf("⚠ Response received but might be
incomplete (matched: %v)",
+ roundResult.MatchedKeywords)
+ }
+
+ t.Logf("Response preview: %s", truncateString(response,
200))
+
+ // Small delay between messages
+ time.Sleep(500 * time.Millisecond)
+ }
+
+ // Print summary
+ printTestSummary(t, results)
+
+ // Export results to file
+ exportResultsToFile(t, results)
+ })
+}
+
+// ConversationRound defines a single round of conversation
+type ConversationRound struct {
+ Name string
+ Message string
+ Expected []string
+}
+
+// RoundResult stores the result of a single conversation round
+type RoundResult struct {
+ RoundNumber int
+ Name string
+ UserMessage string
+ Response string
+ Duration time.Duration
+ Timestamp time.Time
+ Success bool
+ Expected []string
+ MatchedKeywords []string
+}
+
+// TestResults stores all test results
+type TestResults struct {
+ SessionID string
+ Rounds []RoundResult
+}
+
+// createSession creates a new chat session
+func createSession(t *testing.T, ctx context.Context, baseURL string) string {
+ req, err := http.NewRequestWithContext(ctx, "POST",
baseURL+"/api/v1/ai/sessions", nil)
+ if err != nil {
+ t.Fatalf("Failed to create request: %v", err)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("Create session failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ t.Fatalf("Create session status = %d, body: %s",
resp.StatusCode, string(body))
+ }
+
+ var result struct {
+ Message string `json:"message"`
+ Data struct {
+ SessionID string `json:"session_id"`
+ } `json:"data"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ t.Fatalf("Failed to decode response: %v", err)
+ }
+
+ return result.Data.SessionID
+}
+
+// sendMessage sends a chat message and returns the response
+func sendMessage(t *testing.T, ctx context.Context, baseURL, sessionID,
message string) string {
+ chatReq := map[string]string{
+ "message": message,
+ "sessionID": sessionID,
+ }
+ body, err := json.Marshal(chatReq)
+ if err != nil {
+ t.Fatalf("Failed to marshal request: %v", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST",
baseURL+"/api/v1/ai/chat/stream", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("Failed to create request: %v", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "text/event-stream")
+
+ // Use 10 minute timeout to match server timeout (15m) with buffer
+ client := &http.Client{Timeout: 600 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("Chat request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ t.Fatalf("Chat status = %d, body: %s", resp.StatusCode,
string(body))
+ }
+
+ // Read SSE stream and collect response
+ return readSSEStream(t, resp.Body)
+}
+
+// readSSEStream reads the SSE stream and returns the accumulated response
+func readSSEStream(t *testing.T, body io.ReadCloser) string {
+ buf := new(strings.Builder)
+ chunk := make([]byte, 4096)
+ noDataCount := 0
+ maxNoDataCounts := 10 // Allow up to 10 consecutive reads with no new
data (5 seconds total)
+
+ for {
+ n, err := body.Read(chunk)
+ if n > 0 {
+ chunkStr := string(chunk[:n])
+ buf.Write(chunk[:n])
+ noDataCount = 0
+
+ // Check if stream is complete - look for message_stop
event
+ if strings.Contains(chunkStr, "event: message_stop") {
+ t.Logf("Found message_stop event, stream
complete")
+ break
+ }
+ }
+ if err != nil {
+ if err == io.EOF {
+ t.Logf("EOF received, stream complete")
+ break
+ }
+ t.Logf("Read error (non-fatal): %v", err)
+ break
+ }
+
+ // Check for timeout - if no new data for 500ms for multiple
consecutive reads
+ if n == 0 {
+ noDataCount++
+ if noDataCount > maxNoDataCounts {
+ t.Logf("No new data for %d consecutive reads,
assuming stream complete", noDataCount)
+ break
+ }
+ time.Sleep(500 * time.Millisecond)
+ }
+
+ // Timeout protection - increased limit
+ if buf.Len() > 500000 {
+ t.Logf("Response too large, stopping read")
+ break
+ }
+ }
+
+ // Extract text content from SSE events
+ return extractTextFromSSE(buf.String())
+}
+
+// extractTextFromSSE extracts text content from SSE format
+func extractTextFromSSE(sseData string) string {
+ lines := strings.Split(sseData, "\n")
+ var textParts []string
+
+ for _, line := range lines {
+ if strings.HasPrefix(line, "data:") {
+ data := strings.TrimPrefix(line, "data:")
+ data = strings.TrimSpace(data)
+
+ // Skip [DONE] markers
+ if data == "[DONE]" || data == "" {
+ continue
+ }
+
+ // Try to parse JSON data
+ var parsed map[string]interface{}
+ if err := json.Unmarshal([]byte(data), &parsed); err ==
nil {
+ // Handle content_block_delta event with nested
delta.text structure
+ // Format: {"type": "content_block_delta",
"index": 0, "delta": {"type": "text_delta", "text": "..."}}
+ if delta, ok :=
parsed["delta"].(map[string]interface{}); ok {
+ if text, ok := delta["text"].(string);
ok && text != "" {
+ textParts = append(textParts,
text)
+ }
+ }
+ // Handle direct text field (for other event
types)
+ if text, ok := parsed["text"].(string); ok &&
text != "" {
+ textParts = append(textParts, text)
+ }
+ // Handle content_block.text (for
content_block_start events)
+ if contentBlock, ok :=
parsed["content_block"].(map[string]interface{}); ok {
+ if text, ok :=
contentBlock["text"].(string); ok && text != "" {
+ textParts = append(textParts,
text)
+ }
+ }
+ }
+ }
+ }
+
+ result := strings.Join(textParts, "")
+ // Debug: log if we got very little content (likely only status
messages)
+ if len(result) < 200 && strings.Contains(result, "分析问题中") {
+ // This is likely just status messages, add a marker
+ result += "[SSE_CAPTURE_INCOMPLETE: Only status messages
captured]"
+ }
+ return result
+}
+
+// validateResponse checks if response contains expected keywords
+func validateResponse(response string, expected []string) bool {
+ responseLower := strings.ToLower(response)
+ matchCount := 0
+
+ for _, keyword := range expected {
+ if strings.Contains(responseLower, strings.ToLower(keyword)) {
+ matchCount++
+ }
+ }
+
+ // Consider successful if at least half of expected keywords are found
+ return matchCount >= len(expected)/2
+}
+
+// findMatchedKeywords returns which expected keywords were found in response
+func findMatchedKeywords(response string, expected []string) []string {
+ responseLower := strings.ToLower(response)
+ var matched []string
+
+ for _, keyword := range expected {
+ if strings.Contains(responseLower, strings.ToLower(keyword)) {
+ matched = append(matched, keyword)
+ }
+ }
+
+ return matched
+}
+
+// printTestSummary prints test summary
+func printTestSummary(t *testing.T, results *TestResults) {
+ t.Log("\n" + strings.Repeat("=", 60))
+ t.Log("MULTI-TURN CONVERSATION TEST SUMMARY")
+ t.Log(strings.Repeat("=", 60))
+
+ successCount := 0
+ totalDuration := time.Duration(0)
+
+ for _, round := range results.Rounds {
+ if round.Success {
+ successCount++
+ }
+ totalDuration += round.Duration
+ }
+
+ successRate := float64(successCount) / float64(len(results.Rounds)) *
100
+ avgDuration := totalDuration / time.Duration(len(results.Rounds))
+
+ t.Logf("Session ID: %s", results.SessionID)
+ t.Logf("Total Rounds: %d", len(results.Rounds))
+ t.Logf("Success Rate: %.1f%% (%d/%d)", successRate, successCount,
len(results.Rounds))
+ t.Logf("Total Duration: %v", totalDuration)
+ t.Logf("Average Response Time: %v", avgDuration)
+
+ t.Log("\nRound-by-Round Results:")
+ for _, round := range results.Rounds {
+ status := "✓"
+ if !round.Success {
+ status = "⚠"
+ }
+ t.Logf(" %s Round %d: %s (%v) - Matched: %v",
+ status, round.RoundNumber, round.Name, round.Duration,
round.MatchedKeywords)
+ }
+
+ t.Log(strings.Repeat("=", 60))
+}
+
+// exportResultsToFile exports test results to a markdown file
+func exportResultsToFile(t *testing.T, results *TestResults) {
+ _, file, _, _ := runtime.Caller(0)
+ aiDir := filepath.Dir(filepath.Dir(filepath.Dir(file)))
+
Review Comment:
exportResultsToFile writes markdown artifacts into the repo workspace
(ai/test/results). This makes `go test ./...` non-hermetic and can create
noisy/unexpected files in CI or developer runs. Consider gating export behind
an env var (or writing to t.TempDir()).
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]