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.
This commit is contained in:
toki 2026-06-01 21:50:13 +09:00
parent e74b50c7f3
commit 9ef4418f8f
3 changed files with 125 additions and 10 deletions

View file

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

View file

@ -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) {

View file

@ -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")