Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/pkg/agent/crash_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ func TestCheckAndRestartCrashedAgents_ConsentStuck(t *testing.T) {
m.mu.RLock()
agent := m.agents["cxa"]
m.mu.RUnlock()
m.visiblePaneCapture = func(*AgentProcess) string {
termSeams(m).captureVisiblePane = func(*AgentProcess) string {
return "Bypass Permissions mode\n❯ 1. No, exit\nEnter to confirm\n"
}

Expand Down
6 changes: 3 additions & 3 deletions src/pkg/agent/dismiss_prompts_fake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ func newDismissPromptHarness(t *testing.T, name, initialPane string, readyAfterK
m.mu.RUnlock()

script := &scriptedPromptPane{initialPane: initialPane, readyAfterKeys: readyAfterKeys}
m.visiblePaneCapture = script.capture
m.sendKeysForAgent = script.sendKeys
m.promptDismissSleep = func(time.Duration) { runtime.Gosched() }
termSeams(m).captureVisiblePane = script.capture
termSeams(m).sendKeys = script.sendKeys
termSeams(m).sleep = func(time.Duration) { runtime.Gosched() }
m.promptDismissTimeout = 200 * time.Millisecond
return m, agent, script
}
Expand Down
2 changes: 1 addition & 1 deletion src/pkg/agent/enters_prompt_guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func entersGuardManager(t *testing.T, pane string) (*Manager, *AgentProcess, *sy
m := NewManager(map[string]config.AgentConfig{
"worker": makeAgentConfig("codex", "gpt-5-codex"),
}, slog.New(slog.NewTextHandler(logBuf, nil)), ProjectContext{})
m.visiblePaneCapture = func(*AgentProcess) string { return pane }
termSeams(m).captureVisiblePane = func(*AgentProcess) string { return pane }
m.mu.RLock()
agent := m.agents["worker"]
m.mu.RUnlock()
Expand Down
20 changes: 2 additions & 18 deletions src/pkg/agent/kick_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,30 +160,14 @@ func (m *Manager) agentKickLogDir(name string) string {
// CaptureFullLog (live "full log") and archiveKickLogLocked (durable
// snapshot), so both always see the same bytes.
func (m *Manager) captureScrollbackForAgent(agent *AgentProcess) (string, error) {
if m.captureFullLogFn != nil {
return m.captureFullLogFn(agent)
}
// -S -<n>: start n lines back into history; -E -: through the last visible
// line; -J: join wrapped lines so copied text is not hard-wrapped at the
// pane width; -p: print to stdout.
cmd := m.tmuxCmd(agent, "capture-pane", "-t", agent.tmuxSession, "-p", "-J",
"-S", fmt.Sprintf("-%d", fullLogCaptureLines), "-E", "-")
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("capturing pane for %s: %w", agent.Name, err)
}
return string(out), nil
return m.term().CaptureFullLog(agent)
}

// clearScrollbackForAgent drops the session's scrollback history (the visible
// pane is untouched) so the NEXT archive covers only the kick being delivered
// now. Only called after its content has been archived.
func (m *Manager) clearScrollbackForAgent(agent *AgentProcess) {
if m.clearHistoryFn != nil {
m.clearHistoryFn(agent)
return
}
_ = m.tmuxCmd(agent, "clear-history", "-t", agent.tmuxSession).Run()
m.term().ClearHistory(agent)
}

// archiveKickLogLocked snapshots the agent's current scrollback to a durable
Expand Down
22 changes: 11 additions & 11 deletions src/pkg/agent/kick_logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ func kickLogTestManager(t *testing.T, captured string) (*Manager, *AgentProcess,
kickLogDir: dir,
kickLogRetention: defaultKickLogRetention,
kickLogMaxBytes: defaultKickLogMaxBytes,
captureFullLogFn: func(*AgentProcess) (string, error) { return captured, nil },
clearHistoryFn: func(*AgentProcess) {},
}
termSeams(m).captureFullLog = func(*AgentProcess) (string, error) { return captured, nil }
termSeams(m).clearHistory = func(*AgentProcess) {}
return m, agent, dir
}

Expand Down Expand Up @@ -153,7 +153,7 @@ func TestArchiveKickLogLocked_RetentionZeroDisables(t *testing.T) {
// must proceed.
func TestArchiveKickLogLocked_CaptureErrorIsNonFatal(t *testing.T) {
m, agent, _ := kickLogTestManager(t, "")
m.captureFullLogFn = func(*AgentProcess) (string, error) { return "", fmt.Errorf("boom") }
termSeams(m).captureFullLog = func(*AgentProcess) (string, error) { return "", fmt.Errorf("boom") }
if m.archiveKickLogLocked(agent, "restart") {
t.Fatal("archived despite capture error")
}
Expand Down Expand Up @@ -222,15 +222,15 @@ func TestPruneKickLogs_SizeCapKeepsNewest(t *testing.T) {
func TestDeliverKickLocked_ArchivesAndClearsBeforeInput(t *testing.T) {
m, agent, _ := kickLogTestManager(t, "previous kick output")
var events []string
m.captureFullLogFn = func(*AgentProcess) (string, error) {
termSeams(m).captureFullLog = func(*AgentProcess) (string, error) {
events = append(events, "capture")
return "previous kick output", nil
}
m.clearHistoryFn = func(*AgentProcess) { events = append(events, "clear") }
m.sendKeysForAgent = func(_ *AgentProcess, keys ...string) {
termSeams(m).clearHistory = func(*AgentProcess) { events = append(events, "clear") }
termSeams(m).sendKeys = func(_ *AgentProcess, keys ...string) {
events = append(events, "sendkeys:"+strings.Join(keys, "+"))
}
m.visiblePaneCapture = func(*AgentProcess) string { return "" }
termSeams(m).captureVisiblePane = func(*AgentProcess) string { return "" }
agent.kickLogPending = true

m.deliverKickLocked(agent, "next task", "send-kick")
Expand All @@ -257,9 +257,9 @@ func TestDeliverKickLocked_ArchivesAndClearsBeforeInput(t *testing.T) {
func TestDeliverKickLocked_NoRotationWithoutPendingOutput(t *testing.T) {
m, agent, _ := kickLogTestManager(t, "boot banner")
captured := false
m.captureFullLogFn = func(*AgentProcess) (string, error) { captured = true; return "boot banner", nil }
m.sendKeysForAgent = func(*AgentProcess, ...string) {}
m.visiblePaneCapture = func(*AgentProcess) string { return "" }
termSeams(m).captureFullLog = func(*AgentProcess) (string, error) { captured = true; return "boot banner", nil }
termSeams(m).sendKeys = func(*AgentProcess, ...string) {}
termSeams(m).captureVisiblePane = func(*AgentProcess) string { return "" }

m.deliverKickLocked(agent, "first task", "startup")

Expand All @@ -279,7 +279,7 @@ func TestDeliverKickLocked_NoRotationWithoutPendingOutput(t *testing.T) {
// fails (no tmux in the environment): the archive is the whole point.
func TestRestart_ArchivesPendingKickOutput(t *testing.T) {
m, agent, dir := kickLogTestManager(t, "output of the run being restarted")
m.sendKeysForAgent = func(*AgentProcess, ...string) {}
termSeams(m).sendKeys = func(*AgentProcess, ...string) {}
agent.kickLogPending = true
// Paused short-circuits Restart right after ensureTmuxSession, keeping the
// test away from token minting and a real CLI launch.
Expand Down
4 changes: 2 additions & 2 deletions src/pkg/agent/login_code_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func TestSubmitLoginCodeTypesCodeAndSubmitsOnce(t *testing.T) {
t.Cleanup(func() { tmuxSessionExists = origExists })

var typed []string
m.sendLiteralForAgent = func(_ *AgentProcess, text string) { typed = append(typed, text) }
termSeams(m).sendLiteral = func(_ *AgentProcess, text string) { typed = append(typed, text) }

if err := m.SubmitLoginCode("scanner", " 4/0AVMBsJh-code "); err != nil {
t.Fatalf("SubmitLoginCode: %v", err)
Expand All @@ -74,7 +74,7 @@ func TestSubmitLoginCodeRefusesWithoutTyping(t *testing.T) {
}, discardLogger(), ProjectContext{ACMMLevel: 5})

var typed []string
m.sendLiteralForAgent = func(_ *AgentProcess, text string) { typed = append(typed, text) }
termSeams(m).sendLiteral = func(_ *AgentProcess, text string) { typed = append(typed, text) }

if err := m.SubmitLoginCode("scanner", "code\nrm -rf /"); err == nil {
t.Fatal("accepted a newline-bearing code")
Expand Down
74 changes: 14 additions & 60 deletions src/pkg/agent/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -569,23 +569,21 @@ type Manager struct {
sandboxPRClient PRCreator
sandboxAuditCallback atomic.Pointer[func(agent, action, detail string)]

paneCapture func(agent *AgentProcess) string
visiblePaneCapture func(agent *AgentProcess) string
sessionAttached func(agent *AgentProcess) bool
sendLiteralForAgent func(agent *AgentProcess, text string)
sendKeysForAgent func(agent *AgentProcess, keys ...string)
promptDismissSleep func(time.Duration)
// terminal is every interaction with the agent's interactive terminal
// (pane capture, keystrokes, scrollback). nil means the real tmux-backed
// implementation (see Manager.term / tmuxTerminal in terminal.go); tests
// install a funcTerminal to fake individual methods. Replaces the eight
// ad-hoc func-typed seam fields removed in issue #5636 phase 1.
terminal TerminalSession
promptDismissTimeout time.Duration

// Per-kick durable log archiving (#4296, #4295) — see kick_logs.go.
// kickLogDir/kickLogRetention/kickLogMaxBytes are resolved once in
// NewManager from env overrides; captureFullLogFn and clearHistoryFn are
// test seams over the tmux capture-pane / clear-history subprocesses.
// NewManager from env overrides; the capture/clear-history subprocesses
// are reached through m.terminal above.
kickLogDir string
kickLogRetention int
kickLogMaxBytes int64
captureFullLogFn func(agent *AgentProcess) (string, error)
clearHistoryFn func(agent *AgentProcess)
}

// SetPersistPauseCallback wires a function that persists an agent's paused
Expand Down Expand Up @@ -4047,16 +4045,7 @@ func (m *Manager) tmuxRawCmd(args ...string) *exec.Cmd {
// captureTmuxPaneForAgent captures pane content using the agent's tmux socket.
// Includes scrollback for diff-based output signal detection.
func (m *Manager) captureTmuxPaneForAgent(agent *AgentProcess) string {
if m.paneCapture != nil {
return m.paneCapture(agent)
}
cmd := m.tmuxCmd(agent, "capture-pane", "-t", agent.tmuxSession, "-p",
"-S", fmt.Sprintf("-%d", tmuxCaptureLines))
out, err := cmd.Output()
if err != nil {
return ""
}
return string(out)
return m.term().CapturePane(agent)
}

// CaptureFullLog returns the agent's full retained tmux scrollback for its
Expand Down Expand Up @@ -4088,33 +4077,11 @@ func (m *Manager) CaptureFullLog(name string) (string, error) {

// captureVisiblePaneForAgent captures only the visible pane (no scrollback).
func (m *Manager) captureVisiblePaneForAgent(agent *AgentProcess) string {
if m.visiblePaneCapture != nil {
return m.visiblePaneCapture(agent)
}
cmd := m.tmuxCmd(agent, "capture-pane", "-t", agent.tmuxSession, "-p")
out, err := cmd.Output()
if err != nil {
return ""
}
return string(out)
return m.term().CaptureVisiblePane(agent)
}

func (m *Manager) tmuxSessionHasAttachedClientForAgent(agent *AgentProcess) bool {
if m.sessionAttached != nil {
return m.sessionAttached(agent)
}
if agent == nil || agent.tmuxSession == "" {
return true
}
out, err := m.tmuxCmd(agent, "display-message", "-p", "-t", agent.tmuxSession, "#{session_attached}").Output()
if err != nil {
return true
}
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
if err != nil {
return true
}
return n > 0
return m.term().SessionAttached(agent)
}

func (m *Manager) Stop(name string) error {
Expand Down Expand Up @@ -5066,11 +5033,7 @@ func (m *Manager) deliverStartupKick(agent *AgentProcess, prompt string, gen int

// tmuxSendLiteralForAgent sends text using the agent's tmux socket.
func (m *Manager) tmuxSendLiteralForAgent(agent *AgentProcess, text string) {
if m.sendLiteralForAgent != nil {
m.sendLiteralForAgent(agent, text)
return
}
_ = m.tmuxCmd(agent, "send-keys", "-t", agent.tmuxSession, "-l", text).Run()
m.term().SendLiteral(agent, text)
}

// launchFailurePrefix opens every in-pane launch-failure banner so the line is
Expand Down Expand Up @@ -5270,11 +5233,7 @@ func (m *Manager) dismissInferencePrompts(agent *AgentProcess) {
}

func (m *Manager) sleepDuringPromptDismiss(d time.Duration) {
if m.promptDismissSleep != nil {
m.promptDismissSleep(d)
return
}
time.Sleep(d)
m.term().Sleep(d)
}

// selectedMenuOption returns the trimmed text of the "❯"-selected line of an
Expand Down Expand Up @@ -5868,12 +5827,7 @@ func (m *Manager) tmuxSendEntersForAgent(agent *AgentProcess) {

// tmuxSendKeysForAgent sends key sequences (C-c, C-u, etc.) using the agent's tmux socket.
func (m *Manager) tmuxSendKeysForAgent(agent *AgentProcess, keys ...string) {
if m.sendKeysForAgent != nil {
m.sendKeysForAgent(agent, keys...)
return
}
args := append([]string{"send-keys", "-t", agent.tmuxSession}, keys...)
_ = m.tmuxCmd(agent, args...).Run()
m.term().SendKeys(agent, keys...)
}

const (
Expand Down
120 changes: 120 additions & 0 deletions src/pkg/agent/terminal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package agent

import (
"fmt"
"strconv"
"strings"
"time"
)

// TerminalSession abstracts every interaction the Manager has with an
// agent's interactive terminal (today: a per-agent tmux session). It was
// extracted from eight ad-hoc func-typed test-seam fields on Manager
// (issue #5636, phase 1) so terminal IO has one named contract instead of
// scattered nilable callbacks.
//
// The production implementation is tmuxTerminal below; tests install a
// funcTerminal (see terminal_seams_test.go) to fake individual methods.
type TerminalSession interface {
// CapturePane returns the agent's pane content including scrollback
// (bounded by tmuxCaptureLines), for diff-based output detection.
CapturePane(agent *AgentProcess) string
// CaptureVisiblePane returns only the visible pane, no scrollback.
CaptureVisiblePane(agent *AgentProcess) string
// SessionAttached reports whether a client is attached to the agent's
// session. Implementations should fail open (true) when unsure.
SessionAttached(agent *AgentProcess) bool
// SendLiteral types text into the agent's pane verbatim.
SendLiteral(agent *AgentProcess, text string)
// SendKeys sends key sequences (C-c, C-u, Enter, ...) to the pane.
SendKeys(agent *AgentProcess, keys ...string)
// Sleep paces interactive prompt-dismissal loops.
Sleep(d time.Duration)
// CaptureFullLog returns the agent's full retained scrollback (bounded
// by fullLogCaptureLines), joining wrapped lines.
CaptureFullLog(agent *AgentProcess) (string, error)
// ClearHistory drops the session's scrollback history; the visible
// pane is untouched.
ClearHistory(agent *AgentProcess)
}

// term returns the Manager's terminal, defaulting to the real tmux-backed
// implementation. A zero-value Manager therefore behaves exactly as before
// the TerminalSession extraction: every call reaches tmux.
func (m *Manager) term() TerminalSession {
if m.terminal != nil {
return m.terminal
}
return tmuxTerminal{m: m}
}

// tmuxTerminal is the production TerminalSession: each method shells out to
// tmux over the agent's per-UID socket via Manager.tmuxCmd, exactly as the
// pre-extraction Manager methods did.
type tmuxTerminal struct {
m *Manager
}

func (t tmuxTerminal) CapturePane(agent *AgentProcess) string {
cmd := t.m.tmuxCmd(agent, "capture-pane", "-t", agent.tmuxSession, "-p",
"-S", fmt.Sprintf("-%d", tmuxCaptureLines))
out, err := cmd.Output()
if err != nil {
return ""
}
return string(out)
}

func (t tmuxTerminal) CaptureVisiblePane(agent *AgentProcess) string {
cmd := t.m.tmuxCmd(agent, "capture-pane", "-t", agent.tmuxSession, "-p")
out, err := cmd.Output()
if err != nil {
return ""
}
return string(out)
}

func (t tmuxTerminal) SessionAttached(agent *AgentProcess) bool {
if agent == nil || agent.tmuxSession == "" {
return true
}
out, err := t.m.tmuxCmd(agent, "display-message", "-p", "-t", agent.tmuxSession, "#{session_attached}").Output()
if err != nil {
return true
}
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
if err != nil {
return true
}
return n > 0
}

func (t tmuxTerminal) SendLiteral(agent *AgentProcess, text string) {
_ = t.m.tmuxCmd(agent, "send-keys", "-t", agent.tmuxSession, "-l", text).Run()
}

func (t tmuxTerminal) SendKeys(agent *AgentProcess, keys ...string) {
args := append([]string{"send-keys", "-t", agent.tmuxSession}, keys...)
_ = t.m.tmuxCmd(agent, args...).Run()
}

func (t tmuxTerminal) Sleep(d time.Duration) {
time.Sleep(d)
}

func (t tmuxTerminal) CaptureFullLog(agent *AgentProcess) (string, error) {
// -S -<n>: start n lines back into history; -E -: through the last visible
// line; -J: join wrapped lines so copied text is not hard-wrapped at the
// pane width; -p: print to stdout.
cmd := t.m.tmuxCmd(agent, "capture-pane", "-t", agent.tmuxSession, "-p", "-J",
"-S", fmt.Sprintf("-%d", fullLogCaptureLines), "-E", "-")
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("capturing pane for %s: %w", agent.Name, err)
}
return string(out), nil
}

func (t tmuxTerminal) ClearHistory(agent *AgentProcess) {
_ = t.m.tmuxCmd(agent, "clear-history", "-t", agent.tmuxSession).Run()
}
Loading
Loading