返回 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 }
65
66 func (t fakeTool) Name() string { return t.name }
67 func (t fakeTool) Description() string { return "fake tool" }
68 func (t fakeTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
69 func (t fakeTool) ReadOnly() bool { return t.ro }
70 func (t fakeTool) Execute(context.Context, json.RawMessage) (string, error) {
71 return t.out, nil
72 }
73
74 // e2eFactory builds a real Controller around a real Agent driven by the scripted
75 // provider, with the fake tool registered and a transcript dir for persistence.
76 type e2eFactory struct {
77 prov provider.Provider
78 tool tool.Tool
79 policy permission.Policy
80 sessionDir string
81 }
82
83 func (f *e2eFactory) SessionDir() string { return f.sessionDir }
84
85 func (f *e2eFactory) NewSession(_ context.Context, p SessionParams) (*control.Controller, error) {
86 reg := tool.NewRegistry()
87 reg.Add(f.tool)
88 executor := agent.New(f.prov, reg, agent.NewSession("you are a test agent"),
89 agent.Options{MaxSteps: 5}, p.Sink)
90 return control.New(control.Options{
91 Runner: executor,
92 Executor: executor,
93 Sink: p.Sink,
94 Policy: f.policy,
95 Label: "fake-model",
96 SessionDir: f.sessionDir,
97 }), nil
98 }
99
100 func toolCallChunk(id, name, args string) provider.Chunk {
101 return provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: id, Name: name, Arguments: args}}
102 }
103
104 // openSession runs initialize + session/new and returns the session id.
105 func openSession(t *testing.T, c *rpcClient) string {
106 t.Helper()
107 c.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
108 resp := c.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
109 var nr SessionNewResult
110 if err := json.Unmarshal(resp.Result, &nr); err != nil || nr.SessionID == "" {
111 t.Fatalf("session/new: %v (%q)", err, nr.SessionID)
112 }
113 return nr.SessionID
114 }
115
116 // TestE2EToolTurnAndPersistence runs a full turn that streams text, calls a
117 // read-only tool (auto-allowed, no prompt), streams more text, and ends — then
118 // checks the session/update stream, the stopReason, and that the turn was
119 // persisted to the transcript path returned to the client.
120 func TestE2EToolTurnAndPersistence(t *testing.T) {
121 dir := t.TempDir()
122 prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
123 {
124 {Type: provider.ChunkText, Text: "Reading the file."},
125 toolCallChunk("c1", "peek", `{"path":"x"}`),
126 {Type: provider.ChunkDone},
127 },
128 {
129 {Type: provider.ChunkText, Text: "All done."},
130 {Type: provider.ChunkDone},
131 },
132 }}
133 factory := &e2eFactory{
134 prov: prov,
135 tool: fakeTool{name: "peek", ro: true, out: "file contents here"},
136 policy: permission.New("ask", nil, nil, nil),
137 sessionDir: dir,
138 }
139 client, stop := startServer(t, factory)
140 defer stop()
141
142 sid := openSession(t, client)
143 promptCh := client.callAsync("session/prompt", SessionPromptParams{
144 SessionID: sid,
145 Prompt: []ContentBlock{{Type: "text", Text: "look at x"}},
146 })
147 notifs, resp := drainPrompt(t, client, promptCh)
148
149 // The update stream carries both message chunks, the tool call, and its result.
150 kinds := map[string]int{}
151 var toolResultText string
152 for _, n := range notifs {
153 k := updateKind(t, n)
154 kinds[k]++
155 if k == "tool_call_update" {
156 var p struct {
157 Update struct {
158 Status string `json:"status"`
159 Content []struct {
160 Content struct {
161 Text string `json:"text"`
162 } `json:"content"`
163 } `json:"content"`
164 } `json:"update"`
165 }
166 json.Unmarshal(n.Params, &p)
167 if p.Update.Status != "completed" {
168 t.Errorf("tool_call_update status = %q, want completed", p.Update.Status)
169 }
170 if len(p.Update.Content) > 0 {
171 toolResultText = p.Update.Content[0].Content.Text
172 }
173 }
174 }
175 if kinds["agent_message_chunk"] < 2 {
176 t.Errorf("want >=2 message chunks, got %d (all: %v)", kinds["agent_message_chunk"], kinds)
177 }
178 if kinds["tool_call"] != 1 || kinds["tool_call_update"] != 1 {
179 t.Errorf("want 1 tool_call + 1 tool_call_update, got %v", kinds)
180 }
181 if toolResultText != "file contents here" {
182 t.Errorf("tool result text = %q", toolResultText)
183 }
184
185 var pr SessionPromptResult
186 if err := json.Unmarshal(resp.Result, &pr); err != nil {
187 t.Fatalf("prompt result: %v", err)
188 }
189 if pr.StopReason != StopEndTurn {
190 t.Errorf("stopReason = %q, want end_turn", pr.StopReason)
191 }
192
193 // Persistence: a transcript path was returned and the turn is on disk.
194 if pr.TranscriptPath == nil {
195 t.Fatal("no transcriptPath returned")
196 }
197 if !strings.HasPrefix(*pr.TranscriptPath, dir) {
198 t.Errorf("transcriptPath %q not under session dir %q", *pr.TranscriptPath, dir)
199 }
200 data, err := os.ReadFile(*pr.TranscriptPath)
201 if err != nil {
202 t.Fatalf("read transcript: %v", err)
203 }
204 body := string(data)
205 for _, want := range []string{"look at x", "All done.", "peek"} {
206 if !strings.Contains(body, want) {
207 t.Errorf("transcript missing %q; got:\n%s", want, body)
208 }
209 }
210 }
211
212 // TestE2ESessionLoad runs a turn in one server (saving a transcript keyed by
213 // session id), then resumes it in a fresh server pointed at the same session dir
214 // — simulating a restart — and checks the conversation is replayed to the client
215 // as session/update notifications.
216 func TestE2ESessionLoad(t *testing.T) {
217 dir := t.TempDir()
218 mkFactory := func() *e2eFactory {
219 return &e2eFactory{
220 prov: &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
221 {
222 {Type: provider.ChunkText, Text: "Reading the file."},
223 toolCallChunk("c1", "peek", `{"path":"x"}`),
224 {Type: provider.ChunkDone},
225 },
226 {{Type: provider.ChunkText, Text: "All done."}, {Type: provider.ChunkDone}},
227 }},
228 tool: fakeTool{name: "peek", ro: true, out: "file contents here"},
229 policy: permission.New("ask", nil, nil, nil),
230 sessionDir: dir,
231 }
232 }
233
234 // Run 1: create a session and run a turn, persisting the transcript.
235 client1, stop1 := startServer(t, mkFactory())
236 sid := openSession(t, client1)
237 promptCh := client1.callAsync("session/prompt", SessionPromptParams{
238 SessionID: sid,
239 Prompt: []ContentBlock{{Type: "text", Text: "look at x"}},
240 })
241 drainPrompt(t, client1, promptCh)
242 stop1()
243
244 // Run 2: a brand-new server (same session dir) resumes by id.
245 client2, stop2 := startServer(t, mkFactory())
246 defer stop2()
247 loadCh := client2.callAsync("session/load", SessionLoadParams{SessionID: sid})
248 notifs, resp := drainPrompt(t, client2, loadCh)
249
250 if resp.Error != nil {
251 t.Fatalf("session/load errored: %+v", resp.Error)
252 }
253
254 // The replay reconstructs the conversation: the user turn, the tool call and
255 // its result, and the assistant's answers.
256 kinds := map[string]int{}
257 texts := map[string]string{}
258 for _, n := range notifs {
259 k := updateKind(t, n)
260 kinds[k]++
261 var p struct {
262 Update struct {
263 Content struct {
264 Text string `json:"text"`
265 } `json:"content"`
266 } `json:"update"`
267 }
268 json.Unmarshal(n.Params, &p)
269 if p.Update.Content.Text != "" {
270 texts[k] += p.Update.Content.Text
271 }
272 }
273 if kinds["user_message_chunk"] != 1 || !strings.Contains(texts["user_message_chunk"], "look at x") {
274 t.Errorf("user replay = %dx %q, want the original prompt", kinds["user_message_chunk"], texts["user_message_chunk"])
275 }
276 if !strings.Contains(texts["agent_message_chunk"], "All done.") {
277 t.Errorf("assistant replay = %q, want it to include the answer", texts["agent_message_chunk"])
278 }
279 if kinds["tool_call"] != 1 || kinds["tool_call_update"] != 1 {
280 t.Errorf("tool replay = %v, want 1 tool_call + 1 tool_call_update", kinds)
281 }
282 }
283
284 func TestE2ESessionListResumeAndDelete(t *testing.T) {
285 dir := t.TempDir()
286 cwd := t.TempDir()
287 mkFactory := func() *e2eFactory {
288 return &e2eFactory{
289 prov: &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
290 {{Type: provider.ChunkText, Text: "Stored answer."}, {Type: provider.ChunkDone}},
291 }},
292 tool: fakeTool{name: "peek", ro: true, out: "unused"},
293 policy: permission.New("ask", nil, nil, nil),
294 sessionDir: dir,
295 }
296 }
297
298 client1, stop1 := startServer(t, mkFactory())
299 client1.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
300 newResp := client1.call(t, "session/new", SessionNewParams{Cwd: cwd})
301 var nr SessionNewResult
302 if err := json.Unmarshal(newResp.Result, &nr); err != nil || nr.SessionID == "" {
303 t.Fatalf("session/new: %v (%q)", err, nr.SessionID)
304 }
305 promptCh := client1.callAsync("session/prompt", SessionPromptParams{
306 SessionID: nr.SessionID,
307 Prompt: []ContentBlock{{Type: "text", Text: "remember this session"}},
308 })
309 _, promptResp := drainPrompt(t, client1, promptCh)
310 var pr SessionPromptResult
311 if err := json.Unmarshal(promptResp.Result, &pr); err != nil {
312 t.Fatalf("prompt result: %v", err)
313 }
314 if pr.TranscriptPath == nil {
315 t.Fatal("prompt did not return a transcript path")
316 }
317 transcript := *pr.TranscriptPath
318 stop1()
319
320 client2, stop2 := startServer(t, mkFactory())
321 defer stop2()
322 client2.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
323 listResp := client2.call(t, "session/list", SessionListParams{Cwd: cwd})
324 var lr SessionListResult
325 if err := json.Unmarshal(listResp.Result, &lr); err != nil {
326 t.Fatalf("session/list result: %v", err)
327 }
328 if len(lr.Sessions) != 1 {
329 t.Fatalf("session/list returned %d sessions, want 1: %+v", len(lr.Sessions), lr.Sessions)
330 }
331 got := lr.Sessions[0]
332 if got.SessionID != nr.SessionID || got.Cwd != cwd {
333 t.Fatalf("listed session = %+v, want id %q cwd %q", got, nr.SessionID, cwd)
334 }
335 if !strings.Contains(got.Title, "remember this session") {
336 t.Fatalf("listed title = %q, want prompt preview", got.Title)
337 }
338 if got.UpdatedAt == "" {
339 t.Fatal("listed session missing updatedAt")
340 }
341
342 resumeResp := client2.call(t, "session/resume", SessionResumeParams{SessionID: nr.SessionID, Cwd: cwd})
343 if resumeResp.Error != nil {
344 t.Fatalf("session/resume errored: %+v", resumeResp.Error)
345 }
346 select {
347 case n := <-client2.notifs:
348 t.Fatalf("session/resume replayed an unexpected notification: %+v", n)
349 default:
350 }
351
352 deleteResp := client2.call(t, "session/delete", SessionDeleteParams{SessionID: nr.SessionID})
353 if deleteResp.Error != nil {
354 t.Fatalf("session/delete errored: %+v", deleteResp.Error)
355 }
356 listResp = client2.call(t, "session/list", SessionListParams{Cwd: cwd})
357 if err := json.Unmarshal(listResp.Result, &lr); err != nil {
358 t.Fatalf("session/list after delete: %v", err)
359 }
360 if len(lr.Sessions) != 0 {
361 t.Fatalf("session/list after delete = %+v, want empty", lr.Sessions)
362 }
363 if _, err := os.Stat(transcript); !os.IsNotExist(err) {
364 t.Fatalf("transcript after delete stat err = %v, want not exist", err)
365 }
366 }
367
368 func TestE2ESessionListSkipsUnpromptedSessionAfterRestart(t *testing.T) {
369 dir := t.TempDir()
370 factory := &e2eFactory{
371 prov: &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
372 {{Type: provider.ChunkText, Text: "unused"}, {Type: provider.ChunkDone}},
373 }},
374 tool: fakeTool{name: "peek", ro: true, out: "unused"},
375 policy: permission.New("ask", nil, nil, nil),
376 sessionDir: dir,
377 }
378
379 client1, stop1 := startServer(t, factory)
380 client1.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
381 resp := client1.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
382 var nr SessionNewResult
383 if err := json.Unmarshal(resp.Result, &nr); err != nil || nr.SessionID == "" {
384 t.Fatalf("session/new: %v (%q)", err, nr.SessionID)
385 }
386 stop1()
387
388 client2, stop2 := startServer(t, factory)
389 defer stop2()
390 client2.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
391 listResp := client2.call(t, "session/list", SessionListParams{})
392 var lr SessionListResult
393 if err := json.Unmarshal(listResp.Result, &lr); err != nil {
394 t.Fatalf("session/list result: %v", err)
395 }
396 if len(lr.Sessions) != 0 {
397 t.Fatalf("session/list returned unprompted session: %+v", lr.Sessions)
398 }
399 }
400
401 func TestE2EDeleteActiveSessionDoesNotRecreateFiles(t *testing.T) {
402 dir := t.TempDir()
403 releaseTool := make(chan struct{})
404 started := make(chan struct{})
405 prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
406 {
407 {Type: provider.ChunkText, Text: "Starting."},
408 toolCallChunk("c1", "slow", `{}`),
409 {Type: provider.ChunkDone},
410 },
411 {{Type: provider.ChunkText, Text: "unreachable"}, {Type: provider.ChunkDone}},
412 }}
413 factory := &e2eFactory{
414 prov: prov,
415 tool: blockingTool{started: started, release: releaseTool},
416 policy: permission.New("ask", nil, nil, nil),
417 sessionDir: dir,
418 }
419 client, stop := startServer(t, factory)
420 defer stop()
421
422 sid := openSession(t, client)
423 promptCh := client.callAsync("session/prompt", SessionPromptParams{
424 SessionID: sid,
425 Prompt: []ContentBlock{{Type: "text", Text: "delete me while running"}},
426 })
427
428 select {
429 case <-started:
430 case <-time.After(2 * time.Second):
431 t.Fatal("tool never started")
432 }
433 deleteResp := client.call(t, "session/delete", SessionDeleteParams{SessionID: sid})
434 if deleteResp.Error != nil {
435 t.Fatalf("session/delete errored: %+v", deleteResp.Error)
436 }
437
438 select {
439 case resp := <-promptCh:
440 if resp.Error != nil {
441 t.Fatalf("prompt errored after delete: %+v", resp.Error)
442 }
443 var pr SessionPromptResult
444 if err := json.Unmarshal(resp.Result, &pr); err != nil {
445 t.Fatalf("prompt result: %v", err)
446 }
447 if pr.StopReason != StopCancelled {
448 t.Fatalf("stopReason = %q, want cancelled", pr.StopReason)
449 }
450 case <-time.After(2 * time.Second):
451 t.Fatal("prompt did not finish after delete")
452 }
453
454 listResp := client.call(t, "session/list", SessionListParams{})
455 var lr SessionListResult
456 if err := json.Unmarshal(listResp.Result, &lr); err != nil {
457 t.Fatalf("session/list result: %v", err)
458 }
459 if len(lr.Sessions) != 0 {
460 t.Fatalf("session/list after active delete = %+v, want empty", lr.Sessions)
461 }
462 for _, path := range []string{transcriptPath(dir, sid), acpMetaPath(transcriptPath(dir, sid))} {
463 if _, err := os.Stat(path); !os.IsNotExist(err) {
464 t.Fatalf("%s after active delete stat err = %v, want not exist", path, err)
465 }
466 }
467 close(releaseTool)
468 }
469
470 // TestE2EApprovalRoundTrip drives a write tool through the gate: the policy asks,
471 // the controller raises an ApprovalRequest, the sink forwards it as
472 // session/request_permission, the client allows it, and the tool then runs.
473 func TestE2EApprovalRoundTrip(t *testing.T) {
474 prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
475 {
476 {Type: provider.ChunkText, Text: "Writing."},
477 toolCallChunk("w1", "writeit", `{"path":"out"}`),
478 {Type: provider.ChunkDone},
479 },
480 {
481 {Type: provider.ChunkText, Text: "Wrote it."},
482 {Type: provider.ChunkDone},
483 },
484 }}
485 factory := &e2eFactory{
486 prov: prov,
487 tool: fakeTool{name: "writeit", ro: false, out: "written ok"},
488 policy: permission.New("ask", nil, nil, nil),
489 sessionDir: t.TempDir(),
490 }
491 client, stop := startServer(t, factory)
492 defer stop()
493
494 sid := openSession(t, client)
495 promptCh := client.callAsync("session/prompt", SessionPromptParams{
496 SessionID: sid,
497 Prompt: []ContentBlock{{Type: "text", Text: "write out"}},
498 })
499
500 // Answer the permission request the write tool raises, capturing it to assert.
501 reqSeen := make(chan PermissionRequestParams, 1)
502 go func() {
503 req := <-client.reqs
504 var pr PermissionRequestParams
505 json.Unmarshal(req.Params, &pr)
506 reqSeen <- pr
507 if _, ok := invalidACPv1PermissionOptionKind(pr.Options); ok {
508 client.replyError(req.ID, ErrInvalidParams, "Invalid params")
509 return
510 }
511 client.reply(req.ID, PermissionRequestResult{
512 Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptAllowOnce)},
513 })
514 }()
515
516 notifs, resp := drainPrompt(t, client, promptCh)
517
518 select {
519 case pr := <-reqSeen:
520 if pr.SessionID != sid {
521 t.Errorf("permission sessionId = %q, want %q", pr.SessionID, sid)
522 }
523 if pr.ToolCall.Kind != "edit" {
524 t.Errorf("permission kind = %q, want edit", pr.ToolCall.Kind)
525 }
526 if !strings.Contains(pr.ToolCall.Title, "writeit") {
527 t.Errorf("permission title = %q, want it to mention writeit", pr.ToolCall.Title)
528 }
529 assertACPv1PermissionOptionKinds(t, pr.Options)
530 case <-time.After(2 * time.Second):
531 t.Fatal("no permission request was raised")
532 }
533
534 // The allowed tool ran: a completed tool_call_update with its output.
535 var ran bool
536 for _, n := range notifs {
537 if updateKind(t, n) != "tool_call_update" {
538 continue
539 }
540 var p struct {
541 Update struct {
542 Status string `json:"status"`
543 Content []struct {
544 Content struct {
545 Text string `json:"text"`
546 } `json:"content"`
547 } `json:"content"`
548 } `json:"update"`
549 }
550 json.Unmarshal(n.Params, &p)
551 if p.Update.Status == "completed" && len(p.Update.Content) > 0 &&
552 p.Update.Content[0].Content.Text == "written ok" {
553 ran = true
554 }
555 }
556 if !ran {
557 t.Error("approved tool did not run to completion")
558 }
559
560 var pr SessionPromptResult
561 json.Unmarshal(resp.Result, &pr)
562 if pr.StopReason != StopEndTurn {
563 t.Errorf("stopReason = %q, want end_turn", pr.StopReason)
564 }
565 }
566
567 // TestE2ECancelMidTurn cancels while the tool is executing and checks the turn
568 // ends with stopReason cancelled.
569 func TestE2ECancelMidTurn(t *testing.T) {
570 releaseTool := make(chan struct{})
571 started := make(chan struct{})
572 prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
573 {
574 {Type: provider.ChunkText, Text: "Starting."},
575 toolCallChunk("c1", "slow", `{}`),
576 {Type: provider.ChunkDone},
577 },
578 {{Type: provider.ChunkText, Text: "unreachable"}, {Type: provider.ChunkDone}},
579 }}
580 factory := &e2eFactory{
581 prov: prov,
582 tool: blockingTool{started: started, release: releaseTool},
583 policy: permission.New("ask", nil, nil, nil),
584 sessionDir: t.TempDir(),
585 }
586 client, stop := startServer(t, factory)
587 defer stop()
588
589 sid := openSession(t, client)
590 promptCh := client.callAsync("session/prompt", SessionPromptParams{
591 SessionID: sid,
592 Prompt: []ContentBlock{{Type: "text", Text: "go"}},
593 })
594
595 select {
596 case <-started:
597 case <-time.After(2 * time.Second):
598 t.Fatal("tool never started")
599 }
600 client.notify("session/cancel", SessionCancelParams{SessionID: sid})
601
602 select {
603 case resp := <-promptCh:
604 var pr SessionPromptResult
605 json.Unmarshal(resp.Result, &pr)
606 if pr.StopReason != StopCancelled {
607 t.Errorf("stopReason = %q, want cancelled", pr.StopReason)
608 }
609 case <-time.After(2 * time.Second):
610 t.Fatal("cancel did not end the turn")
611 }
612 close(releaseTool) // let the tool goroutine unwind
613 }
614
615 // blockingTool blocks in Execute until released or ctx is cancelled, signalling
616 // when it has started so the test can cancel mid-execution.
617 type blockingTool struct {
618 started chan struct{}
619 release chan struct{}
620 }
621
622 func (t blockingTool) Name() string { return "slow" }
623 func (t blockingTool) Description() string { return "blocks until cancelled" }
624 func (t blockingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
625 func (t blockingTool) ReadOnly() bool { return true }
626 func (t blockingTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
627 close(t.started)
628 select {
629 case <-ctx.Done():
630 return "", ctx.Err()
631 case <-t.release:
632 return "released", nil
633 }
634 }
635
635 lines GO