From 28f68a6880906d3072e2d45a30db3d1dd7972bae Mon Sep 17 00:00:00 2001 From: toki Date: Wed, 8 Jul 2026 11:45:26 +0900 Subject: [PATCH] =?UTF-8?q?fix(cli):=20terminal=20=EC=B6=9C=EB=A0=A5=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cli/codex_app_server_internal_test.go | 4 +- .../adapters/cli/lifecycle_blackbox_test.go | 6 +- apps/node/internal/adapters/cli/persistent.go | 20 ++-- .../cli/persistent_execute_blackbox_test.go | 12 +-- .../adapters/cli/persistent_output_filter.go | 96 +++++++++++++++++-- .../cli/persistent_output_filter_test.go | 34 +++++++ 6 files changed, 145 insertions(+), 27 deletions(-) diff --git a/apps/node/internal/adapters/cli/codex_app_server_internal_test.go b/apps/node/internal/adapters/cli/codex_app_server_internal_test.go index ea7058e..2a3c269 100644 --- a/apps/node/internal/adapters/cli/codex_app_server_internal_test.go +++ b/apps/node/internal/adapters/cli/codex_app_server_internal_test.go @@ -1068,7 +1068,7 @@ echo '{"id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{}}}' Command: scriptPath, Args: []string{}, } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() proc, err := startCodexAppServerProc(ctx, profile, workspace) @@ -1078,7 +1078,7 @@ echo '{"id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{}}}' defer proc.close() markerPath := filepath.Join(workspace, "cwd.txt") - cwdBytes := readFileEventually(t, markerPath, 2*time.Second) + cwdBytes := readFileEventually(t, markerPath, 5*time.Second) cwd := strings.TrimSpace(string(cwdBytes)) resolvedWorkspace, err := os.Readlink(workspace) diff --git a/apps/node/internal/adapters/cli/lifecycle_blackbox_test.go b/apps/node/internal/adapters/cli/lifecycle_blackbox_test.go index 5389e81..67ea722 100644 --- a/apps/node/internal/adapters/cli/lifecycle_blackbox_test.go +++ b/apps/node/internal/adapters/cli/lifecycle_blackbox_test.go @@ -28,7 +28,7 @@ func TestCLIStartPersistentReturnsErrorWhenProcessExitsDuringStartup(t *testing. Args: []string{"-c", "exit 2"}, Persistent: true, Terminal: true, - StartupIdleTimeoutMS: 50, + StartupIdleTimeoutMS: 200, }, }, } @@ -58,7 +58,7 @@ func TestCLIStartCleansUpStartedProfilesWhenLaterProfileFails(t *testing.T) { Args: []string{"-c", "exit 2"}, Persistent: true, Terminal: true, - StartupIdleTimeoutMS: 50, + StartupIdleTimeoutMS: 200, }, }, } @@ -106,7 +106,7 @@ func TestCLIStartDeterministicOrder(t *testing.T) { Args: []string{"-c", "exit 2"}, Persistent: true, Terminal: true, - StartupIdleTimeoutMS: 50, + StartupIdleTimeoutMS: 200, }, }, } diff --git a/apps/node/internal/adapters/cli/persistent.go b/apps/node/internal/adapters/cli/persistent.go index f7a18f0..f8bdd25 100644 --- a/apps/node/internal/adapters/cli/persistent.go +++ b/apps/node/internal/adapters/cli/persistent.go @@ -183,17 +183,19 @@ func (e *persistentExecutor) Execute(ctx context.Context, spec runtime.Execution Timestamp: time.Now(), }) } - if idleTimer == nil { - idleTimer = time.NewTimer(idleTimeout) - idleC = idleTimer.C - } else { - if !idleTimer.Stop() { - select { - case <-idleTimer.C: - default: + if waitForFilteredMessage || outputFilter.HasOutput() || !profile.Terminal { + if idleTimer == nil { + idleTimer = time.NewTimer(idleTimeout) + idleC = idleTimer.C + } else { + if !idleTimer.Stop() { + select { + case <-idleTimer.C: + default: + } } + idleTimer.Reset(idleTimeout) } - idleTimer.Reset(idleTimeout) } case <-idleC: if delta := outputFilter.Flush(); delta != "" { diff --git a/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go b/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go index cf734c8..73f1c86 100644 --- a/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go +++ b/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go @@ -251,7 +251,7 @@ func TestCLIExecutePersistentTerminalAcceptsClaudeBypassWarning(t *testing.T) { Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 100, - StartupIdleTimeoutMS: 50, + StartupIdleTimeoutMS: 250, }, }, } @@ -263,7 +263,7 @@ func TestCLIExecutePersistentTerminalAcceptsClaudeBypassWarning(t *testing.T) { } defer func() { _ = c.Stop(ctx) }() - execCtx, cancel := context.WithTimeout(ctx, 1*time.Second) + execCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() sink := &testutil.FakeSink{} @@ -297,7 +297,7 @@ func TestCLIExecutePersistentTerminalAcceptsClaudeWorkspaceTrustAndBypassWarning Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 100, - StartupIdleTimeoutMS: 50, + StartupIdleTimeoutMS: 250, }, }, } @@ -309,7 +309,7 @@ func TestCLIExecutePersistentTerminalAcceptsClaudeWorkspaceTrustAndBypassWarning } defer func() { _ = c.Stop(ctx) }() - execCtx, cancel := context.WithTimeout(ctx, 1*time.Second) + execCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() sink := &testutil.FakeSink{} @@ -355,7 +355,7 @@ func TestCLIExecutePersistentClaudeTUICancelsSessionLimitUpgradePrompt(t *testin } defer func() { _ = c.Stop(ctx) }() - execCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + execCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() sink := &testutil.FakeSink{} @@ -409,7 +409,7 @@ func TestCLIExecutePersistentClaudeTUIReplaysPromptAfterStartupReadyRace(t *test } defer func() { _ = c.Stop(ctx) }() - execCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + execCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() sink := &testutil.FakeSink{} diff --git a/apps/node/internal/adapters/cli/persistent_output_filter.go b/apps/node/internal/adapters/cli/persistent_output_filter.go index 39af59a..e07c18f 100644 --- a/apps/node/internal/adapters/cli/persistent_output_filter.go +++ b/apps/node/internal/adapters/cli/persistent_output_filter.go @@ -17,21 +17,26 @@ type persistentOutputFilter interface { CompletionMessage() string } -type passthroughOutputFilter struct{} +type passthroughOutputFilter struct { + emitted bool +} -func (passthroughOutputFilter) Filter(chunk string) string { +func (f *passthroughOutputFilter) Filter(chunk string) string { + if chunk != "" { + f.emitted = true + } return chunk } -func (passthroughOutputFilter) Flush() string { +func (f *passthroughOutputFilter) Flush() string { return "" } -func (passthroughOutputFilter) HasOutput() bool { - return false +func (f *passthroughOutputFilter) HasOutput() bool { + return f.emitted } -func (passthroughOutputFilter) CompletionMessage() string { +func (f *passthroughOutputFilter) CompletionMessage() string { return "" } @@ -39,7 +44,10 @@ func newPersistentOutputFilter(target string, profile config.CLIProfileConf, pro if profile.Terminal && isClaudeTerminalProfile(target, profile) { return newClaudeTUIOutputFilter(prompt, baselineAssistant) } - return passthroughOutputFilter{} + if profile.Terminal { + return newTerminalPassthroughOutputFilter(prompt) + } + return &passthroughOutputFilter{} } func isClaudeTerminalProfile(target string, profile config.CLIProfileConf) bool { @@ -57,6 +65,80 @@ type claudeTUIOutputFilter struct { seenAssistant bool } +type terminalPassthroughOutputFilter struct { + prompt string + promptBuf strings.Builder + promptDone bool + suppressLineEnd bool + emitted bool +} + +func newTerminalPassthroughOutputFilter(prompt string) *terminalPassthroughOutputFilter { + return &terminalPassthroughOutputFilter{prompt: strings.TrimRight(prompt, "\r\n")} +} + +func (f *terminalPassthroughOutputFilter) Filter(chunk string) string { + if chunk == "" { + return "" + } + if !f.promptDone && f.prompt != "" { + f.promptBuf.WriteString(chunk) + raw := f.promptBuf.String() + if strings.HasPrefix(f.prompt, raw) { + if raw == f.prompt { + f.promptDone = true + f.suppressLineEnd = true + f.promptBuf.Reset() + } + return "" + } + if strings.HasPrefix(raw, f.prompt) { + rest := raw[len(f.prompt):] + if rest == "" { + f.promptDone = true + f.suppressLineEnd = true + f.promptBuf.Reset() + return "" + } + if strings.HasPrefix(rest, "\r") || strings.HasPrefix(rest, "\n") { + f.promptDone = true + f.promptBuf.Reset() + return f.emit(strings.TrimLeft(rest, "\r\n")) + } + } + f.promptDone = true + f.promptBuf.Reset() + return f.emit(raw) + } + if f.suppressLineEnd { + f.suppressLineEnd = false + chunk = strings.TrimLeft(chunk, "\r\n") + if chunk == "" { + return "" + } + } + return f.emit(chunk) +} + +func (f *terminalPassthroughOutputFilter) Flush() string { + return "" +} + +func (f *terminalPassthroughOutputFilter) HasOutput() bool { + return f.emitted +} + +func (f *terminalPassthroughOutputFilter) CompletionMessage() string { + return "" +} + +func (f *terminalPassthroughOutputFilter) emit(chunk string) string { + if chunk != "" { + f.emitted = true + } + return chunk +} + type claudeMessageSnapshot struct { text string closed bool 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 6cc07a4..2b81a45 100644 --- a/apps/node/internal/adapters/cli/persistent_output_filter_test.go +++ b/apps/node/internal/adapters/cli/persistent_output_filter_test.go @@ -84,6 +84,40 @@ func TestPassthroughOutputFilterKeepsNonClaudeTerminalOutput(t *testing.T) { } } +func TestTerminalPassthroughOutputFilterSuppressesPromptEcho(t *testing.T) { + filter := newPersistentOutputFilter("raw-terminal", testProfile("sh", true), "hello", "") + + if got := filter.Filter("he"); got != "" { + t.Fatalf("first prompt echo Filter() = %q, want empty", got) + } + if filter.HasOutput() { + t.Fatalf("HasOutput() = true while only prompt echo was seen") + } + if got := filter.Filter("llo\r\n"); got != "" { + t.Fatalf("completed prompt echo Filter() = %q, want empty", got) + } + if filter.HasOutput() { + t.Fatalf("HasOutput() = true after only prompt echo was seen") + } + + got := filter.Filter("reply:hello\r\n") + if got != "reply:hello\r\n" { + t.Fatalf("reply Filter() = %q, want reply", got) + } + if !filter.HasOutput() { + t.Fatalf("HasOutput() = false after reply") + } +} + +func TestTerminalPassthroughOutputFilterSuppressesPromptEchoBeforeReplyInSameChunk(t *testing.T) { + filter := newPersistentOutputFilter("raw-terminal", testProfile("sh", true), "hello", "") + + got := filter.Filter("hello\r\nreply:hello\r\n") + if got != "reply:hello\r\n" { + t.Fatalf("Filter() = %q, want reply without echoed prompt", got) + } +} + func TestClaudeTUIOutputFilterSuppressesStartupNoiseBeforeAssistantMarker(t *testing.T) { filter := newClaudeTUIOutputFilter("hello")