claude-failover/internal/watcher/session_watcher_test.go
Ubuntu 6b109ed1bc fix(dispatcher): send a lone Enter after the task paste to submit it
Multi-line task bodies arrived in Claude Code as "[Pasted text #N +M lines]"
and sat in the input buffer forever — the trailing Enter that SendKeys
appends to the paste is consumed as a newline inside the paste, not as a
submit. Observed live on ccl-auto-11 (secumon) and ccl-auto-12 (secuops):
prompt visible, agent idle.

- tmux.Client grows a SendEnter(session) method. ExecClient runs
  `tmux send-keys -t <sess> Enter` (no preceding text), which Claude's
  TUI accepts as the explicit submit action after a paste.
- Dispatcher: after SendKeys(msg), sleep 500ms for the paste to register,
  then SendEnter. Same sequence a human would perform.
- Five mockTmux implementations updated (quota, dispatcher, switcher,
  lifecycle, watcher tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:49:59 +00:00

184 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package watcher
import (
"log"
"os"
"path/filepath"
"testing"
"time"
"forge.secuaas.ovh/olivier/claude-failover/internal/state"
)
// mockTmux is a minimal in-memory tmux.Client for tests.
type mockTmux struct {
sessions map[string]bool
paneOutput map[string]string
sentKeys []string
}
func newMockTmux() *mockTmux {
return &mockTmux{
sessions: make(map[string]bool),
paneOutput: make(map[string]string),
}
}
func (m *mockTmux) HasSession(name string) bool { return m.sessions[name] }
func (m *mockTmux) CreateSession(name, _ string) error { m.sessions[name] = true; return nil }
func (m *mockTmux) KillSession(_ string) error { return nil }
func (m *mockTmux) SendKeys(_, keys string) error { m.sentKeys = append(m.sentKeys, keys); return nil }
func (m *mockTmux) SendEnter(_ string) error { m.sentKeys = append(m.sentKeys, "<ENTER>"); return nil }
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
return m.paneOutput[session], nil
}
func newTestWatcher(tc *mockTmux, s *state.State, signalDir string) *SessionWatcher {
return &SessionWatcher{
tmux: tc,
state: s,
done: make(chan string, 32),
interval: time.Second,
idleTimeout: 60 * time.Minute,
signalDir: signalDir,
logger: log.Default(),
}
}
// TestSignalFileTriggersDone verifies that agent-done-<session> causes idle + done signal.
func TestSignalFileTriggersDone(t *testing.T) {
dir := t.TempDir()
tc := newMockTmux()
tc.sessions["sess-a"] = true
s := state.New("")
s.SetWorking("sess-a", "task-1")
w := newTestWatcher(tc, s, dir)
sig := filepath.Join(dir, "agent-done-sess-a")
if err := os.WriteFile(sig, []byte("done"), 0644); err != nil {
t.Fatal(err)
}
w.poll()
select {
case got := <-w.done:
if got != "sess-a" {
t.Errorf("expected sess-a on done, got %q", got)
}
default:
t.Fatal("expected done signal, got none")
}
if st := s.GetSession("sess-a"); st == nil || st.State != "idle" {
t.Errorf("expected sess-a idle, got %v", st)
}
if _, err := os.Stat(sig); !os.IsNotExist(err) {
t.Error("expected signal file to be deleted")
}
}
// TestPromptDetectionTriggersDone verifies that in pane output signals completion.
func TestPromptDetectionTriggersDone(t *testing.T) {
dir := t.TempDir()
tc := newMockTmux()
tc.sessions["sess-b"] = true
tc.paneOutput["sess-b"] = "some output\n "
s := state.New("")
s.SetWorking("sess-b", "task-2")
w := newTestWatcher(tc, s, dir)
w.poll()
select {
case got := <-w.done:
if got != "sess-b" {
t.Errorf("expected sess-b, got %q", got)
}
default:
t.Fatal("expected done signal from prompt detection")
}
if st := s.GetSession("sess-b"); st == nil || st.State != "idle" {
t.Errorf("expected sess-b idle, got %v", st)
}
}
// TestSpinnerSuppressesCompletion verifies that an active spinner prevents false completion.
func TestSpinnerSuppressesCompletion(t *testing.T) {
dir := t.TempDir()
tc := newMockTmux()
tc.sessions["sess-c"] = true
tc.paneOutput["sess-c"] = "doing work 5s · \n "
s := state.New("")
s.SetWorking("sess-c", "task-3")
w := newTestWatcher(tc, s, dir)
w.poll()
select {
case name := <-w.done:
t.Errorf("unexpected done signal for %q (spinner should suppress)", name)
default:
// Correct: no signal while spinner is active.
}
if st := s.GetSession("sess-c"); st == nil || st.State != "working" {
t.Errorf("expected sess-c still working, got %v", st)
}
}
// TestIdleTimeoutTriggersDone verifies that a session exceeding idleTimeout is completed.
func TestIdleTimeoutTriggersDone(t *testing.T) {
dir := t.TempDir()
tc := newMockTmux()
tc.sessions["sess-d"] = true
tc.paneOutput["sess-d"] = "still running..."
s := state.New("")
s.SetWorking("sess-d", "task-4")
w := &SessionWatcher{
tmux: tc,
state: s,
done: make(chan string, 32),
interval: time.Second,
idleTimeout: 1 * time.Millisecond,
signalDir: dir,
logger: log.Default(),
}
time.Sleep(5 * time.Millisecond)
w.poll()
select {
case got := <-w.done:
if got != "sess-d" {
t.Errorf("expected sess-d, got %q", got)
}
default:
t.Fatal("expected done signal from idle timeout")
}
}
// TestHasSpinnerPatterns verifies spinner pattern detection.
func TestHasSpinnerPatterns(t *testing.T) {
cases := []struct {
input string
want bool
}{
{"5s · running", true},
{"12s ⠋ working", true},
{" prompt only", false},
{"no spinner here", false},
}
for _, c := range cases {
if got := hasSpinner(c.input); got != c.want {
t.Errorf("hasSpinner(%q) = %v, want %v", c.input, got, c.want)
}
}
}