Skip to content

Commit 6187975

Browse files
committed
Add PTY coverage for unified loop view
1 parent a7ca648 commit 6187975

5 files changed

Lines changed: 227 additions & 1 deletion

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Decision: Close the TUI-testability review nit with a committed PTY e2e test (creack/pty +
2+
3+
Status: proposed
4+
By: smart
5+
6+
Close the TUI-testability review nit with a committed PTY e2e test (creack/pty + test-binary helper-process pattern) instead of leaving terminal coverage manual: the test runs agents go on a real pseudo-terminal and asserts split view, ready screen, and accept/archive in CI; creack/pty is test-only
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# PTY e2e test for the unified view
2+
3+
status: archived
4+
previous_status: active
5+
archived_at: 2026-06-11T07:14:36Z
6+
7+
Summary:
8+
- decisions: 1
9+
- handoffs: 2
10+
- verification records: 1
11+
- findings open: 0
12+
- findings fixed: 0
13+
- findings verified: 0
14+
15+
## Scope
16+
17+
- define scope
18+
19+
## Final Status
20+
21+
Starting task.
22+
23+
Detailed transient chunks remain available in git history.

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ module github.com/mekilis/agents
22

33
go 1.23.0
44

5-
require golang.org/x/term v0.30.0
5+
require (
6+
github.com/creack/pty v1.1.24
7+
golang.org/x/term v0.30.0
8+
)
69

710
require golang.org/x/sys v0.31.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
2+
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
13
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
24
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
35
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=

internal/app/loopview_pty_test.go

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
//go:build !windows
2+
3+
package app
4+
5+
import (
6+
"bytes"
7+
"io"
8+
"os"
9+
"os/exec"
10+
"path/filepath"
11+
"strings"
12+
"sync"
13+
"testing"
14+
"time"
15+
16+
"github.com/creack/pty"
17+
)
18+
19+
const ptyHelperEnv = "AGENTS_PTY_HELPER"
20+
21+
// TestHelperProcess re-runs the CLI inside the test binary so the PTY test
22+
// can drive `agents go` as a real subprocess. It is inert in normal runs.
23+
func TestHelperProcess(t *testing.T) {
24+
if os.Getenv(ptyHelperEnv) != "1" {
25+
t.Skip("helper process only runs under the PTY e2e test")
26+
}
27+
args := os.Args
28+
for i, arg := range args {
29+
if arg == "--" {
30+
args = args[i+1:]
31+
break
32+
}
33+
}
34+
os.Exit(Run(args, os.Stdin, os.Stdout, os.Stderr))
35+
}
36+
37+
// TestGoUnifiedViewUnderPTY proves the TUI path end-to-end on a real
38+
// pseudo-terminal: the split view renders both lanes live, the loop reaches
39+
// the human boundary, the Ready-for-you screen shows the evidence, and
40+
// accept archives the task with a suggested commit message.
41+
func TestGoUnifiedViewUnderPTY(t *testing.T) {
42+
if testing.Short() {
43+
t.Skip("PTY e2e test skipped in -short mode")
44+
}
45+
home := t.TempDir()
46+
cwd := t.TempDir()
47+
// macOS resolves /var to /private/var; the workspace trust store records
48+
// the resolved path, so use it consistently.
49+
if resolved, err := filepath.EvalSymlinks(cwd); err == nil {
50+
cwd = resolved
51+
}
52+
53+
writeStub := func(name, body string) string {
54+
path := filepath.Join(cwd, name)
55+
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
56+
t.Fatal(err)
57+
}
58+
return path
59+
}
60+
// Stub records use far-future timestamps so they sort after go's own
61+
// handoff; the scripts locate the task dir by glob since go creates it.
62+
implementer := writeStub("implementer.sh", strings.Join([]string{
63+
"#!/bin/sh",
64+
"set -eu",
65+
"echo \"editing main.go\"",
66+
"task_dir=$(echo .agents/tasks/*)",
67+
"mkdir -p \"$task_dir/handoffs\"",
68+
"cat > \"$task_dir/handoffs/2099-01-01T000100Z-handoff-to-reviewer.md\" <<'EOF'",
69+
"# Handoff: reviewer",
70+
"",
71+
"To: reviewer",
72+
"",
73+
"Ready for review.",
74+
"EOF",
75+
"",
76+
}, "\n"))
77+
reviewer := writeStub("reviewer.sh", strings.Join([]string{
78+
"#!/bin/sh",
79+
"set -eu",
80+
"echo \"checking diff\"",
81+
"task_dir=$(echo .agents/tasks/*)",
82+
"mkdir -p \"$task_dir/verification\"",
83+
"cat > \"$task_dir/verification/2099-01-01T000200Z-checks.md\" <<'EOF'",
84+
"# Verification: checks",
85+
"",
86+
"Result: pass",
87+
"By: reviewer",
88+
"",
89+
"All checks pass.",
90+
"EOF",
91+
"",
92+
}, "\n"))
93+
if err := os.MkdirAll(filepath.Join(cwd, ".agents"), 0o755); err != nil {
94+
t.Fatal(err)
95+
}
96+
cfg := "roles:\n implementer: " + implementer + " {prompt_file}\n reviewer: " + reviewer + " {prompt_file}\n coordinator: codex\n"
97+
if err := os.WriteFile(filepath.Join(cwd, ".agents", "config.yml"), []byte(cfg), 0o644); err != nil {
98+
t.Fatal(err)
99+
}
100+
if _, err := trustWorkspace(env{cwd: cwd, home: home, trustPath: filepath.Join(home, ".agents", "trust.json")}); err != nil {
101+
t.Fatal(err)
102+
}
103+
104+
cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess", "--", "go", "History: support /history in the terminal")
105+
cmd.Dir = cwd
106+
cmd.Env = append(os.Environ(), ptyHelperEnv+"=1", "HOME="+home)
107+
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 30, Cols: 100})
108+
if err != nil {
109+
t.Skipf("cannot allocate pty: %v", err)
110+
}
111+
defer func() { _ = ptmx.Close() }()
112+
113+
var mu sync.Mutex
114+
var output bytes.Buffer
115+
readDone := make(chan struct{})
116+
go func() {
117+
defer close(readDone)
118+
buf := make([]byte, 4096)
119+
for {
120+
n, readErr := ptmx.Read(buf)
121+
if n > 0 {
122+
mu.Lock()
123+
output.Write(buf[:n])
124+
mu.Unlock()
125+
}
126+
if readErr != nil {
127+
return
128+
}
129+
}
130+
}()
131+
snapshot := func() string {
132+
mu.Lock()
133+
defer mu.Unlock()
134+
return output.String()
135+
}
136+
waitFor := func(marker string) {
137+
t.Helper()
138+
deadline := time.Now().Add(30 * time.Second)
139+
for time.Now().Before(deadline) {
140+
if strings.Contains(snapshot(), marker) {
141+
return
142+
}
143+
time.Sleep(50 * time.Millisecond)
144+
}
145+
t.Fatalf("timed out waiting for %q in PTY output:\n%s", marker, snapshot())
146+
}
147+
148+
// The loop runs to the human boundary, then the view flips to the
149+
// Ready-for-you screen; accept archives and suggests a commit message.
150+
waitFor("Your call:")
151+
if _, err := io.WriteString(ptmx, "a"); err != nil {
152+
t.Fatal(err)
153+
}
154+
155+
waitErr := make(chan error, 1)
156+
go func() { waitErr <- cmd.Wait() }()
157+
select {
158+
case err := <-waitErr:
159+
if err != nil {
160+
t.Fatalf("agents go exited with error: %v\n%s", err, snapshot())
161+
}
162+
case <-time.After(30 * time.Second):
163+
_ = cmd.Process.Kill()
164+
t.Fatalf("agents go did not exit after accept:\n%s", snapshot())
165+
}
166+
_ = ptmx.Close()
167+
<-readDone
168+
169+
got := snapshot()
170+
for _, want := range []string{
171+
"Building", // split view lane
172+
"Checking", // split view lane
173+
"editing main.go", // live implementer output
174+
"checking diff", // live reviewer output
175+
"Ready for you — History", // boundary screen
176+
"support /history in the terminal", // scope section
177+
"Verification evidence:", // evidence section
178+
"archived task: History", // accept archived
179+
"Suggested commit message", // accept suggested a message
180+
"[q]uit [p]ause", // key hints while running
181+
} {
182+
if !strings.Contains(got, want) {
183+
t.Fatalf("PTY output missing %q:\n%s", want, got)
184+
}
185+
}
186+
if _, err := os.Stat(filepath.Join(cwd, ".agents", "archive")); err != nil {
187+
t.Fatalf("expected archive directory after accept: %v", err)
188+
}
189+
if active := readActiveTask(cwd); active.Path != "" {
190+
t.Fatalf("expected active task cleared after accept, got %+v", active)
191+
}
192+
}

0 commit comments

Comments
 (0)