返回 DeepSeek-Reasonix
e2e_test.go
根目录 / internal / acp / e2e_test.go
1 package acp
2
3 import (
4 "context"
5 "encoding/json"
6 "os"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/control"
14 "reasonix/internal/permission"
15 "reasonix/internal/provider"
16 "reasonix/internal/tool"
17 )
18
19 // These tests drive the full real stack — acp.Serve → control.Controller →
20 // agent.Agent — with a scripted provider and a fake tool standing in for the
21 // model and a real tool. They are the keyless, deterministic counterpart to a
22 // live network run: they exercise session/update streaming, the gate→approval
23 // round-trip, cancellation, and transcript persistence end to end.
24
25 // scriptedProvider returns the i-th preset response on the i-th Stream call (the
26 // agent calls Stream once per step), repeating the last response thereafter.
27 type scriptedProvider struct {
28 name string
29 responses [][]provider.Chunk
30 mu sync.Mutex
31 calls int
32 }
33
34 func (p *scriptedProvider) Name() string { return p.name }
35
36 func (p *scriptedProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
37 // Respect ctx like a real provider: a cancelled turn fails the next step's
38 // completion rather than streaming on.
39 if err := ctx.Err(); err != nil {
40 return nil, err
41 }
42 p.mu.Lock()
43 i := p.calls
44 if i >= len(p.responses) {
45 i = len(p.responses) - 1
46 }
47 p.calls++
48 resp := p.responses[i]
49 p.mu.Unlock()
50
51 ch := make(chan provider.Chunk, len(resp))
52 for _, c := range resp {
53 ch <- c
54 }
55 close(ch)
56 return ch, nil
57 }
58
59 // fakeTool is a no-op tool whose read-only flag and output the test controls.
60 type fakeTool struct {
61 name string
62 ro bool
63 out string
64 executed chan struct{}
65 }
66
67 func (t fakeTool) Name() string { return t.name }
68 func (t fakeTool) Description() string { return "fake tool" }
69 func (t fakeTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
70 func (t fakeTool) ReadOnly() bool { return t.ro }
71 func (t fakeTool) Execute(context.Context, json.RawMessage) (string, error) {
72 if t.executed != nil {
73 close(t.executed)
74 }
75 return t.out, nil
76 }
77
78 // e2eFactory builds a real Controller around a real Agent driven by the scripted
79 // provider, with the fake tool registered and a transcript dir for persistence.
80 type e2eFactory struct {
81 prov provider.Provider
82 tool tool.Tool
83 policy permission.Policy
84 sessionDir string
85 options *agent.Options
86 }
87
88 func (f *e2eFactory) SessionDir() string { return f.sessionDir }
89
90 func (f *e2eFactory) NewSession(_ context.Context, p SessionParams) (*control.Controller, error) {
91 reg := tool.NewRegistry()
92 reg.Add(f.tool)
93 opts := agent.Options{MaxSteps: 5}
94 if f.options != nil {
95 opts = *f.options
96 }
97 executor := agent.New(f.prov, reg, agent.NewSession("you are a test agent"),
98 opts, p.Sink)
99 return control.New(control.Options{
100 Runner: executor,
101 Executor: executor,
102 Sink: p.Sink,
103 Policy: f.policy,
104 Label: "fake-model",
105 SessionDir: f.sessionDir,
106 }), nil
107 }
108
109 func TestE2EExplicitMaxStepsReturnsSuccessfulPause(t *testing.T) {
110 responses := [][]provider.Chunk{
111 {toolCallChunk("c1", "peek", `{}`), {Type: provider.ChunkDone}},
112 {toolCallChunk("c2", "peek", `{}`), {Type: provider.ChunkDone}},
113 {toolCallChunk("c3", "peek", `{}`), {Type: provider.ChunkDone}},
114 {toolCallChunk("c4", "peek", `{}`), {Type: provider.ChunkDone}},
115 {toolCallChunk("c5", "peek", `{}`), {Type: provider.ChunkDone}},
116 {{Type: provider.ChunkText, Text: "Saved progress summary."}, {Type: provider.ChunkDone}},
117 }
118 assertE2ERunPause(t, responses, nil, StopMaxTurnRequests, "paused after 5 tool-call rounds")
119 }
120
121 func TestE2ETaskBudgetReturnsSuccessfulPause(t *testing.T) {
122 responses := [][]provider.Chunk{
123 {
124 toolCallChunk("c1", "peek", `{}`),
125 {Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 100, CacheMissTokens: 100}},
126 {Type: provider.ChunkDone},
127 },
128 {{Type: provider.ChunkText, Text: "Saved budget summary."}, {Type: provider.ChunkDone}},
129 }
130 options := &agent.Options{
131 Pricing: &provider.Pricing{Input: 1, Currency: "USD"},
132 TaskBudget: agent.TaskBudget{Cost: 0.000001},
133 }
134 assertE2ERunPause(t, responses, options, StopEndTurn, "paused after reaching this task's cost budget")
135 }
136
137 func assertE2ERunPause(t *testing.T, responses [][]provider.Chunk, options *agent.Options, wantStop StopReason, wantWarning string) {
138 t.Helper()
139 factory := &e2eFactory{
140 prov: &scriptedProvider{name: "fake", responses: responses},
141 tool: fakeTool{name: "peek", ro: true, out: "ok"},
142 policy: permission.New("ask", nil, nil, nil),
143 sessionDir: t.TempDir(),
144 options: options,
145 }
146 client, stop := startServer(t, factory)
147 defer stop()
148
149 sid := openSession(t, client)
150 promptCh := client.callAsync("session/prompt", SessionPromptParams{
151 SessionID: sid,
152 Prompt: []ContentBlock{{Type: "text", Text: "keep working"}},
153 })
154 notifications, resp := drainPrompt(t, client, promptCh)
155 if resp.Error != nil {
156 t.Fatalf("controlled pause returned JSON-RPC error: %+v", resp.Error)
157 }
158 var result SessionPromptResult
159 if err := json.Unmarshal(resp.Result, &result); err != nil {
160 t.Fatalf("prompt result: %v", err)
161 }
162 if result.StopReason != wantStop {
163 t.Errorf("stopReason = %q, want %q", result.StopReason, wantStop)
164 }
165
166 var warned, paused bool
167 for _, notification := range notifications {
168 if notification.Method == sessionStatusUpdateMethod {
169 var update ReasonixStatusUpdate
170 if err := json.Unmarshal(notification.Params, &update); err != nil {
171 t.Fatalf("status update: %v", err)
172 }
173 if update.Event == "pause" && update.Status.TurnOutcome.Kind == "paused" {
174 paused = true
175 }
176 }
177 var params SessionUpdateParams
178 if err := json.Unmarshal(notification.Params, &params); err != nil {
179 continue
180 }
181 update, ok := params.Update.(map[string]any)
182 if !ok || update["sessionUpdate"] != "agent_message_chunk" {
183 continue
184 }
185 content, _ := update["content"].(map[string]any)
186 text, _ := content["text"].(string)
187 if strings.Contains(text, "[warning]") && strings.Contains(text, wantWarning) {
188 warned = true
189 }
190 }
191 if !warned {
192 t.Fatalf("missing controlled-pause warning containing %q", wantWarning)
193 }
194 if !paused {
195 t.Fatal("missing paused status outcome")
196 }
197 }
198
199 func toolCallChunk(id, name, args string) provider.Chunk {
200 return provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: id, Name: name, Arguments: args}}
201 }
202
203 // openSession runs initialize + session/new and returns the session id.
204 func openSession(t *testing.T, c *rpcClient) string {
205 t.Helper()
206 c.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
207 resp := c.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
208 var nr SessionNewResult
209 if err := json.Unmarshal(resp.Result, &nr); err != nil || nr.SessionID == "" {
210 t.Fatalf("session/new: %v (%q)", err, nr.SessionID)
211 }
212 return nr.SessionID
213 }
214
215 // TestE2EToolTurnAndPersistence runs a full turn that streams text, calls a
216 // read-only tool (auto-allowed, no prompt), streams more text, and ends — then
217 // checks the session/update stream, the stopReason, and that the turn was
218 // persisted to the transcript path returned to the client.
219 func TestE2EToolTurnAndPersistence(t *testing.T) {
220 dir := t.TempDir()
221 prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
222 {
223 {Type: provider.ChunkText, Text: "Reading the file."},
224 toolCallChunk("c1", "peek", `{"path":"x"}`),
225 {Type: provider.ChunkDone},
226 },
227 {
228 {Type: provider.ChunkText, Text: "All done."},
229 {Type: provider.ChunkDone},
230 },
231 }}
232 factory := &e2eFactory{
233 prov: prov,
234 tool: fakeTool{name: "peek", ro: true, out: "file contents here"},
235 policy: permission.New("ask", nil, nil, nil),
236 sessionDir: dir,
237 }
238 client, stop := startServer(t, factory)
239 defer stop()
240
241 sid := openSession(t, client)
242 promptCh := client.callAsync("session/prompt", SessionPromptParams{
243 SessionID: sid,
244 Prompt: []ContentBlock{{Type: "text", Text: "look at x"}},
245 })
246 notifs, resp := drainPrompt(t, client, promptCh)
247
248 // The update stream carries both message chunks, the tool call, and its result.
249 kinds := map[string]int{}
250 var toolResultText string
251 for _, n := range notifs {
252 k := updateKind(t, n)
253 kinds[k]++
254 if k == "tool_call_update" {
255 var p struct {
256 Update struct {
257 Status string `json:"status"`
258 Content []struct {
259 Content struct {
260 Text string `json:"text"`
261 } `json:"content"`
262 } `json:"content"`
263 } `json:"update"`
264 }
265 json.Unmarshal(n.Params, &p)
266 if p.Update.Status != "completed" {
267 t.Errorf("tool_call_update status = %q, want completed", p.Update.Status)
268 }
269 if len(p.Update.Content) > 0 {
270 toolResultText = p.Update.Content[0].Content.Text
271 }
272 }
273 }
274 if kinds["agent_message_chunk"] < 2 {
275 t.Errorf("want >=2 message chunks, got %d (all: %v)", kinds["agent_message_chunk"], kinds)
276 }
277 if kinds["tool_call"] != 1 || kinds["tool_call_update"] != 1 {
278 t.Errorf("want 1 tool_call + 1 tool_call_update, got %v", kinds)
279 }
280 if toolResultText != "file contents here" {
281 t.Errorf("tool result text = %q", toolResultText)
282 }
283
284 var pr SessionPromptResult
285 if err := json.Unmarshal(resp.Result, &pr); err != nil {
286 t.Fatalf("prompt result: %v", err)
287 }
288 if pr.StopReason != StopEndTurn {
289 t.Errorf("stopReason = %q, want end_turn", pr.StopReason)
290 }
291
292 // Persistence: a transcript path was returned and the turn is on disk.
293 if pr.TranscriptPath == nil {
294 t.Fatal("no transcriptPath returned")
295 }
296 if !strings.HasPrefix(*pr.TranscriptPath, dir) {
297 t.Errorf("transcriptPath %q not under session dir %q", *pr.TranscriptPath, dir)
298 }
299 data, err := os.ReadFile(*pr.TranscriptPath)
300 if err != nil {
301 t.Fatalf("read transcript: %v", err)
302 }
303 body := string(data)
304 for _, want := range []string{"look at x", "All done.", "peek"} {
305 if !strings.Contains(body, want) {
306 t.Errorf("transcript missing %q; got:\n%s", want, body)
307 }
308 }
309 }
310
311 // TestE2ESessionLoad runs a turn in one server (saving a transcript keyed by
312 // session id), then resumes it in a fresh server pointed at the same session dir
313 // — simulating a restart — and checks the conversation is replayed to the client
314 // as session/update notifications.
315 func TestE2ESessionLoad(t *testing.T) {
316 dir := t.TempDir()
317 mkFactory := func() *e2eFactory {
318 return &e2eFactory{
319 prov: &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
320 {
321 {Type: provider.ChunkText, Text: "Reading the file."},
322 toolCallChunk("c1", "peek", `{"path":"x"}`),
323 {Type: provider.ChunkDone},
324 },
325 {{Type: provider.ChunkText, Text: "All done."}, {Type: provider.ChunkDone}},
326 }},
327 tool: fakeTool{name: "peek", ro: true, out: "file contents here"},
328 policy: permission.New("ask", nil, nil, nil),
329 sessionDir: dir,
330 }
331 }
332
333 // Run 1: create a session and run a turn, persisting the transcript.
334 client1, stop1 := startServer(t, mkFactory())
335 sid := openSession(t, client1)
336 promptCh := client1.callAsync("session/prompt", SessionPromptParams{
337 SessionID: sid,
338 Prompt: []ContentBlock{{Type: "text", Text: "look at x"}},
339 })
340 drainPrompt(t, client1, promptCh)
341 stop1()
342
343 // Run 2: a brand-new server (same session dir) resumes by id.
344 client2, stop2 := startServer(t, mkFactory())
345 defer stop2()
346 loadCh := client2.callAsync("session/load", SessionLoadParams{SessionID: sid})
347 notifs, resp := drainPrompt(t, client2, loadCh)
348
349 if resp.Error != nil {
350 t.Fatalf("session/load errored: %+v", resp.Error)
351 }
352
353 // The replay reconstructs the conversation: the user turn, the tool call and
354 // its result, and the assistant's answers.
355 kinds := map[string]int{}
356 texts := map[string]string{}
357 for _, n := range notifs {
358 k := updateKind(t, n)
359 kinds[k]++
360 var p struct {
361 Update struct {
362 Content struct {
363 Text string `json:"text"`
364 } `json:"content"`
365 } `json:"update"`
366 }
367 json.Unmarshal(n.Params, &p)
368 if p.Update.Content.Text != "" {
369 texts[k] += p.Update.Content.Text
370 }
371 }
372 if kinds["user_message_chunk"] != 1 || !strings.Contains(texts["user_message_chunk"], "look at x") {
373 t.Errorf("user replay = %dx %q, want the original prompt", kinds["user_message_chunk"], texts["user_message_chunk"])
374 }
375 if !strings.Contains(texts["agent_message_chunk"], "All done.") {
376 t.Errorf("assistant replay = %q, want it to include the answer", texts["agent_message_chunk"])
377 }
378 if kinds["tool_call"] != 1 || kinds["tool_call_update"] != 1 {
379 t.Errorf("tool replay = %v, want 1 tool_call + 1 tool_call_update", kinds)
380 }
381 }
382
383 func TestE2ESessionListResumeAndDelete(t *testing.T) {
384 dir := t.TempDir()
385 cwd := t.TempDir()
386 mkFactory := func() *e2eFactory {
387 return &e2eFactory{
388 prov: &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
389 {{Type: provider.ChunkText, Text: "Stored answer."}, {Type: provider.ChunkDone}},
390 }},
391 tool: fakeTool{name: "peek", ro: true, out: "unused"},
392 policy: permission.New("ask", nil, nil, nil),
393 sessionDir: dir,
394 }
395 }
396
397 client1, stop1 := startServer(t, mkFactory())
398 client1.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
399 newResp := client1.call(t, "session/new", SessionNewParams{Cwd: cwd})
400 var nr SessionNewResult
401 if err := json.Unmarshal(newResp.Result, &nr); err != nil || nr.SessionID == "" {
402 t.Fatalf("session/new: %v (%q)", err, nr.SessionID)
403 }
404 promptCh := client1.callAsync("session/prompt", SessionPromptParams{
405 SessionID: nr.SessionID,
406 Prompt: []ContentBlock{{Type: "text", Text: "remember this session"}},
407 })
408 _, promptResp := drainPrompt(t, client1, promptCh)
409 var pr SessionPromptResult
410 if err := json.Unmarshal(promptResp.Result, &pr); err != nil {
411 t.Fatalf("prompt result: %v", err)
412 }
413 if pr.TranscriptPath == nil {
414 t.Fatal("prompt did not return a transcript path")
415 }
416 transcript := *pr.TranscriptPath
417 stop1()
418
419 client2, stop2 := startServer(t, mkFactory())
420 defer stop2()
421 client2.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
422 listResp := client2.call(t, "session/list", SessionListParams{Cwd: cwd})
423 var lr SessionListResult
424 if err := json.Unmarshal(listResp.Result, &lr); err != nil {
425 t.Fatalf("session/list result: %v", err)
426 }
427 if len(lr.Sessions) != 1 {
428 t.Fatalf("session/list returned %d sessions, want 1: %+v", len(lr.Sessions), lr.Sessions)
429 }
430 got := lr.Sessions[0]
431 if got.SessionID != nr.SessionID || got.Cwd != cwd {
432 t.Fatalf("listed session = %+v, want id %q cwd %q", got, nr.SessionID, cwd)
433 }
434 if !strings.Contains(got.Title, "remember this session") {
435 t.Fatalf("listed title = %q, want prompt preview", got.Title)
436 }
437 if got.UpdatedAt == "" {
438 t.Fatal("listed session missing updatedAt")
439 }
440
441 resumeResp := client2.call(t, "session/resume", SessionResumeParams{SessionID: nr.SessionID, Cwd: cwd})
442 if resumeResp.Error != nil {
443 t.Fatalf("session/resume errored: %+v", resumeResp.Error)
444 }
445 select {
446 case n := <-client2.notifs:
447 // Resume publishes current host state after its response. This is
448 // not transcript replay: the saved conversation has no active plan.
449 requirePlanFrame(t, n)
450 var params struct {
451 Update planUpdate `json:"update"`
452 }
453 if err := json.Unmarshal(n.Params, &params); err != nil || len(params.Update.Entries) != 0 {
454 t.Fatalf("resumed plan = %+v, err = %v; want an empty current plan", params, err)
455 }
456 case <-time.After(5 * time.Second):
457 t.Fatal("session/resume did not publish current plan state")
458 }
459
460 deleteResp := client2.call(t, "session/delete", SessionDeleteParams{SessionID: nr.SessionID})
461 if deleteResp.Error != nil {
462 t.Fatalf("session/delete errored: %+v", deleteResp.Error)
463 }
464 listResp = client2.call(t, "session/list", SessionListParams{Cwd: cwd})
465 if err := json.Unmarshal(listResp.Result, &lr); err != nil {
466 t.Fatalf("session/list after delete: %v", err)
467 }
468 if len(lr.Sessions) != 0 {
469 t.Fatalf("session/list after delete = %+v, want empty", lr.Sessions)
470 }
471 if _, err := os.Stat(transcript); !os.IsNotExist(err) {
472 t.Fatalf("transcript after delete stat err = %v, want not exist", err)
473 }
474 }
475
476 func TestE2ESessionListSkipsUnpromptedSessionAfterRestart(t *testing.T) {
477 dir := t.TempDir()
478 factory := &e2eFactory{
479 prov: &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
480 {{Type: provider.ChunkText, Text: "unused"}, {Type: provider.ChunkDone}},
481 }},
482 tool: fakeTool{name: "peek", ro: true, out: "unused"},
483 policy: permission.New("ask", nil, nil, nil),
484 sessionDir: dir,
485 }
486
487 client1, stop1 := startServer(t, factory)
488 client1.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
489 resp := client1.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
490 var nr SessionNewResult
491 if err := json.Unmarshal(resp.Result, &nr); err != nil || nr.SessionID == "" {
492 t.Fatalf("session/new: %v (%q)", err, nr.SessionID)
493 }
494 stop1()
495
496 client2, stop2 := startServer(t, factory)
497 defer stop2()
498 client2.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
499 listResp := client2.call(t, "session/list", SessionListParams{})
500 var lr SessionListResult
501 if err := json.Unmarshal(listResp.Result, &lr); err != nil {
502 t.Fatalf("session/list result: %v", err)
503 }
504 if len(lr.Sessions) != 0 {
505 t.Fatalf("session/list returned unprompted session: %+v", lr.Sessions)
506 }
507 }
508
509 func TestE2EDeleteActiveSessionDoesNotRecreateFiles(t *testing.T) {
510 dir := t.TempDir()
511 releaseTool := make(chan struct{})
512 started := make(chan struct{})
513 prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
514 {
515 {Type: provider.ChunkText, Text: "Starting."},
516 toolCallChunk("c1", "slow", `{}`),
517 {Type: provider.ChunkDone},
518 },
519 {{Type: provider.ChunkText, Text: "unreachable"}, {Type: provider.ChunkDone}},
520 }}
521 factory := &e2eFactory{
522 prov: prov,
523 tool: blockingTool{started: started, release: releaseTool},
524 policy: permission.New("ask", nil, nil, nil),
525 sessionDir: dir,
526 }
527 client, stop := startServer(t, factory)
528 defer stop()
529
530 sid := openSession(t, client)
531 promptCh := client.callAsync("session/prompt", SessionPromptParams{
532 SessionID: sid,
533 Prompt: []ContentBlock{{Type: "text", Text: "delete me while running"}},
534 })
535
536 waitForACPToolStart(t, started, promptCh)
537 deleteResp := client.call(t, "session/delete", SessionDeleteParams{SessionID: sid})
538 if deleteResp.Error != nil {
539 t.Fatalf("session/delete errored: %+v", deleteResp.Error)
540 }
541
542 select {
543 case resp := <-promptCh:
544 if resp.Error != nil {
545 t.Fatalf("prompt errored after delete: %+v", resp.Error)
546 }
547 var pr SessionPromptResult
548 if err := json.Unmarshal(resp.Result, &pr); err != nil {
549 t.Fatalf("prompt result: %v", err)
550 }
551 if pr.StopReason != StopCancelled {
552 t.Fatalf("stopReason = %q, want cancelled", pr.StopReason)
553 }
554 case <-time.After(2 * time.Second):
555 t.Fatal("prompt did not finish after delete")
556 }
557
558 listResp := client.call(t, "session/list", SessionListParams{})
559 var lr SessionListResult
560 if err := json.Unmarshal(listResp.Result, &lr); err != nil {
561 t.Fatalf("session/list result: %v", err)
562 }
563 if len(lr.Sessions) != 0 {
564 t.Fatalf("session/list after active delete = %+v, want empty", lr.Sessions)
565 }
566 for _, path := range []string{transcriptPath(dir, sid), acpMetaPath(transcriptPath(dir, sid))} {
567 if _, err := os.Stat(path); !os.IsNotExist(err) {
568 t.Fatalf("%s after active delete stat err = %v, want not exist", path, err)
569 }
570 }
571 close(releaseTool)
572 }
573
574 // TestE2EApprovalRoundTrip drives a write tool through the gate: the policy asks,
575 // the controller raises an ApprovalRequest, the sink forwards it as
576 // session/request_permission, the client allows it, and the tool then runs.
577 func TestE2EApprovalRoundTrip(t *testing.T) {
578 toolExecuted := make(chan struct{})
579 prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
580 {
581 {Type: provider.ChunkText, Text: "Writing."},
582 toolCallChunk("w1", "writeit", `{"path":"README.md"}`),
583 {Type: provider.ChunkDone},
584 },
585 {
586 {Type: provider.ChunkText, Text: "Wrote it."},
587 {Type: provider.ChunkDone},
588 },
589 }}
590 factory := &e2eFactory{
591 prov: prov,
592 tool: fakeTool{name: "writeit", ro: false, out: "written ok", executed: toolExecuted},
593 policy: permission.New("ask", nil, nil, nil),
594 sessionDir: t.TempDir(),
595 }
596 client, stop := startServer(t, factory)
597 defer stop()
598
599 sid := openSession(t, client)
600 if resp := client.call(t, "session/set_config_option", SetSessionConfigOptionParams{
601 SessionID: sid,
602 ConfigID: "tool_approval",
603 Value: control.ToolApprovalReadOnly,
604 }); resp.Error != nil {
605 t.Fatalf("set read-only permission: %+v", resp.Error)
606 }
607 promptCh := client.callAsync("session/prompt", SessionPromptParams{
608 SessionID: sid,
609 Prompt: []ContentBlock{{Type: "text", Text: "write README.md"}},
610 })
611
612 // Answer the permission request while the prompt is still in flight. The
613 // turn cannot finish until this round-trip completes.
614 var req frame
615 select {
616 case req = <-client.reqs:
617 case early := <-promptCh:
618 t.Fatalf("prompt returned before requesting permission: error=%+v result=%s", early.Error, early.Result)
619 case <-time.After(10 * time.Second):
620 t.Fatal("no permission request was raised before the ACP hang guard")
621 }
622 var pr PermissionRequestParams
623 if err := json.Unmarshal(req.Params, &pr); err != nil {
624 t.Fatalf("permission params: %v", err)
625 }
626 if pr.SessionID != sid {
627 t.Errorf("permission sessionId = %q, want %q", pr.SessionID, sid)
628 }
629 if pr.ToolCall.Kind != "edit" {
630 t.Errorf("permission kind = %q, want edit", pr.ToolCall.Kind)
631 }
632 if !strings.Contains(pr.ToolCall.Title, "writeit") {
633 t.Errorf("permission title = %q, want it to mention writeit", pr.ToolCall.Title)
634 }
635 assertACPv1PermissionOptionKinds(t, pr.Options)
636 if _, ok := invalidACPv1PermissionOptionKind(pr.Options); ok {
637 client.replyError(req.ID, ErrInvalidParams, "Invalid params")
638 } else {
639 client.reply(req.ID, PermissionRequestResult{
640 Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptAllowOnce)},
641 })
642 }
643
644 waitForACPToolExecution(t, toolExecuted, promptCh)
645 notifs, resp := drainPromptWithin(t, client, promptCh, 10*time.Second)
646
647 // The allowed tool ran: a completed tool_call_update with its output.
648 var ran bool
649 for _, n := range notifs {
650 if updateKind(t, n) != "tool_call_update" {
651 continue
652 }
653 var p struct {
654 Update struct {
655 Status string `json:"status"`
656 Content []struct {
657 Content struct {
658 Text string `json:"text"`
659 } `json:"content"`
660 } `json:"content"`
661 } `json:"update"`
662 }
663 json.Unmarshal(n.Params, &p)
664 if p.Update.Status == "completed" && len(p.Update.Content) > 0 &&
665 strings.HasPrefix(p.Update.Content[0].Content.Text, "written ok") {
666 ran = true
667 }
668 }
669 if !ran {
670 t.Error("approved tool did not run to completion")
671 }
672
673 var result SessionPromptResult
674 json.Unmarshal(resp.Result, &result)
675 // Adaptive standard execution may pause the turn for missing readiness
676 // after a write; controlled readiness pauses use ACP v1 end_turn.
677 if result.StopReason != StopEndTurn {
678 t.Errorf("stopReason = %q, want end_turn", result.StopReason)
679 }
680 }
681
682 // waitForACPToolExecution separates permission-delivery correctness from the
683 // status and transcript work that follows a completed tool call.
684 func waitForACPToolExecution(t *testing.T, executed <-chan struct{}, prompt <-chan frame) {
685 t.Helper()
686 select {
687 case <-executed:
688 return
689 case early := <-prompt:
690 t.Fatalf("prompt returned before the approved tool executed: error=%+v result=%s", early.Error, early.Result)
691 case <-time.After(10 * time.Second):
692 t.Fatal("approved tool did not execute before the ACP hang guard")
693 }
694 }
695
696 // TestE2ECancelMidTurn cancels while the tool is executing and checks the turn
697 // ends with stopReason cancelled.
698 func TestE2ECancelMidTurn(t *testing.T) {
699 releaseTool := make(chan struct{})
700 started := make(chan struct{})
701 cancelled := make(chan struct{})
702 defer close(releaseTool) // always let the tool goroutine unwind on failure
703 prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
704 {
705 {Type: provider.ChunkText, Text: "Starting."},
706 toolCallChunk("c1", "slow", `{}`),
707 {Type: provider.ChunkDone},
708 },
709 {{Type: provider.ChunkText, Text: "unreachable"}, {Type: provider.ChunkDone}},
710 }}
711 factory := &e2eFactory{
712 prov: prov,
713 tool: blockingTool{started: started, cancelled: cancelled, release: releaseTool},
714 policy: permission.New("ask", nil, nil, nil),
715 sessionDir: t.TempDir(),
716 }
717 client, stop := startServer(t, factory)
718 defer stop()
719
720 sid := openSession(t, client)
721 promptCh := client.callAsync("session/prompt", SessionPromptParams{
722 SessionID: sid,
723 Prompt: []ContentBlock{{Type: "text", Text: "go"}},
724 })
725
726 waitForACPToolStart(t, started, promptCh)
727 client.notify("session/cancel", SessionCancelParams{SessionID: sid})
728 waitForACPToolCancellation(t, cancelled, promptCh)
729
730 select {
731 case resp := <-promptCh:
732 var pr SessionPromptResult
733 json.Unmarshal(resp.Result, &pr)
734 if pr.StopReason != StopCancelled {
735 t.Errorf("stopReason = %q, want cancelled", pr.StopReason)
736 }
737 case <-time.After(10 * time.Second):
738 t.Fatal("turn did not finish after the tool observed cancellation")
739 }
740 }
741
742 // waitForACPToolStart distinguishes a blocked turn from a provider/RPC error
743 // that completed the prompt before tool execution. The ten-second branch is a
744 // hang guard, not an ordering assertion; ordering is proved by the started and
745 // prompt result events themselves. Windows full-package CI has measured more
746 // than two seconds of scheduler delay while the same focused test stays fast.
747 func waitForACPToolStart(t *testing.T, started <-chan struct{}, prompt <-chan frame) {
748 t.Helper()
749 select {
750 case <-started:
751 return
752 case early := <-prompt:
753 t.Fatalf("prompt returned before the tool started: error=%+v result=%s", early.Error, early.Result)
754 case <-time.After(10 * time.Second):
755 t.Fatal("tool did not start before the ACP hang guard")
756 }
757 }
758
759 // waitForACPToolCancellation proves that the notification reached the running
760 // tool before waiting for the prompt response. This separates cancellation
761 // delivery from the slower status/transcript finalization that follows it on
762 // loaded Windows runners.
763 func waitForACPToolCancellation(t *testing.T, cancelled <-chan struct{}, prompt <-chan frame) {
764 t.Helper()
765 select {
766 case <-cancelled:
767 return
768 case early := <-prompt:
769 t.Fatalf("prompt returned before the tool observed cancellation: error=%+v result=%s", early.Error, early.Result)
770 case <-time.After(10 * time.Second):
771 t.Fatal("tool did not observe cancellation before the ACP hang guard")
772 }
773 }
774
775 // blockingTool blocks in Execute until released or ctx is cancelled, signalling
776 // when it has started so the test can cancel mid-execution.
777 type blockingTool struct {
778 started chan struct{}
779 cancelled chan struct{}
780 release chan struct{}
781 }
782
783 func (t blockingTool) Name() string { return "slow" }
784 func (t blockingTool) Description() string { return "blocks until cancelled" }
785 func (t blockingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
786 func (t blockingTool) ReadOnly() bool { return true }
787 func (t blockingTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
788 close(t.started)
789 select {
790 case <-ctx.Done():
791 if t.cancelled != nil {
792 close(t.cancelled)
793 }
794 return "", ctx.Err()
795 case <-t.release:
796 return "released", nil
797 }
798 }
799
799 lines GO