diff --git a/lib/msgfmt/message_box.go b/lib/msgfmt/message_box.go index 1ac75c9e..ca86dd77 100644 --- a/lib/msgfmt/message_box.go +++ b/lib/msgfmt/message_box.go @@ -4,15 +4,22 @@ import ( "strings" ) +// containsHorizontalBorder reports whether the line contains a +// horizontal border made of box-drawing characters (─ or ╌). +func containsHorizontalBorder(line string) bool { + return strings.Contains(line, "───────────────") || + strings.Contains(line, "╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌") +} + // Usually something like -// ─────────────── +// ─────────────── (or ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌) // > -// ─────────────── +// ─────────────── (or ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌) // Used by Claude Code, Goose, and Aider. func findGreaterThanMessageBox(lines []string) int { for i := len(lines) - 1; i >= max(len(lines)-6, 0); i-- { if strings.Contains(lines[i], ">") { - if i > 0 && strings.Contains(lines[i-1], "───────────────") { + if i > 0 && containsHorizontalBorder(lines[i-1]) { return i - 1 } return i @@ -22,14 +29,14 @@ func findGreaterThanMessageBox(lines []string) int { } // Usually something like -// ─────────────── +// ─────────────── (or ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌) // | -// ─────────────── +// ─────────────── (or ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌) func findGenericSlimMessageBox(lines []string) int { for i := len(lines) - 3; i >= max(len(lines)-9, 0); i-- { - if strings.Contains(lines[i], "───────────────") && + if containsHorizontalBorder(lines[i]) && (strings.Contains(lines[i+1], "|") || strings.Contains(lines[i+1], "│") || strings.Contains(lines[i+1], "❯")) && - strings.Contains(lines[i+2], "───────────────") { + containsHorizontalBorder(lines[i+2]) { return i } } diff --git a/lib/msgfmt/testdata/initialization/claude/ready/msg_dashed_box.txt b/lib/msgfmt/testdata/initialization/claude/ready/msg_dashed_box.txt new file mode 100644 index 00000000..f7ab0382 --- /dev/null +++ b/lib/msgfmt/testdata/initialization/claude/ready/msg_dashed_box.txt @@ -0,0 +1,8 @@ + 1 function greet() { + 2 - console.log("Hello, World!"); + 2 + console.log("Hello, Claude!"); + 3 } + ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + > Try "what does this code do?" + ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + Syntax theme: Monokai Extended (ctrl+t to disable) diff --git a/lib/msgfmt/testdata/initialization/claude/ready/msg_slim_dashed_box.txt b/lib/msgfmt/testdata/initialization/claude/ready/msg_slim_dashed_box.txt new file mode 100644 index 00000000..87e285f5 --- /dev/null +++ b/lib/msgfmt/testdata/initialization/claude/ready/msg_slim_dashed_box.txt @@ -0,0 +1,8 @@ +╭────────────────────────────────────────────╮ +│ ✻ Welcome to Claude Code! │ +│ │ +│ /help for help │ +╰────────────────────────────────────────────╯ + ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + │ Type your message... + ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ diff --git a/lib/screentracker/pty_conversation.go b/lib/screentracker/pty_conversation.go index 80c9b942..8e325ea7 100644 --- a/lib/screentracker/pty_conversation.go +++ b/lib/screentracker/pty_conversation.go @@ -571,6 +571,14 @@ func (c *PTYConversation) statusLocked() ConversationStatus { return ConversationStatusChanging } + // The send loop gates stableSignal on initialPromptReady. + // Report "changing" until readiness is detected so that Send() + // rejects with ErrMessageValidationChanging instead of blocking + // indefinitely on a stableSignal that will never fire. + if !c.initialPromptReady { + return ConversationStatusChanging + } + // Handle initial prompt readiness: report "changing" until the queue is drained // to avoid the status flipping "changing" -> "stable" -> "changing" if len(c.outboundQueue) > 0 || c.sendingMessage { diff --git a/lib/screentracker/pty_conversation_test.go b/lib/screentracker/pty_conversation_test.go index 8d65e064..77de7eea 100644 --- a/lib/screentracker/pty_conversation_test.go +++ b/lib/screentracker/pty_conversation_test.go @@ -1200,7 +1200,7 @@ func TestStatePersistence(t *testing.T) { func TestInitialPromptReadiness(t *testing.T) { discardLogger := slog.New(slog.NewTextHandler(io.Discard, nil)) - t.Run("agent not ready - status is stable until agent becomes ready", func(t *testing.T) { + t.Run("agent not ready - status is changing until agent becomes ready", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) t.Cleanup(cancel) mClock := quartz.NewMock(t) @@ -1223,9 +1223,9 @@ func TestInitialPromptReadiness(t *testing.T) { // Take a snapshot with "loading...". Threshold is 1 (stability 0 / interval 1s = 0 + 1 = 1). advanceFor(ctx, t, mClock, 1*time.Second) - // Screen is stable and agent is not ready, so initial prompt hasn't been enqueued yet. - // Status should be stable. - assert.Equal(t, st.ConversationStatusStable, c.Status()) + // Screen is stable but agent is not ready. Status must be + // "changing" so that Send() rejects instead of blocking. + assert.Equal(t, st.ConversationStatusChanging, c.Status()) }) t.Run("agent becomes ready - prompt enqueued and status changes to changing", func(t *testing.T) { @@ -1248,10 +1248,9 @@ func TestInitialPromptReadiness(t *testing.T) { c := st.NewPTY(ctx, cfg, &testEmitter{}) c.Start(ctx) - // Agent not ready initially, status should be stable + // Agent not ready initially, status should be changing. advanceFor(ctx, t, mClock, 1*time.Second) - assert.Equal(t, st.ConversationStatusStable, c.Status()) - + assert.Equal(t, st.ConversationStatusChanging, c.Status()) // Agent becomes ready, prompt gets enqueued, status becomes "changing" agent.setScreen("ready") advanceFor(ctx, t, mClock, 1*time.Second) @@ -1283,10 +1282,9 @@ func TestInitialPromptReadiness(t *testing.T) { c := st.NewPTY(ctx, cfg, &testEmitter{}) c.Start(ctx) - // Status is "stable" while waiting for readiness (prompt not yet enqueued). + // Status is "changing" while waiting for readiness (prompt not yet enqueued). advanceFor(ctx, t, mClock, 1*time.Second) - assert.Equal(t, st.ConversationStatusStable, c.Status()) - + assert.Equal(t, st.ConversationStatusChanging, c.Status()) // Agent becomes ready. The snapshot loop detects this, enqueues the prompt, // then sees queue + stable + ready and signals the send loop. // writeStabilize runs with onWrite changing the screen, so it completes. @@ -1304,7 +1302,7 @@ func TestInitialPromptReadiness(t *testing.T) { assert.Equal(t, st.ConversationStatusStable, c.Status()) }) - t.Run("no initial prompt - normal status logic applies", func(t *testing.T) { + t.Run("ReadyForInitialPrompt always false - status is changing", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) t.Cleanup(cancel) mClock := quartz.NewMock(t) @@ -1325,8 +1323,10 @@ func TestInitialPromptReadiness(t *testing.T) { advanceFor(ctx, t, mClock, 1*time.Second) - // Status should be stable because no initial prompt to wait for. - assert.Equal(t, st.ConversationStatusStable, c.Status()) + // Even without an initial prompt, stableSignal gates on + // initialPromptReady. Status must reflect that Send() + // would block. + assert.Equal(t, st.ConversationStatusChanging, c.Status()) }) t.Run("no initial prompt configured - normal status logic applies", func(t *testing.T) { @@ -1743,3 +1743,38 @@ func TestInitialPromptSent(t *testing.T) { } }) } + +func TestSendRejectsWhenInitialPromptNotReady(t *testing.T) { + // Regression test for https://github.com/coder/agentapi/issues/209. + // Send() used to block forever when ReadyForInitialPrompt never + // returned true, because statusLocked() reported "stable" while + // stableSignal required initialPromptReady. Now statusLocked() + // returns "changing" and Send() rejects immediately. + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + t.Cleanup(cancel) + + mClock := quartz.NewMock(t) + agent := &testAgent{screen: "onboarding screen without message box"} + cfg := st.PTYConversationConfig{ + Clock: mClock, + SnapshotInterval: 100 * time.Millisecond, + ScreenStabilityLength: 200 * time.Millisecond, + AgentIO: agent, + ReadyForInitialPrompt: func(message string) bool { + return false // Simulates failed message box detection. + }, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + c := st.NewPTY(ctx, cfg, &testEmitter{}) + c.Start(ctx) + + // Fill snapshot buffer to reach stability. + advanceFor(ctx, t, mClock, 300*time.Millisecond) + + // Status reports "changing" because initialPromptReady is false. + assert.Equal(t, st.ConversationStatusChanging, c.Status()) + + // Send() rejects immediately instead of blocking forever. + err := c.Send(st.MessagePartText{Content: "hello"}) + assert.ErrorIs(t, err, st.ErrMessageValidationChanging) +}