writeStabilize writes messageParts to the PTY and waits for the agent to process them. It operates in two phases: Phase 1 (echo detection): writes the message text and waits for the screen to change and stabilize. This detects agents that echo typed input. If the screen doesn't change within writeS
(ctx context.Context, messageParts ...MessagePart)
| 446 | // started processing. This phase is fatal on timeout: if the |
| 447 | // agent doesn't react to Enter, it's unresponsive. |
| 448 | func (c *PTYConversation) writeStabilize(ctx context.Context, messageParts ...MessagePart) error { |
| 449 | screenBeforeMessage := c.cfg.AgentIO.ReadScreen() |
| 450 | for _, part := range messageParts { |
| 451 | if err := part.Do(c.cfg.AgentIO); err != nil { |
| 452 | return xerrors.Errorf("failed to write message part: %w", err) |
| 453 | } |
| 454 | } |
| 455 | // Phase 1: wait for the screen to stabilize after the |
| 456 | // message is written (echo detection). |
| 457 | if err := util.WaitFor(ctx, util.WaitTimeout{ |
| 458 | Timeout: writeStabilizeEchoTimeout, |
| 459 | MinInterval: 50 * time.Millisecond, |
| 460 | InitialWait: true, |
| 461 | Clock: c.cfg.Clock, |
| 462 | }, func() (bool, error) { |
| 463 | screen := c.cfg.AgentIO.ReadScreen() |
| 464 | if screen != screenBeforeMessage { |
| 465 | stabilityTimer := c.cfg.Clock.NewTimer(1 * time.Second) |
| 466 | select { |
| 467 | case <-ctx.Done(): |
| 468 | stabilityTimer.Stop() |
| 469 | return false, ctx.Err() |
| 470 | case <-stabilityTimer.C: |
| 471 | } |
| 472 | stabilityTimer.Stop() |
| 473 | newScreen := c.cfg.AgentIO.ReadScreen() |
| 474 | return newScreen == screen, nil |
| 475 | } |
| 476 | return false, nil |
| 477 | }); err != nil { |
| 478 | if !errors.Is(err, util.WaitTimedOut) { |
| 479 | // Context cancellation or condition errors are fatal. |
| 480 | return xerrors.Errorf("failed to wait for screen to stabilize: %w", err) |
| 481 | } |
| 482 | // Phase 1 timeout is non-fatal: the agent may not echo |
| 483 | // input (e.g. TUI agents buffer bracketed-paste content |
| 484 | // internally). Proceed to Phase 2 to send the carriage |
| 485 | // return. |
| 486 | c.cfg.Logger.Info( |
| 487 | "echo detection timed out, sending carriage return", |
| 488 | "timeout", writeStabilizeEchoTimeout, |
| 489 | ) |
| 490 | } |
| 491 | |
| 492 | // Phase 2: wait for the screen to change after the |
| 493 | // carriage return is written (processing detection). |
| 494 | screenBeforeCarriageReturn := c.cfg.AgentIO.ReadScreen() |
| 495 | lastCarriageReturnTime := time.Time{} |
| 496 | if err := util.WaitFor(ctx, util.WaitTimeout{ |
| 497 | Timeout: writeStabilizeProcessTimeout, |
| 498 | MinInterval: 25 * time.Millisecond, |
| 499 | Clock: c.cfg.Clock, |
| 500 | }, func() (bool, error) { |
| 501 | // we don't want to spam additional carriage returns because the agent may process them |
| 502 | // (aider does this), but we do want to retry sending one if nothing's |
| 503 | // happening for a while |
| 504 | if c.cfg.Clock.Since(lastCarriageReturnTime) >= 3*time.Second { |
| 505 | lastCarriageReturnTime = c.cfg.Clock.Now() |
no test coverage detected