|
1 | 1 | package screentracker_test |
2 | 2 |
|
3 | 3 | import ( |
4 | | - "bytes" |
5 | 4 | "context" |
6 | 5 | "encoding/json" |
7 | 6 | "fmt" |
@@ -450,139 +449,106 @@ func TestMessages(t *testing.T) { |
450 | 449 | assert.ErrorIs(t, c.Send(st.MessagePartText{Content: ""}), st.ErrMessageValidationEmpty) |
451 | 450 | }) |
452 | 451 |
|
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) |
456 | 455 |
|
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) { |
461 | 461 | if string(data) == "\r" { |
462 | | - agent.screen = "processing..." |
| 462 | + a.screen = "processing..." |
463 | 463 | } |
464 | 464 | } |
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) |
477 | 468 |
|
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"}) |
481 | 472 |
|
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 |
491 | 481 | } |
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 |
493 | 496 | }) |
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 |
530 | 525 | }) |
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 | +} |
586 | 552 |
|
587 | 553 | func TestStatePersistence(t *testing.T) { |
588 | 554 | t.Run("SaveState creates file with correct structure", func(t *testing.T) { |
|
0 commit comments