feat: update readme & agent TUI reliability (#239)

This commit is contained in:
Carlos Florêncio
2026-02-17 22:59:53 +00:00
committed by GitHub
parent 1f4170da8b
commit 90bec9aaa0
47 changed files with 2129 additions and 911 deletions
@@ -595,97 +595,183 @@ func TestInteractiveRunner_IsProcessReadyOrPending(t *testing.T) {
_ = runner.Stop(ctx, info.ID)
}
func TestCursorPositionQueryDetection(t *testing.T) {
func TestContainsDSRQuery(t *testing.T) {
tests := []struct {
name string
data []byte
wantDSR bool // Device Status Report (cursor position query)
wantDA bool // Device Attributes query
name string
data []byte
want bool
}{
{
name: "cursor position query ESC[6n",
data: []byte("\x1b[6n"),
wantDSR: true,
wantDA: false,
},
{
name: "cursor position query ESC[?6n",
data: []byte("\x1b[?6n"),
wantDSR: true,
wantDA: false,
},
{
name: "device attributes query ESC[c",
data: []byte("\x1b[c"),
wantDSR: false,
wantDA: true,
},
{
name: "device attributes query ESC[0c",
data: []byte("\x1b[0c"),
wantDSR: false,
wantDA: true,
},
{
name: "no escape sequence",
data: []byte("hello world"),
wantDSR: false,
wantDA: false,
},
{
name: "mixed content with DSR",
data: []byte("some text\x1b[6nmore text"),
wantDSR: true,
wantDA: false,
},
{
name: "both DSR and DA",
data: []byte("\x1b[6n\x1b[c"),
wantDSR: true,
wantDA: true,
},
{"ESC[6n", []byte("\x1b[6n"), true},
{"ESC[?6n", []byte("\x1b[?6n"), true},
{"mixed content with DSR", []byte("text\x1b[6nmore"), true},
{"no escape", []byte("hello world"), false},
{"partial ESC[6", []byte("\x1b[6"), false},
{"ESC[c is not DSR", []byte("\x1b[c"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotDSR := containsCursorPositionQuery(tt.data)
gotDA := containsDeviceAttributesQuery(tt.data)
if gotDSR != tt.wantDSR {
t.Errorf("containsCursorPositionQuery() = %v, want %v", gotDSR, tt.wantDSR)
}
if gotDA != tt.wantDA {
t.Errorf("containsDeviceAttributesQuery() = %v, want %v", gotDA, tt.wantDA)
if got := containsDSRQuery(tt.data); got != tt.want {
t.Errorf("containsDSRQuery(%q) = %v, want %v", tt.data, got, tt.want)
}
})
}
}
// containsCursorPositionQuery checks for DSR cursor position query (CSI 6 n)
func containsCursorPositionQuery(data []byte) bool {
return bytesContains(data, []byte("\x1b[6n")) || bytesContains(data, []byte("\x1b[?6n"))
}
// containsDeviceAttributesQuery checks for DA query (CSI c or CSI 0 c)
func containsDeviceAttributesQuery(data []byte) bool {
return bytesContains(data, []byte("\x1b[c")) || bytesContains(data, []byte("\x1b[0c"))
}
// bytesContains is a simple contains check (bytes.Contains equivalent for tests)
func bytesContains(data, substr []byte) bool {
for i := 0; i <= len(data)-len(substr); i++ {
match := true
for j := 0; j < len(substr); j++ {
if data[i+j] != substr[j] {
match = false
break
}
}
if match {
return true
}
func TestContainsDA1Query(t *testing.T) {
tests := []struct {
name string
data []byte
want bool
}{
{"ESC[c (DA1 no param)", []byte("\x1b[c"), true},
{"ESC[0c (DA1 explicit 0)", []byte("\x1b[0c"), true},
{"mixed with DA1", []byte("text\x1b[cmore"), true},
{"both DSR and DA1", []byte("\x1b[6n\x1b[c"), true},
{"no escape", []byte("hello world"), false},
{"ESC[1c is cursor forward, not DA1", []byte("\x1b[1c"), false},
{"ESC[2c is cursor forward, not DA1", []byte("\x1b[2c"), false},
{"ESC[5c is cursor forward, not DA1", []byte("\x1b[5c"), false},
{"ESC[9c is cursor forward, not DA1", []byte("\x1b[9c"), false},
{"partial ESC[", []byte("\x1b["), false},
{"empty data", []byte{}, false},
}
return false
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := containsDA1Query(tt.data); got != tt.want {
t.Errorf("containsDA1Query(%q) = %v, want %v", tt.data, got, tt.want)
}
})
}
}
func TestInteractiveRunner_ResizeByProcessID(t *testing.T) {
log := newTestLogger(t)
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
// Start a deferred process
req := InteractiveStartRequest{
SessionID: "resize-pid-test",
Command: []string{"cat"},
}
info, err := runner.Start(context.Background(), req)
if err != nil {
t.Fatalf("Start() error = %v", err)
}
// Process should not be running yet
if runner.IsProcessRunning(info.ID) {
t.Error("process should not be running before resize")
}
// Trigger start via ResizeByProcessID
if err := runner.ResizeByProcessID(info.ID, 80, 24); err != nil {
t.Fatalf("ResizeByProcessID() error = %v", err)
}
// Give process time to start
time.Sleep(100 * time.Millisecond)
if !runner.IsProcessRunning(info.ID) {
t.Error("process should be running after ResizeByProcessID")
}
// Subsequent resize should succeed
if err := runner.ResizeByProcessID(info.ID, 120, 40); err != nil {
t.Errorf("second ResizeByProcessID() error = %v", err)
}
// Non-existent process should fail
if err := runner.ResizeByProcessID("nonexistent", 80, 24); err == nil {
t.Error("ResizeByProcessID() should fail for nonexistent process")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = runner.Stop(ctx, info.ID)
}
func TestInteractiveRunner_IsUserShellIsolation(t *testing.T) {
log := newTestLogger(t)
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
// Start a regular (non-user-shell) process
agentReq := InteractiveStartRequest{
SessionID: "isolation-test",
Command: []string{"cat"},
ImmediateStart: true,
DefaultCols: 80,
DefaultRows: 24,
}
agentInfo, err := runner.Start(context.Background(), agentReq)
if err != nil {
t.Fatalf("Start() agent error = %v", err)
}
// Start a user shell process for the same session
shellReq := InteractiveStartRequest{
SessionID: "isolation-test",
Command: []string{"cat"},
IsUserShell: true,
ImmediateStart: true,
DefaultCols: 80,
DefaultRows: 24,
}
shellInfo, err := runner.Start(context.Background(), shellReq)
if err != nil {
t.Fatalf("Start() shell error = %v", err)
}
time.Sleep(100 * time.Millisecond)
// ResizeBySession should target the agent process, not the user shell
if err := runner.ResizeBySession("isolation-test", 120, 40); err != nil {
t.Errorf("ResizeBySession() error = %v", err)
}
// GetPtyWriterBySession should return the agent process, not the user shell
_, procID, err := runner.GetPtyWriterBySession("isolation-test")
if err != nil {
t.Fatalf("GetPtyWriterBySession() error = %v", err)
}
if procID != agentInfo.ID {
t.Errorf("GetPtyWriterBySession() returned process %q, want agent process %q", procID, agentInfo.ID)
}
// SetDirectOutput on user shell should NOT track at session level
writer := &mockDirectWriter{}
if err := runner.SetDirectOutput(shellInfo.ID, writer); err != nil {
t.Fatalf("SetDirectOutput() shell error = %v", err)
}
// Session-level WebSocket should NOT be set for user shells
if runner.HasActiveWebSocketBySession("isolation-test") {
t.Error("user shell should not set session-level WebSocket tracking")
}
// SetDirectOutput on agent process SHOULD track at session level
agentWriter := &mockDirectWriter{}
if err := runner.SetDirectOutput(agentInfo.ID, agentWriter); err != nil {
t.Fatalf("SetDirectOutput() agent error = %v", err)
}
if !runner.HasActiveWebSocketBySession("isolation-test") {
t.Error("agent process should set session-level WebSocket tracking")
}
// ClearDirectOutputBySession should clear agent but not user shell
runner.ClearDirectOutputBySession("isolation-test")
// Agent should have its direct output cleared
if runner.HasActiveWebSocket(agentInfo.ID) {
t.Error("agent WebSocket should be cleared after ClearDirectOutputBySession")
}
// User shell should still have its direct output
if !runner.HasActiveWebSocket(shellInfo.ID) {
t.Error("user shell WebSocket should NOT be cleared by ClearDirectOutputBySession")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = runner.Stop(ctx, agentInfo.ID)
_ = runner.Stop(ctx, shellInfo.ID)
}
// --- User Shell Tests ---