From 971f18e6e0debd945244ef2e49327cadb632071d Mon Sep 17 00:00:00 2001 From: Nomikfk1215 Date: Sun, 10 May 2026 15:40:32 +0800 Subject: [PATCH 1/3] fix: make mention indexing non-blocking --- internal/mention/index.go | 209 +++++++++++++++++++++++++++++---- internal/mention/index_test.go | 62 ++++++++++ tui/component_palettes.go | 11 +- tui/model_test.go | 21 ++++ 4 files changed, 280 insertions(+), 23 deletions(-) diff --git a/internal/mention/index.go b/internal/mention/index.go index c5cc0a29..4f97b4c4 100644 --- a/internal/mention/index.go +++ b/internal/mention/index.go @@ -2,6 +2,7 @@ package mention import ( "bufio" + "io" "io/fs" "log" "os" @@ -20,6 +21,9 @@ const ( mentionTrieRecallMultiplier = 8 mentionTrieMinRecall = 64 mentionIgnoreMaxLineBytes = 4 * 1024 * 1024 + mentionIndexSeedMaxFiles = 200 + mentionIndexSeedMaxVisits = 1000 + mentionIndexSeedMaxDepth = 2 ) var ( @@ -50,6 +54,8 @@ type IndexStats struct { MaxFiles int Truncated bool Ready bool + Building bool + Partial bool } type mentionIgnoreMatcher struct { @@ -76,6 +82,11 @@ type WorkspaceFileIndex struct { trie *mentionTrie truncated bool maxFiles int + partial bool +} + +var startMentionIndexAsyncRebuild = func(idx *WorkspaceFileIndex, root string, maxFiles int) { + go idx.rebuildStarted(root, maxFiles) } func NewWorkspaceFileIndex(workspace string) *WorkspaceFileIndex { @@ -129,7 +140,7 @@ func (idx *WorkspaceFileIndex) SearchWithRecency(query string, limit int, recenc limit = defaultSearchLimit } - idx.ensureInitialBuild() + idx.ensureSeeded() idx.ensureFreshAsync() files, trie := idx.snapshotSearchData() if len(files) == 0 { @@ -187,7 +198,7 @@ func (idx *WorkspaceFileIndex) Prewarm() { if idx == nil { return } - idx.ensureInitialBuild() + idx.rebuildBlocking() } func (idx *WorkspaceFileIndex) Stats() IndexStats { @@ -201,6 +212,8 @@ func (idx *WorkspaceFileIndex) Stats() IndexStats { MaxFiles: idx.maxFiles, Truncated: idx.truncated, Ready: idx.ready, + Building: idx.building, + Partial: idx.partial, } } @@ -215,35 +228,43 @@ func (idx *WorkspaceFileIndex) snapshotSearchData() ([]Candidate, *mentionTrie) return out, idx.trie } -func (idx *WorkspaceFileIndex) ensureInitialBuild() { +func (idx *WorkspaceFileIndex) ensureSeeded() { if idx == nil { return } idx.mu.RLock() - ready := idx.ready + hasData := idx.ready || len(idx.files) > 0 idx.mu.RUnlock() - if ready { + if hasData { return } - idx.rebuildBlocking() + + root := idx.root + maxFiles := idx.maxFiles + matcher := loadMentionIgnoreMatcher(root) + files, truncated := buildMentionSeed(root, maxFiles, matcher) + + idx.mu.Lock() + defer idx.mu.Unlock() + if idx.ready || len(idx.files) > 0 { + return + } + idx.files = files + idx.trie = newMentionTrie(files) + idx.truncated = truncated + idx.partial = len(files) > 0 } func (idx *WorkspaceFileIndex) ensureFreshAsync() { if idx == nil { return } - idx.mu.RLock() - stale := idx.shouldRebuildLocked() - ready := idx.ready - building := idx.building - idx.mu.RUnlock() + idx.mu.Lock() + root, maxFiles, ok := idx.startRebuildLocked() + idx.mu.Unlock() - if !ready { - idx.rebuildBlocking() - return - } - if stale && !building { - go idx.rebuildBlocking() + if ok { + startMentionIndexAsyncRebuild(idx, root, maxFiles) } } @@ -252,15 +273,24 @@ func (idx *WorkspaceFileIndex) rebuildBlocking() { return } idx.mu.Lock() - if idx.building || !idx.shouldRebuildLocked() { - idx.mu.Unlock() + root, maxFiles, ok := idx.startRebuildLocked() + idx.mu.Unlock() + if !ok { return } + + idx.rebuildStarted(root, maxFiles) +} + +func (idx *WorkspaceFileIndex) startRebuildLocked() (root string, maxFiles int, ok bool) { + if idx == nil || idx.building || !idx.shouldRebuildLocked() { + return "", 0, false + } idx.building = true - root := idx.root - maxFiles := idx.maxFiles - idx.mu.Unlock() + return idx.root, idx.maxFiles, true +} +func (idx *WorkspaceFileIndex) rebuildStarted(root string, maxFiles int) { matcher := loadMentionIgnoreMatcher(root) files, truncated := buildMentionIndex(root, maxFiles, matcher) @@ -269,6 +299,7 @@ func (idx *WorkspaceFileIndex) rebuildBlocking() { idx.trie = newMentionTrie(files) idx.truncated = truncated idx.ready = true + idx.partial = false idx.lastBuild = time.Now() idx.building = false idx.mu.Unlock() @@ -380,6 +411,140 @@ func buildMentionIndex(root string, maxFiles int, matcher mentionIgnoreMatcher) return files, truncated } +func buildMentionSeed(root string, maxFiles int, matcher mentionIgnoreMatcher) ([]Candidate, bool) { + root = strings.TrimSpace(root) + if root == "" { + return nil, false + } + if maxFiles <= 0 { + maxFiles = mentionIndexDefaultMaxFiles + } + limit := minInt(maxFiles, mentionIndexSeedMaxFiles) + if limit <= 0 { + return nil, false + } + + maxVisits := minInt(mentionMaxVisitsFromEnv(), mentionIndexSeedMaxVisits) + maxDirs := mentionMaxDirsFromEnv() + maxDuration := mentionMaxDurationFromEnv() + started := time.Now() + + type pendingDir struct { + abs string + rel string + depth int + } + + files := make([]Candidate, 0, minInt(limit, 64)) + queue := []pendingDir{{abs: root}} + truncated := false + visits := 0 + dirs := 0 + + for len(queue) > 0 && len(files) < limit { + current := queue[0] + queue = queue[1:] + + remainingVisits := maxVisits - visits + if remainingVisits <= 0 { + truncated = true + break + } + + entries, hitReadLimit := readMentionSeedDir(current.abs, remainingVisits) + if hitReadLimit { + truncated = true + } + + for _, entry := range entries { + if maxDuration > 0 && time.Since(started) > maxDuration { + truncated = true + break + } + visits++ + if visits > maxVisits { + truncated = true + break + } + + name := entry.Name() + rel := name + if current.rel != "" { + rel = filepath.ToSlash(filepath.Join(current.rel, name)) + } + + if entry.IsDir() { + dirs++ + if dirs > maxDirs { + truncated = true + break + } + if shouldSkipMentionDir(name) || matcher.SkipDir(name, rel) { + continue + } + files = append(files, Candidate{ + Path: rel + "/", + BaseName: name, + Kind: "dir", + }) + if len(files) >= limit { + truncated = true + break + } + if current.depth+1 < mentionIndexSeedMaxDepth { + queue = append(queue, pendingDir{ + abs: filepath.Join(current.abs, name), + rel: rel, + depth: current.depth + 1, + }) + } + continue + } + if entry.Type()&fs.ModeSymlink != 0 { + continue + } + if shouldSkipMentionFile(name) || matcher.SkipFile(name, rel) { + continue + } + files = append(files, Candidate{ + Path: rel, + BaseName: filepath.Base(rel), + TypeTag: mentionTypeTag(rel), + }) + if len(files) >= limit { + truncated = true + break + } + } + } + + sort.Slice(files, func(i, j int) bool { + return files[i].Path < files[j].Path + }) + return files, truncated +} + +func readMentionSeedDir(dir string, limit int) ([]fs.DirEntry, bool) { + if limit <= 0 { + return nil, true + } + f, err := os.Open(dir) + if err != nil { + return nil, false + } + defer f.Close() + + entries, err := f.ReadDir(limit) + hitLimit := err == nil && len(entries) == limit + if err != nil && err != io.EOF { + return nil, false + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + return entries, hitLimit +} + func mentionCandidateIndices(files []Candidate, trie *mentionTrie, query string, limit int) ([]int, map[int]struct{}) { if len(files) == 0 { return nil, nil diff --git a/internal/mention/index_test.go b/internal/mention/index_test.go index 0e7dbefc..6e03934c 100644 --- a/internal/mention/index_test.go +++ b/internal/mention/index_test.go @@ -188,6 +188,68 @@ func TestWorkspaceFileIndexSearchWithRecencyPrioritizesRecent(t *testing.T) { } } +func TestWorkspaceFileIndexColdSearchUsesSeedAndSchedulesRebuild(t *testing.T) { + workspace := t.TempDir() + mustWriteMentionFile(t, filepath.Join(workspace, "shallow.go"), "package main") + mustWriteMentionFile(t, filepath.Join(workspace, "deep", "a", "b", "target.go"), "package main") + + oldStart := startMentionIndexAsyncRebuild + calls := 0 + startMentionIndexAsyncRebuild = func(_ *WorkspaceFileIndex, _ string, _ int) { + calls++ + } + t.Cleanup(func() { + startMentionIndexAsyncRebuild = oldStart + }) + + index := NewWorkspaceFileIndex(workspace) + results := index.Search("shallow", 10) + if len(results) == 0 || results[0].Path != "shallow.go" { + t.Fatalf("expected cold search to return seed result shallow.go, got %#v", results) + } + if calls != 1 { + t.Fatalf("expected cold search to schedule async rebuild once, got %d", calls) + } + stats := index.Stats() + if !stats.Partial || !stats.Building || stats.Ready { + t.Fatalf("expected partial building stats before full rebuild, got %+v", stats) + } + + files, _ := index.snapshotSearchData() + paths := make([]string, 0, len(files)) + for _, item := range files { + paths = append(paths, item.Path) + } + if containsString(paths, "deep/a/b/target.go") { + t.Fatalf("expected seed index not to include deep target file, got %v", paths) + } +} + +func TestWorkspaceFileIndexAsyncRebuildReplacesSeed(t *testing.T) { + workspace := t.TempDir() + mustWriteMentionFile(t, filepath.Join(workspace, "shallow.go"), "package main") + mustWriteMentionFile(t, filepath.Join(workspace, "deep", "a", "b", "target.go"), "package main") + + index := NewWorkspaceFileIndex(workspace) + _ = index.Search("shallow", 10) + + deadline := time.Now().Add(800 * time.Millisecond) + for { + results := index.Search("target", 10) + if len(results) > 0 && results[0].Path == "deep/a/b/target.go" { + stats := index.Stats() + if !stats.Ready || stats.Partial { + t.Fatalf("expected full index stats after async rebuild, got %+v", stats) + } + break + } + if time.Now().After(deadline) { + t.Fatalf("expected async rebuild to find deep target file") + } + time.Sleep(20 * time.Millisecond) + } +} + func TestWorkspaceFileIndexUsesTriePrefixRecall(t *testing.T) { idx := NewStaticWorkspaceFileIndex([]Candidate{ {Path: "README.md"}, diff --git a/tui/component_palettes.go b/tui/component_palettes.go index df13ee1d..4b5d6654 100644 --- a/tui/component_palettes.go +++ b/tui/component_palettes.go @@ -157,8 +157,17 @@ func (m model) renderMentionPalette() string { metaText := "* recent + file/dir * agent Type @query Up/Down Enter/Tab insert Esc close" if m.mentionIndex != nil { stats := m.mentionIndex.Stats() - if stats.Truncated && stats.MaxFiles > 0 { + switch { + case stats.Partial && stats.Building: + metaText = "* recent indexing... showing partial results Enter/Tab insert Esc close" + case stats.Partial: + metaText = "* recent showing partial results Enter/Tab insert Esc close" + case stats.Building: + metaText = "* recent refreshing index... Enter/Tab insert Esc close" + case stats.Truncated && stats.MaxFiles > 0: metaText = fmt.Sprintf("* recent indexed first %d files Enter/Tab insert Esc close", stats.MaxFiles) + case stats.Ready: + metaText = "* recent index ready Type @query Up/Down Enter/Tab insert Esc close" } } rows = append(rows, commandPaletteMetaStyle.Render(metaText)) diff --git a/tui/model_test.go b/tui/model_test.go index 9d6d7274..75a76385 100644 --- a/tui/model_test.go +++ b/tui/model_test.go @@ -5261,6 +5261,27 @@ func TestRenderMentionPaletteShowsTruncatedMeta(t *testing.T) { } } +func TestRenderMentionPaletteShowsReadyMeta(t *testing.T) { + index := mention.NewStaticWorkspaceFileIndex([]mention.Candidate{ + {Path: "a.go", BaseName: "a.go", TypeTag: "go"}, + }, 0, false) + + m := model{ + screen: screenChat, + width: 100, + mentionOpen: true, + mentionResults: []mention.Candidate{ + {Path: "a.go", BaseName: "a.go", TypeTag: "go"}, + }, + mentionIndex: index, + } + + view := m.renderMentionPalette() + if !strings.Contains(view, "index ready") { + t.Fatalf("expected mention palette to show ready hint, got %q", view) + } +} + func TestCommandPaletteAllowsTypingJKWhenOpen(t *testing.T) { input := textarea.New() input.Focus() From 4ff413e5c383de954677d480702c2a98d888bc67 Mon Sep 17 00:00:00 2001 From: Nomikfk1215 Date: Sun, 10 May 2026 17:09:40 +0800 Subject: [PATCH 2/3] fix: emit progress events during reasoning_content streaming to prevent TUI stuck on thinking DeepSeek-compatible models stream reasoning_content chunks before producing any text content or tool calls, which could last minutes. The OpenAI provider silently accumulated these into Message.Meta without notifying the TUI, leaving the UI stuck on thinking with no progress indication. Add EventThinkingProgress agent/TUI event type that carries only non-sensitive metadata (charCount, active) - never the reasoning text. - llm/types.go: add OnStreamProgress callback to ChatRequest - agent/events.go: add EventThinkingProgress and fields - provider/openai.go: fire OnStreamProgress on reasoning_content chunks - agent/completion_runtime.go: wire callback to emit progress events - tui: update thinking card with receiving hidden reasoning note - tests: verify reasoning_content triggers progress, never leaks to text Co-Authored-By: Claude Opus 4.7 --- internal/agent/completion_runtime.go | 8 +++ internal/agent/events.go | 5 ++ internal/app/tui_adapter.go | 30 ++++++---- internal/llm/types.go | 7 +++ internal/provider/openai.go | 9 +++ internal/provider/openai_test.go | 62 +++++++++++++++++++ tui/component_chat_stream.go | 21 +++++++ tui/component_chat_stream_test.go | 90 ++++++++++++++++++++++++++++ tui/component_run_flow.go | 8 +++ tui/model.go | 1 + tui/ports.go | 5 ++ 11 files changed, 233 insertions(+), 13 deletions(-) diff --git a/internal/agent/completion_runtime.go b/internal/agent/completion_runtime.go index 5061cb36..fe06ce89 100644 --- a/internal/agent/completion_runtime.go +++ b/internal/agent/completion_runtime.go @@ -21,6 +21,14 @@ func (e *defaultEngine) completeTurn(ctx context.Context, request llm.ChatReques return runner.client.CreateMessage(ctx, request) } + request.OnStreamProgress = func(charCount int, active bool) { + runner.emit(Event{ + Type: EventThinkingProgress, + ReasoningCharCount: charCount, + ReasoningActive: active, + }) + } + reply, err := runner.client.StreamMessage(ctx, request, func(delta string) { if delta != "" { emitTurnEvent(ctx, TurnEvent{ diff --git a/internal/agent/events.go b/internal/agent/events.go index 447516ee..0f1f5f3a 100644 --- a/internal/agent/events.go +++ b/internal/agent/events.go @@ -17,6 +17,7 @@ const ( EventPlanUpdated EventType = "plan_updated" EventUsageUpdated EventType = "usage_updated" EventRunFinished EventType = "run_finished" + EventThinkingProgress EventType = "thinking_progress" ) type Event struct { @@ -33,6 +34,10 @@ type Event struct { Usage llm.Usage AgentID string // non-empty when emitted by a subagent InvocationID string // non-empty when emitted by a subagent, globally unique per invocation + + // EventThinkingProgress fields — reasoning progress without exposing content. + ReasoningCharCount int + ReasoningActive bool } type Observer interface { diff --git a/internal/app/tui_adapter.go b/internal/app/tui_adapter.go index 48313003..08b8b1aa 100644 --- a/internal/app/tui_adapter.go +++ b/internal/app/tui_adapter.go @@ -190,19 +190,21 @@ func (a *tuiRunnerAdapter) DispatchSubAgent( func mapAgentEvent(event agent.Event) tui.Event { return tui.Event{ - Type: mapAgentEventType(event.Type), - SessionID: string(event.SessionID), - UserInput: event.UserInput, - Content: event.Content, - ToolName: event.ToolName, - ToolCallID: event.ToolCallID, - ToolArguments: event.ToolArguments, - ToolResult: event.ToolResult, - Error: event.Error, - Plan: event.Plan, - Usage: event.Usage, - AgentID: event.AgentID, - InvocationID: event.InvocationID, + Type: mapAgentEventType(event.Type), + SessionID: string(event.SessionID), + UserInput: event.UserInput, + Content: event.Content, + ToolName: event.ToolName, + ToolCallID: event.ToolCallID, + ToolArguments: event.ToolArguments, + ToolResult: event.ToolResult, + Error: event.Error, + Plan: event.Plan, + Usage: event.Usage, + AgentID: event.AgentID, + InvocationID: event.InvocationID, + ReasoningCharCount: event.ReasoningCharCount, + ReasoningActive: event.ReasoningActive, } } @@ -224,6 +226,8 @@ func mapAgentEventType(value agent.EventType) tui.EventType { return tui.EventUsageUpdated case agent.EventRunFinished: return tui.EventRunFinished + case agent.EventThinkingProgress: + return tui.EventThinkingProgress default: return tui.EventType(value) } diff --git a/internal/llm/types.go b/internal/llm/types.go index fa67e235..7bc329ce 100644 --- a/internal/llm/types.go +++ b/internal/llm/types.go @@ -341,6 +341,13 @@ type ChatRequest struct { Tools []ToolDefinition Assets map[AssetID]ImageAsset Temperature float64 + + // OnStreamProgress is called during streaming when the provider receives + // non-content progress updates (e.g. reasoning_content chunks from + // DeepSeek-style models). charCount is the cumulative number of + // characters received so far; active is true while reasoning is still + // streaming. + OnStreamProgress func(charCount int, active bool) } type Client interface { diff --git a/internal/provider/openai.go b/internal/provider/openai.go index 27f5cbbc..4804b657 100644 --- a/internal/provider/openai.go +++ b/internal/provider/openai.go @@ -196,6 +196,9 @@ func (c *OpenAICompatible) StreamMessage(ctx context.Context, req llm.ChatReques } if delta.Reasoning != "" { appendOpenAIReasoningContent(&assembled, delta.Reasoning) + if req.OnStreamProgress != nil { + req.OnStreamProgress(len(openAIReasoningContent(assembled)), true) + } } if delta.Content != "" { assembled.Content += delta.Content @@ -261,6 +264,12 @@ func (c *OpenAICompatible) StreamMessage(ctx context.Context, req llm.ChatReques } assembled.Normalize() + if req.OnStreamProgress != nil { + reasoningChars := len(openAIReasoningContent(assembled)) + if reasoningChars > 0 { + req.OnStreamProgress(reasoningChars, false) + } + } return assembled, nil } diff --git a/internal/provider/openai_test.go b/internal/provider/openai_test.go index 2dcf7935..85b737e9 100644 --- a/internal/provider/openai_test.go +++ b/internal/provider/openai_test.go @@ -616,6 +616,68 @@ func TestOpenAIMessagesDoesNotSendReasoningToProviderWithoutReasoningContent(t * } } +func TestOpenAICompatibleStreamMessageFiresProgressCallbackForReasoningContent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(strings.Join([]string{ + `data: {"choices":[{"delta":{"role":"assistant","reasoning_content":"thinking step 1"}}]}`, + `data: {"choices":[{"delta":{"reasoning_content":" step 2"}}]}`, + `data: {"choices":[{"delta":{"content":"actual answer"}}]}`, + `data: [DONE]`, + "", + }, "\n"))) + })) + defer server.Close() + + client := NewOpenAICompatible(Config{BaseURL: server.URL, APIKey: "test-key", Model: "fallback-model"}) + + var progressCalls []struct { + charCount int + active bool + } + req := llm.ChatRequest{ + OnStreamProgress: func(charCount int, active bool) { + progressCalls = append(progressCalls, struct { + charCount int + active bool + }{charCount, active}) + }, + } + msg, err := client.StreamMessage(context.Background(), req, nil) + if err != nil { + t.Fatal(err) + } + + // Reasoning should trigger at least 2 progress calls (one per reasoning delta). + if len(progressCalls) < 2 { + t.Fatalf("expected at least 2 progress calls for reasoning chunks, got %d", len(progressCalls)) + } + + // All intermediate calls should be active=true. + for i, call := range progressCalls[:len(progressCalls)-1] { + if !call.active { + t.Fatalf("progress call[%d]: expected active=true, got false", i) + } + } + + // The final call (after streaming loop) should be active=false. + last := progressCalls[len(progressCalls)-1] + if last.active { + t.Fatalf("final progress call: expected active=false after stream ends, got true") + } + if last.charCount <= 0 { + t.Fatalf("final progress call: expected positive charCount, got %d", last.charCount) + } + + // Reasoning content must NOT appear in assembled message content. + if msg.Content != "actual answer" { + t.Fatalf("expected message content to exclude reasoning, got %q", msg.Content) + } + if got := openAIReasoningContent(msg); got != "thinking step 1 step 2" { + t.Fatalf("expected reasoning in Meta, got %q", got) + } +} + func TestOpenAIMessagesDegradesMissingImageAsset(t *testing.T) { messages, err := openAIMessages(llm.ChatRequest{ Messages: []llm.Message{{ diff --git a/tui/component_chat_stream.go b/tui/component_chat_stream.go index 0173d7aa..29c1970a 100644 --- a/tui/component_chat_stream.go +++ b/tui/component_chat_stream.go @@ -271,6 +271,8 @@ func (m *model) updateThinkingCard() { item.Status = "thinking" if strings.TrimSpace(item.Body) == "" { item.Body = m.thinkingText() + } else if m.reasoningProgressActive { + item.Body = "receiving hidden reasoning..." } } @@ -296,6 +298,25 @@ func (m *model) ensureThinkingCard() { m.streamingIndex = len(m.chatItems) - 1 } +func (m *model) applyReasoningProgress(_ int, active bool) { + m.reasoningProgressActive = active + m.ensureThinkingCard() + if m.streamingIndex < 0 || m.streamingIndex >= len(m.chatItems) { + return + } + item := &m.chatItems[m.streamingIndex] + if item.Kind != "assistant" || (item.Status != "pending" && item.Status != "thinking") { + return + } + item.Title = thinkingLabel + item.Status = "thinking" + if active { + item.Body = "receiving hidden reasoning..." + } else { + item.Body = m.thinkingText() + } +} + func (m *model) failLatestAssistant(errText string) { errText = strings.TrimSpace(errText) if errText == "" { diff --git a/tui/component_chat_stream_test.go b/tui/component_chat_stream_test.go index a0b00201..5fe7cdc0 100644 --- a/tui/component_chat_stream_test.go +++ b/tui/component_chat_stream_test.go @@ -943,6 +943,96 @@ func TestHandleAgentEventPlanUpdatedCopiesPlan(t *testing.T) { } } +func TestHandleAgentEventThinkingProgressCreatesThinkingCard(t *testing.T) { + m := model{ + phase: "thinking", + spinner: spinner.New(), + } + m.handleAgentEvent(Event{ + Type: EventThinkingProgress, + ReasoningCharCount: 150, + ReasoningActive: true, + }) + + if !m.reasoningProgressActive { + t.Fatal("expected reasoningProgressActive to be true after progress event") + } + if m.phase != "thinking" { + t.Fatalf("expected thinking phase, got %q", m.phase) + } + if len(m.chatItems) != 1 { + t.Fatalf("expected 1 chat item (thinking card), got %d", len(m.chatItems)) + } + item := m.chatItems[0] + if item.Kind != "assistant" { + t.Fatalf("expected assistant kind, got %q", item.Kind) + } + if item.Status != "thinking" { + t.Fatalf("expected thinking status, got %q", item.Status) + } + if !strings.Contains(item.Body, "receiving hidden reasoning") { + t.Fatalf("expected reasoning progress note in body, got %q", item.Body) + } + // Reasoning content must NOT appear in the body. + if strings.Contains(item.Body, "step") { + t.Fatalf("body must not contain reasoning content, got %q", item.Body) + } +} + +func TestHandleAgentEventThinkingProgressInactiveRemovesNote(t *testing.T) { + m := model{ + phase: "thinking", + reasoningProgressActive: true, + spinner: spinner.New(), + } + m.handleAgentEvent(Event{ + Type: EventThinkingProgress, + ReasoningCharCount: 300, + ReasoningActive: false, + }) + + if m.reasoningProgressActive { + t.Fatal("expected reasoningProgressActive to be false after inactive event") + } + if len(m.chatItems) != 1 { + t.Fatalf("expected thinking card, got %d items", len(m.chatItems)) + } + if strings.Contains(m.chatItems[0].Body, "receiving hidden reasoning") { + t.Fatalf("expected reasoning note removed, got %q", m.chatItems[0].Body) + } +} + +func TestHandleAgentEventAssistantDeltaClearsReasoningProgress(t *testing.T) { + m := model{ + phase: "thinking", + reasoningProgressActive: true, + } + m.handleAgentEvent(Event{Type: EventAssistantDelta, Content: "Here is the answer."}) + + if m.reasoningProgressActive { + t.Fatal("expected reasoningProgressActive to be cleared by assistant delta") + } + if m.phase != "responding" { + t.Fatalf("expected responding phase, got %q", m.phase) + } +} + +func TestHandleAgentEventToolCallStartedClearsReasoningProgress(t *testing.T) { + m := model{ + phase: "thinking", + reasoningProgressActive: true, + chatItems: []chatEntry{ + {Kind: "assistant", Title: thinkingLabel, Body: "thinking...", Status: "thinking"}, + }, + streamingIndex: 0, + } + m.handleAgentEvent(Event{Type: EventToolCallStarted, ToolName: "list_files"}) + + if m.reasoningProgressActive { + t.Fatal("expected reasoningProgressActive to be cleared by tool call start") + } +} + // --- Rate limit error scenarios (from screenshot) --- func TestRunFailedWithToolResultErrorContent(t *testing.T) { diff --git a/tui/component_run_flow.go b/tui/component_run_flow.go index aa17fe53..e3fffd13 100644 --- a/tui/component_run_flow.go +++ b/tui/component_run_flow.go @@ -35,6 +35,7 @@ func (m *model) beginRunWithInput(promptInput RunPromptInput, mode, note string) } m.statusNote = note m.phase = "thinking" + m.reasoningProgressActive = false m.llmConnected = true m.busy = true m.runStartedAt = time.Now() @@ -210,7 +211,13 @@ func (m *model) handleAgentEvent(event Event) { case EventRunStarted: m.tempEstimatedOutput = 0 m.lastTokenReceivedAt = time.Now() + case EventThinkingProgress: + m.phase = "thinking" + m.llmConnected = true + m.lastTokenReceivedAt = time.Now() + m.applyReasoningProgress(event.ReasoningCharCount, event.ReasoningActive) case EventAssistantDelta: + m.reasoningProgressActive = false m.phase = "responding" m.statusNote = "LLM is responding..." m.llmConnected = true @@ -222,6 +229,7 @@ func (m *model) handleAgentEvent(event Event) { m.lastTokenReceivedAt = time.Now() m.finishAssistantMessage(event.Content) case EventToolCallStarted: + m.reasoningProgressActive = false m.phase = "tool" m.llmConnected = true m.lastTokenReceivedAt = time.Now() diff --git a/tui/model.go b/tui/model.go index 3be86cd9..94469b6d 100644 --- a/tui/model.go +++ b/tui/model.go @@ -415,6 +415,7 @@ type model struct { runIndicatorState runIndicatorState streamingIndex int suppressedAssistantDelta string + reasoningProgressActive bool statusNote string phase string llmConnected bool diff --git a/tui/ports.go b/tui/ports.go index ca381a99..a32420c2 100644 --- a/tui/ports.go +++ b/tui/ports.go @@ -25,6 +25,7 @@ const ( EventPlanUpdated EventType = "plan_updated" EventUsageUpdated EventType = "usage_updated" EventRunFinished EventType = "run_finished" + EventThinkingProgress EventType = "thinking_progress" ) type Event struct { @@ -41,6 +42,10 @@ type Event struct { Usage llm.Usage AgentID string // non-empty when emitted by a subagent InvocationID string // non-empty when emitted by a subagent, globally unique per invocation + + // EventThinkingProgress fields — reasoning progress without exposing content. + ReasoningCharCount int + ReasoningActive bool } type ApprovalRequest struct { From c44a35d0e59c247c16a950946e8e3cbe80c7f95c Mon Sep 17 00:00:00 2001 From: Nomikfk1215 Date: Sun, 10 May 2026 23:50:25 +0800 Subject: [PATCH 3/3] test: add coverage for reasoning_content progress event pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the remaining uncovered branches in completion_runtime.go (0% → covered), tui_adapter.go (EventThinkingProgress mapping), and component_chat_stream.go (updateThinkingCard reasoning note + applyReasoningProgress edge cases). - agent: test that completeTurn wires OnStreamProgress and emits EventThinkingProgress - app: test mapAgentEvent maps ReasoningCharCount/ReasoningActive and mapAgentEventType handles EventThinkingProgress - tui: test updateThinkingCard preserves reasoning note; test applyReasoningProgress skips non-thinking items Co-Authored-By: Claude Opus 4.7 --- internal/agent/runner_usage_stream_test.go | 65 ++++++++++++++++++++++ internal/app/tui_adapter_test.go | 28 ++++++---- tui/component_chat_stream.go | 18 +++--- tui/component_chat_stream_test.go | 29 ++++++++++ 4 files changed, 120 insertions(+), 20 deletions(-) diff --git a/internal/agent/runner_usage_stream_test.go b/internal/agent/runner_usage_stream_test.go index a0d65b31..181f56fa 100644 --- a/internal/agent/runner_usage_stream_test.go +++ b/internal/agent/runner_usage_stream_test.go @@ -78,3 +78,68 @@ func TestRunPromptEmitsUsageEventForStreamingReplyUsage(t *testing.T) { t.Fatalf("expected exactly 1 usage event, got %d", usageEvents) } } + +type reasoningProgressStreamClient struct { + reply llm.Message +} + +func (c *reasoningProgressStreamClient) CreateMessage(context.Context, llm.ChatRequest) (llm.Message, error) { + return c.reply, nil +} + +func (c *reasoningProgressStreamClient) StreamMessage(_ context.Context, req llm.ChatRequest, onDelta func(string)) (llm.Message, error) { + if req.OnStreamProgress != nil { + req.OnStreamProgress(50, true) + req.OnStreamProgress(100, false) + } + if onDelta != nil && strings.TrimSpace(c.reply.Content) != "" { + onDelta(c.reply.Content) + } + return c.reply, nil +} + +func TestCompleteTurnEmitsThinkingProgressViaOnStreamProgress(t *testing.T) { + workspace := t.TempDir() + store, err := session.NewStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sess := session.New(workspace) + + var events []Event + runner := NewRunner(Options{ + Workspace: workspace, + Config: config.Config{ + Provider: config.ProviderConfig{Model: "test-model"}, + MaxIterations: 1, + Stream: true, + }, + Client: &reasoningProgressStreamClient{reply: llm.Message{ + Role: llm.RoleAssistant, + Content: "final answer", + }}, + Store: store, + Registry: tools.DefaultRegistry(), + Observer: ObserverFunc(func(event Event) { events = append(events, event) }), + Stdin: strings.NewReader(""), + Stdout: io.Discard, + }) + + if _, err := runner.RunPrompt(context.Background(), sess, "hello", "build", io.Discard); err != nil { + t.Fatal(err) + } + + progressEvents := 0 + for _, event := range events { + if event.Type != EventThinkingProgress { + continue + } + progressEvents++ + if event.ReasoningCharCount != 50 && event.ReasoningCharCount != 100 { + t.Fatalf("unexpected reasoning char count: %d", event.ReasoningCharCount) + } + } + if progressEvents != 2 { + t.Fatalf("expected exactly 2 thinking progress events, got %d", progressEvents) + } +} diff --git a/internal/app/tui_adapter_test.go b/internal/app/tui_adapter_test.go index d8168a75..17fe765b 100644 --- a/internal/app/tui_adapter_test.go +++ b/internal/app/tui_adapter_test.go @@ -167,16 +167,18 @@ func TestTUIRunnerAdapterNilGuardMethods(t *testing.T) { func TestMapAgentEventAndType(t *testing.T) { source := agent.Event{ - Type: agent.EventToolCallCompleted, - SessionID: "sess-1", - UserInput: "/review check changes", - Content: "done", - ToolName: "delegate_subagent", - ToolArguments: `{"agent":"review"}`, - ToolResult: `{"ok":true}`, - Error: "none", - Plan: planpkg.State{}, - Usage: llm.Usage{InputTokens: 12, OutputTokens: 34}, + Type: agent.EventToolCallCompleted, + SessionID: "sess-1", + UserInput: "/review check changes", + Content: "done", + ToolName: "delegate_subagent", + ToolArguments: `{"agent":"review"}`, + ToolResult: `{"ok":true}`, + Error: "none", + Plan: planpkg.State{}, + Usage: llm.Usage{InputTokens: 12, OutputTokens: 34}, + ReasoningCharCount: 200, + ReasoningActive: true, } mapped := mapAgentEvent(source) @@ -189,10 +191,16 @@ func TestMapAgentEventAndType(t *testing.T) { if mapped.Usage.InputTokens != 12 || mapped.Usage.OutputTokens != 34 { t.Fatalf("expected mapped usage to be preserved, got %#v", mapped.Usage) } + if mapped.ReasoningCharCount != 200 || !mapped.ReasoningActive { + t.Fatalf("expected reasoning progress fields to be mapped, got charCount=%d active=%v", mapped.ReasoningCharCount, mapped.ReasoningActive) + } if got := mapAgentEventType(agent.EventRunStarted); got != tui.EventRunStarted { t.Fatalf("expected run-started mapping, got %q", got) } + if got := mapAgentEventType(agent.EventThinkingProgress); got != tui.EventThinkingProgress { + t.Fatalf("expected thinking-progress mapping, got %q", got) + } const unknown = agent.EventType("custom") if got := mapAgentEventType(unknown); got != tui.EventType(unknown) { t.Fatalf("expected fallback mapping for unknown event type, got %q", got) diff --git a/tui/component_chat_stream.go b/tui/component_chat_stream.go index 29c1970a..3f850dd1 100644 --- a/tui/component_chat_stream.go +++ b/tui/component_chat_stream.go @@ -304,16 +304,14 @@ func (m *model) applyReasoningProgress(_ int, active bool) { if m.streamingIndex < 0 || m.streamingIndex >= len(m.chatItems) { return } - item := &m.chatItems[m.streamingIndex] - if item.Kind != "assistant" || (item.Status != "pending" && item.Status != "thinking") { - return - } - item.Title = thinkingLabel - item.Status = "thinking" - if active { - item.Body = "receiving hidden reasoning..." - } else { - item.Body = m.thinkingText() + if item := &m.chatItems[m.streamingIndex]; item.Kind == "assistant" && (item.Status == "pending" || item.Status == "thinking") { + item.Title = thinkingLabel + item.Status = "thinking" + if active { + item.Body = "receiving hidden reasoning..." + } else { + item.Body = m.thinkingText() + } } } diff --git a/tui/component_chat_stream_test.go b/tui/component_chat_stream_test.go index 5fe7cdc0..e9718470 100644 --- a/tui/component_chat_stream_test.go +++ b/tui/component_chat_stream_test.go @@ -1033,6 +1033,35 @@ func TestHandleAgentEventToolCallStartedClearsReasoningProgress(t *testing.T) { } } +func TestUpdateThinkingCardPreservesReasoningNote(t *testing.T) { + m := model{ + busy: true, + streamingIndex: 0, + reasoningProgressActive: true, + chatItems: []chatEntry{ + {Kind: "assistant", Title: thinkingLabel, Body: "old body", Status: "thinking"}, + }, + } + m.updateThinkingCard() + + if m.chatItems[0].Body != "receiving hidden reasoning..." { + t.Fatalf("expected reasoning note to be preserved, got %q", m.chatItems[0].Body) + } +} + +func TestApplyReasoningProgressSkipsWhenItemIsNotThinkingAssistant(t *testing.T) { + m := model{ + streamingIndex: 0, + chatItems: []chatEntry{ + {Kind: "tool", Title: "TOOL | list_files", Body: "done", Status: "done"}, + }, + } + m.applyReasoningProgress(100, true) + if m.chatItems[0].Body != "done" { + t.Fatalf("expected tool item to be untouched, got %q", m.chatItems[0].Body) + } +} + // --- Rate limit error scenarios (from screenshot) --- func TestRunFailedWithToolResultErrorContent(t *testing.T) {