ci-platforms / windows amd64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci / checks (push) Failing after 44m58s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 12m46s
ci / release (push) Skipped
GitHub / windows amd64 native GitHub-hosted native CI: success
GitHub / darwin arm64 native GitHub-hosted native CI: success
GitHub / darwin amd64 native GitHub-hosted native CI: success
GitHub / linux amd64 native GitHub-hosted native CI: success
295 lines
8.3 KiB
Go
295 lines
8.3 KiB
Go
package integration
|
|
|
|
import (
|
|
"slices"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"aego/app/agentui/backend"
|
|
"aego/app/edit"
|
|
"aego/core/log"
|
|
"aego/editor/core/agentui"
|
|
"aego/editor/core/tasks"
|
|
"aego/editor/core/workspace"
|
|
agentpanel "aego/editor/panels/agent"
|
|
"aego/project"
|
|
)
|
|
|
|
func agentSession(t *testing.T, b backend.Backend) (*edit.Session, string) {
|
|
t.Helper()
|
|
root := copyExample(t, "platformer")
|
|
p, err := project.Load(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, err := edit.Open(p, edit.Config{Headless: true, NoWatch: true, Logger: log.Nop(), AgentBackend: b}, combatModule{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(s.Close)
|
|
s.Step()
|
|
if _, err := s.App.Context().Docs.Open(bridgeScene); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.App.Workspace().Show(agentpanel.ID)
|
|
return s, root
|
|
}
|
|
|
|
func stepUntil(t *testing.T, s *edit.Session, done func() bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(30 * time.Second)
|
|
for !done() {
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out")
|
|
}
|
|
s.Step()
|
|
}
|
|
}
|
|
|
|
// Allocation budgets measure a settled editor, not background preview,
|
|
// language refresh or agent-availability work.
|
|
func editorSettled(s *edit.Session) bool {
|
|
panel := s.App.Agent()
|
|
if code, ok := s.App.Context().Code.(interface{ Refreshing() bool }); ok && code.Refreshing() {
|
|
return false
|
|
}
|
|
return s.App.Idle() && !s.App.Previews().Busy() &&
|
|
(panel == nil || (panel.Checked() && !panel.Busy()))
|
|
}
|
|
|
|
func waitEditorSettled(t *testing.T, s *edit.Session) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(30 * time.Second)
|
|
stable := 0
|
|
for stable < 10 {
|
|
s.Step()
|
|
if editorSettled(s) {
|
|
stable++
|
|
} else {
|
|
stable = 0
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("editor or agent did not settle before measuring allocations")
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// Measure ordinary idle frames just after the real Assets maintenance scan.
|
|
// The scan remains enabled; if another scan interrupts the window, fail rather
|
|
// than silently counting unrelated filesystem allocations against a panel.
|
|
func editorFrameAllocs(t *testing.T, s *edit.Session, step func()) float64 {
|
|
t.Helper()
|
|
waitEditorSettled(t, s)
|
|
assets := s.App.AssetsPanel()
|
|
if assets == nil {
|
|
t.Fatal("the editor has no Assets panel")
|
|
}
|
|
stamp := assets.DirectoryRefreshes()
|
|
if _, visible := s.App.Workspace().Tree.PanelRect(workspace.Assets); visible {
|
|
deadline := time.Now().Add(30 * time.Second)
|
|
seen, stable := false, 0
|
|
for stable < 10 {
|
|
s.Step()
|
|
if next := assets.DirectoryRefreshes(); next != stamp {
|
|
stamp, seen, stable = next, true, 0
|
|
}
|
|
if seen && editorSettled(s) {
|
|
stable++
|
|
} else {
|
|
stable = 0
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("Assets maintenance or the editor did not settle before measuring allocations")
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
allocs := testing.AllocsPerRun(30, step)
|
|
if assets.DirectoryRefreshes() != stamp {
|
|
t.Fatal("Assets maintenance interrupted the allocation measurement")
|
|
}
|
|
return allocs
|
|
}
|
|
|
|
type delayedAvailabilityBackend struct {
|
|
backend.Fake
|
|
started chan struct{}
|
|
release chan struct{}
|
|
start sync.Once
|
|
finish sync.Once
|
|
}
|
|
|
|
func (b *delayedAvailabilityBackend) Available(backend.Config) (string, error) {
|
|
b.start.Do(func() { close(b.started) })
|
|
<-b.release
|
|
return "fake", nil
|
|
}
|
|
|
|
func (b *delayedAvailabilityBackend) unblock() {
|
|
b.finish.Do(func() { close(b.release) })
|
|
}
|
|
|
|
func TestAgentEditorIdleWaitsForAvailability(t *testing.T) {
|
|
b := &delayedAvailabilityBackend{started: make(chan struct{}), release: make(chan struct{})}
|
|
defer b.unblock()
|
|
s, _ := agentSession(t, b)
|
|
stepUntil(t, s, func() bool {
|
|
select {
|
|
case <-b.started:
|
|
return s.App.Idle() && !s.App.Previews().Busy()
|
|
default:
|
|
return false
|
|
}
|
|
})
|
|
if editorSettled(s) || s.App.Agent().Checked() {
|
|
t.Fatal("editor was considered idle while agent availability was still running")
|
|
}
|
|
b.unblock()
|
|
waitEditorSettled(t, s)
|
|
if !s.App.Agent().Checked() || s.App.Agent().Missing() != nil {
|
|
t.Fatal("agent availability did not settle successfully")
|
|
}
|
|
}
|
|
|
|
func TestEditorSettledWaitsPastFixedFrameWarmup(t *testing.T) {
|
|
s := workspaceSession(t)
|
|
steps := 0
|
|
s.App.Tasks().Step(tasks.Spec{Title: "delayed test task"}, tasks.LaneMain, func(*tasks.Task) (bool, error) {
|
|
steps++
|
|
return steps >= 180, nil
|
|
})
|
|
for range 60 {
|
|
s.Step()
|
|
}
|
|
if !s.App.Tasks().Busy() || editorSettled(s) {
|
|
t.Fatal("a fixed 60-frame warmup did not leave the delayed task active")
|
|
}
|
|
waitEditorSettled(t, s)
|
|
if s.App.Tasks().Busy() || steps < 180 {
|
|
t.Fatal("the editor was considered settled before the delayed task finished")
|
|
}
|
|
}
|
|
|
|
// The panel shows the proposal as a card, Apply makes one Undo step, and the session log holds every kind of entry.
|
|
func TestAgentPanelAppliesACardAndLogsTheSession(t *testing.T) {
|
|
fake := &backend.Fake{Turns: [][]backend.Step{{
|
|
{Text: "Adding a crate."},
|
|
{Tool: "scene_add_node", Args: map[string]any{"file": bridgeScene, "name": "AgentCrate"}},
|
|
{Text: "Done."},
|
|
}}}
|
|
s, root := agentSession(t, fake)
|
|
panel := s.App.Agent()
|
|
if panel == nil {
|
|
t.Fatal("no agent panel")
|
|
}
|
|
panel.Submit("add a crate")
|
|
stepUntil(t, s, func() bool { return len(panel.Pending()) == 1 })
|
|
panel.Decide(panel.Pending()[0], true)
|
|
stepUntil(t, s, func() bool {
|
|
d := s.App.Context().Docs.Edit()
|
|
return len(d.History.Steps(nil)) == 1 && !d.Scene().Find(d.Scene().Root(), "AgentCrate").IsZero()
|
|
})
|
|
for range 30 {
|
|
s.Step()
|
|
}
|
|
ids, err := agentui.List(root)
|
|
if err != nil || len(ids) != 1 {
|
|
t.Fatalf("sessions %v %v", ids, err)
|
|
}
|
|
entries, err := agentui.Load(root, ids[0])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var kinds, states []string
|
|
for _, e := range entries {
|
|
kinds = append(kinds, e.Kind)
|
|
if e.Kind == "card" {
|
|
states = append(states, e.State)
|
|
}
|
|
}
|
|
for _, k := range []string{"user", "text", "tool", "result", "card"} {
|
|
if !slices.Contains(kinds, k) {
|
|
t.Fatalf("the log has no %s entry: %v", k, kinds)
|
|
}
|
|
}
|
|
if !slices.Contains(states, "pending") || !slices.Contains(states, "applied") {
|
|
t.Fatalf("card states %v", states)
|
|
}
|
|
}
|
|
|
|
func TestAgentPanelExplainsAMissingClaude(t *testing.T) {
|
|
s, _ := agentSession(t, backend.Claude{})
|
|
panel := s.App.Agent()
|
|
s.App.SetAgentCommand("/nonexistent/claude-for-aego-tests")
|
|
stepUntil(t, s, panel.Checked)
|
|
panel.Recheck()
|
|
stepUntil(t, s, func() bool { return panel.Missing() != nil })
|
|
}
|
|
|
|
func TestAgentAskFillsTheInput(t *testing.T) {
|
|
s, _ := agentSession(t, &backend.Fake{})
|
|
panel := s.App.Agent()
|
|
panel.Ask("In scenes/main.aescene, Player: ")
|
|
s.Step()
|
|
if panel.Input() != "In scenes/main.aescene, Player: " {
|
|
t.Fatalf("input %q", panel.Input())
|
|
}
|
|
}
|
|
|
|
// An idle Agent panel adds no allocations to a frame until the first message.
|
|
func TestAgentPanelIdleAllocs(t *testing.T) {
|
|
skipUnderRace(t)
|
|
measure := func(b backend.Backend) float64 {
|
|
root := copyExample(t, "platformer")
|
|
p, err := project.Load(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, err := edit.Open(p, edit.Config{Headless: true, NoWatch: true, Logger: log.Nop(), AgentBackend: b}, combatModule{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
s.Step()
|
|
if _, err := s.App.Context().Docs.Open(bridgeScene); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for range 60 {
|
|
s.Step()
|
|
}
|
|
return editorFrameAllocs(t, s, func() { s.Step() })
|
|
}
|
|
without := measure(nil)
|
|
with := measure(&backend.Fake{})
|
|
if with > without {
|
|
t.Fatalf("frame allocations: %v without the agent panel, %v with it idle", without, with)
|
|
}
|
|
}
|
|
|
|
// Apply All takes the waiting change and the rest of the answer's, but leaves a removal to the user.
|
|
func TestAgentPanelApplyAllForTheTurn(t *testing.T) {
|
|
fake := &backend.Fake{Turns: [][]backend.Step{{
|
|
{Tool: "scene_add_node", Args: map[string]any{"file": bridgeScene, "name": "AgentA"}},
|
|
{Tool: "scene_add_node", Args: map[string]any{"file": bridgeScene, "name": "AgentB"}},
|
|
{Tool: "scene_remove_node", Args: map[string]any{"file": bridgeScene, "node": "AgentA"}},
|
|
{Text: "done"},
|
|
}}}
|
|
s, _ := agentSession(t, fake)
|
|
panel := s.App.Agent()
|
|
panel.Submit("add two nodes")
|
|
stepUntil(t, s, func() bool { return len(panel.Pending()) == 1 })
|
|
panel.ApplyAll()
|
|
d := s.App.Context().Docs.Edit()
|
|
stepUntil(t, s, func() bool { return len(d.History.Steps(nil)) == 2 && len(panel.Pending()) == 1 })
|
|
for range 20 {
|
|
s.Step()
|
|
}
|
|
if n := len(d.History.Steps(nil)); n != 2 {
|
|
t.Fatalf("Apply All applied a removal: %d steps", n)
|
|
}
|
|
panel.Decide(panel.Pending()[0], false)
|
|
stepUntil(t, s, func() bool { return !panel.Busy() })
|
|
}
|