返回 DeepSeek-Reasonix
client_integration_test.go
根目录 / internal / acp / client_integration_test.go
1 package acp
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "path/filepath"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/control"
15 "reasonix/internal/event"
16 "reasonix/internal/permission"
17 "reasonix/internal/provider"
18 )
19
20 // scriptedRequester answers agent → client requests from a per-method script
21 // and records the call order.
22 type scriptedRequester struct {
23 mu sync.Mutex
24 calls []string
25 params map[string]json.RawMessage
26 results map[string]any
27 errs map[string]error
28 }
29
30 func newScriptedRequester() *scriptedRequester {
31 return &scriptedRequester{
32 params: map[string]json.RawMessage{},
33 results: map[string]any{},
34 errs: map[string]error{},
35 }
36 }
37
38 func (r *scriptedRequester) Request(_ context.Context, method string, params any) (json.RawMessage, error) {
39 raw, _ := json.Marshal(params)
40 r.mu.Lock()
41 r.calls = append(r.calls, method)
42 r.params[method] = raw
43 res, err := r.results[method], r.errs[method]
44 r.mu.Unlock()
45 if err != nil {
46 return nil, err
47 }
48 out, _ := json.Marshal(res)
49 return out, nil
50 }
51
52 func (r *scriptedRequester) callOrder() []string {
53 r.mu.Lock()
54 defer r.mu.Unlock()
55 return append([]string(nil), r.calls...)
56 }
57
58 func TestClientIOReadWriteTextFile(t *testing.T) {
59 req := newScriptedRequester()
60 req.results["fs/read_text_file"] = FSReadTextFileResult{Content: "buffer content"}
61 req.results["fs/write_text_file"] = struct{}{}
62 io := newClientIO(req, "sess-1", ClientCapabilities{FS: FSCapabilities{ReadTextFile: true, WriteTextFile: true}})
63
64 content, ok := io.ReadTextFile(context.Background(), "/proj/a.go")
65 if !ok || content != "buffer content" {
66 t.Fatalf("ReadTextFile = %q, %v; want buffer content, true", content, ok)
67 }
68 var readParams FSReadTextFileParams
69 json.Unmarshal(req.params["fs/read_text_file"], &readParams)
70 if readParams.SessionID != "sess-1" || readParams.Path != "/proj/a.go" {
71 t.Fatalf("fs/read_text_file params = %+v", readParams)
72 }
73
74 handled, err := io.WriteTextFile(context.Background(), "/proj/a.go", "new content")
75 if !handled || err != nil {
76 t.Fatalf("WriteTextFile = %v, %v; want true, nil", handled, err)
77 }
78
79 // Without the capability, both degrade to unhandled so tools use the disk.
80 none := newClientIO(req, "sess-1", ClientCapabilities{})
81 if _, ok := none.ReadTextFile(context.Background(), "/proj/a.go"); ok {
82 t.Fatal("ReadTextFile without capability must report ok=false")
83 }
84 if handled, _ := none.WriteTextFile(context.Background(), "/proj/a.go", "x"); handled {
85 t.Fatal("WriteTextFile without capability must report handled=false")
86 }
87
88 // A client read error falls back (ok=false); a client write error surfaces
89 // (falling back could double-apply).
90 req.errs["fs/read_text_file"] = fmt.Errorf("not open")
91 if _, ok := io.ReadTextFile(context.Background(), "/proj/a.go"); ok {
92 t.Fatal("ReadTextFile client error must report ok=false")
93 }
94 req.errs["fs/write_text_file"] = fmt.Errorf("readonly buffer")
95 handled, err = io.WriteTextFile(context.Background(), "/proj/a.go", "x")
96 if !handled || err == nil {
97 t.Fatalf("WriteTextFile client error = %v, %v; want handled=true with error", handled, err)
98 }
99 }
100
101 func TestClientIORunCommandLifecycle(t *testing.T) {
102 req := newScriptedRequester()
103 req.results["terminal/create"] = TerminalCreateResult{TerminalID: "term-1"}
104 req.results["terminal/wait_for_exit"] = TerminalWaitResult{}
105 exitZero := 0
106 req.results["terminal/output"] = TerminalOutputResult{Output: "hello from client", ExitStatus: &TerminalExitStatus{ExitCode: &exitZero}}
107 req.results["terminal/release"] = struct{}{}
108 io := newClientIO(req, "sess-1", ClientCapabilities{Terminal: true})
109
110 out, ok, err := io.RunCommand(context.Background(), "echo hello", "/proj", time.Minute, nil)
111 if !ok || err != nil || out != "hello from client" {
112 t.Fatalf("RunCommand = %q, %v, %v", out, ok, err)
113 }
114 order := io2str(req.callOrder())
115 if order != "terminal/create,terminal/wait_for_exit,terminal/output,terminal/release" {
116 t.Fatalf("call order = %s", order)
117 }
118
119 // A nonzero exit surfaces as an error alongside the captured output.
120 exitOne := 1
121 req.results["terminal/output"] = TerminalOutputResult{Output: "boom", ExitStatus: &TerminalExitStatus{ExitCode: &exitOne}}
122 out, ok, err = io.RunCommand(context.Background(), "false", "/proj", time.Minute, nil)
123 if !ok || err == nil || !strings.Contains(err.Error(), "exit status 1") || out != "boom" {
124 t.Fatalf("RunCommand nonzero exit = %q, %v, %v", out, ok, err)
125 }
126
127 // No terminal capability → unhandled, local bash runs instead.
128 none := newClientIO(req, "sess-1", ClientCapabilities{})
129 if _, ok, _ := none.RunCommand(context.Background(), "echo", "/proj", time.Minute, nil); ok {
130 t.Fatal("RunCommand without capability must report ok=false")
131 }
132
133 // terminal/create failure degrades to unhandled rather than failing the call.
134 req.errs["terminal/create"] = fmt.Errorf("client rejected")
135 if _, ok, _ := io.RunCommand(context.Background(), "echo", "/proj", time.Minute, nil); ok {
136 t.Fatal("RunCommand with failed create must report ok=false")
137 }
138 }
139
140 func io2str(calls []string) string { return strings.Join(calls, ",") }
141
142 func TestUpdateSinkToolLocations(t *testing.T) {
143 s := newUpdateSink(&fakeNotifier{}, "sess")
144 cwd := t.TempDir()
145 s.bindCwd(cwd)
146
147 locs := s.toolLocations("read_file", `{"path":"pkg/a.go","offset":41}`)
148 if len(locs) != 1 || locs[0].Path != filepath.Join(cwd, "pkg", "a.go") {
149 t.Fatalf("read_file locations = %+v", locs)
150 }
151 if locs[0].Line == nil || *locs[0].Line != 42 {
152 t.Fatalf("read_file line = %v, want 42 (offset is 0-based)", locs[0].Line)
153 }
154 // A platform-absolute path passes through untouched.
155 abs := filepath.Join(t.TempDir(), "b.go")
156 absArgs, _ := json.Marshal(map[string]string{"path": abs})
157 if locs := s.toolLocations("edit_file", string(absArgs)); len(locs) != 1 || locs[0].Path != abs || locs[0].Line != nil {
158 t.Fatalf("edit_file locations = %+v, want %s", locs, abs)
159 }
160 if locs := s.toolLocations("bash", `{"command":"ls"}`); locs != nil {
161 t.Fatalf("bash should have no locations, got %+v", locs)
162 }
163 if locs := s.toolLocations("grep", `{"path":"pkg","pattern":"x"}`); locs != nil {
164 t.Fatalf("grep (directory scope) should have no locations, got %+v", locs)
165 }
166 if locs := s.toolLocations("read_file", `{"offset":1}`); locs != nil {
167 t.Fatalf("path-less args should have no locations, got %+v", locs)
168 }
169 }
170
171 func TestPlanEntriesFromCommittedTodos(t *testing.T) {
172 entries := planEntriesFromTodos([]event.Todo{
173 {Content: "First", Status: "in_progress"},
174 {Content: "Second", Status: "pending"},
175 {Content: "Done", Status: "completed"},
176 {Content: "", Status: "pending"},
177 {Content: "Weird", Status: "???"},
178 })
179 if len(entries) != 4 {
180 t.Fatalf("entries = %+v; want 4 entries", entries)
181 }
182 if entries[0].Priority != "medium" || entries[0].Status != "in_progress" {
183 t.Fatalf("first entry = %+v", entries[0])
184 }
185 if entries[1].Priority != "medium" {
186 t.Fatalf("sub-step entry = %+v", entries[1])
187 }
188 if entries[3].Status != "pending" {
189 t.Fatalf("unknown status must degrade to pending, got %+v", entries[3])
190 }
191 if got := planEntriesFromTodos(nil); len(got) != 0 {
192 t.Fatalf("empty committed todos = %+v", got)
193 }
194 }
195
196 func TestUpdateSinkEmitsPlanForTodoWrite(t *testing.T) {
197 n := &fakeNotifier{}
198 s := newUpdateSink(n, "sess-1")
199 s.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
200 ID: "t1", Name: "todo_write", Args: `{"todos":[{"content":"Do it","status":"pending"}]}`,
201 }})
202 if call := n.updateMap(t, 0); call["sessionUpdate"] != "tool_call" {
203 t.Fatalf("dispatch update = %v, want tool_call", call["sessionUpdate"])
204 }
205 s.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{
206 ID: "t1", Name: "todo_write", TodoWritten: true,
207 Todos: []event.Todo{{Content: "Do it", Status: "pending"}},
208 }})
209 plan := n.updateMap(t, 1)
210 if plan["sessionUpdate"] != "plan" {
211 t.Fatalf("committed update = %v, want plan", plan["sessionUpdate"])
212 }
213 raw, _ := json.Marshal(plan)
214 if !strings.Contains(string(raw), `"content":"Do it"`) {
215 t.Fatalf("plan update missing entry: %s", raw)
216 }
217 call := n.updateMap(t, 2)
218 if call["sessionUpdate"] != "tool_call_update" {
219 t.Fatalf("result update = %v, want tool_call_update", call["sessionUpdate"])
220 }
221 }
222
223 // recordingFactory wraps e2eFactory and captures the SessionParams the service
224 // hands to NewSession, so tests can assert the client-capability wiring.
225 type recordingFactory struct {
226 inner *e2eFactory
227 mu sync.Mutex
228 params []SessionParams
229 }
230
231 func (f *recordingFactory) SessionDir() string { return f.inner.SessionDir() }
232
233 func (f *recordingFactory) NewSession(ctx context.Context, p SessionParams) (*control.Controller, error) {
234 f.mu.Lock()
235 f.params = append(f.params, p)
236 f.mu.Unlock()
237 return f.inner.NewSession(ctx, p)
238 }
239
240 func (f *recordingFactory) last() SessionParams {
241 f.mu.Lock()
242 defer f.mu.Unlock()
243 return f.params[len(f.params)-1]
244 }
245
246 func TestSessionParamsCarryClientIOFromInitializeCaps(t *testing.T) {
247 dir := t.TempDir()
248 prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
249 {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
250 }}
251 factory := &recordingFactory{inner: &e2eFactory{
252 prov: prov,
253 tool: fakeTool{name: "peek", ro: true, out: "ok"},
254 policy: permission.New("ask", nil, nil, nil),
255 sessionDir: dir,
256 }}
257 client, stop := startServer(t, factory)
258 defer stop()
259
260 client.call(t, "initialize", InitializeParams{
261 ProtocolVersion: 1,
262 ClientCapabilities: ClientCapabilities{
263 FS: FSCapabilities{ReadTextFile: true, WriteTextFile: true},
264 Terminal: true,
265 },
266 })
267 client.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
268 p := factory.last()
269 if p.FileOverlay == nil {
270 t.Fatal("SessionParams.FileOverlay should be bound when the client declares fs capabilities")
271 }
272 if p.Terminal == nil {
273 t.Fatal("SessionParams.Terminal should be bound when the client declares the terminal capability")
274 }
275 }
276
277 func TestSessionParamsNilClientIOWithoutCaps(t *testing.T) {
278 dir := t.TempDir()
279 prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
280 {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
281 }}
282 factory := &recordingFactory{inner: &e2eFactory{
283 prov: prov,
284 tool: fakeTool{name: "peek", ro: true, out: "ok"},
285 policy: permission.New("ask", nil, nil, nil),
286 sessionDir: dir,
287 }}
288 client, stop := startServer(t, factory)
289 defer stop()
290
291 client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
292 client.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
293 p := factory.last()
294 if p.FileOverlay != nil || p.Terminal != nil {
295 t.Fatalf("SessionParams overlay/terminal must stay nil without client capabilities; got %v / %v", p.FileOverlay, p.Terminal)
296 }
297 }
298
299 func TestE2ESessionModes(t *testing.T) {
300 dir := t.TempDir()
301 prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
302 {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
303 }}
304 factory := &e2eFactory{
305 prov: prov,
306 tool: fakeTool{name: "peek", ro: true, out: "ok"},
307 policy: permission.New("ask", nil, nil, nil),
308 sessionDir: dir,
309 }
310 client, stop := startServer(t, factory)
311 defer stop()
312
313 client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
314 cwd := t.TempDir()
315 resp := client.call(t, "session/new", SessionNewParams{Cwd: cwd})
316 var nr SessionNewResult
317 if err := json.Unmarshal(resp.Result, &nr); err != nil {
318 t.Fatalf("session/new result: %v", err)
319 }
320 if nr.Modes == nil || nr.Modes.CurrentModeID != sessionModeNormal || len(nr.Modes.AvailableModes) != 3 {
321 t.Fatalf("session/new modes = %+v, want normal current with 3 available", nr.Modes)
322 }
323
324 setResp := client.call(t, "session/set_mode", SessionSetModeParams{SessionID: nr.SessionID, ModeID: sessionModePlan})
325 if setResp.Error != nil {
326 t.Fatalf("session/set_mode: %+v", setResp.Error)
327 }
328 // The switch is confirmed with a current_mode_update notification.
329 deadline := time.After(5 * time.Second)
330 for {
331 select {
332 case n := <-client.notifs:
333 var p struct {
334 Update struct {
335 SessionUpdate string `json:"sessionUpdate"`
336 CurrentModeID string `json:"currentModeId"`
337 } `json:"update"`
338 }
339 if json.Unmarshal(n.Params, &p) == nil && p.Update.SessionUpdate == "current_mode_update" {
340 if p.Update.CurrentModeID != sessionModePlan {
341 t.Fatalf("current_mode_update = %q, want plan", p.Update.CurrentModeID)
342 }
343 goto unknownMode
344 }
345 case <-deadline:
346 t.Fatal("no current_mode_update notification after session/set_mode")
347 }
348 }
349 unknownMode:
350 bad := client.call(t, "session/set_mode", SessionSetModeParams{SessionID: nr.SessionID, ModeID: "yolo"})
351 if bad.Error == nil {
352 t.Fatal("unknown modeId must be rejected")
353 }
354
355 // Reconnecting to the live session must report its actual mode, not a
356 // hardcoded default — the mode picker would otherwise go stale.
357 loadResp := client.call(t, "session/load", SessionLoadParams{SessionID: nr.SessionID, Cwd: cwd})
358 if loadResp.Error != nil {
359 t.Fatalf("session/load: %+v", loadResp.Error)
360 }
361 var lr SessionLoadResult
362 if err := json.Unmarshal(loadResp.Result, &lr); err != nil {
363 t.Fatalf("session/load result: %v", err)
364 }
365 if lr.Modes == nil || lr.Modes.CurrentModeID != sessionModePlan {
366 t.Fatalf("session/load modes = %+v, want current plan for the live session", lr.Modes)
367 }
368 }
369
370 // TestRebuildSessionKeepsClientIOAndMode pins two rebuild invariants: a
371 // model/effort switch must rebuild the controller with the same client
372 // capability wiring (fs overlay, host terminal) the original had, and must
373 // re-apply the session's ACP mode — a fresh controller boots with normal
374 // switches, which would silently drop a user-selected plan mode.
375 func TestRebuildSessionKeepsClientIOAndMode(t *testing.T) {
376 dir := t.TempDir()
377 path := filepath.Join(dir, "sess-rebuild.jsonl")
378 base := agent.NewSession("sys prompt")
379 base.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
380 if err := base.Save(path); err != nil {
381 t.Fatalf("save session: %v", err)
382 }
383
384 sink := newUpdateSink(&fakeNotifier{}, "sess-rebuild")
385 sess := &acpSession{
386 id: "sess-rebuild",
387 sink: sink,
388 cwd: dir,
389 model: "fast",
390 transcript: path,
391 modeID: sessionModePlan,
392 }
393 lease, err := agent.TryAcquireSessionLease(path)
394 if err != nil {
395 t.Fatalf("acquire session lease: %v", err)
396 }
397 sess.lease = lease
398 t.Cleanup(sess.releaseSessionLease)
399 t.Cleanup(func() {
400 if ctrl := sess.currentCtrl(); ctrl != nil {
401 ctrl.Close()
402 }
403 })
404
405 factory := &configurableFactory{dir: dir}
406 svc := &service{
407 factory: factory,
408 sessions: map[string]*acpSession{sess.id: sess},
409 clientCaps: ClientCapabilities{
410 FS: FSCapabilities{ReadTextFile: true, WriteTextFile: true},
411 Terminal: true,
412 },
413 }
414 oldCtrl := control.New(control.Options{
415 Executor: agent.New(nil, nil, base, agent.Options{}, event.Discard),
416 SessionDir: dir,
417 SessionPath: path,
418 Label: "fast",
419 })
420 sess.ctrl = oldCtrl
421
422 if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: "pro"}, []sessionConfigDelta{{axis: "model", model: "pro"}}); err != nil {
423 t.Fatalf("rebuildSession: %v", err)
424 }
425 if sess.ctrl == oldCtrl {
426 t.Fatal("session controller was not replaced")
427 }
428
429 factory.mu.Lock()
430 last := factory.builds[len(factory.builds)-1]
431 factory.mu.Unlock()
432 if last.FileOverlay == nil {
433 t.Fatal("rebuild must keep the fs overlay wiring")
434 }
435 if last.Terminal == nil {
436 t.Fatal("rebuild must keep the host terminal wiring")
437 }
438 if !sess.ctrl.PlanMode() {
439 t.Fatal("rebuild must re-apply the session's plan mode to the new controller")
440 }
441 }
442
442 lines GO