635 lines
20 KiB
Go
635 lines
20 KiB
Go
package acp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/control"
|
|
"reasonix/internal/permission"
|
|
"reasonix/internal/provider"
|
|
"reasonix/internal/tool"
|
|
)
|
|
|
|
// These tests drive the full real stack — acp.Serve → control.Controller →
|
|
// agent.Agent — with a scripted provider and a fake tool standing in for the
|
|
// model and a real tool. They are the keyless, deterministic counterpart to a
|
|
// live network run: they exercise session/update streaming, the gate→approval
|
|
// round-trip, cancellation, and transcript persistence end to end.
|
|
|
|
// scriptedProvider returns the i-th preset response on the i-th Stream call (the
|
|
// agent calls Stream once per step), repeating the last response thereafter.
|
|
type scriptedProvider struct {
|
|
name string
|
|
responses [][]provider.Chunk
|
|
mu sync.Mutex
|
|
calls int
|
|
}
|
|
|
|
func (p *scriptedProvider) Name() string { return p.name }
|
|
|
|
func (p *scriptedProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
|
// Respect ctx like a real provider: a cancelled turn fails the next step's
|
|
// completion rather than streaming on.
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
p.mu.Lock()
|
|
i := p.calls
|
|
if i >= len(p.responses) {
|
|
i = len(p.responses) - 1
|
|
}
|
|
p.calls++
|
|
resp := p.responses[i]
|
|
p.mu.Unlock()
|
|
|
|
ch := make(chan provider.Chunk, len(resp))
|
|
for _, c := range resp {
|
|
ch <- c
|
|
}
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
|
|
// fakeTool is a no-op tool whose read-only flag and output the test controls.
|
|
type fakeTool struct {
|
|
name string
|
|
ro bool
|
|
out string
|
|
}
|
|
|
|
func (t fakeTool) Name() string { return t.name }
|
|
func (t fakeTool) Description() string { return "fake tool" }
|
|
func (t fakeTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
|
func (t fakeTool) ReadOnly() bool { return t.ro }
|
|
func (t fakeTool) Execute(context.Context, json.RawMessage) (string, error) {
|
|
return t.out, nil
|
|
}
|
|
|
|
// e2eFactory builds a real Controller around a real Agent driven by the scripted
|
|
// provider, with the fake tool registered and a transcript dir for persistence.
|
|
type e2eFactory struct {
|
|
prov provider.Provider
|
|
tool tool.Tool
|
|
policy permission.Policy
|
|
sessionDir string
|
|
}
|
|
|
|
func (f *e2eFactory) SessionDir() string { return f.sessionDir }
|
|
|
|
func (f *e2eFactory) NewSession(_ context.Context, p SessionParams) (*control.Controller, error) {
|
|
reg := tool.NewRegistry()
|
|
reg.Add(f.tool)
|
|
executor := agent.New(f.prov, reg, agent.NewSession("you are a test agent"),
|
|
agent.Options{MaxSteps: 5}, p.Sink)
|
|
return control.New(control.Options{
|
|
Runner: executor,
|
|
Executor: executor,
|
|
Sink: p.Sink,
|
|
Policy: f.policy,
|
|
Label: "fake-model",
|
|
SessionDir: f.sessionDir,
|
|
}), nil
|
|
}
|
|
|
|
func toolCallChunk(id, name, args string) provider.Chunk {
|
|
return provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: id, Name: name, Arguments: args}}
|
|
}
|
|
|
|
// openSession runs initialize + session/new and returns the session id.
|
|
func openSession(t *testing.T, c *rpcClient) string {
|
|
t.Helper()
|
|
c.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
resp := c.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
|
|
var nr SessionNewResult
|
|
if err := json.Unmarshal(resp.Result, &nr); err != nil || nr.SessionID == "" {
|
|
t.Fatalf("session/new: %v (%q)", err, nr.SessionID)
|
|
}
|
|
return nr.SessionID
|
|
}
|
|
|
|
// TestE2EToolTurnAndPersistence runs a full turn that streams text, calls a
|
|
// read-only tool (auto-allowed, no prompt), streams more text, and ends — then
|
|
// checks the session/update stream, the stopReason, and that the turn was
|
|
// persisted to the transcript path returned to the client.
|
|
func TestE2EToolTurnAndPersistence(t *testing.T) {
|
|
dir := t.TempDir()
|
|
prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
|
|
{
|
|
{Type: provider.ChunkText, Text: "Reading the file."},
|
|
toolCallChunk("c1", "peek", `{"path":"x"}`),
|
|
{Type: provider.ChunkDone},
|
|
},
|
|
{
|
|
{Type: provider.ChunkText, Text: "All done."},
|
|
{Type: provider.ChunkDone},
|
|
},
|
|
}}
|
|
factory := &e2eFactory{
|
|
prov: prov,
|
|
tool: fakeTool{name: "peek", ro: true, out: "file contents here"},
|
|
policy: permission.New("ask", nil, nil, nil),
|
|
sessionDir: dir,
|
|
}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
sid := openSession(t, client)
|
|
promptCh := client.callAsync("session/prompt", SessionPromptParams{
|
|
SessionID: sid,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "look at x"}},
|
|
})
|
|
notifs, resp := drainPrompt(t, client, promptCh)
|
|
|
|
// The update stream carries both message chunks, the tool call, and its result.
|
|
kinds := map[string]int{}
|
|
var toolResultText string
|
|
for _, n := range notifs {
|
|
k := updateKind(t, n)
|
|
kinds[k]++
|
|
if k == "tool_call_update" {
|
|
var p struct {
|
|
Update struct {
|
|
Status string `json:"status"`
|
|
Content []struct {
|
|
Content struct {
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
} `json:"content"`
|
|
} `json:"update"`
|
|
}
|
|
json.Unmarshal(n.Params, &p)
|
|
if p.Update.Status != "completed" {
|
|
t.Errorf("tool_call_update status = %q, want completed", p.Update.Status)
|
|
}
|
|
if len(p.Update.Content) > 0 {
|
|
toolResultText = p.Update.Content[0].Content.Text
|
|
}
|
|
}
|
|
}
|
|
if kinds["agent_message_chunk"] < 2 {
|
|
t.Errorf("want >=2 message chunks, got %d (all: %v)", kinds["agent_message_chunk"], kinds)
|
|
}
|
|
if kinds["tool_call"] != 1 || kinds["tool_call_update"] != 1 {
|
|
t.Errorf("want 1 tool_call + 1 tool_call_update, got %v", kinds)
|
|
}
|
|
if toolResultText != "file contents here" {
|
|
t.Errorf("tool result text = %q", toolResultText)
|
|
}
|
|
|
|
var pr SessionPromptResult
|
|
if err := json.Unmarshal(resp.Result, &pr); err != nil {
|
|
t.Fatalf("prompt result: %v", err)
|
|
}
|
|
if pr.StopReason != StopEndTurn {
|
|
t.Errorf("stopReason = %q, want end_turn", pr.StopReason)
|
|
}
|
|
|
|
// Persistence: a transcript path was returned and the turn is on disk.
|
|
if pr.TranscriptPath == nil {
|
|
t.Fatal("no transcriptPath returned")
|
|
}
|
|
if !strings.HasPrefix(*pr.TranscriptPath, dir) {
|
|
t.Errorf("transcriptPath %q not under session dir %q", *pr.TranscriptPath, dir)
|
|
}
|
|
data, err := os.ReadFile(*pr.TranscriptPath)
|
|
if err != nil {
|
|
t.Fatalf("read transcript: %v", err)
|
|
}
|
|
body := string(data)
|
|
for _, want := range []string{"look at x", "All done.", "peek"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("transcript missing %q; got:\n%s", want, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestE2ESessionLoad runs a turn in one server (saving a transcript keyed by
|
|
// session id), then resumes it in a fresh server pointed at the same session dir
|
|
// — simulating a restart — and checks the conversation is replayed to the client
|
|
// as session/update notifications.
|
|
func TestE2ESessionLoad(t *testing.T) {
|
|
dir := t.TempDir()
|
|
mkFactory := func() *e2eFactory {
|
|
return &e2eFactory{
|
|
prov: &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
|
|
{
|
|
{Type: provider.ChunkText, Text: "Reading the file."},
|
|
toolCallChunk("c1", "peek", `{"path":"x"}`),
|
|
{Type: provider.ChunkDone},
|
|
},
|
|
{{Type: provider.ChunkText, Text: "All done."}, {Type: provider.ChunkDone}},
|
|
}},
|
|
tool: fakeTool{name: "peek", ro: true, out: "file contents here"},
|
|
policy: permission.New("ask", nil, nil, nil),
|
|
sessionDir: dir,
|
|
}
|
|
}
|
|
|
|
// Run 1: create a session and run a turn, persisting the transcript.
|
|
client1, stop1 := startServer(t, mkFactory())
|
|
sid := openSession(t, client1)
|
|
promptCh := client1.callAsync("session/prompt", SessionPromptParams{
|
|
SessionID: sid,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "look at x"}},
|
|
})
|
|
drainPrompt(t, client1, promptCh)
|
|
stop1()
|
|
|
|
// Run 2: a brand-new server (same session dir) resumes by id.
|
|
client2, stop2 := startServer(t, mkFactory())
|
|
defer stop2()
|
|
loadCh := client2.callAsync("session/load", SessionLoadParams{SessionID: sid})
|
|
notifs, resp := drainPrompt(t, client2, loadCh)
|
|
|
|
if resp.Error != nil {
|
|
t.Fatalf("session/load errored: %+v", resp.Error)
|
|
}
|
|
|
|
// The replay reconstructs the conversation: the user turn, the tool call and
|
|
// its result, and the assistant's answers.
|
|
kinds := map[string]int{}
|
|
texts := map[string]string{}
|
|
for _, n := range notifs {
|
|
k := updateKind(t, n)
|
|
kinds[k]++
|
|
var p struct {
|
|
Update struct {
|
|
Content struct {
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
} `json:"update"`
|
|
}
|
|
json.Unmarshal(n.Params, &p)
|
|
if p.Update.Content.Text != "" {
|
|
texts[k] += p.Update.Content.Text
|
|
}
|
|
}
|
|
if kinds["user_message_chunk"] != 1 || !strings.Contains(texts["user_message_chunk"], "look at x") {
|
|
t.Errorf("user replay = %dx %q, want the original prompt", kinds["user_message_chunk"], texts["user_message_chunk"])
|
|
}
|
|
if !strings.Contains(texts["agent_message_chunk"], "All done.") {
|
|
t.Errorf("assistant replay = %q, want it to include the answer", texts["agent_message_chunk"])
|
|
}
|
|
if kinds["tool_call"] != 1 || kinds["tool_call_update"] != 1 {
|
|
t.Errorf("tool replay = %v, want 1 tool_call + 1 tool_call_update", kinds)
|
|
}
|
|
}
|
|
|
|
func TestE2ESessionListResumeAndDelete(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cwd := t.TempDir()
|
|
mkFactory := func() *e2eFactory {
|
|
return &e2eFactory{
|
|
prov: &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
|
|
{{Type: provider.ChunkText, Text: "Stored answer."}, {Type: provider.ChunkDone}},
|
|
}},
|
|
tool: fakeTool{name: "peek", ro: true, out: "unused"},
|
|
policy: permission.New("ask", nil, nil, nil),
|
|
sessionDir: dir,
|
|
}
|
|
}
|
|
|
|
client1, stop1 := startServer(t, mkFactory())
|
|
client1.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
newResp := client1.call(t, "session/new", SessionNewParams{Cwd: cwd})
|
|
var nr SessionNewResult
|
|
if err := json.Unmarshal(newResp.Result, &nr); err != nil || nr.SessionID == "" {
|
|
t.Fatalf("session/new: %v (%q)", err, nr.SessionID)
|
|
}
|
|
promptCh := client1.callAsync("session/prompt", SessionPromptParams{
|
|
SessionID: nr.SessionID,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "remember this session"}},
|
|
})
|
|
_, promptResp := drainPrompt(t, client1, promptCh)
|
|
var pr SessionPromptResult
|
|
if err := json.Unmarshal(promptResp.Result, &pr); err != nil {
|
|
t.Fatalf("prompt result: %v", err)
|
|
}
|
|
if pr.TranscriptPath == nil {
|
|
t.Fatal("prompt did not return a transcript path")
|
|
}
|
|
transcript := *pr.TranscriptPath
|
|
stop1()
|
|
|
|
client2, stop2 := startServer(t, mkFactory())
|
|
defer stop2()
|
|
client2.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
listResp := client2.call(t, "session/list", SessionListParams{Cwd: cwd})
|
|
var lr SessionListResult
|
|
if err := json.Unmarshal(listResp.Result, &lr); err != nil {
|
|
t.Fatalf("session/list result: %v", err)
|
|
}
|
|
if len(lr.Sessions) != 1 {
|
|
t.Fatalf("session/list returned %d sessions, want 1: %+v", len(lr.Sessions), lr.Sessions)
|
|
}
|
|
got := lr.Sessions[0]
|
|
if got.SessionID != nr.SessionID || got.Cwd != cwd {
|
|
t.Fatalf("listed session = %+v, want id %q cwd %q", got, nr.SessionID, cwd)
|
|
}
|
|
if !strings.Contains(got.Title, "remember this session") {
|
|
t.Fatalf("listed title = %q, want prompt preview", got.Title)
|
|
}
|
|
if got.UpdatedAt == "" {
|
|
t.Fatal("listed session missing updatedAt")
|
|
}
|
|
|
|
resumeResp := client2.call(t, "session/resume", SessionResumeParams{SessionID: nr.SessionID, Cwd: cwd})
|
|
if resumeResp.Error != nil {
|
|
t.Fatalf("session/resume errored: %+v", resumeResp.Error)
|
|
}
|
|
select {
|
|
case n := <-client2.notifs:
|
|
t.Fatalf("session/resume replayed an unexpected notification: %+v", n)
|
|
default:
|
|
}
|
|
|
|
deleteResp := client2.call(t, "session/delete", SessionDeleteParams{SessionID: nr.SessionID})
|
|
if deleteResp.Error != nil {
|
|
t.Fatalf("session/delete errored: %+v", deleteResp.Error)
|
|
}
|
|
listResp = client2.call(t, "session/list", SessionListParams{Cwd: cwd})
|
|
if err := json.Unmarshal(listResp.Result, &lr); err != nil {
|
|
t.Fatalf("session/list after delete: %v", err)
|
|
}
|
|
if len(lr.Sessions) != 0 {
|
|
t.Fatalf("session/list after delete = %+v, want empty", lr.Sessions)
|
|
}
|
|
if _, err := os.Stat(transcript); !os.IsNotExist(err) {
|
|
t.Fatalf("transcript after delete stat err = %v, want not exist", err)
|
|
}
|
|
}
|
|
|
|
func TestE2ESessionListSkipsUnpromptedSessionAfterRestart(t *testing.T) {
|
|
dir := t.TempDir()
|
|
factory := &e2eFactory{
|
|
prov: &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
|
|
{{Type: provider.ChunkText, Text: "unused"}, {Type: provider.ChunkDone}},
|
|
}},
|
|
tool: fakeTool{name: "peek", ro: true, out: "unused"},
|
|
policy: permission.New("ask", nil, nil, nil),
|
|
sessionDir: dir,
|
|
}
|
|
|
|
client1, stop1 := startServer(t, factory)
|
|
client1.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
resp := client1.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
|
|
var nr SessionNewResult
|
|
if err := json.Unmarshal(resp.Result, &nr); err != nil || nr.SessionID == "" {
|
|
t.Fatalf("session/new: %v (%q)", err, nr.SessionID)
|
|
}
|
|
stop1()
|
|
|
|
client2, stop2 := startServer(t, factory)
|
|
defer stop2()
|
|
client2.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
listResp := client2.call(t, "session/list", SessionListParams{})
|
|
var lr SessionListResult
|
|
if err := json.Unmarshal(listResp.Result, &lr); err != nil {
|
|
t.Fatalf("session/list result: %v", err)
|
|
}
|
|
if len(lr.Sessions) != 0 {
|
|
t.Fatalf("session/list returned unprompted session: %+v", lr.Sessions)
|
|
}
|
|
}
|
|
|
|
func TestE2EDeleteActiveSessionDoesNotRecreateFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
releaseTool := make(chan struct{})
|
|
started := make(chan struct{})
|
|
prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
|
|
{
|
|
{Type: provider.ChunkText, Text: "Starting."},
|
|
toolCallChunk("c1", "slow", `{}`),
|
|
{Type: provider.ChunkDone},
|
|
},
|
|
{{Type: provider.ChunkText, Text: "unreachable"}, {Type: provider.ChunkDone}},
|
|
}}
|
|
factory := &e2eFactory{
|
|
prov: prov,
|
|
tool: blockingTool{started: started, release: releaseTool},
|
|
policy: permission.New("ask", nil, nil, nil),
|
|
sessionDir: dir,
|
|
}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
sid := openSession(t, client)
|
|
promptCh := client.callAsync("session/prompt", SessionPromptParams{
|
|
SessionID: sid,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "delete me while running"}},
|
|
})
|
|
|
|
select {
|
|
case <-started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("tool never started")
|
|
}
|
|
deleteResp := client.call(t, "session/delete", SessionDeleteParams{SessionID: sid})
|
|
if deleteResp.Error != nil {
|
|
t.Fatalf("session/delete errored: %+v", deleteResp.Error)
|
|
}
|
|
|
|
select {
|
|
case resp := <-promptCh:
|
|
if resp.Error != nil {
|
|
t.Fatalf("prompt errored after delete: %+v", resp.Error)
|
|
}
|
|
var pr SessionPromptResult
|
|
if err := json.Unmarshal(resp.Result, &pr); err != nil {
|
|
t.Fatalf("prompt result: %v", err)
|
|
}
|
|
if pr.StopReason != StopCancelled {
|
|
t.Fatalf("stopReason = %q, want cancelled", pr.StopReason)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("prompt did not finish after delete")
|
|
}
|
|
|
|
listResp := client.call(t, "session/list", SessionListParams{})
|
|
var lr SessionListResult
|
|
if err := json.Unmarshal(listResp.Result, &lr); err != nil {
|
|
t.Fatalf("session/list result: %v", err)
|
|
}
|
|
if len(lr.Sessions) != 0 {
|
|
t.Fatalf("session/list after active delete = %+v, want empty", lr.Sessions)
|
|
}
|
|
for _, path := range []string{transcriptPath(dir, sid), acpMetaPath(transcriptPath(dir, sid))} {
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatalf("%s after active delete stat err = %v, want not exist", path, err)
|
|
}
|
|
}
|
|
close(releaseTool)
|
|
}
|
|
|
|
// TestE2EApprovalRoundTrip drives a write tool through the gate: the policy asks,
|
|
// the controller raises an ApprovalRequest, the sink forwards it as
|
|
// session/request_permission, the client allows it, and the tool then runs.
|
|
func TestE2EApprovalRoundTrip(t *testing.T) {
|
|
prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
|
|
{
|
|
{Type: provider.ChunkText, Text: "Writing."},
|
|
toolCallChunk("w1", "writeit", `{"path":"out"}`),
|
|
{Type: provider.ChunkDone},
|
|
},
|
|
{
|
|
{Type: provider.ChunkText, Text: "Wrote it."},
|
|
{Type: provider.ChunkDone},
|
|
},
|
|
}}
|
|
factory := &e2eFactory{
|
|
prov: prov,
|
|
tool: fakeTool{name: "writeit", ro: false, out: "written ok"},
|
|
policy: permission.New("ask", nil, nil, nil),
|
|
sessionDir: t.TempDir(),
|
|
}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
sid := openSession(t, client)
|
|
promptCh := client.callAsync("session/prompt", SessionPromptParams{
|
|
SessionID: sid,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "write out"}},
|
|
})
|
|
|
|
// Answer the permission request the write tool raises, capturing it to assert.
|
|
reqSeen := make(chan PermissionRequestParams, 1)
|
|
go func() {
|
|
req := <-client.reqs
|
|
var pr PermissionRequestParams
|
|
json.Unmarshal(req.Params, &pr)
|
|
reqSeen <- pr
|
|
if _, ok := invalidACPv1PermissionOptionKind(pr.Options); ok {
|
|
client.replyError(req.ID, ErrInvalidParams, "Invalid params")
|
|
return
|
|
}
|
|
client.reply(req.ID, PermissionRequestResult{
|
|
Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptAllowOnce)},
|
|
})
|
|
}()
|
|
|
|
notifs, resp := drainPrompt(t, client, promptCh)
|
|
|
|
select {
|
|
case pr := <-reqSeen:
|
|
if pr.SessionID != sid {
|
|
t.Errorf("permission sessionId = %q, want %q", pr.SessionID, sid)
|
|
}
|
|
if pr.ToolCall.Kind != "edit" {
|
|
t.Errorf("permission kind = %q, want edit", pr.ToolCall.Kind)
|
|
}
|
|
if !strings.Contains(pr.ToolCall.Title, "writeit") {
|
|
t.Errorf("permission title = %q, want it to mention writeit", pr.ToolCall.Title)
|
|
}
|
|
assertACPv1PermissionOptionKinds(t, pr.Options)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("no permission request was raised")
|
|
}
|
|
|
|
// The allowed tool ran: a completed tool_call_update with its output.
|
|
var ran bool
|
|
for _, n := range notifs {
|
|
if updateKind(t, n) != "tool_call_update" {
|
|
continue
|
|
}
|
|
var p struct {
|
|
Update struct {
|
|
Status string `json:"status"`
|
|
Content []struct {
|
|
Content struct {
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
} `json:"content"`
|
|
} `json:"update"`
|
|
}
|
|
json.Unmarshal(n.Params, &p)
|
|
if p.Update.Status == "completed" && len(p.Update.Content) > 0 &&
|
|
p.Update.Content[0].Content.Text == "written ok" {
|
|
ran = true
|
|
}
|
|
}
|
|
if !ran {
|
|
t.Error("approved tool did not run to completion")
|
|
}
|
|
|
|
var pr SessionPromptResult
|
|
json.Unmarshal(resp.Result, &pr)
|
|
if pr.StopReason != StopEndTurn {
|
|
t.Errorf("stopReason = %q, want end_turn", pr.StopReason)
|
|
}
|
|
}
|
|
|
|
// TestE2ECancelMidTurn cancels while the tool is executing and checks the turn
|
|
// ends with stopReason cancelled.
|
|
func TestE2ECancelMidTurn(t *testing.T) {
|
|
releaseTool := make(chan struct{})
|
|
started := make(chan struct{})
|
|
prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
|
|
{
|
|
{Type: provider.ChunkText, Text: "Starting."},
|
|
toolCallChunk("c1", "slow", `{}`),
|
|
{Type: provider.ChunkDone},
|
|
},
|
|
{{Type: provider.ChunkText, Text: "unreachable"}, {Type: provider.ChunkDone}},
|
|
}}
|
|
factory := &e2eFactory{
|
|
prov: prov,
|
|
tool: blockingTool{started: started, release: releaseTool},
|
|
policy: permission.New("ask", nil, nil, nil),
|
|
sessionDir: t.TempDir(),
|
|
}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
sid := openSession(t, client)
|
|
promptCh := client.callAsync("session/prompt", SessionPromptParams{
|
|
SessionID: sid,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "go"}},
|
|
})
|
|
|
|
select {
|
|
case <-started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("tool never started")
|
|
}
|
|
client.notify("session/cancel", SessionCancelParams{SessionID: sid})
|
|
|
|
select {
|
|
case resp := <-promptCh:
|
|
var pr SessionPromptResult
|
|
json.Unmarshal(resp.Result, &pr)
|
|
if pr.StopReason != StopCancelled {
|
|
t.Errorf("stopReason = %q, want cancelled", pr.StopReason)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("cancel did not end the turn")
|
|
}
|
|
close(releaseTool) // let the tool goroutine unwind
|
|
}
|
|
|
|
// blockingTool blocks in Execute until released or ctx is cancelled, signalling
|
|
// when it has started so the test can cancel mid-execution.
|
|
type blockingTool struct {
|
|
started chan struct{}
|
|
release chan struct{}
|
|
}
|
|
|
|
func (t blockingTool) Name() string { return "slow" }
|
|
func (t blockingTool) Description() string { return "blocks until cancelled" }
|
|
func (t blockingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
|
func (t blockingTool) ReadOnly() bool { return true }
|
|
func (t blockingTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
|
|
close(t.started)
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
case <-t.release:
|
|
return "released", nil
|
|
}
|
|
}
|