cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

main
012 / e2e / live_test.go
live_test.go 7.4K
1package e2e23import (4 "bufio"5 "encoding/json"6 "fmt"7 "io"8 "os"9 "os/exec"10 "strings"11 "sync"12 "testing"13 "time"14)1516// Live mode (docs/agents/live.md): 012 --listen in a terminal, and an17// agent attached with a real 012 mcp --attach, spoken to in MCP's18// JSON-RPC over its standard input and output as a host would.1920// runDir is a short folder for sessions' sockets, whose paths are21// limited to about 100 bytes, shared by the session and its agent.22func runDir(t *testing.T) string {23 t.Helper()24 d, err := os.MkdirTemp("", "o12")25 if err != nil {26 t.Fatal(err)27 }28 t.Cleanup(func() { os.RemoveAll(d) })29 return d30}3132// agent is 012 mcp --attach, as a host runs it.33type agent struct {34 t *testing.T35 cmd *exec.Cmd36 in io.WriteCloser37 out *bufio.Reader38 mu sync.Mutex39 next int40}4142// attach starts 012 mcp --attach target as the host named client, and43// initializes the connection.44func attach(t *testing.T, run, target, client string) *agent {45 t.Helper()46 args := []string{"mcp", "--attach"}47 if target != "" {48 args = append(args, target)49 }50 cmd := exec.Command(binPath, args...)51 cmd.Env = append(os.Environ(), "XDG_RUNTIME_DIR="+run)52 in, err := cmd.StdinPipe()53 if err != nil {54 t.Fatal(err)55 }56 out, err := cmd.StdoutPipe()57 if err != nil {58 t.Fatal(err)59 }60 var stderr strings.Builder61 cmd.Stderr = &stderr62 if err := cmd.Start(); err != nil {63 t.Fatal(err)64 }65 a := &agent{t: t, cmd: cmd, in: in, out: bufio.NewReader(out)}66 t.Cleanup(func() {67 in.Close()68 done := make(chan struct{})69 go func() { cmd.Wait(); close(done) }()70 select {71 case <-done:72 case <-time.After(5 * time.Second):73 cmd.Process.Kill()74 }75 })76 res := a.request("initialize", map[string]any{"protocolVersion": "2025-06-18", "capabilities": map[string]any{},77 "clientInfo": map[string]any{"name": client, "version": "1"}})78 if res["serverInfo"] == nil {79 t.Fatalf("initialize: %v (stderr %s)", res, stderr.String())80 }81 a.send(map[string]any{"jsonrpc": "2.0", "method": "notifications/initialized"})82 return a83}8485func (a *agent) send(msg map[string]any) {86 data, _ := json.Marshal(msg)87 if _, err := a.in.Write(append(data, '\n')); err != nil {88 a.t.Fatal(err)89 }90}9192// request sends a request and returns its result, skipping93// notifications; an error fails the test.94func (a *agent) request(method string, params any) map[string]any {95 a.t.Helper()96 a.mu.Lock()97 a.next++98 id := a.next99 a.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})100 defer a.mu.Unlock()101 for {102 line, err := a.out.ReadBytes('\n')103 if err != nil {104 a.t.Errorf("%s: %v", method, err)105 return nil106 }107 var msg struct {108 ID *int `json:"id"`109 Result map[string]any `json:"result"`110 Error json.RawMessage `json:"error"`111 }112 if json.Unmarshal(line, &msg) != nil || msg.ID == nil || *msg.ID != id {113 continue114 }115 if msg.Error != nil {116 a.t.Errorf("%s: %s", method, msg.Error)117 }118 return msg.Result119 }120}121122// tool calls a tool and returns its structured result.123func (a *agent) tool(name string, args map[string]any) map[string]any {124 a.t.Helper()125 res := a.request("tools/call", map[string]any{"name": name, "arguments": args})126 if res["isError"] == true {127 a.t.Fatalf("%s: %v", name, res["content"])128 }129 out, _ := res["structuredContent"].(map[string]any)130 return out131}132133// startLive starts 012 --listen on a new budget.012 with 40, 2 and134// their sum in A1:A3, and attaches the agent, claude.135func startLive(t *testing.T, o options) (*session, *agent) {136 t.Helper()137 run := runDir(t)138 o.env = append(o.env, "XDG_RUNTIME_DIR="+run)139 s := startWith(t, o, "--listen", "budget.012")140 s.keys("40", "<enter>", "2", "<enter>", "=A1+A2", "<enter>", "<ctrl+home>")141 s.waitFor("42")142 return s, attach(t, run, "", "claude")143}144145func TestLiveAgentSuggestsAndThePersonAccepts(t *testing.T) {146 t.Parallel()147 s, a := startLive(t, options{})148 read := a.tool("read_range", map[string]any{"ref": "A1:A3"})149 if got := fmt.Sprint(read["values"]); got != "[[40] [2] [42]]" {150 t.Fatalf("read %s", got)151 }152 s.waitFor("◆ claude")153 wrote := a.tool("write_cells", map[string]any{"entries": []map[string]any{{"ref": "A1", "input": "48"}}, "message": "round the total"})154 if wrote["suggestion"] != 1.0 {155 t.Fatalf("write %v", wrote)156 }157 s.waitFor("◇ 1 suggestion")158 if !strings.Contains(s.screen(), "◇") || strings.Contains(s.screen(), "50") {159 t.Fatalf("the suggestion was made, or isn't marked:\n%s", s.screen())160 }161 s.keys("<ctrl+alt+a>")162 s.waitFor("A1 40 → 48")163 s.keys("<enter>")164 s.waitFor("Accepted all of claude's suggestion")165 s.eventually("A3 shows 50", func() bool { return strings.Contains(s.screen(), "50") })166 list := a.tool("suggestions", nil)167 if got := list["suggestions"].([]any)[0].(map[string]any)["state"]; got != "accepted" {168 t.Fatalf("suggestions %v", list)169 }170 if undone := a.tool("undo", nil); undone["undone"] != "set" {171 t.Fatalf("undo %v", undone)172 }173 s.eventually("A3 shows 42 again", func() bool { return strings.Contains(s.screen(), "42") })174}175176func TestLiveAgentAsksThePerson(t *testing.T) {177 t.Parallel()178 s, a := startLive(t, options{})179 answer := make(chan map[string]any, 1)180 go func() { answer <- a.tool("ask", map[string]any{"message": "Overwrite A1:A2?"}) }()181 s.waitFor("claude asks: Overwrite A1:A2?")182 s.keys("<enter>")183 select {184 case got := <-answer:185 if got["action"] != "accept" {186 t.Fatalf("answer %v", got)187 }188 case <-time.After(waitTimeout):189 t.Fatal("no answer")190 }191}192193// startLiveScreen starts a live screen's session on the household194// budget, listening, with claude attached.195func startLiveScreen(t *testing.T, o options) *session {196 t.Helper()197 run := runDir(t)198 o.env = append(o.env, "XDG_RUNTIME_DIR="+run)199 s := startWith(t, o, "--listen", "budget.012")200 s.agent = attach(t, run, "", "claude")201 s.waitFor("◆ claude")202 return s203}204205// Golden screens of live mode: claude's suggestion marked in the grid,206// with what it would put in the cell under the pointer on the context207// line; the suggestions panel; and claude's question on the context208// line.209var liveScreens = []screen{210 {name: "live-review", live: true, files: householdBudget, setup: func(s *session) {211 marchSuggestion(s)212 s.keys("<ctrl+alt+a>")213 s.waitFor("B4 450 → 480")214 }},215 {name: "live-ask", live: true, files: householdBudget, setup: func(s *session) {216 s.agent.send(map[string]any{"jsonrpc": "2.0", "id": 99, "method": "tools/call",217 "params": map[string]any{"name": "ask", "arguments": map[string]any{"message": "Overwrite B3:B5 with March's figures?"}}})218 s.waitFor("claude asks: Overwrite B3:B5")219 }},220}221222// marchSuggestion has claude suggest March's food and power bills, and223// puts the pointer on the food line.224func marchSuggestion(s *session) {225 s.agent.tool("write_cells", map[string]any{"message": "March's bills came in higher",226 "entries": []map[string]any{{"ref": "B4", "input": "480"}, {"ref": "B5", "input": "95"}}})227 s.waitFor("◇ 1 suggestion")228 s.keys("<down>", "<down>", "<down>", "<right>")229 s.waitFor("suggests 480")230}231232func init() {233 suggestion := screen{name: "live-suggestion", live: true, files: householdBudget, setup: marchSuggestion}234 light, hc := suggestion, suggestion235 light.name += "-light"236 light.opts.light = true237 hc.name += "-high-contrast"238 hc.opts.config = highContrastConfig239 review, ask := liveScreens[0], liveScreens[1]240 review.name, review.opts.light = review.name+"-light", true241 ask.name, ask.opts.light = ask.name+"-light", true242 screens = append(screens, suggestion, light, hc)243 screens = append(screens, liveScreens...)244 screens = append(screens, review, ask)245}