Files
patterm/internal/harness/scenario.go
Harry Bayliss 2b9e1ed77c Add idle-state classifier and Solo-parity timer tools
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.
2026-05-15 09:49:59 +01:00

89 lines
3.1 KiB
Go

package harness
import (
"encoding/json"
"fmt"
"os"
)
type Scenario struct {
Path string `json:"-"`
Name string `json:"name,omitempty"`
ProjectDir string `json:"project_dir,omitempty"`
Cols uint16 `json:"cols,omitempty"`
Rows uint16 `json:"rows,omitempty"`
Env map[string]string `json:"env,omitempty"`
Trust []string `json:"trust,omitempty"`
Presets ScenarioPresets `json:"presets,omitempty"`
Scripts []ScenarioScript `json:"scripts,omitempty"`
Steps []Step `json:"steps"`
}
type ScenarioPresets struct {
Agents []ScenarioPreset `json:"agents,omitempty"`
Processes []ScenarioPreset `json:"processes,omitempty"`
}
type ScenarioPreset struct {
Name string `json:"name"`
Argv []string `json:"argv"`
Env map[string]string `json:"env,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
Shell bool `json:"shell,omitempty"`
IdleDetection *ScenarioIdleDetection `json:"idle_detection,omitempty"`
}
// ScenarioIdleDetection mirrors preset.IdleDetection so scenarios can
// configure per-strategy idle detection for fake agent presets.
type ScenarioIdleDetection struct {
Strategy string `json:"strategy,omitempty"`
IdleThresholdMS int `json:"idle_threshold_ms,omitempty"`
TitleStatusMap map[string]string `json:"title_status_map,omitempty"`
PermissionPatterns []string `json:"permission_patterns,omitempty"`
ThinkingPatterns []string `json:"thinking_patterns,omitempty"`
ErrorPatterns []string `json:"error_patterns,omitempty"`
}
type ScenarioScript struct {
Name string `json:"name"`
Body string `json:"body"`
}
type Step struct {
Type string `json:"type"`
Chord string `json:"chord,omitempty"`
Text string `json:"text,omitempty"`
Contains string `json:"contains,omitempty"`
Regex string `json:"regex,omitempty"`
TimeoutMS int `json:"timeout_ms,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
SaveAs string `json:"save_as,omitempty"`
From string `json:"from,omitempty"`
Path string `json:"path,omitempty"`
Equals any `json:"equals,omitempty"`
ErrorKind string `json:"error_kind,omitempty"`
CursorRow *int `json:"cursor_row,omitempty"`
CursorCol *int `json:"cursor_col,omitempty"`
AllowSubstring bool `json:"allow_substring,omitempty"`
}
func LoadScenario(path string) (*Scenario, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var sc Scenario
if err := json.Unmarshal(b, &sc); err != nil {
return nil, fmt.Errorf("parse scenario %s: %w", path, err)
}
sc.Path = path
if sc.Cols == 0 {
sc.Cols = 120
}
if sc.Rows == 0 {
sc.Rows = 40
}
return &sc, nil
}