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:
parent
e74b50c7f3
commit
9ef4418f8f
3 changed files with 125 additions and 10 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue