Skip to content

Commit 3cb783f

Browse files
authored
fix(screentracker): prevent Send() from blocking when ReadyForInitialPrompt stays false (#215)
1 parent a80af34 commit 3cb783f

File tree

5 files changed

+86
-20
lines changed

5 files changed

+86
-20
lines changed

lib/msgfmt/message_box.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,22 @@ import (
44
"strings"
55
)
66

7+
// containsHorizontalBorder reports whether the line contains a
8+
// horizontal border made of box-drawing characters (─ or ╌).
9+
func containsHorizontalBorder(line string) bool {
10+
return strings.Contains(line, "───────────────") ||
11+
strings.Contains(line, "╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌")
12+
}
13+
714
// Usually something like
8-
// ───────────────
15+
// ─────────────── (or ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌)
916
// >
10-
// ───────────────
17+
// ─────────────── (or ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌)
1118
// Used by Claude Code, Goose, and Aider.
1219
func findGreaterThanMessageBox(lines []string) int {
1320
for i := len(lines) - 1; i >= max(len(lines)-6, 0); i-- {
1421
if strings.Contains(lines[i], ">") {
15-
if i > 0 && strings.Contains(lines[i-1], "───────────────") {
22+
if i > 0 && containsHorizontalBorder(lines[i-1]) {
1623
return i - 1
1724
}
1825
return i
@@ -22,14 +29,14 @@ func findGreaterThanMessageBox(lines []string) int {
2229
}
2330

2431
// Usually something like
25-
// ───────────────
32+
// ─────────────── (or ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌)
2633
// |
27-
// ───────────────
34+
// ─────────────── (or ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌)
2835
func findGenericSlimMessageBox(lines []string) int {
2936
for i := len(lines) - 3; i >= max(len(lines)-9, 0); i-- {
30-
if strings.Contains(lines[i], "───────────────") &&
37+
if containsHorizontalBorder(lines[i]) &&
3138
(strings.Contains(lines[i+1], "|") || strings.Contains(lines[i+1], "│") || strings.Contains(lines[i+1], "❯")) &&
32-
strings.Contains(lines[i+2], "───────────────") {
39+
containsHorizontalBorder(lines[i+2]) {
3340
return i
3441
}
3542
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
1 function greet() {
2+
2 - console.log("Hello, World!");
3+
2 + console.log("Hello, Claude!");
4+
3 }
5+
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
6+
> Try "what does this code do?"
7+
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
8+
Syntax theme: Monokai Extended (ctrl+t to disable)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
╭────────────────────────────────────────────╮
2+
│ ✻ Welcome to Claude Code! │
3+
│ │
4+
│ /help for help │
5+
╰────────────────────────────────────────────╯
6+
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
7+
│ Type your message...
8+
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌

lib/screentracker/pty_conversation.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,14 @@ func (c *PTYConversation) statusLocked() ConversationStatus {
571571
return ConversationStatusChanging
572572
}
573573

574+
// The send loop gates stableSignal on initialPromptReady.
575+
// Report "changing" until readiness is detected so that Send()
576+
// rejects with ErrMessageValidationChanging instead of blocking
577+
// indefinitely on a stableSignal that will never fire.
578+
if !c.initialPromptReady {
579+
return ConversationStatusChanging
580+
}
581+
574582
// Handle initial prompt readiness: report "changing" until the queue is drained
575583
// to avoid the status flipping "changing" -> "stable" -> "changing"
576584
if len(c.outboundQueue) > 0 || c.sendingMessage {

lib/screentracker/pty_conversation_test.go

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,7 +1200,7 @@ func TestStatePersistence(t *testing.T) {
12001200
func TestInitialPromptReadiness(t *testing.T) {
12011201
discardLogger := slog.New(slog.NewTextHandler(io.Discard, nil))
12021202

1203-
t.Run("agent not ready - status is stable until agent becomes ready", func(t *testing.T) {
1203+
t.Run("agent not ready - status is changing until agent becomes ready", func(t *testing.T) {
12041204
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
12051205
t.Cleanup(cancel)
12061206
mClock := quartz.NewMock(t)
@@ -1223,9 +1223,9 @@ func TestInitialPromptReadiness(t *testing.T) {
12231223
// Take a snapshot with "loading...". Threshold is 1 (stability 0 / interval 1s = 0 + 1 = 1).
12241224
advanceFor(ctx, t, mClock, 1*time.Second)
12251225

1226-
// Screen is stable and agent is not ready, so initial prompt hasn't been enqueued yet.
1227-
// Status should be stable.
1228-
assert.Equal(t, st.ConversationStatusStable, c.Status())
1226+
// Screen is stable but agent is not ready. Status must be
1227+
// "changing" so that Send() rejects instead of blocking.
1228+
assert.Equal(t, st.ConversationStatusChanging, c.Status())
12291229
})
12301230

12311231
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) {
12481248
c := st.NewPTY(ctx, cfg, &testEmitter{})
12491249
c.Start(ctx)
12501250

1251-
// Agent not ready initially, status should be stable
1251+
// Agent not ready initially, status should be changing.
12521252
advanceFor(ctx, t, mClock, 1*time.Second)
1253-
assert.Equal(t, st.ConversationStatusStable, c.Status())
1254-
1253+
assert.Equal(t, st.ConversationStatusChanging, c.Status())
12551254
// Agent becomes ready, prompt gets enqueued, status becomes "changing"
12561255
agent.setScreen("ready")
12571256
advanceFor(ctx, t, mClock, 1*time.Second)
@@ -1283,10 +1282,9 @@ func TestInitialPromptReadiness(t *testing.T) {
12831282
c := st.NewPTY(ctx, cfg, &testEmitter{})
12841283
c.Start(ctx)
12851284

1286-
// Status is "stable" while waiting for readiness (prompt not yet enqueued).
1285+
// Status is "changing" while waiting for readiness (prompt not yet enqueued).
12871286
advanceFor(ctx, t, mClock, 1*time.Second)
1288-
assert.Equal(t, st.ConversationStatusStable, c.Status())
1289-
1287+
assert.Equal(t, st.ConversationStatusChanging, c.Status())
12901288
// Agent becomes ready. The snapshot loop detects this, enqueues the prompt,
12911289
// then sees queue + stable + ready and signals the send loop.
12921290
// writeStabilize runs with onWrite changing the screen, so it completes.
@@ -1304,7 +1302,7 @@ func TestInitialPromptReadiness(t *testing.T) {
13041302
assert.Equal(t, st.ConversationStatusStable, c.Status())
13051303
})
13061304

1307-
t.Run("no initial prompt - normal status logic applies", func(t *testing.T) {
1305+
t.Run("ReadyForInitialPrompt always false - status is changing", func(t *testing.T) {
13081306
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
13091307
t.Cleanup(cancel)
13101308
mClock := quartz.NewMock(t)
@@ -1325,8 +1323,10 @@ func TestInitialPromptReadiness(t *testing.T) {
13251323

13261324
advanceFor(ctx, t, mClock, 1*time.Second)
13271325

1328-
// Status should be stable because no initial prompt to wait for.
1329-
assert.Equal(t, st.ConversationStatusStable, c.Status())
1326+
// Even without an initial prompt, stableSignal gates on
1327+
// initialPromptReady. Status must reflect that Send()
1328+
// would block.
1329+
assert.Equal(t, st.ConversationStatusChanging, c.Status())
13301330
})
13311331

13321332
t.Run("no initial prompt configured - normal status logic applies", func(t *testing.T) {
@@ -1743,3 +1743,38 @@ func TestInitialPromptSent(t *testing.T) {
17431743
}
17441744
})
17451745
}
1746+
1747+
func TestSendRejectsWhenInitialPromptNotReady(t *testing.T) {
1748+
// Regression test for https://github.com/coder/agentapi/issues/209.
1749+
// Send() used to block forever when ReadyForInitialPrompt never
1750+
// returned true, because statusLocked() reported "stable" while
1751+
// stableSignal required initialPromptReady. Now statusLocked()
1752+
// returns "changing" and Send() rejects immediately.
1753+
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
1754+
t.Cleanup(cancel)
1755+
1756+
mClock := quartz.NewMock(t)
1757+
agent := &testAgent{screen: "onboarding screen without message box"}
1758+
cfg := st.PTYConversationConfig{
1759+
Clock: mClock,
1760+
SnapshotInterval: 100 * time.Millisecond,
1761+
ScreenStabilityLength: 200 * time.Millisecond,
1762+
AgentIO: agent,
1763+
ReadyForInitialPrompt: func(message string) bool {
1764+
return false // Simulates failed message box detection.
1765+
},
1766+
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
1767+
}
1768+
c := st.NewPTY(ctx, cfg, &testEmitter{})
1769+
c.Start(ctx)
1770+
1771+
// Fill snapshot buffer to reach stability.
1772+
advanceFor(ctx, t, mClock, 300*time.Millisecond)
1773+
1774+
// Status reports "changing" because initialPromptReady is false.
1775+
assert.Equal(t, st.ConversationStatusChanging, c.Status())
1776+
1777+
// Send() rejects immediately instead of blocking forever.
1778+
err := c.Send(st.MessagePartText{Content: "hello"})
1779+
assert.ErrorIs(t, err, st.ErrMessageValidationChanging)
1780+
}

0 commit comments

Comments
 (0)