1494 lines
46 KiB
Go
1494 lines
46 KiB
Go
package acp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/command"
|
|
"reasonix/internal/control"
|
|
"reasonix/internal/event"
|
|
"reasonix/internal/hook"
|
|
"reasonix/internal/jobs"
|
|
"reasonix/internal/provider"
|
|
"reasonix/internal/skill"
|
|
)
|
|
|
|
// --- fakes: a Factory wrapping a behavior-driven runner in a real Controller ---
|
|
|
|
// fakeRunner stands in for an agent.Runner; it emits to the session's sink and
|
|
// honors ctx cancellation, but runs no model.
|
|
type fakeRunner struct {
|
|
sink event.Sink
|
|
behavior func(ctx context.Context, sink event.Sink, input string) error
|
|
}
|
|
|
|
func (r *fakeRunner) Run(ctx context.Context, input string) error {
|
|
return r.behavior(ctx, r.sink, input)
|
|
}
|
|
|
|
// fakeFactory builds a real control.Controller around the fake runner, so the
|
|
// service exercises the actual controller surface (Run/Cancel/Close) it uses.
|
|
type fakeFactory struct {
|
|
behavior func(ctx context.Context, sink event.Sink, input string) error
|
|
}
|
|
|
|
func (f *fakeFactory) NewSession(_ context.Context, p SessionParams) (*control.Controller, error) {
|
|
runner := &fakeRunner{sink: p.Sink, behavior: f.behavior}
|
|
return control.New(control.Options{Runner: runner, Sink: p.Sink}), nil
|
|
}
|
|
|
|
type commandFactory struct {
|
|
commands []command.Command
|
|
skills []skill.Skill
|
|
seen chan string
|
|
}
|
|
|
|
func (f *commandFactory) NewSession(_ context.Context, p SessionParams) (*control.Controller, error) {
|
|
runner := &fakeRunner{
|
|
sink: p.Sink,
|
|
behavior: func(_ context.Context, sink event.Sink, input string) error {
|
|
f.seen <- input
|
|
sink.Emit(event.Event{Kind: event.Text, Text: input})
|
|
return nil
|
|
},
|
|
}
|
|
return control.New(control.Options{Runner: runner, Sink: p.Sink, Commands: f.commands, Skills: f.skills}), nil
|
|
}
|
|
|
|
type configurableFactory struct {
|
|
mu sync.Mutex
|
|
builds []SessionParams
|
|
dir string
|
|
withHooks bool
|
|
hookEvents []hook.Event
|
|
behavior func(ctx context.Context, sink event.Sink, input string, p SessionParams) error
|
|
managers []*jobs.Manager
|
|
withCtrl func(ctx context.Context, sink event.Sink, input string, p SessionParams, ctrl *control.Controller) error
|
|
}
|
|
|
|
func (f *configurableFactory) NewSession(_ context.Context, p SessionParams) (*control.Controller, error) {
|
|
f.mu.Lock()
|
|
f.builds = append(f.builds, SessionParams{
|
|
Cwd: p.Cwd,
|
|
Model: p.Model,
|
|
EffortOverride: cloneStringPtr(p.EffortOverride),
|
|
FileOverlay: p.FileOverlay,
|
|
Terminal: p.Terminal,
|
|
})
|
|
f.mu.Unlock()
|
|
behavior := f.behavior
|
|
if behavior == nil {
|
|
behavior = func(_ context.Context, sink event.Sink, input string, p SessionParams) error {
|
|
sink.Emit(event.Event{Kind: event.Text, Text: p.Model + ":" + input})
|
|
return nil
|
|
}
|
|
}
|
|
var ctrl *control.Controller
|
|
runner := &fakeRunner{
|
|
sink: p.Sink,
|
|
behavior: func(ctx context.Context, sink event.Sink, input string) error {
|
|
if f.withCtrl != nil {
|
|
return f.withCtrl(ctx, sink, input, p, ctrl)
|
|
}
|
|
return behavior(ctx, sink, input, p)
|
|
},
|
|
}
|
|
opts := control.Options{Runner: runner, Sink: p.Sink, SessionDir: f.dir, OnSessionRecovered: p.OnSessionRecovered}
|
|
if f.withHooks {
|
|
opts.Hooks = f.hookRunner()
|
|
}
|
|
if f.managers != nil {
|
|
jm := jobs.NewManager(event.Discard)
|
|
f.mu.Lock()
|
|
f.managers = append(f.managers, jm)
|
|
f.mu.Unlock()
|
|
opts.Jobs = jm
|
|
}
|
|
ctrl = control.New(opts)
|
|
return ctrl, nil
|
|
}
|
|
|
|
func (f *configurableFactory) SessionDir() string { return f.dir }
|
|
|
|
type teardownFactory struct {
|
|
dir string
|
|
grace time.Duration
|
|
mu sync.Mutex
|
|
manager *jobs.Manager
|
|
}
|
|
|
|
func (f *teardownFactory) SessionDir() string { return f.dir }
|
|
|
|
func (f *teardownFactory) NewSession(_ context.Context, p SessionParams) (*control.Controller, error) {
|
|
jm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(f.grace))
|
|
f.mu.Lock()
|
|
f.manager = jm
|
|
f.mu.Unlock()
|
|
runner := &fakeRunner{
|
|
sink: p.Sink,
|
|
behavior: func(context.Context, event.Sink, string) error { return nil },
|
|
}
|
|
return control.New(control.Options{
|
|
Runner: runner,
|
|
Sink: p.Sink,
|
|
SessionDir: f.dir,
|
|
Jobs: jm,
|
|
}), nil
|
|
}
|
|
|
|
func (f *teardownFactory) lastManager(t *testing.T) *jobs.Manager {
|
|
t.Helper()
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.manager == nil {
|
|
t.Fatal("session manager was not created")
|
|
}
|
|
return f.manager
|
|
}
|
|
|
|
func (f *configurableFactory) SessionConfigState(_ context.Context, p SessionConfigStateParams) (SessionConfigState, error) {
|
|
model := strings.TrimSpace(p.Model)
|
|
if model == "" {
|
|
model = "fast"
|
|
}
|
|
if model != "fast" && model != "pro" {
|
|
return SessionConfigState{}, os.ErrInvalid
|
|
}
|
|
effort := "auto"
|
|
effortOverride := cloneStringPtr(p.EffortOverride)
|
|
if effortOverride != nil && *effortOverride != "" {
|
|
effort = *effortOverride
|
|
}
|
|
modelOptions := []SessionConfigSelectOption{
|
|
{Value: "fast", Name: "Fast"},
|
|
{Value: "pro", Name: "Pro"},
|
|
}
|
|
effortOptions := []SessionConfigSelectOption{
|
|
{Value: "auto", Name: "Auto"},
|
|
{Value: "high", Name: "High"},
|
|
}
|
|
return SessionConfigState{
|
|
Model: model,
|
|
EffortOverride: effortOverride,
|
|
Models: &SessionModelState{
|
|
AvailableModels: []ModelInfo{{ModelID: "fast", Name: "Fast"}, {ModelID: "pro", Name: "Pro"}},
|
|
CurrentModelID: model,
|
|
},
|
|
ConfigOptions: []SessionConfigOption{
|
|
{ID: "model", Name: "Model", Category: "model", Type: "select", CurrentValue: model, Options: modelOptions},
|
|
{ID: "effort", Name: "Effort", Category: "thought_level", Type: "select", CurrentValue: effort, Options: effortOptions},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (f *configurableFactory) buildAt(t *testing.T, idx int) SessionParams {
|
|
t.Helper()
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if len(f.builds) <= idx {
|
|
t.Fatalf("builds = %d, want index %d", len(f.builds), idx)
|
|
}
|
|
return f.builds[idx]
|
|
}
|
|
|
|
func (f *configurableFactory) buildCount() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return len(f.builds)
|
|
}
|
|
|
|
func (f *configurableFactory) managerAt(t *testing.T, idx int) *jobs.Manager {
|
|
t.Helper()
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.managers == nil {
|
|
t.Fatal("factory does not create job managers")
|
|
}
|
|
if len(f.managers) <= idx {
|
|
t.Fatalf("builds = %d, want manager index %d", len(f.builds), idx)
|
|
}
|
|
return f.managers[idx]
|
|
}
|
|
|
|
func (f *configurableFactory) hookRunner() *hook.Runner {
|
|
hooks := []hook.ResolvedHook{
|
|
{HookConfig: hook.HookConfig{Command: "session-start"}, Event: hook.SessionStart},
|
|
{HookConfig: hook.HookConfig{Command: "session-end"}, Event: hook.SessionEnd},
|
|
}
|
|
return hook.NewRunner(hooks, "", func(_ context.Context, in hook.SpawnInput) hook.SpawnResult {
|
|
var payload hook.Payload
|
|
_ = json.Unmarshal([]byte(in.Stdin), &payload)
|
|
f.mu.Lock()
|
|
f.hookEvents = append(f.hookEvents, payload.Event)
|
|
f.mu.Unlock()
|
|
return hook.SpawnResult{ExitCode: 0}
|
|
}, nil)
|
|
}
|
|
|
|
func (f *configurableFactory) hookEventsSnapshot() []hook.Event {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return append([]hook.Event(nil), f.hookEvents...)
|
|
}
|
|
|
|
// --- a minimal JSON-RPC client over the wire, for integration tests ---
|
|
|
|
type frame struct {
|
|
ID *json.RawMessage `json:"id"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params"`
|
|
Result json.RawMessage `json:"result"`
|
|
Error *rpcError `json:"error"`
|
|
}
|
|
|
|
type rpcClient struct {
|
|
enc *json.Encoder
|
|
wmu sync.Mutex
|
|
|
|
mu sync.Mutex
|
|
nextID int64
|
|
waits map[int64]chan frame
|
|
|
|
notifs chan frame
|
|
reqs chan frame
|
|
}
|
|
|
|
func newRPCClient(in io.Writer, out io.Reader) *rpcClient {
|
|
c := &rpcClient{
|
|
enc: json.NewEncoder(in),
|
|
waits: make(map[int64]chan frame),
|
|
notifs: make(chan frame, 64),
|
|
reqs: make(chan frame, 16),
|
|
}
|
|
dec := json.NewDecoder(out)
|
|
go func() {
|
|
for {
|
|
var f frame
|
|
if err := dec.Decode(&f); err != nil {
|
|
return
|
|
}
|
|
switch {
|
|
case f.Method != "" && f.ID != nil:
|
|
c.reqs <- f
|
|
case f.Method != "" && f.ID == nil:
|
|
c.notifs <- f
|
|
case f.Method == "" && f.ID != nil:
|
|
var id int64
|
|
if json.Unmarshal(*f.ID, &id) != nil {
|
|
continue
|
|
}
|
|
c.mu.Lock()
|
|
ch := c.waits[id]
|
|
delete(c.waits, id)
|
|
c.mu.Unlock()
|
|
if ch != nil {
|
|
ch <- f
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
return c
|
|
}
|
|
|
|
func (c *rpcClient) send(v any) {
|
|
c.wmu.Lock()
|
|
_ = c.enc.Encode(v)
|
|
c.wmu.Unlock()
|
|
}
|
|
|
|
func (c *rpcClient) callAsync(method string, params any) chan frame {
|
|
c.mu.Lock()
|
|
c.nextID++
|
|
id := c.nextID
|
|
ch := make(chan frame, 1)
|
|
c.waits[id] = ch
|
|
c.mu.Unlock()
|
|
c.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
|
|
return ch
|
|
}
|
|
|
|
func (c *rpcClient) call(t *testing.T, method string, params any) frame {
|
|
t.Helper()
|
|
select {
|
|
case f := <-c.callAsync(method, params):
|
|
return f
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("%s: timed out", method)
|
|
return frame{}
|
|
}
|
|
}
|
|
|
|
func (c *rpcClient) notify(method string, params any) {
|
|
c.send(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
|
|
}
|
|
|
|
func (c *rpcClient) reply(id *json.RawMessage, result any) {
|
|
c.send(map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
|
|
}
|
|
|
|
func (c *rpcClient) replyError(id *json.RawMessage, code int, message string) {
|
|
c.send(map[string]any{"jsonrpc": "2.0", "id": id, "error": rpcError{Code: code, Message: message}})
|
|
}
|
|
|
|
func startServer(t *testing.T, factory Factory) (*rpcClient, func()) {
|
|
t.Helper()
|
|
inR, inW := io.Pipe()
|
|
outR, outW := io.Pipe()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
_ = Serve(context.Background(), inR, outW, factory, AgentInfo{Name: "reasonix-test", Version: "0"})
|
|
close(done)
|
|
}()
|
|
client := newRPCClient(inW, outR)
|
|
return client, func() {
|
|
_ = inW.Close()
|
|
<-done
|
|
_ = outW.Close()
|
|
}
|
|
}
|
|
|
|
// drainPrompt collects session/update notifications until the prompt's response
|
|
// arrives, then sweeps any notifications still buffered.
|
|
func drainPrompt(t *testing.T, c *rpcClient, promptCh chan frame) ([]frame, frame) {
|
|
t.Helper()
|
|
var notifs []frame
|
|
var resp frame
|
|
for {
|
|
select {
|
|
case f := <-c.notifs:
|
|
notifs = append(notifs, f)
|
|
case resp = <-promptCh:
|
|
for {
|
|
select {
|
|
case f := <-c.notifs:
|
|
notifs = append(notifs, f)
|
|
default:
|
|
return notifs, resp
|
|
}
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("session/prompt: timed out")
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateKind(t *testing.T, f frame) string {
|
|
t.Helper()
|
|
var p struct {
|
|
Update struct {
|
|
SessionUpdate string `json:"sessionUpdate"`
|
|
} `json:"update"`
|
|
}
|
|
if err := json.Unmarshal(f.Params, &p); err != nil {
|
|
t.Fatalf("decode update: %v", err)
|
|
}
|
|
return p.Update.SessionUpdate
|
|
}
|
|
|
|
func configOptionValueFromUpdate(t *testing.T, f frame, id string) (string, bool) {
|
|
t.Helper()
|
|
var p struct {
|
|
Update struct {
|
|
SessionUpdate string `json:"sessionUpdate"`
|
|
ConfigOptions []SessionConfigOption `json:"configOptions"`
|
|
} `json:"update"`
|
|
}
|
|
if err := json.Unmarshal(f.Params, &p); err != nil {
|
|
t.Fatalf("decode config update: %v", err)
|
|
}
|
|
if p.Update.SessionUpdate != "config_option_update" {
|
|
return "", false
|
|
}
|
|
opt, ok := findConfigOption(p.Update.ConfigOptions, id)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
return opt.CurrentValue, true
|
|
}
|
|
|
|
func messageChunkText(t *testing.T, f frame) (string, bool) {
|
|
t.Helper()
|
|
var p struct {
|
|
Update struct {
|
|
SessionUpdate string `json:"sessionUpdate"`
|
|
Content ContentBlock `json:"content"`
|
|
} `json:"update"`
|
|
}
|
|
if err := json.Unmarshal(f.Params, &p); err != nil {
|
|
t.Fatalf("decode message update: %v", err)
|
|
}
|
|
if p.Update.SessionUpdate != "agent_message_chunk" || p.Update.Content.Type != "text" {
|
|
return "", false
|
|
}
|
|
return p.Update.Content.Text, true
|
|
}
|
|
|
|
// --- tests ---
|
|
|
|
func TestServeLifecycle(t *testing.T) {
|
|
factory := &fakeFactory{behavior: func(_ context.Context, sink event.Sink, input string) error {
|
|
sink.Emit(event.Event{Kind: event.Text, Text: "hi " + input})
|
|
sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "c1", Name: "ls", Args: `{}`}})
|
|
sink.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "c1", Name: "ls", Output: "file.go"}})
|
|
return nil
|
|
}}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
initResp := client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
var ir InitializeResult
|
|
if err := json.Unmarshal(initResp.Result, &ir); err != nil {
|
|
t.Fatalf("initialize result: %v", err)
|
|
}
|
|
if ir.ProtocolVersion != ProtocolVersion {
|
|
t.Errorf("protocolVersion = %d, want %d", ir.ProtocolVersion, ProtocolVersion)
|
|
}
|
|
if !ir.AgentCapabilities.PromptCapabilities.EmbeddedContext {
|
|
t.Errorf("embeddedContext should be advertised")
|
|
}
|
|
if ir.AgentCapabilities.SessionCapabilities.List == nil ||
|
|
ir.AgentCapabilities.SessionCapabilities.Resume == nil ||
|
|
ir.AgentCapabilities.SessionCapabilities.Close == nil ||
|
|
ir.AgentCapabilities.SessionCapabilities.Delete == nil {
|
|
t.Errorf("sessionCapabilities = %+v, want list/resume/close/delete", ir.AgentCapabilities.SessionCapabilities)
|
|
}
|
|
if ir.AgentCapabilities.PromptCapabilities.Image {
|
|
t.Errorf("image must not be advertised")
|
|
}
|
|
if len(ir.AuthMethods) != 1 || ir.AuthMethods[0].ID != "reasonix-setup" || ir.AuthMethods[0].Type != "terminal" {
|
|
t.Fatalf("authMethods = %+v, want terminal reasonix setup", ir.AuthMethods)
|
|
}
|
|
if len(ir.AuthMethods[0].Args) != 1 || ir.AuthMethods[0].Args[0] != "setup" {
|
|
t.Fatalf("auth args = %+v, want [setup]", ir.AuthMethods[0].Args)
|
|
}
|
|
|
|
authResp := client.call(t, "authenticate", AuthenticateParams{MethodID: "reasonix-setup"})
|
|
if authResp.Error != nil {
|
|
t.Fatalf("authenticate errored: %+v", authResp.Error)
|
|
}
|
|
badAuthResp := client.call(t, "authenticate", AuthenticateParams{MethodID: "missing"})
|
|
if badAuthResp.Error == nil || badAuthResp.Error.Code != ErrInvalidParams {
|
|
t.Fatalf("bad authenticate = %+v, want invalid params", badAuthResp.Error)
|
|
}
|
|
|
|
newResp := client.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
|
|
var nr SessionNewResult
|
|
if err := json.Unmarshal(newResp.Result, &nr); err != nil || nr.SessionID == "" {
|
|
t.Fatalf("session/new result: %v (%q)", err, nr.SessionID)
|
|
}
|
|
|
|
promptCh := client.callAsync("session/prompt", SessionPromptParams{
|
|
SessionID: nr.SessionID,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "there"}},
|
|
})
|
|
notifs, resp := drainPrompt(t, client, promptCh)
|
|
|
|
kinds := map[string]bool{}
|
|
for _, n := range notifs {
|
|
kinds[updateKind(t, n)] = true
|
|
}
|
|
for _, want := range []string{"agent_message_chunk", "tool_call", "tool_call_update"} {
|
|
if !kinds[want] {
|
|
t.Errorf("missing %s update; saw %v", want, kinds)
|
|
}
|
|
}
|
|
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 %q", pr.StopReason, StopEndTurn)
|
|
}
|
|
}
|
|
|
|
func TestServeAdvertisesAndExpandsCustomCommands(t *testing.T) {
|
|
factory := &commandFactory{
|
|
seen: make(chan string, 1),
|
|
skills: []skill.Skill{{
|
|
Name: "writing-plans", Plugin: "superpowers", Description: "Write a plan", Body: "Plan $ARGUMENTS",
|
|
}},
|
|
commands: []command.Command{
|
|
{
|
|
Name: "review",
|
|
Description: "Review the target",
|
|
ArgHint: "path",
|
|
Body: "Review $1",
|
|
},
|
|
{
|
|
Name: "plan",
|
|
Body: "Plan $ARGUMENTS",
|
|
Hidden: true,
|
|
},
|
|
},
|
|
}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
newResp := client.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
|
|
var nr SessionNewResult
|
|
if err := json.Unmarshal(newResp.Result, &nr); err != nil || nr.SessionID == "" {
|
|
t.Fatalf("session/new result: %v (%q)", err, nr.SessionID)
|
|
}
|
|
|
|
var advertised bool
|
|
var hiddenAdvertised bool
|
|
var pluginSkillAdvertised bool
|
|
select {
|
|
case n := <-client.notifs:
|
|
var p struct {
|
|
Update struct {
|
|
SessionUpdate string `json:"sessionUpdate"`
|
|
AvailableCommands []AvailableCommand `json:"availableCommands"`
|
|
} `json:"update"`
|
|
}
|
|
if err := json.Unmarshal(n.Params, &p); err != nil {
|
|
t.Fatalf("available commands update: %v", err)
|
|
}
|
|
for _, cmd := range p.Update.AvailableCommands {
|
|
if cmd.Name == "superpowers:writing-plans" {
|
|
pluginSkillAdvertised = true
|
|
}
|
|
if cmd.Name == "writing-plans" {
|
|
hiddenAdvertised = true
|
|
}
|
|
if cmd.Name == "plan" {
|
|
hiddenAdvertised = true
|
|
}
|
|
if p.Update.SessionUpdate == "available_commands_update" &&
|
|
cmd.Name == "review" &&
|
|
cmd.Description == "Review the target" &&
|
|
cmd.Input != nil &&
|
|
cmd.Input.Hint == "path" {
|
|
advertised = true
|
|
}
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for available_commands_update")
|
|
}
|
|
if !advertised {
|
|
t.Fatal("review command was not advertised")
|
|
}
|
|
if hiddenAdvertised {
|
|
t.Fatal("hidden compatibility command was advertised")
|
|
}
|
|
if !pluginSkillAdvertised {
|
|
t.Fatal("qualified plugin skill was not advertised")
|
|
}
|
|
|
|
promptCh := client.callAsync("session/prompt", SessionPromptParams{
|
|
SessionID: nr.SessionID,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "/review src/main.go"}},
|
|
})
|
|
_, resp := drainPrompt(t, client, promptCh)
|
|
if resp.Error != nil {
|
|
t.Fatalf("prompt errored: %+v", resp.Error)
|
|
}
|
|
select {
|
|
case got := <-factory.seen:
|
|
if got != "Review src/main.go" {
|
|
t.Fatalf("runner input = %q, want expanded command", got)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("runner did not receive prompt")
|
|
}
|
|
}
|
|
|
|
func TestServeSessionConfigSwitchesModelAndEffort(t *testing.T) {
|
|
factory := &configurableFactory{}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
newResp := client.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
|
|
var nr SessionNewResult
|
|
if err := json.Unmarshal(newResp.Result, &nr); err != nil {
|
|
t.Fatalf("session/new result: %v", err)
|
|
}
|
|
if nr.Models == nil || nr.Models.CurrentModelID != "fast" {
|
|
t.Fatalf("models = %+v, want current fast", nr.Models)
|
|
}
|
|
modelOpt, ok := findConfigOption(nr.ConfigOptions, "model")
|
|
if !ok || modelOpt.CurrentValue != "fast" {
|
|
t.Fatalf("model config = %+v, want current fast", modelOpt)
|
|
}
|
|
if got := factory.buildAt(t, 0).Model; got != "fast" {
|
|
t.Fatalf("initial build model = %q, want fast", got)
|
|
}
|
|
|
|
setModelResp := client.call(t, "session/set_config_option", SetSessionConfigOptionParams{
|
|
SessionID: nr.SessionID,
|
|
ConfigID: "model",
|
|
Value: "pro",
|
|
})
|
|
var modelSet SetSessionConfigOptionResult
|
|
if err := json.Unmarshal(setModelResp.Result, &modelSet); err != nil {
|
|
t.Fatalf("set model result: %v", err)
|
|
}
|
|
modelOpt, _ = findConfigOption(modelSet.ConfigOptions, "model")
|
|
if modelOpt.CurrentValue != "pro" {
|
|
t.Fatalf("model after set_config_option = %q, want pro", modelOpt.CurrentValue)
|
|
}
|
|
if got := factory.buildAt(t, 1).Model; got != "pro" {
|
|
t.Fatalf("second build model = %q, want pro", got)
|
|
}
|
|
|
|
setEffortResp := client.call(t, "session/set_config_option", SetSessionConfigOptionParams{
|
|
SessionID: nr.SessionID,
|
|
ConfigID: "effort",
|
|
Value: "high",
|
|
})
|
|
var effortSet SetSessionConfigOptionResult
|
|
if err := json.Unmarshal(setEffortResp.Result, &effortSet); err != nil {
|
|
t.Fatalf("set effort result: %v", err)
|
|
}
|
|
effortOpt, _ := findConfigOption(effortSet.ConfigOptions, "effort")
|
|
if effortOpt.CurrentValue != "high" {
|
|
t.Fatalf("effort after set_config_option = %q, want high", effortOpt.CurrentValue)
|
|
}
|
|
effortBuild := factory.buildAt(t, 2)
|
|
if effortBuild.Model != "pro" || effortBuild.EffortOverride == nil || *effortBuild.EffortOverride != "high" {
|
|
t.Fatalf("effort build = model:%q effort:%v, want pro/high", effortBuild.Model, effortBuild.EffortOverride)
|
|
}
|
|
|
|
setLegacyResp := client.call(t, "session/set_model", SetSessionModelParams{SessionID: nr.SessionID, ModelID: "fast"})
|
|
if setLegacyResp.Error != nil {
|
|
t.Fatalf("session/set_model errored: %+v", setLegacyResp.Error)
|
|
}
|
|
if got := factory.buildAt(t, 3).Model; got != "fast" {
|
|
t.Fatalf("legacy set_model build model = %q, want fast", got)
|
|
}
|
|
}
|
|
|
|
func TestServeSessionConfigQueuesDuringActivePrompt(t *testing.T) {
|
|
started := make(chan struct{})
|
|
release := make(chan struct{})
|
|
var once sync.Once
|
|
factory := &configurableFactory{
|
|
behavior: func(ctx context.Context, sink event.Sink, input string, p SessionParams) error {
|
|
once.Do(func() { close(started) })
|
|
select {
|
|
case <-release:
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
sink.Emit(event.Event{Kind: event.Text, Text: p.Model + ":" + input})
|
|
return nil
|
|
},
|
|
}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
newResp := client.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
|
|
var nr SessionNewResult
|
|
if err := json.Unmarshal(newResp.Result, &nr); err != nil {
|
|
t.Fatalf("session/new result: %v", err)
|
|
}
|
|
|
|
first := client.callAsync("session/prompt", SessionPromptParams{
|
|
SessionID: nr.SessionID,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "first"}},
|
|
})
|
|
select {
|
|
case <-started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("prompt never started")
|
|
}
|
|
|
|
setResp := client.call(t, "session/set_config_option", SetSessionConfigOptionParams{
|
|
SessionID: nr.SessionID,
|
|
ConfigID: "model",
|
|
Value: "pro",
|
|
})
|
|
if setResp.Error != nil {
|
|
t.Fatalf("set_config_option while running errored: %+v", setResp.Error)
|
|
}
|
|
var set SetSessionConfigOptionResult
|
|
if err := json.Unmarshal(setResp.Result, &set); err != nil {
|
|
t.Fatalf("set model result: %v", err)
|
|
}
|
|
modelOpt, _ := findConfigOption(set.ConfigOptions, "model")
|
|
if modelOpt.CurrentValue != "pro" {
|
|
t.Fatalf("queued model option = %q, want pro", modelOpt.CurrentValue)
|
|
}
|
|
if got := factory.buildCount(); got != 1 {
|
|
t.Fatalf("build count while prompt is active = %d, want only initial build", got)
|
|
}
|
|
|
|
close(release)
|
|
_, resp := drainPrompt(t, client, first)
|
|
if resp.Error != nil {
|
|
t.Fatalf("first prompt errored: %+v", resp.Error)
|
|
}
|
|
if got := factory.buildAt(t, 1).Model; got != "pro" {
|
|
t.Fatalf("queued rebuild model = %q, want pro", got)
|
|
}
|
|
}
|
|
|
|
func TestServeSessionConfigRejectsBackgroundJobsWhileIdle(t *testing.T) {
|
|
dir := t.TempDir()
|
|
factory := &configurableFactory{dir: dir, managers: []*jobs.Manager{}}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
newResp := client.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
|
|
var nr SessionNewResult
|
|
if err := json.Unmarshal(newResp.Result, &nr); err != nil {
|
|
t.Fatalf("session/new result: %v", err)
|
|
}
|
|
|
|
jm := factory.managerAt(t, 0)
|
|
release := make(chan struct{})
|
|
var releaseOnce sync.Once
|
|
started := make(chan struct{})
|
|
sessionPath := transcriptPath(dir, nr.SessionID)
|
|
jm.StartForSession(agent.BranchID(sessionPath), "bash", "server", func(ctx context.Context, _ io.Writer) (string, error) {
|
|
close(started)
|
|
select {
|
|
case <-release:
|
|
return "", nil
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
}
|
|
})
|
|
defer func() {
|
|
releaseOnce.Do(func() { close(release) })
|
|
jm.Close()
|
|
}()
|
|
select {
|
|
case <-started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("background job never started")
|
|
}
|
|
|
|
setResp := client.call(t, "session/set_config_option", SetSessionConfigOptionParams{
|
|
SessionID: nr.SessionID,
|
|
ConfigID: "model",
|
|
Value: "pro",
|
|
})
|
|
if setResp.Error == nil || !strings.Contains(setResp.Error.Message, "stop background jobs") {
|
|
t.Fatalf("set_config_option with background job error = %+v, want stop background jobs RPC error", setResp.Error)
|
|
}
|
|
legacyResp := client.call(t, "session/set_model", SetSessionModelParams{SessionID: nr.SessionID, ModelID: "pro"})
|
|
if legacyResp.Error == nil || !strings.Contains(legacyResp.Error.Message, "stop background jobs") {
|
|
t.Fatalf("set_model with background job error = %+v, want stop background jobs RPC error", legacyResp.Error)
|
|
}
|
|
if got := factory.buildCount(); got != 1 {
|
|
t.Fatalf("build count after rejected switch = %d, want 1", got)
|
|
}
|
|
if running := jm.RunningForSession(agent.BranchID(sessionPath)); len(running) != 1 {
|
|
t.Fatalf("running jobs after rejected switch = %+v, want original job still running", running)
|
|
}
|
|
|
|
releaseOnce.Do(func() { close(release) })
|
|
_ = jm.WaitForSession(context.Background(), agent.BranchID(sessionPath), nil, 5)
|
|
if running := jm.RunningForSession(agent.BranchID(sessionPath)); len(running) != 0 {
|
|
t.Fatalf("running jobs after release = %+v, want none before retry", running)
|
|
}
|
|
|
|
retryResp := client.call(t, "session/set_config_option", SetSessionConfigOptionParams{
|
|
SessionID: nr.SessionID,
|
|
ConfigID: "model",
|
|
Value: "pro",
|
|
})
|
|
if retryResp.Error != nil {
|
|
t.Fatalf("retry set_config_option after jobs stopped errored: %+v", retryResp.Error)
|
|
}
|
|
var retry SetSessionConfigOptionResult
|
|
if err := json.Unmarshal(retryResp.Result, &retry); err != nil {
|
|
t.Fatalf("retry set_config_option result: %v", err)
|
|
}
|
|
modelOpt, _ := findConfigOption(retry.ConfigOptions, "model")
|
|
if modelOpt.CurrentValue != "pro" {
|
|
t.Fatalf("retry model currentValue = %q, want pro", modelOpt.CurrentValue)
|
|
}
|
|
if got := factory.buildCount(); got != 2 {
|
|
t.Fatalf("build count after retry switch = %d, want rebuild", got)
|
|
}
|
|
|
|
promptCh := client.callAsync("session/prompt", SessionPromptParams{
|
|
SessionID: nr.SessionID,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "after-switch"}},
|
|
})
|
|
notifs, resp := drainPrompt(t, client, promptCh)
|
|
if resp.Error != nil {
|
|
t.Fatalf("prompt after retry switch errored: %+v", resp.Error)
|
|
}
|
|
var usedNewModel bool
|
|
for _, n := range notifs {
|
|
if text, ok := messageChunkText(t, n); ok && strings.Contains(text, "pro:after-switch") {
|
|
usedNewModel = true
|
|
break
|
|
}
|
|
}
|
|
if !usedNewModel {
|
|
t.Fatalf("prompt after retry did not use new model; notifications=%+v", notifs)
|
|
}
|
|
}
|
|
|
|
func TestServeQueuedSessionConfigDiscardedWhenPromptLeavesBackgroundJob(t *testing.T) {
|
|
dir := t.TempDir()
|
|
releaseJob := make(chan struct{})
|
|
releaseTurn := make(chan struct{})
|
|
startedJob := make(chan struct{})
|
|
startedTurn := make(chan struct{})
|
|
var jobOnce sync.Once
|
|
factory := &configurableFactory{dir: dir, managers: []*jobs.Manager{}}
|
|
factory.behavior = func(ctx context.Context, sink event.Sink, input string, p SessionParams) error {
|
|
if input == "first" {
|
|
close(startedTurn)
|
|
jm := factory.managerAt(t, 0)
|
|
jobOnce.Do(func() {
|
|
jm.StartForSession(jobs.SessionFromContext(ctx), "bash", "server", func(ctx context.Context, _ io.Writer) (string, error) {
|
|
close(startedJob)
|
|
select {
|
|
case <-releaseJob:
|
|
return "", nil
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
}
|
|
})
|
|
})
|
|
select {
|
|
case <-startedJob:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("background job never started")
|
|
}
|
|
select {
|
|
case <-releaseTurn:
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
sink.Emit(event.Event{Kind: event.Text, Text: p.Model + ":" + input})
|
|
return nil
|
|
}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
newResp := client.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
|
|
var nr SessionNewResult
|
|
if err := json.Unmarshal(newResp.Result, &nr); err != nil {
|
|
t.Fatalf("session/new result: %v", err)
|
|
}
|
|
|
|
first := client.callAsync("session/prompt", SessionPromptParams{
|
|
SessionID: nr.SessionID,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "first"}},
|
|
})
|
|
select {
|
|
case <-startedTurn:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("prompt never started")
|
|
}
|
|
setResp := client.call(t, "session/set_config_option", SetSessionConfigOptionParams{
|
|
SessionID: nr.SessionID,
|
|
ConfigID: "model",
|
|
Value: "pro",
|
|
})
|
|
if setResp.Error != nil {
|
|
t.Fatalf("set_config_option while prompt is running errored: %+v", setResp.Error)
|
|
}
|
|
|
|
close(releaseTurn)
|
|
notifs, resp := drainPrompt(t, client, first)
|
|
if resp.Error != nil {
|
|
t.Fatalf("first prompt errored: %+v", resp.Error)
|
|
}
|
|
warningIndex := -1
|
|
for i, n := range notifs {
|
|
if text, ok := messageChunkText(t, n); ok && strings.Contains(text, "stop background jobs") {
|
|
warningIndex = i
|
|
break
|
|
}
|
|
}
|
|
if warningIndex < 0 {
|
|
t.Fatalf("queued switch updates = %d, want warning mentioning background jobs", len(notifs))
|
|
}
|
|
var sawOldConfig bool
|
|
for _, n := range notifs[warningIndex+1:] {
|
|
if value, ok := configOptionValueFromUpdate(t, n, "model"); ok && value == "fast" {
|
|
sawOldConfig = true
|
|
break
|
|
}
|
|
}
|
|
if !sawOldConfig {
|
|
t.Fatalf("queued switch notifications after warning did not include model currentValue=fast: %+v", notifs[warningIndex+1:])
|
|
}
|
|
|
|
close(releaseJob)
|
|
jm := factory.managerAt(t, 0)
|
|
_ = jm.WaitForSession(context.Background(), agent.BranchID(transcriptPath(dir, nr.SessionID)), nil, 5)
|
|
second := client.callAsync("session/prompt", SessionPromptParams{
|
|
SessionID: nr.SessionID,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "second"}},
|
|
})
|
|
_, resp = drainPrompt(t, client, second)
|
|
if resp.Error != nil {
|
|
t.Fatalf("second prompt errored: %+v", resp.Error)
|
|
}
|
|
if got := factory.buildCount(); got != 1 {
|
|
t.Fatalf("build count after discarded queued switch = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestServeSessionConfigRejectsPendingAsk(t *testing.T) {
|
|
factory := &configurableFactory{
|
|
withCtrl: func(ctx context.Context, _ event.Sink, _ string, _ SessionParams, ctrl *control.Controller) error {
|
|
_, err := ctrl.Ask(ctx, []event.AskQuestion{{
|
|
ID: "choice",
|
|
Prompt: "Pick one",
|
|
Options: []event.AskOption{{Label: "A"}, {Label: "B"}},
|
|
}})
|
|
return err
|
|
},
|
|
}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
newResp := client.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
|
|
var nr SessionNewResult
|
|
if err := json.Unmarshal(newResp.Result, &nr); err != nil {
|
|
t.Fatalf("session/new result: %v", err)
|
|
}
|
|
|
|
promptCh := client.callAsync("session/prompt", SessionPromptParams{
|
|
SessionID: nr.SessionID,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "ask"}},
|
|
})
|
|
var req frame
|
|
select {
|
|
case req = <-client.reqs:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("ask request was not sent to client")
|
|
}
|
|
|
|
setResp := client.call(t, "session/set_config_option", SetSessionConfigOptionParams{
|
|
SessionID: nr.SessionID,
|
|
ConfigID: "model",
|
|
Value: "pro",
|
|
})
|
|
if setResp.Error == nil || !strings.Contains(setResp.Error.Message, "pending") {
|
|
t.Fatalf("set_config_option with pending ask error = %+v, want pending interaction RPC error", setResp.Error)
|
|
}
|
|
if got := factory.buildCount(); got != 1 {
|
|
t.Fatalf("build count while ask is pending = %d, want 1", got)
|
|
}
|
|
|
|
client.reply(req.ID, PermissionRequestResult{
|
|
Outcome: PermissionOutcome{Outcome: "selected", OptionID: "choice:1"},
|
|
})
|
|
_, resp := drainPrompt(t, client, promptCh)
|
|
if resp.Error != nil {
|
|
t.Fatalf("prompt errored: %+v", resp.Error)
|
|
}
|
|
if got := factory.buildCount(); got != 1 {
|
|
t.Fatalf("build count after answered ask = %d, want no queued rebuild", got)
|
|
}
|
|
}
|
|
|
|
func TestServeSessionConfigRebuildPreservesLifecycleHooks(t *testing.T) {
|
|
factory := &configurableFactory{withHooks: true}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
newResp := client.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
|
|
var nr SessionNewResult
|
|
if err := json.Unmarshal(newResp.Result, &nr); err != nil {
|
|
t.Fatalf("session/new result: %v", err)
|
|
}
|
|
|
|
promptCh := client.callAsync("session/prompt", SessionPromptParams{
|
|
SessionID: nr.SessionID,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "one"}},
|
|
})
|
|
_, resp := drainPrompt(t, client, promptCh)
|
|
if resp.Error != nil {
|
|
t.Fatalf("first prompt errored: %+v", resp.Error)
|
|
}
|
|
if got := factory.hookEventsSnapshot(); len(got) != 1 || got[0] != hook.SessionStart {
|
|
t.Fatalf("hook events after first prompt = %v, want [SessionStart]", got)
|
|
}
|
|
|
|
setResp := client.call(t, "session/set_config_option", SetSessionConfigOptionParams{
|
|
SessionID: nr.SessionID,
|
|
ConfigID: "model",
|
|
Value: "pro",
|
|
})
|
|
if setResp.Error != nil {
|
|
t.Fatalf("set_config_option errored: %+v", setResp.Error)
|
|
}
|
|
if got := factory.hookEventsSnapshot(); len(got) != 1 || got[0] != hook.SessionStart {
|
|
t.Fatalf("hook events after config rebuild = %v, want no lifecycle hook", got)
|
|
}
|
|
|
|
promptCh = client.callAsync("session/prompt", SessionPromptParams{
|
|
SessionID: nr.SessionID,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "two"}},
|
|
})
|
|
_, resp = drainPrompt(t, client, promptCh)
|
|
if resp.Error != nil {
|
|
t.Fatalf("second prompt errored: %+v", resp.Error)
|
|
}
|
|
if got := factory.hookEventsSnapshot(); len(got) != 1 || got[0] != hook.SessionStart {
|
|
t.Fatalf("hook events after second prompt = %v, want no duplicate SessionStart", got)
|
|
}
|
|
|
|
closeResp := client.call(t, "session/close", SessionCloseParams{SessionID: nr.SessionID})
|
|
if closeResp.Error != nil {
|
|
t.Fatalf("session/close errored: %+v", closeResp.Error)
|
|
}
|
|
if got := factory.hookEventsSnapshot(); len(got) != 2 || got[0] != hook.SessionStart || got[1] != hook.SessionEnd {
|
|
t.Fatalf("hook events after close = %v, want [SessionStart SessionEnd]", got)
|
|
}
|
|
}
|
|
|
|
func TestServeSessionLoadFallsBackFromStaleSavedModel(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cwd := t.TempDir()
|
|
sessionID := "stale-model"
|
|
path := transcriptPath(dir, sessionID)
|
|
saved := agent.NewSession("")
|
|
saved.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
|
|
if err := saved.Save(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
effort := "high"
|
|
if err := saveACPMeta(path, acpSessionMeta{
|
|
SessionID: sessionID,
|
|
Cwd: cwd,
|
|
Model: "missing/model",
|
|
EffortOverride: &effort,
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
factory := &configurableFactory{dir: dir}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
loadResp := client.call(t, "session/load", SessionLoadParams{SessionID: sessionID, Cwd: cwd})
|
|
if loadResp.Error != nil {
|
|
t.Fatalf("session/load with stale saved model errored: %+v", loadResp.Error)
|
|
}
|
|
if got := factory.buildAt(t, 0).Model; got != "fast" {
|
|
t.Fatalf("fallback build model = %q, want fast", got)
|
|
}
|
|
meta, ok, err := loadACPMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("load rewritten meta = %v, ok=%v", err, ok)
|
|
}
|
|
if meta.Model != "fast" {
|
|
t.Fatalf("rewritten meta model = %q, want fast", meta.Model)
|
|
}
|
|
}
|
|
|
|
func TestServeSessionLoadRejectsCleanupPending(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cwd := t.TempDir()
|
|
sessionID := "pending-load"
|
|
path := transcriptPath(dir, sessionID)
|
|
saved := agent.NewSession("")
|
|
saved.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
|
|
if err := saved.Save(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := saveACPMeta(path, acpSessionMeta{
|
|
SessionID: sessionID,
|
|
Cwd: cwd,
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := agent.MarkCleanupPending(path, "delete"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
factory := &configurableFactory{dir: dir}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
loadResp := client.call(t, "session/load", SessionLoadParams{SessionID: sessionID, Cwd: cwd})
|
|
if loadResp.Error == nil || !strings.Contains(loadResp.Error.Message, "unknown session") {
|
|
t.Fatalf("session/load cleanup-pending error = %+v, want unknown session", loadResp.Error)
|
|
}
|
|
factory.mu.Lock()
|
|
builds := append([]SessionParams(nil), factory.builds...)
|
|
factory.mu.Unlock()
|
|
if len(builds) != 0 {
|
|
t.Fatalf("cleanup-pending load should not build a controller, got builds %+v", builds)
|
|
}
|
|
}
|
|
|
|
func TestServeCancel(t *testing.T) {
|
|
started := make(chan struct{})
|
|
factory := &fakeFactory{behavior: func(ctx context.Context, _ event.Sink, _ string) error {
|
|
close(started)
|
|
<-ctx.Done()
|
|
return ctx.Err()
|
|
}}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
newResp := client.call(t, "session/new", SessionNewParams{})
|
|
var nr SessionNewResult
|
|
json.Unmarshal(newResp.Result, &nr)
|
|
|
|
promptCh := client.callAsync("session/prompt", SessionPromptParams{
|
|
SessionID: nr.SessionID,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "loop"}},
|
|
})
|
|
|
|
select {
|
|
case <-started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("prompt never started")
|
|
}
|
|
client.notify("session/cancel", SessionCancelParams{SessionID: nr.SessionID})
|
|
|
|
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 prompt")
|
|
}
|
|
}
|
|
|
|
func TestServePromptErrorIsNotReportedAsCancelled(t *testing.T) {
|
|
factory := &fakeFactory{behavior: func(context.Context, event.Sink, string) error {
|
|
return errors.New("provider failed")
|
|
}}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
newResp := client.call(t, "session/new", SessionNewParams{})
|
|
var nr SessionNewResult
|
|
json.Unmarshal(newResp.Result, &nr)
|
|
|
|
promptCh := client.callAsync("session/prompt", SessionPromptParams{
|
|
SessionID: nr.SessionID,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "fail"}},
|
|
})
|
|
_, resp := drainPrompt(t, client, promptCh)
|
|
var pr SessionPromptResult
|
|
if err := json.Unmarshal(resp.Result, &pr); err != nil {
|
|
t.Fatalf("prompt result: %v", err)
|
|
}
|
|
if pr.StopReason != StopError {
|
|
t.Errorf("stopReason = %q, want error", pr.StopReason)
|
|
}
|
|
}
|
|
|
|
func TestServeRejectsConcurrentPromptForSameSession(t *testing.T) {
|
|
started := make(chan struct{})
|
|
release := make(chan struct{})
|
|
var once sync.Once
|
|
factory := &fakeFactory{behavior: func(_ context.Context, _ event.Sink, _ string) error {
|
|
once.Do(func() { close(started) })
|
|
<-release
|
|
return nil
|
|
}}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
newResp := client.call(t, "session/new", SessionNewParams{})
|
|
var nr SessionNewResult
|
|
json.Unmarshal(newResp.Result, &nr)
|
|
|
|
first := client.callAsync("session/prompt", SessionPromptParams{
|
|
SessionID: nr.SessionID,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "first"}},
|
|
})
|
|
select {
|
|
case <-started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("first prompt never started")
|
|
}
|
|
|
|
second := client.call(t, "session/prompt", SessionPromptParams{
|
|
SessionID: nr.SessionID,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "second"}},
|
|
})
|
|
if second.Error == nil {
|
|
t.Fatal("second concurrent prompt should return an error")
|
|
}
|
|
if second.Error.Code != ErrInvalidRequest || !strings.Contains(second.Error.Message, "active prompt") {
|
|
t.Fatalf("second prompt error = %+v, want active-prompt invalid request", second.Error)
|
|
}
|
|
|
|
close(release)
|
|
select {
|
|
case resp := <-first:
|
|
if resp.Error != nil {
|
|
t.Fatalf("first prompt errored: %+v", resp.Error)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("first prompt did not finish")
|
|
}
|
|
}
|
|
|
|
func TestServeSessionClose(t *testing.T) {
|
|
factory := &fakeFactory{behavior: func(context.Context, event.Sink, string) error { return nil }}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
newResp := client.call(t, "session/new", SessionNewParams{})
|
|
var nr SessionNewResult
|
|
json.Unmarshal(newResp.Result, &nr)
|
|
|
|
closeResp := client.call(t, "session/close", SessionCloseParams{SessionID: nr.SessionID})
|
|
if closeResp.Error != nil {
|
|
t.Fatalf("session/close errored: %+v", closeResp.Error)
|
|
}
|
|
|
|
promptResp := client.call(t, "session/prompt", SessionPromptParams{
|
|
SessionID: nr.SessionID,
|
|
Prompt: []ContentBlock{{Type: "text", Text: "after close"}},
|
|
})
|
|
if promptResp.Error == nil || !strings.Contains(promptResp.Error.Message, "unknown session") {
|
|
t.Fatalf("prompt after close error = %+v, want unknown session", promptResp.Error)
|
|
}
|
|
}
|
|
|
|
func TestSessionDeleteWithStuckJobReturnsAfterSingleGrace(t *testing.T) {
|
|
dir := t.TempDir()
|
|
grace := time.Second
|
|
maxElapsed := grace + 750*time.Millisecond
|
|
factory := &teardownFactory{dir: dir, grace: grace}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
|
newResp := client.call(t, "session/new", SessionNewParams{Cwd: t.TempDir()})
|
|
var nr SessionNewResult
|
|
if err := json.Unmarshal(newResp.Result, &nr); err != nil || nr.SessionID == "" {
|
|
t.Fatalf("session/new: %v (%q)", err, nr.SessionID)
|
|
}
|
|
path := transcriptPath(dir, nr.SessionID)
|
|
if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write transcript: %v", err)
|
|
}
|
|
releaseJob := startNonCooperativeACPJob(t, factory.lastManager(t), path)
|
|
defer releaseJob()
|
|
|
|
start := time.Now()
|
|
resp := client.call(t, "session/delete", SessionDeleteParams{SessionID: nr.SessionID})
|
|
elapsed := time.Since(start)
|
|
if resp.Error != nil {
|
|
t.Fatalf("session/delete errored: %+v", resp.Error)
|
|
}
|
|
if elapsed > maxElapsed {
|
|
t.Fatalf("session/delete took %s, want one teardown grace plus scheduling slack", elapsed)
|
|
}
|
|
if !agent.IsCleanupPending(path) {
|
|
t.Fatalf("stuck ACP delete should mark cleanup pending")
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("stuck ACP transcript should remain until delayed cleanup: %v", err)
|
|
}
|
|
releaseJob()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for agent.IsCleanupPending(path) {
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("cleanup-pending marker was not cleared after stuck job release")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestServeRejectsPathLikeSessionID(t *testing.T) {
|
|
factory := &fakeFactory{behavior: func(context.Context, event.Sink, string) error { return nil }}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
resp := client.call(t, "session/delete", SessionDeleteParams{SessionID: "../outside"})
|
|
if resp.Error == nil {
|
|
t.Fatal("session/delete with path-like sessionId should fail")
|
|
}
|
|
if resp.Error.Code != ErrInvalidParams || !strings.Contains(resp.Error.Message, "invalid sessionId") {
|
|
t.Fatalf("session/delete error = %+v, want invalid sessionId", resp.Error)
|
|
}
|
|
}
|
|
|
|
func TestListACPMetasSkipsCleanupPending(t *testing.T) {
|
|
dir := t.TempDir()
|
|
visibleID := "visible"
|
|
pendingID := "pending"
|
|
for _, id := range []string{visibleID, pendingID} {
|
|
path := transcriptPath(dir, id)
|
|
if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := saveACPMeta(path, acpSessionMeta{
|
|
SessionID: id,
|
|
Cwd: t.TempDir(),
|
|
Title: id,
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := agent.MarkCleanupPending(transcriptPath(dir, pendingID), "delete"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
metas, err := listACPMetas(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(metas) != 1 || metas[0].SessionID != visibleID {
|
|
t.Fatalf("listACPMetas = %+v, want only %q", metas, visibleID)
|
|
}
|
|
}
|
|
|
|
func TestDeleteSessionFilesDeletesOwnedSubagents(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "session.jsonl")
|
|
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ref := "sa_20260102_030405_000000000_aabbccddeeff"
|
|
writeACPSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath))
|
|
jobsDir := jobs.ArtifactDir(sessionPath)
|
|
if err := os.MkdirAll(jobsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(jobsDir, "bash-1.log"), []byte("output"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := deleteSessionFiles(sessionPath); err != nil {
|
|
t.Fatalf("deleteSessionFiles: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "subagents", ref+".jsonl")); !os.IsNotExist(err) {
|
|
t.Fatalf("subagent jsonl should be deleted, stat err = %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "subagents", ref+".meta.json")); !os.IsNotExist(err) {
|
|
t.Fatalf("subagent meta should be deleted, stat err = %v", err)
|
|
}
|
|
if _, err := os.Stat(jobsDir); !os.IsNotExist(err) {
|
|
t.Fatalf("jobs sidecar should be deleted, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReconcileCleanupPendingDeletesACPMeta(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := transcriptPath(dir, "pending-acp")
|
|
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := saveACPMeta(sessionPath, acpSessionMeta{Cwd: t.TempDir(), Model: "test-model"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
jobsDir := jobs.ArtifactDir(sessionPath)
|
|
if err := os.MkdirAll(jobsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(jobsDir, "bash-1.log"), []byte("output"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := ReconcileCleanupPending(dir); err != nil {
|
|
t.Fatalf("ReconcileCleanupPending: %v", err)
|
|
}
|
|
for _, path := range []string{sessionPath, acpMetaPath(sessionPath), jobsDir, agent.CleanupPendingPath(sessionPath)} {
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatalf("%s still exists after reconciliation (err=%v)", path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeACPSubagentArtifact(t *testing.T, dir, ref, parentSession string) {
|
|
t.Helper()
|
|
subagentDir := filepath.Join(dir, "subagents")
|
|
if err := os.MkdirAll(subagentDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(subagentDir, ref+".jsonl"), []byte(`{"role":"user","content":"sub"}`+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, err := json.Marshal(agent.SubagentMeta{
|
|
Ref: ref,
|
|
Status: agent.SubagentCompleted,
|
|
Kind: "task",
|
|
Name: "task",
|
|
ParentSession: parentSession,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(subagentDir, ref+".meta.json"), data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func startNonCooperativeACPJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {
|
|
t.Helper()
|
|
started := make(chan struct{})
|
|
release := make(chan struct{})
|
|
jm.StartForSession(agent.BranchID(sessionPath), "bash", "stuck job", func(ctx context.Context, _ io.Writer) (string, error) {
|
|
close(started)
|
|
<-ctx.Done()
|
|
<-release
|
|
return "", ctx.Err()
|
|
})
|
|
select {
|
|
case <-started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("background job never started")
|
|
}
|
|
released := false
|
|
return func() {
|
|
if released {
|
|
return
|
|
}
|
|
released = true
|
|
close(release)
|
|
}
|
|
}
|
|
|
|
func TestServeUnknownMethod(t *testing.T) {
|
|
factory := &fakeFactory{behavior: func(context.Context, event.Sink, string) error { return nil }}
|
|
client, stop := startServer(t, factory)
|
|
defer stop()
|
|
|
|
resp := client.call(t, "does/not/exist", nil)
|
|
if resp.Error == nil {
|
|
t.Fatal("expected an error response")
|
|
}
|
|
if resp.Error.Code != ErrMethodNotFound {
|
|
t.Errorf("error code = %d, want %d", resp.Error.Code, ErrMethodNotFound)
|
|
}
|
|
}
|