From 9ef4418f8f9a30eb60dfb82c88a92fcddfb37d38 Mon Sep 17 00:00:00 2001 From: toki Date: Mon, 1 Jun 2026 21:50:13 +0900 Subject: [PATCH] fix(node): suppress previous assistant messages in Claude TUI output filter Add baselineAssistant tracking to prevent repainted/stale assistant messages from being emitted when a new prompt is being entered. Introduce latestClaudeAssistantMessageAfterPromptFromCleanOutput to detect assistant messages that appear after the current prompt echo, and isClaudePromptEchoLine to recognize prompt echo lines. Add tests for suppression of previous replies while prompt echoes and for allowing same text after current prompt. --- apps/node/internal/adapters/cli/persistent.go | 6 +- .../adapters/cli/persistent_output_filter.go | 100 ++++++++++++++++-- .../cli/persistent_output_filter_test.go | 29 ++++- 3 files changed, 125 insertions(+), 10 deletions(-) diff --git a/apps/node/internal/adapters/cli/persistent.go b/apps/node/internal/adapters/cli/persistent.go index db48b40..807cc89 100644 --- a/apps/node/internal/adapters/cli/persistent.go +++ b/apps/node/internal/adapters/cli/persistent.go @@ -83,8 +83,12 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec, idleTimeout = 1500 * time.Millisecond } targetName := cliTargetName(spec) - outputFilter := newPersistentOutputFilter(targetName, profile, prompt) waitForFilteredMessage := profile.Terminal && isClaudeTerminalProfile(targetName, profile) + baselineAssistant := "" + if waitForFilteredMessage { + baselineAssistant, _ = latestClaudeAssistantMessageFromCleanOutput(cleanClaudeTerminalOutput(sess.getTail())) + } + outputFilter := newPersistentOutputFilter(targetName, profile, prompt, baselineAssistant) _ = sink.Emit(ctx, runtime.RuntimeEvent{ RunID: spec.RunID, diff --git a/apps/node/internal/adapters/cli/persistent_output_filter.go b/apps/node/internal/adapters/cli/persistent_output_filter.go index d8ff01d..742debc 100644 --- a/apps/node/internal/adapters/cli/persistent_output_filter.go +++ b/apps/node/internal/adapters/cli/persistent_output_filter.go @@ -35,9 +35,9 @@ func (passthroughOutputFilter) CompletionMessage() string { return "" } -func newPersistentOutputFilter(target string, profile config.CLIProfileConf, prompt string) persistentOutputFilter { +func newPersistentOutputFilter(target string, profile config.CLIProfileConf, prompt string, baselineAssistant string) persistentOutputFilter { if profile.Terminal && isClaudeTerminalProfile(target, profile) { - return newClaudeTUIOutputFilter(prompt) + return newClaudeTUIOutputFilter(prompt, baselineAssistant) } return passthroughOutputFilter{} } @@ -50,10 +50,11 @@ func isClaudeTerminalProfile(target string, profile config.CLIProfileConf) bool } type claudeTUIOutputFilter struct { - raw strings.Builder - prompt string - emitted string - seenAssistant bool + raw strings.Builder + prompt string + baselineAssistant string + emitted string + seenAssistant bool } type claudeMessageSnapshot struct { @@ -61,8 +62,12 @@ type claudeMessageSnapshot struct { closed bool } -func newClaudeTUIOutputFilter(prompt string) *claudeTUIOutputFilter { - return &claudeTUIOutputFilter{prompt: strings.TrimSpace(prompt)} +func newClaudeTUIOutputFilter(prompt string, baselineAssistant ...string) *claudeTUIOutputFilter { + filter := &claudeTUIOutputFilter{prompt: strings.TrimSpace(prompt)} + if len(baselineAssistant) > 0 { + filter.baselineAssistant = strings.TrimSpace(baselineAssistant[0]) + } + return filter } func (f *claudeTUIOutputFilter) Filter(chunk string) string { @@ -93,6 +98,13 @@ func (f *claudeTUIOutputFilter) CompletionMessage() string { func (f *claudeTUIOutputFilter) currentMessage() claudeMessageSnapshot { screen := status.RenderVisibleScreen(f.raw.String(), terminalRows, terminalCols) clean := cleanClaudeTerminalOutput(f.raw.String()) + if promptMessage, promptClosed, promptSeen := latestClaudeAssistantMessageAfterPromptFromCleanOutput(clean, f.prompt); promptSeen { + if promptMessage == "" || isClaudePromptEchoMessage(promptMessage, f.prompt) { + return claudeMessageSnapshot{} + } + f.seenAssistant = true + return claudeMessageSnapshot{text: promptMessage, closed: promptClosed} + } message, closed := latestClaudeAssistantMessage(screen) cleanMessage, cleanClosed := latestClaudeAssistantMessageFromCleanOutput(clean) if cleanMessage != "" { @@ -100,6 +112,9 @@ func (f *claudeTUIOutputFilter) currentMessage() claudeMessageSnapshot { cleanMessage = "" } } + if f.emitted == "" && f.baselineAssistant != "" && cleanMessage == f.baselineAssistant { + cleanMessage = "" + } if cleanMessage != "" { f.seenAssistant = true if cleanMessage == message { @@ -112,6 +127,10 @@ func (f *claudeTUIOutputFilter) currentMessage() claudeMessageSnapshot { message = "" closed = false } + if f.emitted == "" && f.baselineAssistant != "" && message == f.baselineAssistant { + message = "" + closed = false + } if message != "" { f.seenAssistant = true } @@ -220,7 +239,56 @@ func latestClaudeAssistantMessageFromCleanOutput(text string) (string, bool) { if start < 0 { return "", false } + return claudeAssistantMessageFromCleanLines(lines, start) +} +func latestClaudeAssistantMessageAfterPromptFromCleanOutput(text, prompt string) (string, bool, bool) { + normalizedPrompt := normalizeClaudePromptEchoText(prompt) + if normalizedPrompt == "" { + return "", false, false + } + + lines := strings.SplitAfter(text, "\n") + var assistantIndexes []int + var promptIndexes []int + for i, raw := range lines { + line := strings.TrimSpace(strings.TrimRight(raw, "\r\n")) + if _, ok := claudeAssistantLine(line); ok { + assistantIndexes = append(assistantIndexes, i) + continue + } + if isClaudePromptEchoLine(line, normalizedPrompt) { + promptIndexes = append(promptIndexes, i) + } + } + + start := -1 + for _, assistantIndex := range assistantIndexes { + for _, promptIndex := range promptIndexes { + if promptIndex < assistantIndex { + start = assistantIndex + break + } + } + } + if start >= 0 { + message, closed := claudeAssistantMessageFromCleanLines(lines, start) + return message, closed, true + } + + lastAssistantIndex := -1 + if len(assistantIndexes) > 0 { + lastAssistantIndex = assistantIndexes[len(assistantIndexes)-1] + } + for _, promptIndex := range promptIndexes { + if promptIndex > lastAssistantIndex { + return "", false, true + } + } + return "", false, false +} + +func claudeAssistantMessageFromCleanLines(lines []string, start int) (string, bool) { out := make([]string, 0, len(lines)-start) closed := false if text, ok := claudeAssistantLine(strings.TrimRight(lines[start], "\r\n")); ok { @@ -248,6 +316,22 @@ func latestClaudeAssistantMessageFromCleanOutput(text string) (string, bool) { return strings.Join(trimTrailingEmptyLines(out), "\n"), closed } +func isClaudePromptEchoLine(line, normalizedPrompt string) bool { + line = strings.TrimSpace(line) + if line == "" || isClaudeTUIChromeLine(line) { + return false + } + normalizedLine := normalizeClaudePromptEchoText(line) + minLen := 6 + if len(normalizedPrompt) < minLen { + minLen = len(normalizedPrompt) + } + if len(normalizedLine) < minLen { + return false + } + return strings.Contains(normalizedPrompt, normalizedLine) +} + func appendClaudeMessageLine(out *[]string, line string) { line = strings.TrimSpace(line) if line == "" || isClaudeTUIBoundaryLine(line) { diff --git a/apps/node/internal/adapters/cli/persistent_output_filter_test.go b/apps/node/internal/adapters/cli/persistent_output_filter_test.go index 30596e8..101fecc 100644 --- a/apps/node/internal/adapters/cli/persistent_output_filter_test.go +++ b/apps/node/internal/adapters/cli/persistent_output_filter_test.go @@ -36,6 +36,33 @@ func TestClaudeTUIOutputFilterSuppressesRepaintedMessage(t *testing.T) { } } +func TestClaudeTUIOutputFilterSuppressesPreviousAssistantWhilePromptEchoes(t *testing.T) { + filter := newClaudeTUIOutputFilter("second prompt", "OLD_REPLY") + + for _, chunk := range []string{ + "\u25cf OLD_REPLY\r\n\u276f second", + " prompt\r\n", + } { + if got := filter.Filter(chunk); got != "" { + t.Fatalf("Filter(%q) = %q, want previous reply suppressed", chunk, got) + } + } + + got := filter.Filter("\u25cf NEW_REPLY\r\n\u276f ") + if got != "NEW_REPLY" { + t.Fatalf("Filter() = %q, want new assistant reply", got) + } +} + +func TestClaudeTUIOutputFilterAllowsSameTextAfterCurrentPrompt(t *testing.T) { + filter := newClaudeTUIOutputFilter("repeat prompt", "SAME_REPLY") + + got := filter.Filter("\u25cf SAME_REPLY\r\n\u276f repeat prompt\r\n\u25cf SAME_REPLY\r\n\u276f ") + if got != "SAME_REPLY" { + t.Fatalf("Filter() = %q, want repeated reply after current prompt", got) + } +} + func TestClaudeTUIOutputFilterUsesLatestAssistantMessage(t *testing.T) { filter := newClaudeTUIOutputFilter("") @@ -48,7 +75,7 @@ func TestClaudeTUIOutputFilterUsesLatestAssistantMessage(t *testing.T) { } func TestPassthroughOutputFilterKeepsNonClaudeTerminalOutput(t *testing.T) { - filter := newPersistentOutputFilter("raw-terminal", testProfile("sh", true), "") + filter := newPersistentOutputFilter("raw-terminal", testProfile("sh", true), "", "") got := filter.Filter("\x1b[31mreply:hello\x1b[0m")