Skip to content

Commit a610148

Browse files
committed
fix(screentracker): address PR review comments from mafredri
- Add send-message-no-echo-context-cancelled test: verifies the errors.Is(WaitTimedOut) guard by cancelling context during Phase 1 and asserting context.Canceled propagates (P2) - Fix gofmt: correct indentation, proper brace placement (P2) - Fix constant comment: describe WaitFor timeout semantics accurately, note 1s stability check can extend past timeout, add TODO tag (P3) - Drop send-tui-selection-esc-cancels test from screentracker, add ESC limitation comment to formatPaste in claude.go instead (P3) - Shorten log message to match codebase style (Note) - Rename tests to send-message-* prefix, use newConversation helper with opts callbacks (Note)
1 parent 26d03e9 commit a610148

File tree

3 files changed

+107
-134
lines changed

3 files changed

+107
-134
lines changed

lib/httpapi/claude.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import (
55
st "github.com/coder/agentapi/lib/screentracker"
66
)
77

8+
// formatPaste wraps message in bracketed paste escape sequences.
9+
// These sequences start with ESC (\x1b), which TUI selection
10+
// widgets (e.g. Claude Code's numbered-choice prompt) interpret
11+
// as "cancel". For selection prompts, callers should use
12+
// MessageTypeRaw to send raw keystrokes directly instead.
813
func formatPaste(message string) []st.MessagePart {
914
return []st.MessagePart{
1015
// Bracketed paste mode start sequence

lib/screentracker/pty_conversation.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,16 @@ import (
1818
)
1919

2020
const (
21-
// writeStabilizeEchoTimeout is the maximum time to wait for
22-
// the screen to change after writing message text to the PTY
23-
// (echo detection). This is intentionally short: terminal
24-
// echo is near-instant when it occurs. Non-echoing agents
25-
// (e.g. TUI agents using bracketed paste) will hit this
26-
// timeout, which is non-fatal — see the Phase 1 error
27-
// handler in writeStabilize. Move to PTYConversationConfig
28-
// if agents need different echo detection windows.
21+
// writeStabilizeEchoTimeout is the timeout for the echo
22+
// detection WaitFor loop in writeStabilize Phase 1. The
23+
// effective ceiling may be slightly longer because the 1s
24+
// stability check inside the condition runs outside
25+
// WaitFor's timeout select. Non-echoing agents (e.g. TUI
26+
// agents using bracketed paste) will hit this timeout,
27+
// which is non-fatal.
28+
//
29+
// TODO: move to PTYConversationConfig if agents need
30+
// different echo detection windows.
2931
writeStabilizeEchoTimeout = 2 * time.Second
3032

3133
// writeStabilizeProcessTimeout is the maximum time to wait
@@ -478,7 +480,7 @@ func (c *PTYConversation) writeStabilize(ctx context.Context, messageParts ...Me
478480
// internally). Proceed to Phase 2 to send the carriage
479481
// return.
480482
c.cfg.Logger.Info(
481-
"screen did not stabilize after writing message, proceeding to send carriage return",
483+
"echo detection timed out, sending carriage return",
482484
"timeout", writeStabilizeEchoTimeout,
483485
)
484486
}

lib/screentracker/pty_conversation_test.go

Lines changed: 91 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package screentracker_test
22

33
import (
4-
"bytes"
54
"context"
65
"encoding/json"
76
"fmt"
@@ -450,139 +449,106 @@ func TestMessages(t *testing.T) {
450449
assert.ErrorIs(t, c.Send(st.MessagePartText{Content: ""}), st.ErrMessageValidationEmpty)
451450
})
452451

453-
t.Run("send-no-echo-agent-reacts", func(t *testing.T) {
454-
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
455-
t.Cleanup(cancel)
452+
t.Run("send-message-no-echo-agent-reacts", func(t *testing.T) {
453+
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
454+
t.Cleanup(cancel)
456455

457-
// Given: an agent that doesn't echo typed input but
458-
// reacts to carriage return by updating the screen.
459-
agent := &testAgent{screen: "prompt"}
460-
agent.onWrite = func(data []byte) {
456+
// Given: an agent that doesn't echo typed input but
457+
// reacts to carriage return by updating the screen.
458+
c, _, mClock := newConversation(ctx, t, func(cfg *st.PTYConversationConfig) {
459+
a := &testAgent{screen: "prompt"}
460+
a.onWrite = func(data []byte) {
461461
if string(data) == "\r" {
462-
agent.screen = "processing..."
462+
a.screen = "processing..."
463463
}
464464
}
465-
mClock := quartz.NewMock(t)
466-
mClock.Set(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
467-
cfg := st.PTYConversationConfig{
468-
Clock: mClock,
469-
AgentIO: agent,
470-
SnapshotInterval: interval,
471-
ScreenStabilityLength: 200 * time.Millisecond,
472-
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
473-
}
474-
c := st.NewPTY(ctx, cfg, &testEmitter{})
475-
c.Start(ctx)
476-
advanceFor(ctx, t, mClock, interval*threshold)
465+
cfg.AgentIO = a
466+
})
467+
advanceFor(ctx, t, mClock, interval*threshold)
477468

478-
// When: a message is sent. Phase 1 times out (no echo),
479-
// Phase 2 writes \r and the agent reacts.
480-
sendAndAdvance(ctx, t, c, mClock, st.MessagePartText{Content: "hello"})
469+
// When: a message is sent. Phase 1 times out (no echo),
470+
// Phase 2 writes \r and the agent reacts.
471+
sendAndAdvance(ctx, t, c, mClock, st.MessagePartText{Content: "hello"})
481472

482-
// Then: Send succeeds and the user message is recorded.
483-
msgs := c.Messages()
484-
require.True(t, len(msgs) >= 2)
485-
var foundUserMsg bool
486-
for _, msg := range msgs {
487-
if msg.Role == st.ConversationRoleUser && msg.Message == "hello" {
488-
foundUserMsg = true
489-
break
490-
}
473+
// Then: Send succeeds and the user message is recorded.
474+
msgs := c.Messages()
475+
require.True(t, len(msgs) >= 2)
476+
var foundUserMsg bool
477+
for _, msg := range msgs {
478+
if msg.Role == st.ConversationRoleUser && msg.Message == "hello" {
479+
foundUserMsg = true
480+
break
491481
}
492-
assert.True(t, foundUserMsg, "expected user message 'hello' in conversation")
482+
}
483+
assert.True(t, foundUserMsg, "expected user message 'hello' in conversation")
484+
})
485+
486+
t.Run("send-message-no-echo-no-react", func(t *testing.T) {
487+
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
488+
t.Cleanup(cancel)
489+
490+
// Given: an agent that is completely unresponsive — it
491+
// neither echoes input nor reacts to carriage return.
492+
c, _, mClock := newConversation(ctx, t, func(cfg *st.PTYConversationConfig) {
493+
a := &testAgent{screen: "prompt"}
494+
a.onWrite = func(data []byte) {}
495+
cfg.AgentIO = a
493496
})
494-
t.Run("send-no-echo-no-react", func(t *testing.T) {
495-
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
496-
t.Cleanup(cancel)
497-
498-
// Given: an agent that is completely unresponsive — it
499-
// neither echoes input nor reacts to carriage return.
500-
agent := &testAgent{screen: "prompt"}
501-
agent.onWrite = func(data []byte) {}
502-
mClock := quartz.NewMock(t)
503-
mClock.Set(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
504-
cfg := st.PTYConversationConfig{
505-
Clock: mClock,
506-
AgentIO: agent,
507-
SnapshotInterval: interval,
508-
ScreenStabilityLength: 200 * time.Millisecond,
509-
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
510-
}
511-
c := st.NewPTY(ctx, cfg, &testEmitter{})
512-
c.Start(ctx)
513-
advanceFor(ctx, t, mClock, interval*threshold)
514-
515-
// When: a message is sent. Both Phase 1 (echo) and
516-
// Phase 2 (processing) time out.
517-
// Note: can't use sendAndAdvance here because it calls
518-
// require.NoError internally.
519-
var sendErr error
520-
var sendDone atomic.Bool
521-
go func() {
522-
sendErr = c.Send(st.MessagePartText{Content: "hello"})
523-
sendDone.Store(true)
524-
}()
525-
advanceUntil(ctx, t, mClock, func() bool { return sendDone.Load() })
526-
527-
// Then: Send fails with a Phase 2 error (not Phase 1).
528-
require.Error(t, sendErr)
529-
assert.Contains(t, sendErr.Error(), "failed to wait for processing to start")
497+
advanceFor(ctx, t, mClock, interval*threshold)
498+
499+
// When: a message is sent. Both Phase 1 (echo) and
500+
// Phase 2 (processing) time out.
501+
// Note: can't use sendAndAdvance here because it calls
502+
// require.NoError internally.
503+
var sendErr error
504+
var sendDone atomic.Bool
505+
go func() {
506+
sendErr = c.Send(st.MessagePartText{Content: "hello"})
507+
sendDone.Store(true)
508+
}()
509+
advanceUntil(ctx, t, mClock, func() bool { return sendDone.Load() })
510+
511+
// Then: Send fails with a Phase 2 error (not Phase 1).
512+
require.Error(t, sendErr)
513+
assert.Contains(t, sendErr.Error(), "failed to wait for processing to start")
514+
})
515+
516+
t.Run("send-message-no-echo-context-cancelled", func(t *testing.T) {
517+
// Given: a non-echoing agent and a cancellable context.
518+
sendCtx, sendCancel := context.WithCancel(context.Background())
519+
t.Cleanup(sendCancel)
520+
521+
c, _, mClock := newConversation(sendCtx, t, func(cfg *st.PTYConversationConfig) {
522+
a := &testAgent{screen: "prompt"}
523+
a.onWrite = func(data []byte) {}
524+
cfg.AgentIO = a
530525
})
531-
t.Run("send-tui-selection-esc-cancels", func(t *testing.T) {
532-
// Documents a known limitation: when a TUI agent shows a
533-
// selection prompt, sending a user message wraps it in
534-
// bracketed paste. The ESC (\x1b) in the paste-start
535-
// sequence cancels the selection widget. The user's
536-
// intended choice never reaches the selection handler.
537-
// For selection prompts, callers should use
538-
// MessageTypeRaw to send raw keystrokes directly.
539-
//
540-
// See lib/httpapi/claude.go formatClaudeCodeMessage for
541-
// the full format; this test focuses on the ESC
542-
// invariant only.
543-
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
544-
t.Cleanup(cancel)
545-
546-
// Given: a TUI agent showing a selection prompt where
547-
// ESC cancels the selection and changes the screen.
548-
agent := &testAgent{screen: "selection prompt"}
549-
selectionCancelled := false
550-
agent.onWrite = func(data []byte) {
551-
if bytes.Contains(data, []byte("\x1b")) {
552-
selectionCancelled = true
553-
agent.screen = "selection cancelled"
554-
} else if string(data) == "\r" {
555-
agent.screen = "post-cancel"
556-
}
557-
}
558-
mClock := quartz.NewMock(t)
559-
mClock.Set(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
560-
cfg := st.PTYConversationConfig{
561-
Clock: mClock,
562-
AgentIO: agent,
563-
SnapshotInterval: interval,
564-
ScreenStabilityLength: 200 * time.Millisecond,
565-
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
566-
}
567-
c := st.NewPTY(ctx, cfg, &testEmitter{})
568-
c.Start(ctx)
569-
advanceFor(ctx, t, mClock, interval*threshold)
570-
571-
// When: a message is sent using bracketed paste, which
572-
// contains ESC in the start sequence (\x1b[200~).
573-
sendAndAdvance(ctx, t, c, mClock,
574-
st.MessagePartText{Content: "\x1b[200~", Hidden: true},
575-
st.MessagePartText{Content: "2"},
576-
st.MessagePartText{Content: "\x1b[201~", Hidden: true},
577-
)
578-
579-
// Then: Send succeeds, but the selection was cancelled
580-
// by ESC — option "2" was never delivered to the
581-
// selection handler.
582-
assert.True(t, selectionCancelled,
583-
"ESC in bracketed paste cancels TUI selection prompts; "+
584-
"use MessageTypeRaw for selection prompts instead")
585-
})}
526+
advanceFor(sendCtx, t, mClock, interval*threshold)
527+
528+
// When: a message is sent and the context is cancelled
529+
// during Phase 1 (before echo detection completes).
530+
var sendErr error
531+
var sendDone atomic.Bool
532+
go func() {
533+
sendErr = c.Send(st.MessagePartText{Content: "hello"})
534+
sendDone.Store(true)
535+
}()
536+
537+
// Advance past the snapshot interval so the send loop
538+
// picks up the message, then cancel the context.
539+
advanceFor(sendCtx, t, mClock, interval)
540+
sendCancel()
541+
542+
// Wait for Send to complete (it should fail quickly
543+
// after cancellation).
544+
require.Eventually(t, sendDone.Load, 5*time.Second, 10*time.Millisecond)
545+
546+
// Then: the error wraps context.Canceled, not a Phase 2 error.
547+
require.Error(t, sendErr)
548+
assert.ErrorIs(t, sendErr, context.Canceled)
549+
assert.NotContains(t, sendErr.Error(), "failed to wait for processing to start")
550+
})
551+
}
586552

587553
func TestStatePersistence(t *testing.T) {
588554
t.Run("SaveState creates file with correct structure", func(t *testing.T) {

0 commit comments

Comments
 (0)