1package ui23import (4 "context"5 "os"6 "path/filepath"7 "strings"8 "testing"9 "time"1011 tea "charm.land/bubbletea/v2"12 sdk "github.com/modelcontextprotocol/go-sdk/mcp"1314 "src.rest/cat/012/internal/cowork"15 "src.rest/cat/012/internal/headless"16 "src.rest/cat/012/internal/mcp"17 "src.rest/cat/012/internal/sheet"18)1920// liveSession is a local session listening for agents, and the agent in21// its room, as 012 --listen and 012 mcp --attach make them.22type liveSession struct {23 *seshion24 agent *cowork.Agent25}2627// testAgent is one of the agents in the room without a connection.28type testAgent struct{ kick chan struct{} }2930func (a *testAgent) Name() string { return "claude" }31func (a *testAgent) Agent() {}32func (a *testAgent) Notify() {33 select {34 case a.kick <- struct{}{}:35 default:36 }37}3839// startLive opens a session on a workbook of 40, 2 and their sum in40// A1:A3, listening for agents, with the agent attached.41func startLive(t *testing.T) *liveSession {42 t.Helper()43 run, err := os.MkdirTemp("", "o12")44 if err != nil {45 t.Fatal(err)46 }47 t.Cleanup(func() { os.RemoveAll(run) })48 t.Setenv("XDG_RUNTIME_DIR", run)49 s := sheet.New()50 for i, in := range []string{"40", "2", "=A1+A2"} {51 s.Set(sheet.Addr{Row: i}, in)52 }53 s.Book().ClearHistory()54 path := filepath.Join(t.TempDir(), "budget.012")55 m := New(s, path)56 m.SetMachine("here")57 m.nb.still = true58 sh := InRooms(Guard(m))59 ls := &liveSession{seshion: &seshion{t: t, sh: sh, m: m}}60 m.Listen()61 m.share.still = true62 ls.run(sh.Init())63 ls.send(tea.WindowSizeMsg{Width: 80, Height: 20})64 t.Cleanup(m.StopListening)65 if m.share.seat == nil || m.agents.listener == nil {66 t.Fatalf("not listening: %s", m.warn)67 }68 seat, ok := m.share.reg.JoinOpen(m.share.seat.Key(), &testAgent{kick: make(chan struct{}, 1)})69 if !ok {70 t.Fatal("the agent couldn't join")71 }72 ls.agent = cowork.NewAgent(seat)73 ls.sync()74 return ls75}7677// sync hands the session the room's changes.78func (ls *liveSession) sync() {79 for range 10 {80 select {81 case <-ls.m.share.link.kick:82 ls.send(roomMsg{})83 default:84 return85 }86 }87}8889// suggest has the agent write entries, pairs of a reference and an90// input, as write_cells does.91func (ls *liveSession) suggest(message string, entries ...string) mcp.Made {92 ls.t.Helper()93 var es []headless.Entry94 for i := 0; i+1 < len(entries); i += 2 {95 es = append(es, headless.Entry{Ref: entries[i], Input: entries[i+1]})96 }97 made, err := ls.agent.Make(context.Background(), mcp.Making{Label: "set", Message: message, Fn: func(w *sheet.Workbook) error {98 _, err := headless.Set(w, es, headless.SetOptions{})99 return err100 }})101 if err != nil {102 ls.t.Fatal(err)103 }104 ls.sync()105 return made106}107108func TestListenSharesTheWorkbookAsItIs(t *testing.T) {109 ls := startLive(t)110 ls.press("<down>", "<down>", "<down>", "7", "<enter>")111 if !ls.m.book().CanUndo() {112 t.Fatal("the person can't undo their step")113 }114 if !ls.m.changed || ls.m.filename == "" {115 t.Fatal("the session lost its file")116 }117 var peers string118 _, peers = ls.m.Others()119 if peers != "claude" {120 t.Fatalf("others: %q", peers)121 }122 if got := line(ls.m, ls.m.height-1); !strings.Contains(got, "◆ claude") {123 t.Errorf("status line %q", got)124 }125 eps, _ := cowork.Endpoints(filepath.Join(os.Getenv("XDG_RUNTIME_DIR"), "012"))126 if len(eps) != 1 || eps[0].Workbook != "budget" {127 t.Fatalf("endpoints %+v", eps)128 }129}130131func TestSuggestionMarkedAndAccepted(t *testing.T) {132 ls := startLive(t)133 made := ls.suggest("keep the total at 50", "A1", "48")134 if made.Suggestion != 1 || made.Applied {135 t.Fatalf("made %+v", made)136 }137 if got := ls.m.sheet.Value(addr("A3")).Num; got != 42 {138 t.Fatalf("the suggestion was made: A3 = %v", got)139 }140 if !strings.Contains(ls.screen(), "◇40") && !strings.Contains(ls.screen(), "◇ ") {141 t.Errorf("no mark on A1:\n%s", ls.screen())142 }143 ls.press("<down>", "<up>")144 ctx := line(ls.m, contextLine)145 if !strings.Contains(ctx, "◆ claude") || !strings.Contains(ctx, "suggests 48, now 40: keep the total at 50") {146 t.Errorf("context line %q", ctx)147 }148 if got := line(ls.m, ls.m.height-1); !strings.Contains(got, "◇ 1 suggestion") {149 t.Errorf("status line %q", got)150 }151 ls.press("<ctrl+alt+a>")152 if !strings.Contains(ls.screen(), "Suggestions") || !strings.Contains(ls.screen(), "A1 40 → 48") {153 t.Fatalf("the panel:\n%s", ls.screen())154 }155 ls.press("<enter>")156 if got := ls.m.sheet.Value(addr("A3")).Num; got != 50 {157 t.Fatalf("A3 = %v after accepting", got)158 }159 if ls.m.overlay != nil {160 t.Error("the panel stayed open with nothing waiting")161 }162 if !strings.Contains(ls.m.note, "Accepted all of claude's suggestion") {163 t.Errorf("note %q", ls.m.note)164 }165 if ls.m.book().CanUndo() {166 t.Error("the person's undo takes back the agent's step")167 }168 if label, err := ls.agent.Undo(context.Background()); err != nil || label != "set" {169 t.Fatalf("the agent's undo: %q %v", label, err)170 }171 ls.sync()172 if got := ls.m.sheet.Value(addr("A3")).Num; got != 42 {173 t.Fatalf("A3 = %v after the agent's undo", got)174 }175}176177func TestSuggestionByCellInThePanel(t *testing.T) {178 ls := startLive(t)179 ls.suggest("", "A1", "1", "A2", "2000")180 ls.press("<ctrl+alt+a>", "<down>", "<down>", "r")181 if got := input(ls.m, "A2"); got != "2" {182 t.Fatalf("A2 = %q after rejecting it", got)183 }184 ls.press("<enter>") // the heading again: the rest185 if got := input(ls.m, "A1"); got != "1" {186 t.Fatalf("A1 = %q after accepting the rest", got)187 }188 list, _ := ls.agent.Suggestions(context.Background())189 if list[0].State != "accepted in part" {190 t.Fatalf("suggestions %+v", list)191 }192}193194func TestSuggestionRejectedTellsTheAgent(t *testing.T) {195 ls := startLive(t)196 ls.suggest("", "B1", "x")197 ls.press("<ctrl+alt+a>", "r")198 made := ls.suggest("", "B2", "y")199 if len(made.Notices) != 1 || !strings.Contains(made.Notices[0], "was rejected") {200 t.Fatalf("notices %v", made.Notices)201 }202}203204func TestInviteChoosesTheScope(t *testing.T) {205 ls := startLive(t)206 ls.m.runCommand("agent.invite")207 ls.press("Read", "<enter>")208 ls.sync()209 if !strings.Contains(ls.m.note, "Agents may change read only") {210 t.Errorf("note %q", ls.m.note)211 }212 _, err := ls.agent.Make(context.Background(), mcp.Making{Label: "set", Fn: func(w *sheet.Workbook) error {213 return w.Sheet(0).Set(sheet.Addr{}, "1")214 }})215 if err == nil || !strings.Contains(err.Error(), "read only") {216 t.Fatalf("a read-only agent wrote: %v", err)217 }218}219220func TestDirectEditsSetting(t *testing.T) {221 ls := startLive(t)222 ls.m.runCommand("agent.direct")223 ls.sync()224 made := ls.suggest("", "A1", "8")225 if !made.Applied || input(ls.m, "A1") != "8" {226 t.Fatalf("made %+v, A1 %q", made, input(ls.m, "A1"))227 }228}229230// ask waits for the agent's question to reach the session and shows it.231func (ls *liveSession) waitAsk() {232 ls.t.Helper()233 for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) {234 ls.sync()235 ls.send(nil)236 if ls.m.agents.asking != nil {237 return238 }239 }240 ls.t.Fatal("the question didn't come")241}242243func TestAgentAsksOnTheContextLine(t *testing.T) {244 ls := startLive(t)245 got := make(chan *sdk.ElicitResult, 1)246 go func() {247 res, err := ls.agent.Ask(context.Background(), &sdk.ElicitParams{Message: "Overwrite B2:B40?"})248 if err != nil {249 t.Error(err)250 }251 got <- res252 }()253 ls.waitAsk()254 if ctx := line(ls.m, contextLine); !strings.Contains(ctx, "◆ claude asks: Overwrite B2:B40?") || !strings.Contains(ctx, "Yes") {255 t.Fatalf("context line %q", ctx)256 }257 ls.press("n")258 if res := <-got; res.Action != "decline" {259 t.Fatalf("answer %+v", res)260 }261}262263func TestAgentAsksForValues(t *testing.T) {264 ls := startLive(t)265 got := make(chan *sdk.ElicitResult, 1)266 go func() {267 res, _ := ls.agent.Ask(context.Background(), &sdk.ElicitParams{Message: "Which quarter?", RequestedSchema: map[string]any{268 "type": "object", "required": []string{"quarter", "target"},269 "properties": map[string]any{270 "quarter": map[string]any{"type": "string", "enum": []string{"Q1", "Q2", "Q3"}},271 "target": map[string]any{"type": "number", "title": "Target"}}}})272 got <- res273 }()274 ls.waitAsk()275 ls.press("3")276 if ctx := line(ls.m, contextLine); !strings.Contains(ctx, "Target:") {277 t.Fatalf("context line %q", ctx)278 }279 ls.press("lots", "<enter>")280 if !strings.Contains(ls.m.errMsg+ls.m.warn, "takes a number") {281 t.Errorf("no complaint about lots: %q %q", ls.m.errMsg, ls.m.warn)282 }283 ls.press("1200", "<enter>")284 res := <-got285 if res.Action != "accept" || res.Content["quarter"] != "Q3" || res.Content["target"] != 1200.0 {286 t.Fatalf("answer %+v", res)287 }288}