@@ -450,137 +450,139 @@ func TestMessages(t *testing.T) {
450450 assert .ErrorIs (t , c .Send (st.MessagePartText {Content : "" }), st .ErrMessageValidationEmpty )
451451 })
452452
453- t .Run ("send-no-echo-agent-reacts" , func (t * testing.T ) {
454- ctx , cancel := context .WithTimeout (context .Background (), testTimeout )
455- t .Cleanup (cancel )
456-
457- agent := & testAgent {screen : "prompt" }
458- // Agent doesn't echo input text, but reacts to carriage return.
459- agent .onWrite = func (data []byte ) {
460- if string (data ) == "\r " {
461- agent .screen = "processing..."
453+ t .Run ("send-no-echo-agent-reacts" , func (t * testing.T ) {
454+ ctx , cancel := context .WithTimeout (context .Background (), testTimeout )
455+ t .Cleanup (cancel )
456+
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 ) {
461+ if string (data ) == "\r " {
462+ agent .screen = "processing..."
463+ }
462464 }
463- }
464- mClock := quartz .NewMock (t )
465- mClock .Set (time .Date (2025 , 1 , 1 , 0 , 0 , 0 , 0 , time .UTC ))
466- cfg := st.PTYConversationConfig {
467- Clock : mClock ,
468- AgentIO : agent ,
469- SnapshotInterval : interval ,
470- ScreenStabilityLength : 200 * time .Millisecond ,
471- Logger : slog .New (slog .NewTextHandler (io .Discard , nil )),
472- }
473- c := st .NewPTY (ctx , cfg , & testEmitter {})
474- c .Start (ctx )
475-
476- // Stabilize: fill snapshot buffer so status becomes stable.
477- advanceFor (ctx , t , mClock , interval * threshold )
478-
479- // Send and advance. Phase 1 times out (2s, non-fatal),
480- // Phase 2 writes \r → onWrite changes screen → succeeds.
481- sendAndAdvance (ctx , t , c , mClock , st.MessagePartText {Content : "hello" })
482-
483- // User message was recorded.
484- msgs := c .Messages ()
485- require .True (t , len (msgs ) >= 2 )
486- assert .Equal (t , st .ConversationRoleUser , msgs [len (msgs )- 1 ].Role )
487- assert .Equal (t , "hello" , msgs [len (msgs )- 1 ].Message )
488- })
489-
490- t .Run ("send-no-echo-no-react" , func (t * testing.T ) {
491- ctx , cancel := context .WithTimeout (context .Background (), testTimeout )
492- t .Cleanup (cancel )
493-
494- agent := & testAgent {screen : "prompt" }
495- // Agent is completely unresponsive: no echo, no reaction.
496- agent .onWrite = func (data []byte ) {}
497- mClock := quartz .NewMock (t )
498- mClock .Set (time .Date (2025 , 1 , 1 , 0 , 0 , 0 , 0 , time .UTC ))
499- cfg := st.PTYConversationConfig {
500- Clock : mClock ,
501- AgentIO : agent ,
502- SnapshotInterval : interval ,
503- ScreenStabilityLength : 200 * time .Millisecond ,
504- Logger : slog .New (slog .NewTextHandler (io .Discard , nil )),
505- }
506- c := st .NewPTY (ctx , cfg , & testEmitter {})
507- c .Start (ctx )
508-
509- // Stabilize.
510- advanceFor (ctx , t , mClock , interval * threshold )
511-
512- // Send in a goroutine; can't use sendAndAdvance because it
513- // calls require.NoError internally.
514- var sendErr error
515- var sendDone atomic.Bool
516- go func () {
517- sendErr = c .Send (st.MessagePartText {Content : "hello" })
518- sendDone .Store (true )
519- }()
520- advanceUntil (ctx , t , mClock , func () bool { return sendDone .Load () })
521-
522- require .Error (t , sendErr )
523- assert .Contains (t , sendErr .Error (), "failed to wait for processing to start" )
524- })
525-
526- t .Run ("send-tui-selection-esc-cancels" , func (t * testing.T ) {
527- // Documents a known limitation: when a TUI agent shows a
528- // selection prompt, sending a user message wraps it in
529- // bracketed paste. The ESC (\x1b) in the paste-start
530- // sequence cancels the selection widget. The user's intended
531- // choice never reaches the selection handler.
532- //
533- // For selection prompts, callers should use MessageTypeRaw
534- // to send raw keystrokes directly to the PTY.
535- //
536- // See lib/httpapi/claude.go for the full formatClaudeCodeMessage
537- // format; this test focuses on the ESC invariant only.
538- ctx , cancel := context .WithTimeout (context .Background (), testTimeout )
539- t .Cleanup (cancel )
540-
541- agent := & testAgent {screen : "selection prompt" }
542- selectionCancelled := false
543- agent .onWrite = func (data []byte ) {
544- // Simulate TUI selection widget: ESC cancels the
545- // selection, changing the screen.
546- if bytes .Contains (data , []byte ("\x1b " )) {
547- selectionCancelled = true
548- agent .screen = "selection cancelled"
549- } else if string (data ) == "\r " {
550- agent .screen = "post-cancel"
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 )),
551473 }
552- }
553- mClock := quartz .NewMock (t )
554- mClock .Set (time .Date (2025 , 1 , 1 , 0 , 0 , 0 , 0 , time .UTC ))
555- cfg := st.PTYConversationConfig {
556- Clock : mClock ,
557- AgentIO : agent ,
558- SnapshotInterval : interval ,
559- ScreenStabilityLength : 200 * time .Millisecond ,
560- Logger : slog .New (slog .NewTextHandler (io .Discard , nil )),
561- }
562- c := st .NewPTY (ctx , cfg , & testEmitter {})
563- c .Start (ctx )
474+ c := st .NewPTY (ctx , cfg , & testEmitter {})
475+ c .Start (ctx )
476+ advanceFor (ctx , t , mClock , interval * threshold )
564477
565- // Stabilize.
566- advanceFor (ctx , t , mClock , interval * threshold )
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" })
567481
568- // Send using bracketed paste, which contains ESC. The
569- // test focuses on the ESC invariant — any input wrapped
570- // in bracketed paste will trigger this.
571- sendAndAdvance (ctx , t , c , mClock ,
572- st.MessagePartText {Content : "\x1b [200~" , Hidden : true },
573- st.MessagePartText {Content : "2" },
574- st.MessagePartText {Content : "\x1b [201~" , Hidden : true },
575- )
576-
577- // The send succeeded, but the selection was cancelled by
578- // ESC — not option "2" being selected.
579- assert .True (t , selectionCancelled ,
580- "ESC in bracketed paste cancels TUI selection prompts; " +
581- "use MessageTypeRaw for selection prompts instead" )
582- })
583- }
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+ }
491+ }
492+ assert .True (t , foundUserMsg , "expected user message 'hello' in conversation" )
493+ })
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" )
530+ })
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+ })}
584586
585587func TestStatePersistence (t * testing.T ) {
586588 t .Run ("SaveState creates file with correct structure" , func (t * testing.T ) {
0 commit comments