Files
2026-07-13 13:00:08 +08:00

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)
}
}