Classifies every running child as idle/working/thinking/permission/error using one of three pluggable strategies (output_activity, osc_title_stability, osc_title_status) plus optional regex promoters applied to the tail of recent output. State and last-match reason are exposed via MCP on ProcessInfo and get_process_status. Per-preset configuration lives on a new preset.IdleDetection block with bundled defaults for the first-party claude/codex/opencode presets. OSC title plumbing is exposed as Emulator.Title(), polled from the session pump after each emulator write so title-change activity feeds into the classifier without an extra cgo callback. The MCP timer surface expands to match Solo: timer_set, timer_fire_when_idle_any/all, timer_cancel, timer_pause, timer_resume, timer_list. timer_wait is now a thin wrapper that shares the same manager so it shows up in timer_list while pending. Timer bodies are delivered to the owner process through the existing InjectAsOrchestrator path. Top-level (non-agent) callers can attach timers to a specific process via owner_process_id; omitting it grants universal cancel/pause/resume/list privileges. The sidebar gains a state glyph per process row and appends a nearest-timer indicator when one is pending or paused. Tests: idle_test.go covers the classify() pure function across the three strategies and regex promotion; timers_test.go covers the manager. Harness scenarios cover output_activity, osc_title_stability, osc_title_status, and regex promotion, plus timer_set delivery, cancel, pause/resume, idle_any-on-transition, idle_all-pending, and idle_all-already-satisfied. A new wait_until_mcp harness step type polls an MCP method until an assertion holds.
113 lines
3.7 KiB
Go
113 lines
3.7 KiB
Go
package app
|
|
|
|
import (
|
|
"regexp"
|
|
"testing"
|
|
)
|
|
|
|
func mustCompile(t *testing.T, p string) *regexp.Regexp {
|
|
t.Helper()
|
|
re, err := regexp.Compile(p)
|
|
if err != nil {
|
|
t.Fatalf("regex %q: %v", p, err)
|
|
}
|
|
return re
|
|
}
|
|
|
|
func TestClassifyOutputActivity(t *testing.T) {
|
|
cfg := &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: 2000}
|
|
|
|
cases := []struct {
|
|
name string
|
|
idleMS int64
|
|
want IdleState
|
|
}{
|
|
{"fresh-spawn no writes", 0, StateWorking},
|
|
{"recent activity", 500, StateWorking},
|
|
{"under threshold", 1999, StateWorking},
|
|
{"at threshold", 2000, StateIdle},
|
|
{"over threshold", 5000, StateIdle},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, _ := classify(cfg, false, false, tc.idleMS, 0, "", nil)
|
|
if got != tc.want {
|
|
t.Fatalf("got %q want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClassifyTitleStability(t *testing.T) {
|
|
cfg := &resolvedIdleDetection{strategy: StrategyOSCTitleStability, idleThresholdMS: 2000}
|
|
// Title change recent → working.
|
|
if got, _ := classify(cfg, false, false, 9999, 500, "step 3", nil); got != StateWorking {
|
|
t.Fatalf("recent title change: got %q", got)
|
|
}
|
|
// Title stable past threshold → idle.
|
|
if got, _ := classify(cfg, false, false, 9999, 5000, "step 3", nil); got != StateIdle {
|
|
t.Fatalf("stable title: got %q", got)
|
|
}
|
|
// No title yet: fall back to output activity.
|
|
if got, _ := classify(cfg, false, false, 100, 0, "", nil); got != StateWorking {
|
|
t.Fatalf("no title yet, recent output: got %q", got)
|
|
}
|
|
if got, _ := classify(cfg, false, false, 5000, 0, "", nil); got != StateIdle {
|
|
t.Fatalf("no title yet, output idle: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestClassifyTitleStatus(t *testing.T) {
|
|
cfg := &resolvedIdleDetection{
|
|
strategy: StrategyOSCTitleStatus,
|
|
idleThresholdMS: 2000,
|
|
titleStatusMap: map[string]IdleState{
|
|
"thinking": StateThinking,
|
|
"permission": StatePermission,
|
|
"error": StateError,
|
|
},
|
|
}
|
|
if got, _ := classify(cfg, false, false, 9999, 500, "Thinking…", nil); got != StateThinking {
|
|
t.Fatalf("thinking title: got %q", got)
|
|
}
|
|
if got, _ := classify(cfg, false, false, 9999, 500, "Waiting for permission", nil); got != StatePermission {
|
|
t.Fatalf("permission title: got %q", got)
|
|
}
|
|
// No match in map → fall back to stability.
|
|
if got, _ := classify(cfg, false, false, 9999, 5000, "ready", nil); got != StateIdle {
|
|
t.Fatalf("unmatched title, stable: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestClassifyPromoterRegex(t *testing.T) {
|
|
cfg := &resolvedIdleDetection{
|
|
strategy: StrategyOutputActivity,
|
|
idleThresholdMS: 2000,
|
|
permissionRegexes: []*regexp.Regexp{mustCompile(t, `Approve\?`)},
|
|
errorRegexes: []*regexp.Regexp{mustCompile(t, `panic:`)},
|
|
thinkingRegexes: []*regexp.Regexp{mustCompile(t, `Thinking`)},
|
|
}
|
|
// Permission promoter beats idle.
|
|
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Approve? [y/n]")); got != StatePermission {
|
|
t.Fatalf("permission promoter: got %q", got)
|
|
}
|
|
// Error trumps permission.
|
|
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("panic: bad\nApprove?")); got != StateError {
|
|
t.Fatalf("error promoter beats permission: got %q", got)
|
|
}
|
|
// Thinking promoter on idle output.
|
|
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Thinking…")); got != StateThinking {
|
|
t.Fatalf("thinking promoter: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestClassifyExitTerminal(t *testing.T) {
|
|
cfg := &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: 2000}
|
|
if got, _ := classify(cfg, true, true, 0, 0, "", nil); got != StateError {
|
|
t.Fatalf("non-zero exit: got %q", got)
|
|
}
|
|
if got, _ := classify(cfg, true, false, 0, 0, "", nil); got != StateIdle {
|
|
t.Fatalf("clean exit: got %q", got)
|
|
}
|
|
}
|