turbo-editors/turbo-corepublic Fork 0
v1.0.1
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

fakeagent_test.go · 160 lines · 4.7 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 17h ago1package acp_test
2
3import (
4 "bufio"
5 "encoding/json"
6 "fmt"
7 "net"
8 "sync"
9 "testing"
10 "time"
11
📦 Turbo Core f3ade8d k33g 9h ago12 "rickub.com/turbo-editors/turbo-core/acp"
🛟 Updated. 28d5985 k33g 17h ago13)
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.
23type 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.
33func 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.
43func (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.
64func (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.
74func (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.
79func (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.
84func (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.
100func (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.
106func (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.
126func 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.
140func 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.
151func 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}