| 🛟 Updated. 28d5985 k33g 12h ago | 1 | package acp_test |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "net" |
| 8 | "sync" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 📦 Turbo Core f3ade8d k33g 4h ago | 12 | "rickub.com/turbo-editors/turbo-core/acp" |
| 🛟 Updated. 28d5985 k33g 12h ago | 13 | ) |
| 14 | |
| 15 | // fakeAgent is an agent living in this process, at the far end of an in-memory |
| 16 | // pipe. |
| 17 | // |
| 18 | // Driving the client against it exercises the real framing, the real |
| 19 | // concurrency and the real decoding, with no subprocess, no model and no |
| 20 | // timing to get lucky with. It is deliberately not built on the client's own |
| 21 | // types for the wire: a peer that shared them could not catch the client |
| 22 | // encoding a field wrongly. |
| 23 | type fakeAgent struct { |
| 24 | t *testing.T |
| 25 | stream net.Conn |
| 26 | reader *bufio.Reader |
| 27 | |
| 28 | mu sync.Mutex |
| 29 | received []string |
| 30 | } |
| 31 | |
| 32 | // newFakeAgent returns an agent and the stream a client talks to it over. |
| 33 | func newFakeAgent(t *testing.T) (*fakeAgent, net.Conn) { |
| 34 | t.Helper() |
| 35 | |
| 36 | theirs, ours := net.Pipe() |
| 37 | agent := &fakeAgent{t: t, stream: theirs, reader: bufio.NewReader(theirs)} |
| 38 | t.Cleanup(func() { _ = theirs.Close() }) |
| 39 | return agent, ours |
| 40 | } |
| 41 | |
| 42 | // read returns the next message the client sent. |
| 43 | func (a *fakeAgent) read() map[string]any { |
| 44 | a.t.Helper() |
| 45 | |
| 46 | _ = a.stream.SetReadDeadline(time.Now().Add(5 * time.Second)) |
| 47 | line, err := a.reader.ReadBytes('\n') |
| 48 | if err != nil { |
| 49 | a.t.Fatalf("reading from the client: %v", err) |
| 50 | } |
| 51 | |
| 52 | var msg map[string]any |
| 53 | if err := json.Unmarshal(line, &msg); err != nil { |
| 54 | a.t.Fatalf("the client wrote %q, which is not JSON: %v", line, err) |
| 55 | } |
| 56 | |
| 57 | a.mu.Lock() |
| 58 | a.received = append(a.received, fmt.Sprint(msg["method"])) |
| 59 | a.mu.Unlock() |
| 60 | return msg |
| 61 | } |
| 62 | |
| 63 | // send writes one raw line to the client. |
| 64 | func (a *fakeAgent) send(line string) { |
| 65 | a.t.Helper() |
| 66 | |
| 67 | _ = a.stream.SetWriteDeadline(time.Now().Add(5 * time.Second)) |
| 68 | if _, err := fmt.Fprintf(a.stream, "%s\n", line); err != nil { |
| 69 | a.t.Fatalf("writing to the client: %v", err) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | // reply answers a request with a result. |
| 74 | func (a *fakeAgent) reply(id any, result string) { |
| 75 | a.send(fmt.Sprintf(`{"jsonrpc":"2.0","id":%v,"result":%s}`, id, result)) |
| 76 | } |
| 77 | |
| 78 | // update sends one session/update. |
| 79 | func (a *fakeAgent) update(sessionID, update string) { |
| 80 | a.send(fmt.Sprintf(`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":%q,"update":%s}}`, sessionID, update)) |
| 81 | } |
| 82 | |
| 83 | // methods returns what the client has sent, in order. |
| 84 | func (a *fakeAgent) methods() []string { |
| 85 | a.mu.Lock() |
| 86 | defer a.mu.Unlock() |
| 87 | |
| 88 | out := make([]string, len(a.received)) |
| 89 | copy(out, a.received) |
| 90 | return out |
| 91 | } |
| 92 | |
| 93 | // handshake plays the opening exchange the way the real docker agent does, |
| 94 | // and returns the session id it handed out. |
| 95 | // |
| 96 | // The shapes here are copied from a recorded conversation with docker agent |
| 97 | // v1.139.0 against a local llama.cpp, not invented: the agentCapabilities |
| 98 | // object really does carry sessionCapabilities, and authMethods really is an |
| 99 | // empty array rather than absent. |
| 100 | func (a *fakeAgent) handshake() string { |
| 101 | return a.handshakeWith(`{"auth":{},"mcpCapabilities":{},"promptCapabilities":{"embeddedContext":true,"image":true},"sessionCapabilities":{"close":{},"list":{},"resume":{}}}`) |
| 102 | } |
| 103 | |
| 104 | // handshakeWith is handshake with the agent declaring the given capabilities |
| 105 | // object, for an agent that takes less than docker agent does. |
| 106 | func (a *fakeAgent) handshakeWith(capabilities string) string { |
| 107 | initialize := a.read() |
| 108 | if initialize["method"] != acp.MethodInitialize { |
| 109 | a.t.Fatalf("the client opened with %v, want initialize", initialize["method"]) |
| 110 | } |
| 111 | a.reply(initialize["id"], `{"agentCapabilities":`+capabilities+`,"agentInfo":{"name":"docker agent","title":"docker agent","version":"v1.139.0"},"authMethods":[],"protocolVersion":1}`) |
| 112 | |
| 113 | newSession := a.read() |
| 114 | if newSession["method"] != acp.MethodNewSession { |
| 115 | a.t.Fatalf("the client asked %v, want session/new", newSession["method"]) |
| 116 | } |
| 117 | const id = "eefb58ee-4f23-4064-9ea4-dba74b957d3a" |
| 118 | a.reply(newSession["id"], fmt.Sprintf(`{"sessionId":%q}`, id)) |
| 119 | return id |
| 120 | } |
| 121 | |
| 122 | // waitFor polls until want is true, or fails the test. |
| 123 | // |
| 124 | // The client's work happens on goroutines of its own, so a test that looked |
| 125 | // once would pass or fail by how fast the machine is. |
| 126 | func waitFor(t *testing.T, what string, want func() bool) { |
| 127 | t.Helper() |
| 128 | |
| 129 | deadline := time.Now().Add(5 * time.Second) |
| 130 | for time.Now().Before(deadline) { |
| 131 | if want() { |
| 132 | return |
| 133 | } |
| 134 | time.Sleep(time.Millisecond) |
| 135 | } |
| 136 | t.Fatalf("timed out waiting for %s", what) |
| 137 | } |
| 138 | |
| 139 | // textOf joins an entry list into something a test can assert on. |
| 140 | func textOf(entries []acp.Entry, kind acp.EntryKind) string { |
| 141 | var out []string |
| 142 | for _, entry := range entries { |
| 143 | if entry.Kind == kind { |
| 144 | out = append(out, entry.Text) |
| 145 | } |
| 146 | } |
| 147 | return joinWith(out, "\n") |
| 148 | } |
| 149 | |
| 150 | // joinWith joins pieces, so the tests need no import for one call. |
| 151 | func joinWith(pieces []string, sep string) string { |
| 152 | result := "" |
| 153 | for i, piece := range pieces { |
| 154 | if i > 0 { |
| 155 | result += sep |
| 156 | } |
| 157 | result += piece |
| 158 | } |
| 159 | return result |
| 160 | } |