Fifteen components that dress an AI agent's work as an RPG party: AgentCard, ToolCall, PlanQuest, ThinkingOrb, TokenMana, ApprovalPrompt, AgentLog, SubAgentParty, MemoryCrystals, AgentStatus, DiffScroll, ModelSelect, PromptBox, CostCoins and Checkpoints. The context window is HP and the token budget MP; tool calls are spells that CAST, glow, then land OK or FAILED; the transcript reads as a battle log whose newest line types itself out; models line up like fighters. Busy signs keep moving under Reduce Motion, decoration stops. Commands and prompts never reach the log, and a veiled PromptBox casts nothing. icon.Crystal is the new 12x12 save and memory crystal.
184 lines
7.1 KiB
Go
184 lines
7.1 KiB
Go
package agent_test
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/agent"
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
|
)
|
|
|
|
func TestAgentCardInksHPByContextLeft(t *testing.T) {
|
|
for used, want := range map[int]pixel.Slot{20_000: pixel.Good, 120_000: pixel.Warn, 180_000: pixel.Bad} {
|
|
a := agent.Agent{Name: "ROBO", Class: "CODER", Level: 7, Used: used, Window: 200_000}
|
|
tt := view(300, 120, newWorld(), func(c *ui.Context) { agent.AgentCard(c, a, agent.AgentCardOptions{}) })
|
|
x, y, _, _ := dots(find(t, tt, "ROBO"))
|
|
// The fill starts two dots into the gauge's well.
|
|
fill := count(tt.Image(), x+38, y+40, 92, 4, want)
|
|
if left := 92 * (200_000 - used) / 200_000; fill < left*4-8 || fill > left*4+8 {
|
|
t.Errorf("%d of 200K used: %d dots of %v, want about %d", used, fill, want, left*4)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestToolCallStripesItsOutcome(t *testing.T) {
|
|
for out, ink := range map[agent.Outcome]pixel.Slot{agent.Casting: pixel.Info, agent.OK: pixel.Good, agent.Failed: pixel.Bad} {
|
|
wd := newWorld()
|
|
wd.reduce = true
|
|
tt := view(500, 100, wd, func(c *ui.Context) {
|
|
agent.ToolCall(c, "read_file", out, agent.ToolCallOptions{Args: "main.go"})
|
|
})
|
|
x, y, w, h := dots(find(t, tt, "CAST read_file"))
|
|
if got := at(tt.Image(), x+2, y+5); got != ink || h != 38 {
|
|
t.Errorf("outcome %d: stripe %v, want %v; %d dots high", out, got, ink, h)
|
|
}
|
|
// Casting is busy: it moves even under Reduce Motion.
|
|
if moving := moves(tt, wd); moving != (out == agent.Casting) {
|
|
t.Errorf("outcome %d: moving is %v", out, moving)
|
|
}
|
|
if out == agent.Failed && count(tt.Image(), x+w-60, y, 56, 22, pixel.Good) > 0 {
|
|
t.Error("a failed cast shows Good")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlanQuestMarksEachObjective(t *testing.T) {
|
|
steps := []agent.PlanStep{{Text: "Read the code", Done: true}, {Text: "Write a test"}, {Text: "Ship it"}}
|
|
tt := view(400, 200, newWorld(), func(c *ui.Context) { agent.PlanQuest(c, "Fix the bug", steps, agent.PlanQuestOptions{}) })
|
|
x, y, _, h := dots(find(t, tt, "Fix the bug"))
|
|
row := func(i int, s pixel.Slot) int { return count(tt.Image(), x, y+20+i*16, 12, 16, s) }
|
|
if row(0, pixel.Good) == 0 || row(1, pixel.Accent) == 0 || row(2, pixel.Well) == 0 || row(2, pixel.Accent) > 0 {
|
|
t.Fatal("not a check, a ▶ and an open box")
|
|
}
|
|
for i := range steps {
|
|
steps[i].Done = true
|
|
}
|
|
tt.Frame()
|
|
_, _, _, done := dots(find(t, tt, "Fix the bug"))
|
|
if done != h+18 || count(tt.Image(), x, y+h, 200, 18, pixel.Good) == 0 {
|
|
t.Fatalf("all done: %d dots high, was %d, and no QUEST COMPLETE!", done, h)
|
|
}
|
|
}
|
|
|
|
func TestThinkingOrbSwirlsOnlyWhileThinking(t *testing.T) {
|
|
for _, thinking := range []bool{false, true} {
|
|
wd := newWorld()
|
|
wd.reduce = true
|
|
tt := view(100, 100, wd, func(c *ui.Context) { agent.ThinkingOrb(c, "Mind", thinking, agent.ThinkingOrbOptions{}) })
|
|
x, y, _, _ := dots(find(t, tt, "Mind"))
|
|
if moves(tt, wd) != thinking || (count(tt.Image(), x+3, y+1, 39, 39, pixel.Magic) > 0) != thinking {
|
|
t.Errorf("thinking %v: the swirl does not match", thinking)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTokenManaRunsDry(t *testing.T) {
|
|
for used, dry := range map[int]bool{0: false, 99_999: false, 100_000: true, 250_000: true} {
|
|
tt := view(300, 100, newWorld(), func(c *ui.Context) {
|
|
agent.TokenMana(c, "Budget", used, 100_000, agent.TokenManaOptions{})
|
|
})
|
|
x, y, _, _ := dots(find(t, tt, "Budget"))
|
|
img := tt.Image()
|
|
if got := count(img, x+2, y+9, 116, 4, pixel.Bad) > 0; got != dry {
|
|
t.Errorf("%d used: dry veil %v", used, got)
|
|
}
|
|
if full := count(img, x+2, y+9, 116, 4, pixel.Info); used == 0 && full < 116*4-9*4 {
|
|
t.Errorf("a full budget fills %d dots", full)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAgentStatusRings(t *testing.T) {
|
|
for s, want := range map[agent.Status]struct {
|
|
ink pixel.Slot
|
|
moves, still bool
|
|
}{agent.Idle: {pixel.Faint, true, false}, agent.Working: {pixel.Info, true, true}, agent.Blocked: {pixel.Warn, false, false}, agent.Done: {pixel.Good, false, false}} {
|
|
for _, reduce := range []bool{false, true} {
|
|
wd := newWorld()
|
|
wd.reduce = reduce
|
|
tt := view(200, 200, wd, func(c *ui.Context) { agent.AgentStatus(c, "Robo", s, agent.AgentStatusOptions{}) })
|
|
x, y, w, _ := dots(find(t, tt, "Robo"))
|
|
if count(tt.Image(), x, y, w, 36, want.ink) == 0 {
|
|
t.Errorf("status %d: no %v ring", s, want.ink)
|
|
}
|
|
// Working runs on; Idle's Z rests under Reduce Motion.
|
|
if got, wantMove := moves(tt, wd), want.moves && (!reduce || want.still); got != wantMove {
|
|
t.Errorf("status %d, reduce %v: moving %v", s, reduce, got)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSubAgentPartyDrawsTheSummoning(t *testing.T) {
|
|
party := []agent.Member{{Name: "LEAD", Status: agent.Working, Summoned: []agent.Member{
|
|
{Name: "SCOUT", Status: agent.Done}, {Name: "SMITH", Status: agent.Blocked, Summoned: []agent.Member{{Name: "IMP", Status: agent.Idle}}},
|
|
}}}
|
|
tt := view(400, 200, newWorld(), func(c *ui.Context) { agent.SubAgentParty(c, "Party", party, agent.SubAgentPartyOptions{}) })
|
|
x, y, _, h := dots(find(t, tt, "Party"))
|
|
img := tt.Image()
|
|
if h != 4*16 {
|
|
t.Fatalf("four members in %d dots", h)
|
|
}
|
|
// Robot inks: LEAD Info, SCOUT Good, SMITH Warn, IMP Faint.
|
|
for i, s := range []pixel.Slot{pixel.Info, pixel.Good, pixel.Warn, pixel.Faint} {
|
|
if count(img, x+[]int{0, 12, 12, 24}[i], y+i*16+2, 12, 12, s) == 0 {
|
|
t.Errorf("row %d has no %v robot", i, s)
|
|
}
|
|
}
|
|
// SCOUT's line runs on to SMITH; SMITH's ends at IMP's.
|
|
if at(img, x+5, y+16+12) != pixel.Edge || at(img, x+17, y+48+12) == pixel.Edge {
|
|
t.Error("the summoning lines run wrong")
|
|
}
|
|
}
|
|
|
|
func TestDiffScrollSharesBlocks(t *testing.T) {
|
|
files := []agent.FileDiff{{Path: "a.go", Added: 10}, {Path: "b.go", Removed: 10}, {Path: "c.go"}}
|
|
tt := view(500, 200, newWorld(), func(c *ui.Context) { agent.DiffScroll(c, "Changes", files, agent.DiffScrollOptions{}) })
|
|
x, y, w, _ := dots(find(t, tt, "Changes"))
|
|
img := tt.Image()
|
|
for i, want := range [][5]pixel.Slot{
|
|
{pixel.Good, pixel.Good, pixel.Good, pixel.Good, pixel.Good}, {pixel.Bad, pixel.Bad, pixel.Bad, pixel.Bad, pixel.Bad},
|
|
{pixel.Shade, pixel.Shade, pixel.Shade, pixel.Shade, pixel.Shade}, {pixel.Good, pixel.Good, pixel.Good, pixel.Bad, pixel.Bad},
|
|
} {
|
|
// The total row sits four dots lower, past the rule.
|
|
ry := y + 10 + i*16 + 6
|
|
if i == 3 {
|
|
ry += 4
|
|
}
|
|
for j, s := range want {
|
|
if got := at(img, x+w-32+j*5+1, ry+1); got != s {
|
|
t.Errorf("row %d block %d is %v, want %v", i, j, got, s)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCostCoinsDropOnARise(t *testing.T) {
|
|
for _, reduce := range []bool{false, true} {
|
|
wd := newWorld()
|
|
wd.reduce = reduce
|
|
cents := 1234
|
|
tt := view(200, 200, wd, func(c *ui.Context) { agent.CostCoins(c, "Spent", cents, agent.CostCoinsOptions{}) })
|
|
x, y, _, _ := dots(find(t, tt, "Spent"))
|
|
// Bronze cents stack third; a fifth coin lands at 26.
|
|
air := func() int {
|
|
img := tt.Image()
|
|
return count(img, x+38, y+21, 10, 2, pixel.Warn) + count(img, x+38, y+21, 10, 2, pixel.Bad)
|
|
}
|
|
if air() > 0 {
|
|
t.Fatal("a coin hangs before any change")
|
|
}
|
|
cents = 1235
|
|
tt.Frame()
|
|
if got := air() > 0; got == reduce {
|
|
t.Errorf("reduce %v: the new coin falling is %v", reduce, got)
|
|
}
|
|
later(tt, wd, time.Second)
|
|
if air() > 0 {
|
|
t.Errorf("reduce %v: the coin never lands", reduce)
|
|
}
|
|
}
|
|
}
|