434 lines
15 KiB
Go
434 lines
15 KiB
Go
package acp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/control"
|
|
"reasonix/internal/event"
|
|
"reasonix/internal/permission"
|
|
"reasonix/internal/provider"
|
|
)
|
|
|
|
// scriptedRequester answers agent → client requests from a per-method script
|
|
// and records the call order.
|
|
type scriptedRequester struct {
|
|
mu sync.Mutex
|
|
calls []string
|
|
params map[string]json.RawMessage
|
|
results map[string]any
|
|
errs map[string]error
|
|
}
|
|
|
|
func newScriptedRequester() *scriptedRequester {
|
|
return &scriptedRequester{
|
|
params: map[string]json.RawMessage{},
|
|
results: map[string]any{},
|
|
errs: map[string]error{},
|
|
}
|
|
}
|
|
|
|
func (r *scriptedRequester) Request(_ context.Context, method string, params any) (json.RawMessage, error) {
|
|
raw, _ := json.Marshal(params)
|
|
r.mu.Lock()
|
|
r.calls = append(r.calls, method)
|
|
r.params[method] = raw
|
|
res, err := r.results[method], r.errs[method]
|
|
r.mu.Unlock()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out, _ := json.Marshal(res)
|
|
return out, nil
|
|
}
|
|
|
|
func (r *scriptedRequester) callOrder() []string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return append([]string(nil), r.calls...)
|
|
}
|
|
|
|
func TestClientIOReadWriteTextFile(t *testing.T) {
|
|
req := newScriptedRequester()
|
|
req.results["fs/read_text_file"] = FSReadTextFileResult{Content: "buffer content"}
|
|
req.results["fs/write_text_file"] = struct{}{}
|
|
io := newClientIO(req, "sess-1", ClientCapabilities{FS: FSCapabilities{ReadTextFile: true, WriteTextFile: true}})
|
|
|
|
content, ok := io.ReadTextFile(context.Background(), "/proj/a.go")
|
|
if !ok || content != "buffer content" {
|
|
t.Fatalf("ReadTextFile = %q, %v; want buffer content, true", content, ok)
|
|
}
|
|
var readParams FSReadTextFileParams
|
|
json.Unmarshal(req.params["fs/read_text_file"], &readParams)
|
|
if readParams.SessionID != "sess-1" || readParams.Path != "/proj/a.go" {
|
|
t.Fatalf("fs/read_text_file params = %+v", readParams)
|
|
}
|
|
|
|
handled, err := io.WriteTextFile(context.Background(), "/proj/a.go", "new content")
|
|
if !handled || err != nil {
|
|
t.Fatalf("WriteTextFile = %v, %v; want true, nil", handled, err)
|
|
}
|
|
|
|
// Without the capability, both degrade to unhandled so tools use the disk.
|
|
none := newClientIO(req, "sess-1", ClientCapabilities{})
|
|
if _, ok := none.ReadTextFile(context.Background(), "/proj/a.go"); ok {
|
|
t.Fatal("ReadTextFile without capability must report ok=false")
|
|
}
|
|
if handled, _ := none.WriteTextFile(context.Background(), "/proj/a.go", "x"); handled {
|
|
t.Fatal("WriteTextFile without capability must report handled=false")
|
|
}
|
|
|
|
// A client read error falls back (ok=false); a client write error surfaces
|
|
// (falling back could double-apply).
|
|
req.errs["fs/read_text_file"] = fmt.Errorf("not open")
|
|
if _, ok := io.ReadTextFile(context.Background(), "/proj/a.go"); ok {
|
|
t.Fatal("ReadTextFile client error must report ok=false")
|
|
}
|
|
req.errs["fs/write_text_file"] = fmt.Errorf("readonly buffer")
|
|
handled, err = io.WriteTextFile(context.Background(), "/proj/a.go", "x")
|
|
if !handled || err == nil {
|
|
t.Fatalf("WriteTextFile client error = %v, %v; want handled=true with error", handled, err)
|
|
}
|
|
}
|
|
|
|
func TestClientIORunCommandLifecycle(t *testing.T) {
|
|
req := newScriptedRequester()
|
|
req.results["terminal/create"] = TerminalCreateResult{TerminalID: "term-1"}
|
|
req.results["terminal/wait_for_exit"] = TerminalWaitResult{}
|
|
exitZero := 0
|
|
req.results["terminal/output"] = TerminalOutputResult{Output: "hello from client", ExitStatus: &TerminalExitStatus{ExitCode: &exitZero}}
|
|
req.results["terminal/release"] = struct{}{}
|
|
io := newClientIO(req, "sess-1", ClientCapabilities{Terminal: true})
|
|
|
|
out, ok, err := io.RunCommand(context.Background(), "echo hello", "/proj", time.Minute)
|
|
if !ok || err != nil || out != "hello from client" {
|
|
t.Fatalf("RunCommand = %q, %v, %v", out, ok, err)
|
|
}
|
|
order := io2str(req.callOrder())
|
|
if order != "terminal/create,terminal/wait_for_exit,terminal/output,terminal/release" {
|
|
t.Fatalf("call order = %s", order)
|
|
}
|
|
|
|
// A nonzero exit surfaces as an error alongside the captured output.
|
|
exitOne := 1
|
|
req.results["terminal/output"] = TerminalOutputResult{Output: "boom", ExitStatus: &TerminalExitStatus{ExitCode: &exitOne}}
|
|
out, ok, err = io.RunCommand(context.Background(), "false", "/proj", time.Minute)
|
|
if !ok || err == nil || !strings.Contains(err.Error(), "exit status 1") || out != "boom" {
|
|
t.Fatalf("RunCommand nonzero exit = %q, %v, %v", out, ok, err)
|
|
}
|
|
|
|
// No terminal capability → unhandled, local bash runs instead.
|
|
none := newClientIO(req, "sess-1", ClientCapabilities{})
|
|
if _, ok, _ := none.RunCommand(context.Background(), "echo", "/proj", time.Minute); ok {
|
|
t.Fatal("RunCommand without capability must report ok=false")
|
|
}
|
|
|
|
// terminal/create failure degrades to unhandled rather than failing the call.
|
|
req.errs["terminal/create"] = fmt.Errorf("client rejected")
|
|
if _, ok, _ := io.RunCommand(context.Background(), "echo", "/proj", time.Minute); ok {
|
|
t.Fatal("RunCommand with failed create must report ok=false")
|
|
}
|
|
}
|
|
|
|
func io2str(calls []string) string { return strings.Join(calls, ",") }
|
|
|
|
func TestUpdateSinkToolLocations(t *testing.T) {
|
|
s := newUpdateSink(&fakeNotifier{}, "sess")
|
|
cwd := t.TempDir()
|
|
s.bindCwd(cwd)
|
|
|
|
locs := s.toolLocations("read_file", `{"path":"pkg/a.go","offset":41}`)
|
|
if len(locs) != 1 || locs[0].Path != filepath.Join(cwd, "pkg", "a.go") {
|
|
t.Fatalf("read_file locations = %+v", locs)
|
|
}
|
|
if locs[0].Line == nil || *locs[0].Line != 42 {
|
|
t.Fatalf("read_file line = %v, want 42 (offset is 0-based)", locs[0].Line)
|
|
}
|
|
// A platform-absolute path passes through untouched.
|
|
abs := filepath.Join(t.TempDir(), "b.go")
|
|
absArgs, _ := json.Marshal(map[string]string{"path": abs})
|
|
if locs := s.toolLocations("edit_file", string(absArgs)); len(locs) != 1 || locs[0].Path != abs || locs[0].Line != nil {
|
|
t.Fatalf("edit_file locations = %+v, want %s", locs, abs)
|
|
}
|
|
if locs := s.toolLocations("bash", `{"command":"ls"}`); locs != nil {
|
|
t.Fatalf("bash should have no locations, got %+v", locs)
|
|
}
|
|
if locs := s.toolLocations("grep", `{"path":"pkg","pattern":"x"}`); locs != nil {
|
|
t.Fatalf("grep (directory scope) should have no locations, got %+v", locs)
|
|
}
|
|
if locs := s.toolLocations("read_file", `{"offset":1}`); locs != nil {
|
|
t.Fatalf("path-less args should have no locations, got %+v", locs)
|
|
}
|
|
}
|
|
|
|
func TestPlanEntriesFromTodoArgs(t *testing.T) {
|
|
entries, ok := planEntriesFromTodoArgs(`{"todos":[
|
|
{"content":"Phase one","status":"in_progress","level":0},
|
|
{"content":"Sub step","status":"pending","level":1},
|
|
{"content":"Done step","status":"completed"},
|
|
{"content":"","status":"pending"},
|
|
{"content":"Weird","status":"???"}
|
|
]}`)
|
|
if !ok || len(entries) != 4 {
|
|
t.Fatalf("entries = %+v, ok=%v; want 4 entries", entries, ok)
|
|
}
|
|
if entries[0].Priority != "high" || entries[0].Status != "in_progress" {
|
|
t.Fatalf("phase entry = %+v", entries[0])
|
|
}
|
|
if entries[1].Priority != "medium" {
|
|
t.Fatalf("sub-step entry = %+v", entries[1])
|
|
}
|
|
if entries[3].Status != "pending" {
|
|
t.Fatalf("unknown status must degrade to pending, got %+v", entries[3])
|
|
}
|
|
if _, ok := planEntriesFromTodoArgs(`{"todos":[]}`); ok {
|
|
t.Fatal("empty todos must not produce a plan update")
|
|
}
|
|
if _, ok := planEntriesFromTodoArgs(`not json`); ok {
|
|
t.Fatal("malformed args must not produce a plan update")
|
|
}
|
|
}
|
|
|
|
func TestUpdateSinkEmitsPlanForTodoWrite(t *testing.T) {
|
|
n := &fakeNotifier{}
|
|
s := newUpdateSink(n, "sess-1")
|
|
s.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
|
|
ID: "t1", Name: "todo_write", Args: `{"todos":[{"content":"Do it","status":"pending"}]}`,
|
|
}})
|
|
// The plan update precedes the tool_call for the same dispatch.
|
|
plan := n.updateMap(t, 0)
|
|
if plan["sessionUpdate"] != "plan" {
|
|
t.Fatalf("first update = %v, want plan", plan["sessionUpdate"])
|
|
}
|
|
raw, _ := json.Marshal(plan)
|
|
if !strings.Contains(string(raw), `"content":"Do it"`) {
|
|
t.Fatalf("plan update missing entry: %s", raw)
|
|
}
|
|
call := n.updateMap(t, 1)
|
|
if call["sessionUpdate"] != "tool_call" {
|
|
t.Fatalf("second update = %v, want tool_call", call["sessionUpdate"])
|
|
}
|
|
}
|
|
|
|
// recordingFactory wraps e2eFactory and captures the SessionParams the service
|
|
// hands to NewSession, so tests can assert the client-capability wiring.
|
|
type recordingFactory struct {
|
|
inner *e2eFactory
|
|
mu sync.Mutex
|
|
params []SessionParams
|
|
}
|
|
|
|
func (f *recordingFactory) SessionDir() string { return f.inner.SessionDir() }
|
|
|
|
func (f *recordingFactory) NewSession(ctx context.Context, p SessionParams) (*control.Controller, error) {
|
|
f.mu.Lock()
|
|
f.params = append(f.params, p)
|
|
f.mu.Unlock()
|
|
return f.inner.NewSession(ctx, p)
|
|
}
|
|
|
|
func (f *recordingFactory) last() SessionParams {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.params[len(f.params)-1]
|
|
}
|
|
|
|
func TestSessionParamsCarryClientIOFromInitializeCaps(t *testing.T) {
|
|
dir := t.TempDir()
|
|
prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
|
|
{{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
|
|
}}
|
|
factory := &recordingFactory{inner: &e2eFactory{
|
|
prov: prov,
|
|
tool: fakeTool{name: "peek", ro: true, out: "ok"},
|
|
policy: permission.New("ask", nil, nil, nil),
|
|
sessionDir: dir,
|
|
}}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
client.call(t, "initialize", InitializeParams{
|
|
ProtocolVersion: 1,
|
|
ClientCapabilities: ClientCapabilities{
|
|
FS: FSCapabilities{ReadTextFile: true, WriteTextFile: true},
|
|
Terminal: true,
|
|
},
|
|
})
|
|
client.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
|
|
p := factory.last()
|
|
if p.FileOverlay == nil {
|
|
t.Fatal("SessionParams.FileOverlay should be bound when the client declares fs capabilities")
|
|
}
|
|
if p.Terminal == nil {
|
|
t.Fatal("SessionParams.Terminal should be bound when the client declares the terminal capability")
|
|
}
|
|
}
|
|
|
|
func TestSessionParamsNilClientIOWithoutCaps(t *testing.T) {
|
|
dir := t.TempDir()
|
|
prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
|
|
{{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
|
|
}}
|
|
factory := &recordingFactory{inner: &e2eFactory{
|
|
prov: prov,
|
|
tool: fakeTool{name: "peek", ro: true, out: "ok"},
|
|
policy: permission.New("ask", nil, nil, nil),
|
|
sessionDir: dir,
|
|
}}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
client.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
|
|
p := factory.last()
|
|
if p.FileOverlay != nil || p.Terminal != nil {
|
|
t.Fatalf("SessionParams overlay/terminal must stay nil without client capabilities; got %v / %v", p.FileOverlay, p.Terminal)
|
|
}
|
|
}
|
|
|
|
func TestE2ESessionModes(t *testing.T) {
|
|
dir := t.TempDir()
|
|
prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
|
|
{{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
|
|
}}
|
|
factory := &e2eFactory{
|
|
prov: prov,
|
|
tool: fakeTool{name: "peek", ro: true, out: "ok"},
|
|
policy: permission.New("ask", nil, nil, nil),
|
|
sessionDir: dir,
|
|
}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
cwd := t.TempDir()
|
|
resp := client.call(t, "session/new", SessionNewParams{Cwd: cwd})
|
|
var nr SessionNewResult
|
|
if err := json.Unmarshal(resp.Result, &nr); err != nil {
|
|
t.Fatalf("session/new result: %v", err)
|
|
}
|
|
if nr.Modes == nil || nr.Modes.CurrentModeID != sessionModeDefault || len(nr.Modes.AvailableModes) != 3 {
|
|
t.Fatalf("session/new modes = %+v, want default current with 3 available", nr.Modes)
|
|
}
|
|
|
|
setResp := client.call(t, "session/set_mode", SessionSetModeParams{SessionID: nr.SessionID, ModeID: sessionModePlan})
|
|
if setResp.Error != nil {
|
|
t.Fatalf("session/set_mode: %+v", setResp.Error)
|
|
}
|
|
// The switch is confirmed with a current_mode_update notification.
|
|
deadline := time.After(5 * time.Second)
|
|
for {
|
|
select {
|
|
case n := <-client.notifs:
|
|
var p struct {
|
|
Update struct {
|
|
SessionUpdate string `json:"sessionUpdate"`
|
|
CurrentModeID string `json:"currentModeId"`
|
|
} `json:"update"`
|
|
}
|
|
if json.Unmarshal(n.Params, &p) == nil && p.Update.SessionUpdate == "current_mode_update" {
|
|
if p.Update.CurrentModeID != sessionModePlan {
|
|
t.Fatalf("current_mode_update = %q, want plan", p.Update.CurrentModeID)
|
|
}
|
|
goto unknownMode
|
|
}
|
|
case <-deadline:
|
|
t.Fatal("no current_mode_update notification after session/set_mode")
|
|
}
|
|
}
|
|
unknownMode:
|
|
bad := client.call(t, "session/set_mode", SessionSetModeParams{SessionID: nr.SessionID, ModeID: "yolo"})
|
|
if bad.Error == nil {
|
|
t.Fatal("unknown modeId must be rejected")
|
|
}
|
|
|
|
// Reconnecting to the live session must report its actual mode, not a
|
|
// hardcoded default — the mode picker would otherwise go stale.
|
|
loadResp := client.call(t, "session/load", SessionLoadParams{SessionID: nr.SessionID, Cwd: cwd})
|
|
if loadResp.Error != nil {
|
|
t.Fatalf("session/load: %+v", loadResp.Error)
|
|
}
|
|
var lr SessionLoadResult
|
|
if err := json.Unmarshal(loadResp.Result, &lr); err != nil {
|
|
t.Fatalf("session/load result: %v", err)
|
|
}
|
|
if lr.Modes == nil || lr.Modes.CurrentModeID != sessionModePlan {
|
|
t.Fatalf("session/load modes = %+v, want current plan for the live session", lr.Modes)
|
|
}
|
|
}
|
|
|
|
// TestRebuildSessionKeepsClientIOAndMode pins two rebuild invariants: a
|
|
// model/effort switch must rebuild the controller with the same client
|
|
// capability wiring (fs overlay, host terminal) the original had, and must
|
|
// re-apply the session's ACP mode — a fresh controller boots with default
|
|
// switches, which would silently drop a user-selected plan mode.
|
|
func TestRebuildSessionKeepsClientIOAndMode(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "sess-rebuild.jsonl")
|
|
base := agent.NewSession("sys prompt")
|
|
base.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
|
|
if err := base.Save(path); err != nil {
|
|
t.Fatalf("save session: %v", err)
|
|
}
|
|
|
|
sink := newUpdateSink(&fakeNotifier{}, "sess-rebuild")
|
|
sess := &acpSession{
|
|
id: "sess-rebuild",
|
|
sink: sink,
|
|
cwd: dir,
|
|
model: "fast",
|
|
transcript: path,
|
|
modeID: sessionModePlan,
|
|
}
|
|
lease, err := agent.TryAcquireSessionLease(path)
|
|
if err != nil {
|
|
t.Fatalf("acquire session lease: %v", err)
|
|
}
|
|
sess.lease = lease
|
|
t.Cleanup(sess.releaseSessionLease)
|
|
|
|
factory := &configurableFactory{dir: dir}
|
|
svc := &service{
|
|
factory: factory,
|
|
sessions: map[string]*acpSession{sess.id: sess},
|
|
clientCaps: ClientCapabilities{
|
|
FS: FSCapabilities{ReadTextFile: true, WriteTextFile: true},
|
|
Terminal: true,
|
|
},
|
|
}
|
|
oldCtrl := control.New(control.Options{
|
|
Executor: agent.New(nil, nil, base, agent.Options{}, event.Discard),
|
|
SessionDir: dir,
|
|
SessionPath: path,
|
|
Label: "fast",
|
|
})
|
|
sess.ctrl = oldCtrl
|
|
|
|
if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: "pro"}); err != nil {
|
|
t.Fatalf("rebuildSession: %v", err)
|
|
}
|
|
if sess.ctrl == oldCtrl {
|
|
t.Fatal("session controller was not replaced")
|
|
}
|
|
|
|
factory.mu.Lock()
|
|
last := factory.builds[len(factory.builds)-1]
|
|
factory.mu.Unlock()
|
|
if last.FileOverlay == nil {
|
|
t.Fatal("rebuild must keep the fs overlay wiring")
|
|
}
|
|
if last.Terminal == nil {
|
|
t.Fatal("rebuild must keep the host terminal wiring")
|
|
}
|
|
if !sess.ctrl.PlanMode() {
|
|
t.Fatal("rebuild must re-apply the session's plan mode to the new controller")
|
|
}
|
|
}
|